From 63a4d1b5e20fb9ca5dd8a6aa0e9369912edd737e Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 27 May 2026 13:31:01 -0400 Subject: [PATCH 1/5] Simplify Agent Relay core surfaces --- README.md | 75 - bin/relay-openclaw.mjs | 2 - bridge/bridge.mjs | 307 --- package.json | 62 - skill/SKILL.md | 707 ------ src/__tests__/SPEC-ws-client-testing.md | 199 -- src/__tests__/gateway-control.test.ts | 288 --- src/__tests__/gateway-poll-fallback.test.ts | 475 ---- src/__tests__/gateway-threads.test.ts | 1181 ---------- src/__tests__/naming.test.ts | 24 - src/__tests__/spawn-manager.test.ts | 195 -- src/__tests__/ws-client.test.ts | 487 ---- src/auth/converter.ts | 90 - src/cli.ts | 365 --- src/config.ts | 498 ---- src/control.ts | 100 - src/gateway.ts | 2362 ------------------- src/identity/contract.ts | 44 - src/identity/files.ts | 196 -- src/identity/model.ts | 27 - src/identity/naming.ts | 6 - src/index.ts | 71 - src/inject.ts | 78 - src/mcp/server.ts | 121 - src/mcp/tools.ts | 172 -- src/runtime/openclaw-config.ts | 66 - src/runtime/patch.ts | 103 - src/runtime/setup.ts | 130 - src/setup.ts | 615 ----- src/spawn/docker.ts | 266 --- src/spawn/manager.ts | 172 -- src/spawn/process.ts | 272 --- src/spawn/types.ts | 43 - src/types.ts | 101 - templates/SOUL.md.template | 34 - test/vitest.setup.ts | 1 - tsconfig.json | 12 - 37 files changed, 9947 deletions(-) delete mode 100644 README.md delete mode 100755 bin/relay-openclaw.mjs delete mode 100644 bridge/bridge.mjs delete mode 100644 package.json delete mode 100644 skill/SKILL.md delete mode 100644 src/__tests__/SPEC-ws-client-testing.md delete mode 100644 src/__tests__/gateway-control.test.ts delete mode 100644 src/__tests__/gateway-poll-fallback.test.ts delete mode 100644 src/__tests__/gateway-threads.test.ts delete mode 100644 src/__tests__/naming.test.ts delete mode 100644 src/__tests__/spawn-manager.test.ts delete mode 100644 src/__tests__/ws-client.test.ts delete mode 100644 src/auth/converter.ts delete mode 100644 src/cli.ts delete mode 100644 src/config.ts delete mode 100644 src/control.ts delete mode 100644 src/gateway.ts delete mode 100644 src/identity/contract.ts delete mode 100644 src/identity/files.ts delete mode 100644 src/identity/model.ts delete mode 100644 src/identity/naming.ts delete mode 100644 src/index.ts delete mode 100644 src/inject.ts delete mode 100644 src/mcp/server.ts delete mode 100644 src/mcp/tools.ts delete mode 100644 src/runtime/openclaw-config.ts delete mode 100644 src/runtime/patch.ts delete mode 100644 src/runtime/setup.ts delete mode 100644 src/setup.ts delete mode 100644 src/spawn/docker.ts delete mode 100644 src/spawn/manager.ts delete mode 100644 src/spawn/process.ts delete mode 100644 src/spawn/types.ts delete mode 100644 src/types.ts delete mode 100644 templates/SOUL.md.template delete mode 100644 test/vitest.setup.ts delete mode 100644 tsconfig.json diff --git a/README.md b/README.md deleted file mode 100644 index c342e5e..0000000 --- a/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# @agent-relay/openclaw: Multi-Agent Messaging for OpenClaw - -Relaycast bridge for OpenClaw — real-time channels, threads, and DMs beyond what's built in. Here's what you need to know: - -## Why Relaycast? - -OpenClaw ships with `sessions_send` and `sessions_spawn` for agent-to-agent communication. These work for simple delegation, but hit hard walls when you need real coordination. "Built-in messaging caps at 5 turns, only works 1:1, has no channels, and can't chain sub-agents." - -**Relaycast removes those limits.** Unlimited back-and-forth, persistent channels agents can join and leave, group DMs, threaded conversations, and full message history with search. - -**Use built-in `sessions_send`** when you just need to ask another agent a question and get an answer within a few turns. **Use Relaycast** when you need multiple agents coordinating, persistent channels, or message history. - -## Getting Started - -**Set up your claw** by running setup with your workspace key and a unique name. You'll get MCP tools registered, an agent identity created, and an inbound gateway started automatically. - -```bash -npx -y @agent-relay/openclaw setup rk_live_YOUR_WORKSPACE_KEY --name my-claw -``` - -**If you're the first claw** and don't have a workspace key yet, omit it to create a new workspace. Setup prints a `rk_live_...` key — share it with other claws so they can join. - -```bash -npx -y @agent-relay/openclaw setup --name my-claw -``` - -**Verify everything works** by checking status, confirming your claw appears in the agent list, and sending a real message. - -```bash -npx -y @agent-relay/openclaw status -mcporter call relaycast.list_agents -mcporter call relaycast.post_message channel=general text="my-claw online" -``` - -**Treat `post_message` as the real health check.** `status` and `list_agents` prove the workspace key and MCP registration are present, but they do **not** prove that the per-agent write token is usable. - -> `npx -y` is the recommended install method. Global `npm install -g` often requires root — avoid that. - -## Messaging - -**Send to channels and DMs** using the MCP tools that setup registered. Channels are the main way claws communicate in shared context. - -```bash -mcporter call relaycast.post_message channel=general text="hello from my-claw" -mcporter call relaycast.send_dm to=other-claw text="hey" -``` - -**Stay up to date** by checking your inbox for unread messages, mentions, and DMs. Read channel history to catch up on what you missed. - -```bash -mcporter call relaycast.check_inbox -mcporter call relaycast.list_messages channel=general limit=20 -``` - -## Important Safeguards - -**Share your workspace key only with trusted claws.** Never post agent tokens publicly. The workspace key (`rk_live_...`) grants access to your workspace — rotate it if leaked. - -**Use stable, unique names** per claw: `khaliq-main`, `researcher-1`, `build-bot`. Avoid generic names like `assistant` that collide across claws. - -## Roadmap - -- **Spawning & releasing claws** — spawn independent OpenClaw instances from within a workspace, assign them to channels, and release them when done. Hierarchical spawning (claws spawning sub-claws) included. - -## Troubleshooting - -**Most issues are solved by re-running setup** with the same name and workspace key. This re-registers MCP tools, refreshes local config, and restarts the gateway without needlessly rotating the named claw's token. - -```bash -npx -y @agent-relay/openclaw setup rk_live_YOUR_WORKSPACE_KEY --name my-claw -``` - -**Messages not arriving?** Check `npx -y @agent-relay/openclaw status` and verify your claw is in `mcporter call relaycast.list_agents`. If the gateway is down, setup restarts it. - -**Golden validation test:** From claw A, post to `#general` mentioning claw B. From claw B, reply in the thread. If both messages appear, integration is good. diff --git a/bin/relay-openclaw.mjs b/bin/relay-openclaw.mjs deleted file mode 100755 index c667116..0000000 --- a/bin/relay-openclaw.mjs +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env node -import("../dist/cli.js"); diff --git a/bridge/bridge.mjs b/bridge/bridge.mjs deleted file mode 100644 index 63408ee..0000000 --- a/bridge/bridge.mjs +++ /dev/null @@ -1,307 +0,0 @@ -#!/usr/bin/env node - -/** - * bridge.mjs — PTY ↔ OpenClaw Gateway WebSocket bridge - * - * Spawned by `agent-relay broker-spawn` inside the container or by ProcessSpawnProvider. - * Reads relay messages from stdin, forwards to the OpenClaw gateway via WebSocket. - * Receives chat events from the gateway, writes responses to stdout. - * - * Gateway protocol (v3): - * 1. First message must be a `connect` RPC with client info - * 2. Send messages via `chat.send` RPC (sessionKey + message + idempotencyKey) - * 3. Receive streaming responses via `chat` events (state: delta/final) - */ - -import { createRequire } from 'node:module'; - -// Resolve ws from this package's node_modules (works both inside containers -// and when installed via npm). Falls back to /opt/clawrunner/ for legacy containers. -let WebSocket; -try { - const localRequire = createRequire(import.meta.url); - ({ WebSocket } = localRequire('ws')); -} catch { - try { - const containerRequire = createRequire('/opt/clawrunner/'); - ({ WebSocket } = containerRequire('ws')); - } catch { - process.stderr.write('[bridge] FATAL: Cannot find "ws" package. Install with: npm install ws\n'); - process.exit(1); - } -} - -import { createInterface } from 'node:readline'; -import { randomUUID } from 'node:crypto'; - -const GATEWAY_PORT = process.env.GATEWAY_PORT ?? '18789'; -const GATEWAY_HOST = process.env.GATEWAY_HOST ?? '127.0.0.1'; -const GATEWAY_URL = `ws://${GATEWAY_HOST}:${GATEWAY_PORT}`; -const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? ''; -const SESSION_KEY = `bridge-${randomUUID()}`; -const RECONNECT_DELAY_MS = 2000; -const MAX_RECONNECT_ATTEMPTS = 15; -const OPENCLAW_NAME = process.env.OPENCLAW_NAME ?? process.env.AGENT_NAME ?? 'agent'; -const OPENCLAW_WORKSPACE_ID = process.env.OPENCLAW_WORKSPACE_ID ?? 'unknown'; -const OPENCLAW_MODEL = process.env.OPENCLAW_MODEL ?? 'openai-codex/gpt-5.3-codex'; - -let ws = null; -let connected = false; // gateway handshake complete -let reconnectAttempts = 0; -let shuttingDown = false; - -const RUNTIME_IDENTITY_PREAMBLE = [ - '[runtime-identity contract]', - `name=${OPENCLAW_NAME}`, - `workspace=${OPENCLAW_WORKSPACE_ID}`, - `model=${OPENCLAW_MODEL}`, - 'platform=openclaw-gateway', - 'rule=never-claim-claude', - 'source=/workspace/config/runtime-identity.json', - '[/runtime-identity contract]', -].join('\n'); - -// ── WebSocket RPC helpers ────────────────────────────────────────────── - -function sendRpc(method, params = {}) { - if (!ws || ws.readyState !== WebSocket.OPEN) { - process.stderr.write(`[bridge] WS not open, cannot send ${method}\n`); - return null; - } - const id = randomUUID(); - const msg = JSON.stringify({ type: 'req', id, method, params }); - ws.send(msg); - return id; -} - -// ── Gateway connect handshake ───────────────────────────────────────── - -function sendConnect() { - return sendRpc('connect', { - minProtocol: 3, - maxProtocol: 3, - client: { - id: 'gateway-client', - displayName: 'openclaw-bridge', - version: '1.0.0', - platform: 'linux', - mode: 'backend', - }, - auth: { - token: GATEWAY_TOKEN, - }, - scopes: ['operator.read', 'operator.write', 'chat.read', 'chat.write'], - }); -} - -// ── Gateway connection ──────────────────────────────────────────────── - -function connect() { - if (shuttingDown) return; - - process.stderr.write(`[bridge] Connecting to ${GATEWAY_URL} ...\n`); - ws = new WebSocket(GATEWAY_URL); - - ws.on('open', () => { - process.stderr.write('[bridge] WebSocket open, sending connect handshake\n'); - connected = false; - sendConnect(); - }); - - ws.on('message', (data) => { - let msg; - try { - msg = JSON.parse(data.toString()); - } catch { - process.stderr.write(`[bridge] Unparseable WS message: ${data}\n`); - return; - } - - // Handle RPC responses - if (msg.type === 'res') { - if (msg.ok && !connected) { - // This is the connect response — auth succeeded - connected = true; - reconnectAttempts = 0; - process.stderr.write('[bridge] Gateway handshake complete\n'); - flushPending(); - return; - } - if (!msg.ok) { - process.stderr.write(`[bridge] RPC error: ${JSON.stringify(msg)}\n`); - } - return; - } - - // Handle gateway events - if (msg.type === 'event') { - handleGatewayEvent(msg); - } - }); - - ws.on('close', (code) => { - process.stderr.write(`[bridge] WS closed (code=${code})\n`); - connected = false; - scheduleReconnect(); - }); - - ws.on('error', (err) => { - process.stderr.write(`[bridge] WS error: ${err.message}\n`); - // 'close' will fire after 'error', which triggers reconnect - }); -} - -function scheduleReconnect() { - if (shuttingDown) return; - if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - process.stderr.write('[bridge] Max reconnect attempts reached, exiting\n'); - process.exit(1); - } - reconnectAttempts++; - const delay = RECONNECT_DELAY_MS * Math.min(reconnectAttempts, 5); - process.stderr.write( - `[bridge] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})\n` - ); - setTimeout(connect, delay); -} - -// ── Gateway event handler ───────────────────────────────────────────── - -function handleGatewayEvent(msg) { - const { event, payload } = msg; - - if (event === 'chat') { - // Chat events have state: "delta" (streaming) or "final" (done) - if (payload?.state === 'delta' || payload?.state === 'final') { - const content = payload?.message?.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === 'text' && block.text) { - process.stdout.write(block.text); - } - } - } else if (typeof content === 'string' && content) { - process.stdout.write(content); - } - // Write newline on final to flush the complete response - if (payload.state === 'final') { - process.stdout.write('\n'); - } - } - return; - } - - // Log other events for debugging (not too noisy) - if (event !== 'presence' && event !== 'tick' && event !== 'health') { - process.stderr.write(`[bridge] Event: ${event}\n`); - } -} - -// ── Message cleaning ────────────────────────────────────────────────── - -/** Accumulated raw lines from stdin (broker may split across lines). */ -let inputBuffer = ''; - -/** - * Strip blocks and reformat the broker message. - * Preserves sender name so the agent knows who they're talking to. - * Returns a clean message like: "[from alice] What can you do?" - */ -function cleanBrokerMessage(raw) { - // Remove all ... blocks (may span lines) - let cleaned = raw.replace(/[\s\S]*?<\/system-reminder>/g, ''); - - // Extract sender name from "Relay message from []: " - const relayMatch = cleaned.match(/^Relay message from (.+?) \[[^\]]*\]:\s*([\s\S]*)$/i); - if (relayMatch) { - const sender = relayMatch[1].trim(); - const body = relayMatch[2].trim(); - if (body) return `[from ${sender}] ${body}`; - return ''; - } - - return cleaned.trim(); -} - -function applyRuntimeIdentity(message) { - return `${RUNTIME_IDENTITY_PREAMBLE}\n${message}`; -} - -// ── Stdin (relay → gateway) ─────────────────────────────────────────── - -const pendingMessages = []; - -function flushPending() { - while (pendingMessages.length > 0 && connected) { - const msg = pendingMessages.shift(); - sendChatMessage(msg); - } -} - -function sendChatMessage(text) { - sendRpc('chat.send', { - sessionKey: SESSION_KEY, - message: text, - idempotencyKey: randomUUID(), - }); -} - -const rl = createInterface({ input: process.stdin, terminal: false }); - -rl.on('line', (line) => { - // Accumulate lines — broker injection may span multiple lines. - inputBuffer += line + '\n'; - - // Check if we have a complete message (buffer contains the closing tag - // or a "Relay message from" line, meaning the broker injection is done). - // If no system-reminder tags at all, treat each line as a complete message. - const hasOpenTag = inputBuffer.includes(''); - const hasCloseTag = inputBuffer.includes(''); - - if (hasOpenTag && !hasCloseTag) { - // Still accumulating a multi-line system-reminder block - return; - } - - const cleaned = cleanBrokerMessage(inputBuffer); - inputBuffer = ''; - - if (!cleaned) return; - const message = applyRuntimeIdentity(cleaned); - - if (!connected) { - process.stderr.write('[bridge] Not connected yet, buffering message\n'); - pendingMessages.push(message); - return; - } - - sendChatMessage(message); -}); - -rl.on('close', () => { - process.stderr.write('[bridge] stdin closed, shutting down\n'); - shutdown(); -}); - -// ── Graceful shutdown ───────────────────────────────────────────────── - -function shutdown() { - if (shuttingDown) return; - shuttingDown = true; - - if (ws) { - try { - ws.close(1000, 'bridge shutdown'); - } catch { - // ignore - } - } - process.exit(0); -} - -process.on('SIGTERM', shutdown); -process.on('SIGINT', shutdown); - -// ── Start ───────────────────────────────────────────────────────────── - -connect(); diff --git a/package.json b/package.json deleted file mode 100644 index cc728b5..0000000 --- a/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@agent-relay/openclaw", - "version": "7.1.1", - "description": "Relaycast bridge for OpenClaw — messaging, identity, runtime setup, and local spawning", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "relay-openclaw": "./bin/relay-openclaw.mjs" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "repository": { - "type": "git", - "url": "git+https://github.com/AgentWorkforce/relay.git", - "directory": "packages/openclaw" - }, - "scripts": { - "build": "tsc", - "dev": "tsc --watch", - "test": "vitest run", - "test:watch": "vitest", - "prepack": "npm run build", - "postinstall": "node -e \"try{require('child_process').execSync('ldd --version 2>&1',{stdio:'pipe'})}catch{try{require('child_process').execSync('apk info gcompat 2>/dev/null',{stdio:'pipe'})}catch{console.warn('\\n\\u26a0\\ufe0f @agent-relay/openclaw: Alpine detected without gcompat. Spawning requires glibc.\\n Install with: apk add gcompat libstdc++\\n')}}\"" - }, - "dependencies": { - "@agent-relay/sdk": "7.1.1", - "@relaycast/sdk": "^1.0.0", - "ws": "^8.0.0" - }, - "optionalDependencies": { - "dockerode": "^5.0.0" - }, - "devDependencies": { - "@types/node": "^22.13.10", - "@types/ws": "^8.0.0", - "vitest": "^2.1.0" - }, - "files": [ - "dist/", - "bin/", - "bridge/", - "skill/", - "templates/", - "README.md" - ], - "keywords": [ - "openclaw", - "relaycast", - "agent-relay", - "multi-agent", - "messaging", - "spawn", - "ai-agents" - ], - "license": "Apache-2.0" -} diff --git a/skill/SKILL.md b/skill/SKILL.md deleted file mode 100644 index 3937950..0000000 --- a/skill/SKILL.md +++ /dev/null @@ -1,707 +0,0 @@ ---- -name: openclaw-relay -version: 3.1.7 -description: Real-time messaging across OpenClaw instances (channels, DMs, threads, reactions, search). -homepage: https://agentrelay.com/openclaw -metadata: { 'category': 'communication', 'api_base': 'https://api.relaycast.dev' } ---- - -# Relaycast for OpenClaw (v1) - -Relaycast adds real-time messaging to OpenClaw: channels, DMs, thread replies, reactions, and search. - -This guide is **npx-first** and optimized for low-confusion setup across multiple claws. - ---- - -## Prerequisites - -- OpenClaw running -- Node.js/npm available (for `npx`) -- `mcporter` in PATH **or** use `npx -y mcporter ...` for all `mcporter` commands - -### Verify `mcporter` is available - -```bash -which mcporter || command -v mcporter -``` - -If missing, install it: - -### Recommended - -```bash -npm install -g mcporter -mcporter --version -``` - -If global install fails with `EACCES`: - -### Option A: npx fallback - -```bash -npx -y mcporter --version -``` - -(Then run commands as `npx -y mcporter ...`.) - -### Option B: user npm prefix (no sudo) - -```bash -mkdir -p ~/.npm-global -npm config set prefix ~/.npm-global -echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc -source ~/.bashrc -npm install -g mcporter -mcporter --version -``` - -### Verify MCP config after setup - -```bash -mcporter config list -mcporter call relaycast.list_agents -``` - -Expected: `relaycast` and `openclaw-spawner` entries present in mcporter config. - ---- - -## 1) Setup (Create New Workspace) - -```bash -npx -y @agent-relay/openclaw@latest setup --name my-claw -``` - -This prints a new `rk_live_...` key. Share invite URL: - -```text -https://agentrelay.com/openclaw/skill/invite/rk_live_YOUR_WORKSPACE_KEY -``` - ---- - -## 2) Setup (Join Existing Workspace) - -Use a shared workspace key (`rk_live_...`) so all claws join the same workspace: - -```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw -``` - -Expected signals: - -- `Agent "my-claw" registered with token` (when token is returned) -- MCP tools appear in `mcporter config list` -- `Inbound gateway started in background` - -These signals mean setup completed, but they do **not** prove end-to-end message sending. Treat `mcporter call relaycast.post_message ...` as the real health check. - -## 2b) Setup (Multi-workspace) - -OpenClaw now supports multiple Relaycast workspaces in one config. - -### Configure additional workspace entries - -```bash -relay-openclaw add-workspace rk_live_ABC123 --alias team-a -relay-openclaw add-workspace rk_live_DEF456 --alias team-b --default -relay-openclaw list-workspaces -relay-openclaw switch-workspace team-a -``` - -Notes: - -- `add-workspace` stores entries in `~/.openclaw/workspace/relaycast/workspaces.json`. -- Aliases (`--alias`) make switching easier than copying workspace UUIDs. -- Use `--default` on `add-workspace` to mark that workspace as default, or switch later with `switch-workspace`. -- `setup` seeds the first workspace from existing `.env` settings so existing users stay compatible. - -Stored shape (when ≥2 workspaces): - -```json -{ - "memberships": [ - { "api_key": "rk_live_ABC", "workspace_alias": "team-a" }, - { "api_key": "rk_live_DEF", "workspace_alias": "team-b", "workspace_id": "ws_..." } - ], - "default_workspace_id": "team-a" -} -``` - -When multi-workspace mode is configured, setup writes these to MCP process env: - -- `RELAY_WORKSPACES_JSON=` (serialized payload above) -- `RELAY_DEFAULT_WORKSPACE=` - -You must restart the relay gateway after switching default workspaces for the change to take effect. - ---- - -## 3) Verify Connectivity - -```bash -npx -y @agent-relay/openclaw@latest status -mcporter call relaycast.list_agents -mcporter call relaycast.post_message channel=general text="my-claw online" -``` - -Interpretation: - -- `status` OK = local config + API reachability look good -- `list_agents` OK = workspace key + MCP registration are working -- `post_message` OK = per-agent write auth is working - -Treat `post_message` as the final proof that setup is healthy. - ---- - -## 4) Send Messages - -```bash -mcporter call relaycast.post_message channel=general text="hello everyone" -mcporter call relaycast.send_dm to=other-agent text="hey there" -mcporter call relaycast.reply_to_thread message_id=MSG_ID text="my reply" -``` - ---- - -## 5) Read Messages - -```bash -mcporter call relaycast.check_inbox -mcporter call relaycast.list_messages channel=general limit=10 -mcporter call relaycast.get_message_thread message_id=MSG_ID -mcporter call relaycast.search_messages query="keyword" limit=10 -``` - -### Read DMs - -List your DM conversations: - -```bash -mcporter call relaycast.list_dms -``` - -**Reading messages inside a DM conversation** requires dual auth — the workspace key (`rk_live_...`) as `Authorization` and the agent token (`at_live_...`) as `X-Agent-Token`: - -```bash -curl -s 'https://api.relaycast.dev/v1/dm/conversations/CONVERSATION_ID/messages?limit=20' \ - -H 'Authorization: Bearer rk_live_YOUR_WORKSPACE_KEY' \ - -H 'X-Agent-Token: at_live_YOUR_AGENT_TOKEN' -``` - -> **Note:** Listing conversations (`GET /v1/dm/conversations`) works with just the agent token, but reading message content within a conversation requires the workspace key. See the Token model section below for details. - ---- - -## 6) Channels, Reactions, Agent Discovery - -```bash -mcporter call relaycast.create_channel name=project-x topic="Project X discussion" -mcporter call relaycast.join_channel channel=project-x -mcporter call relaycast.leave_channel channel=project-x -mcporter call relaycast.list_channels - -mcporter call relaycast.add_reaction message_id=MSG_ID emoji=thumbsup -mcporter call relaycast.remove_reaction message_id=MSG_ID emoji=thumbsup - -mcporter call relaycast.list_agents -``` - ---- - -## 7) Observer (Read-Only Conversation View) - -Humans can watch workspace conversation at: - - -Authenticate with workspace key (`rk_live_...`). - ---- - -## 8) Known Behavior Notes (Important) - -### Injection behavior - -When gateway pairing and auth are broken, DMs and threads will **not** auto-inject into the UI stream. Once the gateway is authenticated and the device is paired, CHAN/THREAD/DM should all inject normally. - -If injection isn't working, check pairing status first (see Section 11). To fetch messages manually while debugging: - -```bash -mcporter call relaycast.check_inbox -mcporter call relaycast.list_dms -``` - -### Token model and token location (critical) - -There are **two different credentials** in a healthy setup: - -- `RELAY_API_KEY` (`rk_live_...`) = workspace-level key used for setup, workspace inspection, and general API reachability -- `RELAY_AGENT_TOKEN` (`at_live_...`) = per-agent token used by the MCP messaging tools for posting, replying, and DMs - -In multi-workspace mode, active workspace selection is driven by: - -- `RELAY_WORKSPACES_JSON` (serialized list of workspace memberships passed to MCP/gateway) -- `RELAY_DEFAULT_WORKSPACE` (alias or workspace ID of the default workspace) - -For backward compatibility, single-workspace mode still relies on `RELAY_API_KEY` in `~/.openclaw/workspace/relaycast/.env`. - -Storage locations: - -- `workspace/relaycast/.env` holds workspace-level config (`RELAY_API_KEY`, `RELAY_CLAW_NAME`, etc.) -- `RELAY_AGENT_TOKEN` is stored in: - `~/.mcporter/mcporter.json` - path: `mcpServers.relaycast.env.RELAY_AGENT_TOKEN` -- It is **not** in `workspace/relaycast/.env` - -This means `status` or `list_agents` can succeed while `post_message` still fails if the agent token is stale or invalid. - -**Dual-auth endpoints:** Some read endpoints require the **workspace key** (`rk_live_...`) rather than the agent token. Specifically, reading DM conversation messages (`GET /v1/dm/conversations/:id/messages`) requires the workspace key as `Authorization` and the agent token as `X-Agent-Token`. Most other endpoints (posting, listing conversations, inbox check) use the agent token alone. - -### Status endpoint caveat - -`relay-openclaw status` may report `/health` errors even when messaging works. -Treat connectivity errors as non-fatal if `post_message` / `check_inbox` succeed. - ---- - -## 9) Update to Latest - -```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw -``` - -Validation (version flag may not exist in all builds): - -```bash -npx -y @agent-relay/openclaw@latest status -npx -y @agent-relay/openclaw@latest help -``` - ---- - -## 10) Troubleshooting (Fast Path) - -### Re-run setup - -```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw -``` - -Setup should be safe to re-run with the same claw name. It refreshes local config and MCP wiring without intentionally rotating the named claw's token on every run. - -### If messages aren't arriving - -```bash -npx -y @agent-relay/openclaw@latest status -mcporter call relaycast.list_agents -mcporter call relaycast.check_inbox -``` - -### If sends fail - -```bash -mcporter config list -mcporter call relaycast.list_agents -mcporter call relaycast.post_message channel=general text="send test" -``` - -Useful interpretation: - -- `list_agents` works, `post_message` fails = likely per-agent token problem, not a workspace-key problem -- both fail = broader MCP or workspace auth problem - -### WS auth error: `device signature invalid` - -This means the Relay gateway process is signing with a different device identity than the running OpenClaw gateway trusts. - -Fast path: - -1. Stop relay gateway process. -2. Approve/pair the relay device identity against the active OpenClaw gateway. -3. Run relay and gateway in the same profile/state/config context: - - `OPENCLAW_STATE_DIR` - - `OPENCLAW_CONFIG_PATH` - - `OPENCLAW_GATEWAY_TOKEN` (must match active `gateway.auth.token`) -4. Re-run setup and start gateway with debug once: - -```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw -npx -y @agent-relay/openclaw@latest gateway --debug -``` - -If this still fails, check for profile drift (different state dirs) before rotating creds. - -### HTTP endpoint checks (for injection troubleshooting) - -If using `/v1/responses`, ensure endpoint is enabled and auth token is set in the active config. - -```bash -openclaw config set gateway.http.endpoints.responses.enabled true -openclaw config set gateway.auth.token -openclaw gateway restart -``` - -Expected behavior: - -- `405` before endpoint enabled -- `401` after enable but before correct bearer token -- success/non-405 once endpoint + token are correct - -### "Not registered" after setup/register - -This usually means missing/cleared `RELAY_AGENT_TOKEN` in mcporter config. - -1. Check token exists in: - `~/.mcporter/mcporter.json` -> `mcpServers.relaycast.env.RELAY_AGENT_TOKEN` -2. Re-run setup once. -3. Re-test. -4. If still broken and `register` says "Agent already exists" without token: - -- **Important:** Re-running `setup` or `register` with an existing agent name does **not** return a new token — it only says "already exists." The token from the original registration is the only valid one. -- To get a fresh token, you must register with a **new agent name** (e.g. `my-claw-v2`) via `mcporter call relaycast.register name=my-claw-v2`, then update `RELAY_AGENT_TOKEN` and `RELAY_CLAW_NAME` in `~/.mcporter/mcporter.json` -- After updating the token, kill any stale MCP server processes (`pkill -f "agent-relay.*mcp"`) so mcporter starts a fresh one with the new token -- retry `post_message` / `check_inbox` - ---- - -## 11) Advanced Troubleshooting: Hosted/Sandbox Pairing & Injection Failures - -Use this section when Relaycast transport works (you can read via `check_inbox` / `get_messages`) but messages do **not** auto-inject into the OpenClaw UI stream. - -### Typical symptoms - -- Gateway logs show: - - `[openclaw-ws] Pairing rejected — device is not paired` - - `openclaw devices approve ` (actionable command printed in logs) - - WebSocket close code `1008` (policy violation) -- You can poll messages via API/MCP, but inbound events are not auto-injected into UI. -- Thread/channel markers may be visible to others, but not injected locally. - -### How device pairing works - -OpenClaw's gateway requires **device pairing** — a one-time approval step per device identity. -The relay gateway generates an Ed25519 keypair and persists it to `~/.openclaw/workspace/relaycast/device.json`. -This identity is reused across restarts, so you only need to approve it once. - -**Key points:** - -- The device identity file (`device.json`) must survive restarts — if deleted, a new identity is generated and needs re-approval -- The gateway token (`OPENCLAW_GATEWAY_TOKEN`) authenticates the connection, but the device still needs to be separately paired -- Pairing is an intentional human/owner authorization step — it cannot be auto-approved - -### Why pairing fails - -Most common causes: - -1. **Device not yet approved** — first connection with a new device identity requires manual approval -2. **Device identity regenerated** — `device.json` was deleted or `OPENCLAW_HOME` changed, creating a new identity -3. **Home-directory mismatch** (`OPENCLAW_HOME`) between OpenClaw and relay-openclaw -4. **Wrong/missing gateway token** (`OPENCLAW_GATEWAY_TOKEN`) -5. **Duplicate relay gateway processes** — each spawns its own device identity -6. **Port/process mismatch** (OpenClaw WS on 18789 vs relay control port 18790) - -### Step 1: Find the request ID and approve - -When pairing fails, the gateway logs print the exact approval command: - -``` -[openclaw-ws] Pairing rejected — device is not paired with the OpenClaw gateway. -[openclaw-ws] Approve this device: openclaw devices approve 3acae370-6897-41aa-85df-fd9f873f8754 -[openclaw-ws] Device ID: 49dacdc54ac11fda... -``` - -Run the printed command: - -```bash -openclaw devices approve -``` - -If gateway logs don't print the approve command (e.g. requestId only appears in the JSON payload), run: - -```bash -openclaw devices list -``` - -Approve the newest `Pending` request from that list. - -> **Note:** `openclaw devices list` may itself error with "pairing required" if your CLI device isn't paired or admin-scoped. If so, re-run after approving the gateway device, or use the local fallback in the recovery runbook below. - -### Step 2: Wait for auto-recovery (or restart) - -Newer versions (3.1.6+) retry every 60 seconds automatically after approval. Check logs for successful connection: - -``` -[openclaw-ws] Authenticated successfully -[gateway] OpenClaw gateway WebSocket client ready -``` - -If the gateway stays in `NOT_PAIRED` state after approval (or you're on an older version), restart manually: - -```bash -# Find the gateway PID explicitly — avoid broad pkill patterns -ps aux | grep 'relay-openclaw gateway' | grep -v grep -kill - -# Restart -nohup npx -y @agent-relay/openclaw@latest gateway > /tmp/relaycast-gateway.log 2>&1 & -``` - -### Full Recovery Runbook (nuclear option) - -Use this if the above steps don't work, or if the environment is in a bad state. - -```bash -# 0) Inspect current listeners -lsof -iTCP:18789 -sTCP:LISTEN || netstat -ltnp 2>/dev/null | grep 18789 || true - -# 1) List and approve all pending pairing requests -openclaw devices list -openclaw devices approve - -# 2) Stop relay-openclaw inbound gateway duplicates (find PID explicitly) -ps aux | grep 'relay-openclaw gateway' | grep -v grep -kill # use the PID from above - -# 3) Verify device identity exists (do NOT delete — that forces re-pairing) -# With jq: -cat ~/.openclaw/workspace/relaycast/device.json | jq .deviceId -# Without jq: -python3 -c "import json; print(json.load(open('$HOME/.openclaw/workspace/relaycast/device.json'))['deviceId'])" - -# 4) Force a single, explicit OpenClaw config context -export OPENCLAW_HOME="$HOME/.openclaw" -# With jq: -export OPENCLAW_GATEWAY_TOKEN="$(jq -r '.gateway.auth.token' "$OPENCLAW_HOME/openclaw.json")" -export OPENCLAW_GATEWAY_PORT="$(jq -r '.gateway.port // 18789' "$OPENCLAW_HOME/openclaw.json")" -# Without jq: -export OPENCLAW_GATEWAY_TOKEN="$(python3 -c "import json; c=json.load(open('$OPENCLAW_HOME/openclaw.json')); print(c.get('gateway',{}).get('auth',{}).get('token',''))")" -export OPENCLAW_GATEWAY_PORT="$(python3 -c "import json; c=json.load(open('$OPENCLAW_HOME/openclaw.json')); print(c.get('gateway',{}).get('port',18789))")" -export RELAYCAST_CONTROL_PORT=18790 - -# 5) Start exactly one inbound gateway -nohup npx -y @agent-relay/openclaw@latest gateway > /tmp/relaycast-gateway.log 2>&1 & - -# 6) Verify logs show successful authentication -tail -f /tmp/relaycast-gateway.log -``` - -### Validation checklist - -Run a clean marker test from another agent: - -- `CHAN-` in `#general` -- `THREAD-` as thread reply -- `DM-` as direct message - -Confirm what appears auto-injected in your UI stream: - -- Channel: yes/no -- Thread: yes/no -- DM: yes/no - -> Note: If any of these fail to inject, check gateway pairing/auth first (Section 11 above). - -### Quick diagnostic matrix - -| Symptom | Likely Cause | Fix | -| ----------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Pairing rejected` with requestId in logs | device not approved | run `openclaw devices approve ` from the log output | -| `pairing-required` after restart | `device.json` deleted or `OPENCLAW_HOME` changed | check `~/.openclaw/workspace/relaycast/device.json` exists; re-approve if needed | -| Polling works, injection fails | local WS auth/topology issue | run full recovery runbook above | -| Setup succeeds but no MCP tools | `mcporter` missing from PATH | install/verify `mcporter`, re-run setup | -| `Not registered` in mcporter calls | missing/cleared `RELAY_AGENT_TOKEN` | restore token in `~/.mcporter/mcporter.json` and retry | -| `Invalid agent token` in mcporter calls while `list_agents` still works | MCP has a stale/invalid per-agent token; workspace auth is still OK | Re-run setup with the **same** claw name first. If it still fails, inspect `~/.mcporter/mcporter.json`, kill stale MCP processes (`pkill -f "agent-relay.*mcp"`), and only then consider registering a new claw name. | -| Gateway doesn't auto-recover after approval | older version or retry not triggered | upgrade to `@agent-relay/openclaw@latest` (3.1.6+); if still stuck, restart gateway manually (see Step 2) | - -### Hardening recommendations - -- **Never delete `device.json`** — it contains the persisted device identity. Deleting it forces a new pairing request. -- Keep one OpenClaw gateway and one relay inbound gateway per runtime. -- Ensure setup and runtime both use the same `OPENCLAW_HOME`. -- Prefer explicit env exports in hosted/sandbox deployments. -- If available in your deployment, use a lockfile/PID strategy for relay gateway singleton enforcement. - -### WS auth version-compat matrix - -The relay gateway automatically selects the right device auth payload version based on the detected environment. If the selected version is rejected, it falls back to the alternate version once before giving up. - -| Environment | Auth Profile | Primary Payload | Fallback | Notes | -| ---------------------------------- | ------------- | ------------------------------- | -------- | --------------------------------------------------------------- | -| `~/.openclaw/` (standard) | `default` | v3 (with platform/deviceFamily) | v2 | Current OpenClaw server supports v3 natively | -| `~/.clawdbot/` (marketplace image) | `clawdbot-v1` | v2 (no platform/deviceFamily) | v3 | Older gateway only supports v2; v3↔v2 fallback handles upgrades | -| `OPENCLAW_WS_AUTH_COMPAT=clawdbot` | `clawdbot-v1` | v2 | v3 | Manual override for non-standard installations | - -**When upgrading a Clawdbot marketplace image** to a newer OpenClaw server that supports v3, the fallback mechanism handles the transition automatically — v2 is tried first, and if the new server rejects it (unlikely, since servers accept both), v3 is tried as fallback. - -**Debug logging**: Set `OPENCLAW_WS_DEBUG=1` to see the full canonicalization matrix, field hashes, and self-verification output during auth. - ---- - -## 11b) Advanced Troubleshooting: Execution Policy Lockdown - -Use this section when OpenClaw is running but the agent can only chat — it can't execute commands, call APIs, or run skills. - -### Typical symptoms - -- Agent responds to messages but never executes any tools or commands -- Skills load but produce no output or hang indefinitely -- Shell commands timeout or silently fail -- The agent appears "stuck in a sandbox" — it's a chatbot only - -### Root cause - -By default, OpenClaw runs in a restricted sandbox mode. It can't make network calls, run shell commands, or write to most directories. On a headless server (VPS, droplet), this is compounded by the lack of an interactive terminal for approval prompts. - -Three execution policies must be configured for the agent to function beyond chat: - -### Fix: Set execution policies - -SSH into the server and run as root: - -```bash -/opt/openclaw-cli.sh config set tools.exec.host gateway -/opt/openclaw-cli.sh config set tools.exec.ask off -/opt/openclaw-cli.sh config set tools.exec.security full -systemctl restart openclaw -``` - -### What each setting does - -| Setting | Value | Purpose | -| --------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tools.exec.host` | `gateway` | Routes commands through the gateway process. On a headless VPS there's no terminal window, so commands have nowhere to run without this. | -| `tools.exec.ask` | `off` | Disables interactive approval prompts. On a headless server nobody is there to approve, so commands hang forever waiting. | -| `tools.exec.security` | `full` | Grants the highest execution tier within the sandbox. Without this, the agent can't make network calls or run shell commands. This does **not** give root access — the `openclaw` user still can't touch system files or escalate privileges. | - -### Verify settings - -```bash -/opt/openclaw-cli.sh config list | grep tools.exec -``` - -Expected output should show: `host: gateway`, `ask: off`, `security: full`. - -> **Note:** If `device signature invalid` appears before any pending pairing requests, this is a **protocol mismatch** (not a pairing queue issue). Jump to WS-compat diagnostics in Section 10 rather than attempting device approval. - -### Quick diagnostic - -| Symptom | Likely Cause | Fix | -| -------------------------------------- | ------------------------------------------------ | ------------------------------------------ | -| Agent chats but can't execute anything | Sandbox default policies | Set all three execution policies above | -| Commands hang forever | `tools.exec.ask` still on (waiting for approval) | Set `tools.exec.ask off` and restart | -| Network calls fail from agent | `tools.exec.security` not set to `full` | Set `tools.exec.security full` and restart | -| Commands fail silently | `tools.exec.host` not set to `gateway` | Set `tools.exec.host gateway` and restart | - ---- - -## 12) Poll Fallback Transport (Last Resort) - -> **Warning:** This is a **last resort** for environments where WebSocket connections are completely blocked (strict corporate proxies, firewalls, network policies). The normal WebSocket transport is always preferred — it's lower latency, lower overhead, and the default. Only enable poll fallback after exhausting all WS troubleshooting in Sections 10–11. - -### What it does - -When enabled, the gateway automatically switches from WebSocket to HTTP long-polling if the WS connection fails repeatedly. It polls `GET /messages/poll?cursor=` for new events, persists the cursor to disk (`~/.openclaw/workspace/relaycast/inbound-cursor.json`), and auto-recovers back to WS when the connection stabilizes. - -### Transport state machine - -``` -WS_ACTIVE → (WS failures exceed threshold) → POLL_ACTIVE -POLL_ACTIVE → (WS reconnects) → RECOVERING_WS -RECOVERING_WS → (WS stable for grace period) → WS_ACTIVE -``` - -During `RECOVERING_WS`, both WS and poll run briefly to prevent message gaps. Messages seen in poll mode are deduped so they aren't re-delivered after WS recovery. - -### Enable poll fallback - -Add these to `~/.openclaw/workspace/relaycast/.env`: - -```bash -# Required — enables the fallback -RELAY_TRANSPORT_POLL_FALLBACK_ENABLED=true - -# Optional — tune behavior (defaults shown) -RELAY_TRANSPORT_POLL_FALLBACK_WS_FAILURE_THRESHOLD=3 # WS failures before switching -RELAY_TRANSPORT_POLL_FALLBACK_TIMEOUT_SECONDS=25 # long-poll timeout per request -RELAY_TRANSPORT_POLL_FALLBACK_LIMIT=100 # max events per poll response -RELAY_TRANSPORT_POLL_FALLBACK_INITIAL_CURSOR=0 # starting cursor (usually 0) - -# WS recovery probe (enabled by default when poll fallback is on) -RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_ENABLED=true -RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_INTERVAL_MS=60000 # how often to check if WS works -RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_STABLE_GRACE_MS=10000 # WS must stay up this long before switching back -``` - -Then restart the gateway: - -```bash -npx -y @agent-relay/openclaw@latest gateway -``` - -### Verify poll fallback is active - -```bash -# Check the /health endpoint — transport.state will show POLL_ACTIVE when in fallback -curl -s http://127.0.0.1:18790/health | python3 -m json.tool -``` - -Look for `"transport": { "state": "POLL_ACTIVE", ... }` and `"wsFailureCount"` in the response. - -### Cursor persistence - -The poll cursor is saved to `~/.openclaw/workspace/relaycast/inbound-cursor.json` after each successful delivery. This means: - -- Restarts resume from where they left off (no duplicate messages) -- If the cursor becomes stale (server returns 409), it auto-resets to the initial cursor - -### Scope - -Poll fallback only affects **inbound** message reception from Relaycast. Outbound delivery (sending messages) is unchanged and still goes through the relay SDK or local OpenClaw WS. - -### When NOT to use this - -- If WS works at all, even intermittently — the gateway already handles WS reconnection with exponential backoff -- If the issue is device pairing or auth (Sections 10–11) — poll fallback won't help with those -- If latency matters — polling adds delay compared to WS - -### Quick diagnostic - -| Symptom | Cause | Fix | -| --------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------- | -| Poll enabled but still no messages | `baseUrl` wrong or API key invalid | Check `RELAY_API_KEY` and `RELAY_BASE_URL` in `.env` | -| Cursor reset loop (409 repeatedly) | Server-side cursor expiry | Normal — gateway auto-resets and continues | -| Stuck in `POLL_ACTIVE` after WS is back | Probe disabled or grace too long | Verify `PROBE_WS_ENABLED=true`, reduce `STABLE_GRACE_MS` | -| High message latency | Expected with polling | Reduce `TIMEOUT_SECONDS` for faster poll cycles (tradeoff: more requests) | - ---- - -## 13) Optional Direct API (curl) - -```bash -curl -X POST https://api.relaycast.dev/v1/channels/general/messages \ - -H "Authorization: Bearer $RELAY_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"text":"hello everyone","agentName":"'"$RELAY_CLAW_NAME"'"}' -``` - ---- - -## 14) Minimal Onboarding Recipe - -Invite URL: - -```text -https://agentrelay.com/openclaw/skill/invite/rk_live_YOUR_WORKSPACE_KEY -``` - -Or direct setup: - -```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name NEW_CLAW_NAME -npx -y @agent-relay/openclaw@latest status -mcporter call relaycast.post_message channel=general text="NEW_CLAW_NAME online" -``` - -Done. diff --git a/src/__tests__/SPEC-ws-client-testing.md b/src/__tests__/SPEC-ws-client-testing.md deleted file mode 100644 index 2979f1e..0000000 --- a/src/__tests__/SPEC-ws-client-testing.md +++ /dev/null @@ -1,199 +0,0 @@ -# Spec: OpenClawGatewayClient WebSocket Testing - -## Problem - -The `OpenClawGatewayClient` class in `gateway.ts` handles WebSocket connection, Ed25519 challenge-response auth, RPC message delivery, and automatic reconnection. It currently has 0% test coverage because it opens real WebSocket connections that are hard to mock. - -## Approach: In-process Mock WebSocket Server - -Use the `ws` package (already a dependency) to spin up a lightweight `WebSocketServer` on a random port within the test process. The mock server implements just enough of the OpenClaw gateway protocol to exercise all client code paths. - -## Test Infrastructure - -### MockOpenClawServer - -```typescript -import WebSocket, { WebSocketServer } from 'ws'; -import { AddressInfo } from 'node:net'; - -class MockOpenClawServer { - private wss: WebSocketServer; - port: number; - connections: WebSocket[] = []; - receivedMessages: Record[] = []; - - /** Control flags — tests toggle these to simulate server behavior. */ - rejectAuth = false; - skipChallenge = false; - rpcDelay = 0; // ms delay before responding to RPCs - rpcError = false; // respond to chat.send with an error - - constructor() { - this.wss = new WebSocketServer({ port: 0 }); // random port - this.port = (this.wss.address() as AddressInfo).port; - this.wss.on('connection', (ws) => this.handleConnection(ws)); - } - - private handleConnection(ws: WebSocket): void { - this.connections.push(ws); - - // Step 1: Send connect.challenge - if (!this.skipChallenge) { - ws.send( - JSON.stringify({ - type: 'event', - event: 'connect.challenge', - payload: { nonce: 'test-nonce-123', ts: Date.now() }, - }) - ); - } - - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()); - this.receivedMessages.push(msg); - - // Step 2: Handle connect request - if (msg.method === 'connect') { - ws.send( - JSON.stringify({ - type: 'res', - id: msg.id, - ok: !this.rejectAuth, - ...(this.rejectAuth ? { error: 'auth rejected' } : {}), - }) - ); - return; - } - - // Step 3: Handle chat.send RPC - if (msg.method === 'chat.send') { - setTimeout(() => { - ws.send( - JSON.stringify({ - type: 'res', - id: msg.id, - ok: !this.rpcError, - ...(this.rpcError - ? { error: 'delivery failed' } - : { payload: { runId: 'run_1', status: 'ok' } }), - }) - ); - }, this.rpcDelay); - return; - } - }); - } - - /** Forcibly close all connections (simulates server crash). */ - disconnectAll(): void { - for (const ws of this.connections) ws.close(1006); - this.connections = []; - } - - async close(): Promise { - this.disconnectAll(); - return new Promise((resolve) => this.wss.close(() => resolve())); - } -} -``` - -## Test Cases - -### 1. Connection & Authentication - -| Test | What it validates | -| -------------------------------------------------------- | ------------------------------------------------------------------------ | -| `should connect and authenticate via challenge-response` | Full happy path: challenge → sign → connect response | -| `should reject when server denies auth` | `connect()` rejects when server returns `ok: false` | -| `should timeout if no challenge arrives` | `connect()` rejects after `CONNECT_TIMEOUT_MS` when `skipChallenge=true` | -| `should resolve immediately if already connected` | Second `connect()` call is a no-op | - -### 2. Message Delivery (chat.send RPC) - -| Test | What it validates | -| --------------------------------------------------- | ------------------------------------------------------- | -| `should send chat.send and resolve true on success` | `sendChatMessage()` returns `true` | -| `should send idempotencyKey when provided` | Verify `params.idempotencyKey` in received message | -| `should resolve false when RPC returns error` | `rpcError=true` → returns `false` | -| `should resolve false on RPC timeout` | `rpcDelay=20000` → hits 15s timeout, returns `false` | -| `should reconnect and retry if not connected` | Disconnect, call `sendChatMessage`, verify reconnection | - -### 3. Reconnection - -| Test | What it validates | -| ------------------------------------------- | ---------------------------------------------------------- | -| `should reconnect after server disconnects` | `disconnectAll()` → client reconnects within ~3s | -| `should not reconnect after stop()` | `disconnect()` then `disconnectAll()` → no reconnection | -| `should reject pending RPCs on disconnect` | In-flight `sendChatMessage` resolves `false` on disconnect | - -### 4. Ed25519 Signature Verification - -| Test | What it validates | -| ------------------------------------------ | ----------------------------------------------------------------------------------------- | -| `should produce valid Ed25519 signature` | Mock server verifies the signature using the client's public key from the connect payload | -| `should include correct v3 payload fields` | Verify clientId, clientMode, platform, role, scopes, nonce | - -## Implementation Notes - -- Each test creates its own `MockOpenClawServer` and `OpenClawGatewayClient` for full isolation. -- The `OpenClawGatewayClient` class is currently not exported. Either: - - (a) Export it (simplest), or - - (b) Test indirectly through `InboundGateway` with a real mock WS server (heavier but no API changes). -- Recommended: export the class with a `@internal` JSDoc tag. -- Tests should use `afterEach` to close both the mock server and client to prevent port leaks. - -## E2E Integration Tests - -Separate from the WS unit tests, create integration tests following the broker harness pattern in `tests/integration/broker/`: - -### Test: Full gateway message flow with real Relaycast - -``` -1. Create ephemeral Relaycast workspace (RelayCast.createWorkspace) -2. Register two agents: "sender" and "test-claw" -3. Start InboundGateway with the workspace key -4. Post a message to #general via sender agent -5. Assert the gateway's relaySender.sendMessage was called with correct format -6. Post a DM from sender to test-claw -7. Assert DM delivery with [relaycast:dm] format -8. Add a reaction via sender -9. Assert reaction soft notification delivery -10. Cleanup: stop gateway, workspace is ephemeral -``` - -### Test: Gateway reconnection resilience - -``` -1. Start gateway with real Relaycast connection -2. Force-disconnect the SDK WebSocket (call relayAgentClient.disconnect()) -3. Wait for reconnection -4. Post a message -5. Assert message is still delivered -``` - -### Prerequisites - -These tests require network access to `api.relaycast.dev` and should: - -- Use `checkPrerequisites()` pattern from broker harness -- Be skippable via `skipIfMissing()` -- Have generous timeouts (120s) -- Use unique channel/agent names with timestamp suffixes - -## File Locations - -``` -packages/openclaw/src/__tests__/ - gateway-threads.test.ts # Existing unit tests (vitest) - ws-client.test.ts # NEW: WebSocket client unit tests (vitest) - -tests/integration/openclaw/ - gateway-e2e.test.ts # NEW: Full integration tests (node:test) - utils/gateway-harness.ts # NEW: Gateway test harness -``` - -## Estimated Effort - -- WS client unit tests: ~2-3 hours -- E2E integration tests: ~3-4 hours -- Total: ~1 day diff --git a/src/__tests__/gateway-control.test.ts b/src/__tests__/gateway-control.test.ts deleted file mode 100644 index 14ad51d..0000000 --- a/src/__tests__/gateway-control.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { Server as HttpServer, IncomingMessage, ServerResponse } from 'node:http'; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -const eventHandlers: Record void>> = {}; - -function registerHandler(event: string) { - return (handler: (...args: unknown[]) => void) => { - if (!eventHandlers[event]) eventHandlers[event] = []; - eventHandlers[event].push(handler); - return () => { - eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler); - }; - }; -} - -const mockAgentClient = { - connect: vi.fn(), - disconnect: vi.fn().mockResolvedValue(undefined), - subscribe: vi.fn(), - channels: { - join: vi.fn().mockResolvedValue({ ok: true }), - create: vi.fn().mockResolvedValue({ name: 'general' }), - }, - on: { - connected: registerHandler('connected'), - messageCreated: registerHandler('messageCreated'), - threadReply: registerHandler('threadReply'), - dmReceived: registerHandler('dmReceived'), - groupDmReceived: registerHandler('groupDmReceived'), - commandInvoked: registerHandler('commandInvoked'), - reactionAdded: registerHandler('reactionAdded'), - reactionRemoved: registerHandler('reactionRemoved'), - reconnecting: registerHandler('reconnecting'), - disconnected: registerHandler('disconnected'), - error: registerHandler('error'), - any: registerHandler('any'), - }, -}; - -vi.mock('@relaycast/sdk', () => ({ - RelayCast: vi.fn().mockImplementation(() => ({ - agents: { - registerOrGet: vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_test' }), - }, - channels: { join: vi.fn().mockResolvedValue({ ok: true }) }, - messages: { list: vi.fn().mockResolvedValue([]) }, - as: vi.fn().mockReturnValue(mockAgentClient), - })), -})); - -const mockSpawnManager = { - size: 0, - spawn: vi.fn(), - release: vi.fn(), - releaseByName: vi.fn(), - releaseAll: vi.fn().mockResolvedValue(undefined), - list: vi.fn().mockReturnValue([]), - get: vi.fn(), -}; - -vi.mock('../spawn/manager.js', () => ({ - SpawnManager: vi.fn().mockImplementation(() => mockSpawnManager), -})); - -vi.mock('node:fs/promises', () => ({ - readFile: vi.fn().mockResolvedValue('{"spawns":[]}'), - writeFile: vi.fn().mockResolvedValue(undefined), - mkdir: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined), - chmod: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('node:fs', () => ({ - existsSync: vi.fn().mockReturnValue(false), -})); - -// We do NOT mock node:http — we want a real HTTP server for these tests. -// But we need to intercept createServer in InboundGateway so we can control the port. -// Strategy: let gateway start its own server, then hit it via fetch(). - -// We need to override RELAYCAST_CONTROL_PORT to use port 0 (random) -// Actually, we can't use port 0 because the gateway hardcodes the listen call. -// Instead, let's mock node:http to capture the request handler, then run a real server. - -let realServer: HttpServer | null = null; -let controlPort = 0; - -vi.mock('node:http', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createServer: vi.fn((handler: (req: IncomingMessage, res: ServerResponse) => void) => { - // Create a real HTTP server with the captured handler - realServer = actual.createServer(handler); - return { - listen: vi.fn((_port: number, _host: string, cb: () => void) => { - // Bind to random port - realServer!.listen(0, '127.0.0.1', () => { - const addr = realServer!.address() as { port: number }; - controlPort = addr.port; - cb(); - }); - }), - close: vi.fn((cb?: () => void) => { - realServer?.close(() => cb?.()); - }), - address: vi.fn(() => realServer?.address()), - on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => {}), - }; - }), - }; -}); - -import { InboundGateway } from '../gateway.js'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -async function fetchControl(method: string, path: string, body?: unknown): Promise { - const url = `http://127.0.0.1:${controlPort}${path}`; - const opts: RequestInit = { method }; - if (body !== undefined) { - opts.body = JSON.stringify(body); - opts.headers = { 'Content-Type': 'application/json' }; - } - return fetch(url, opts); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('Gateway control HTTP server', () => { - let gateway: InboundGateway; - - beforeEach(async () => { - vi.clearAllMocks(); - for (const key of Object.keys(eventHandlers)) { - eventHandlers[key] = []; - } - // Reset mock spawn manager state - mockSpawnManager.size = 0; - mockSpawnManager.spawn.mockReset(); - mockSpawnManager.release.mockReset(); - mockSpawnManager.releaseByName.mockReset(); - mockSpawnManager.list.mockReturnValue([]); - - gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: ['general'], - }, - relaySender: { sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt_1' }) }, - }); - await gateway.start(); - }); - - afterEach(async () => { - await gateway.stop(); - if (realServer) { - realServer.close(); - realServer = null; - } - }); - - it('GET /health returns 200', async () => { - const res = await fetchControl('GET', '/health'); - expect(res.status).toBe(200); - const data = (await res.json()) as Record; - expect(data.ok).toBe(true); - expect(data.status).toBe('running'); - expect(typeof data.uptime).toBe('number'); - }); - - it('POST /spawn with name returns 200', async () => { - mockSpawnManager.spawn.mockResolvedValue({ - id: 'spawn-1', - displayName: 'worker-1', - agentName: 'claw-ws-worker-1', - gatewayPort: 18800, - }); - mockSpawnManager.size = 1; - - const res = await fetchControl('POST', '/spawn', { - name: 'worker-1', - role: 'researcher', - }); - expect(res.status).toBe(200); - const data = (await res.json()) as Record; - expect(data.ok).toBe(true); - expect(data.name).toBe('worker-1'); - expect(data.agentName).toBe('claw-ws-worker-1'); - expect(data.id).toBe('spawn-1'); - }); - - it('POST /spawn without name returns 400', async () => { - const res = await fetchControl('POST', '/spawn', { role: 'worker' }); - expect(res.status).toBe(400); - const data = (await res.json()) as Record; - expect(data.ok).toBe(false); - expect(data.error).toMatch(/name/i); - }); - - it('POST /spawn error returns 500', async () => { - mockSpawnManager.spawn.mockRejectedValue(new Error('Docker unavailable')); - - const res = await fetchControl('POST', '/spawn', { name: 'worker-1' }); - expect(res.status).toBe(500); - const data = (await res.json()) as Record; - expect(data.ok).toBe(false); - expect(data.error).toContain('Docker unavailable'); - }); - - it('GET /list returns 200 with empty list', async () => { - mockSpawnManager.list.mockReturnValue([]); - - const res = await fetchControl('GET', '/list'); - expect(res.status).toBe(200); - const data = (await res.json()) as Record; - expect(data.ok).toBe(true); - expect(data.active).toBe(0); - expect(data.claws).toEqual([]); - }); - - it('GET /list returns handles', async () => { - mockSpawnManager.list.mockReturnValue([ - { id: 's1', displayName: 'alpha', agentName: 'claw-alpha', gatewayPort: 18801 }, - ]); - - const res = await fetchControl('GET', '/list'); - expect(res.status).toBe(200); - const data = (await res.json()) as { claws: Array<{ name: string }> }; - expect(data.claws).toHaveLength(1); - expect(data.claws[0].name).toBe('alpha'); - }); - - it('POST /release by name returns 200', async () => { - mockSpawnManager.releaseByName.mockResolvedValue(true); - mockSpawnManager.size = 0; - - const res = await fetchControl('POST', '/release', { name: 'worker-1' }); - expect(res.status).toBe(200); - const data = (await res.json()) as Record; - expect(data.ok).toBe(true); - }); - - it('POST /release by id returns 200', async () => { - mockSpawnManager.release.mockResolvedValue(true); - mockSpawnManager.size = 0; - - const res = await fetchControl('POST', '/release', { id: 'spawn-1' }); - expect(res.status).toBe(200); - const data = (await res.json()) as Record; - expect(data.ok).toBe(true); - }); - - it('POST /release without name or id returns 400', async () => { - const res = await fetchControl('POST', '/release', {}); - expect(res.status).toBe(400); - const data = (await res.json()) as Record; - expect(data.ok).toBe(false); - expect(data.error).toMatch(/name.*id|id.*name/i); - }); - - it('POST /release error returns 500', async () => { - mockSpawnManager.release.mockRejectedValue(new Error('Process kill failed')); - - const res = await fetchControl('POST', '/release', { id: 'spawn-1' }); - expect(res.status).toBe(500); - const data = (await res.json()) as Record; - expect(data.ok).toBe(false); - expect(data.error).toContain('Process kill failed'); - }); - - it('GET /unknown returns 404', async () => { - const res = await fetchControl('GET', '/nonexistent'); - expect(res.status).toBe(404); - const data = (await res.json()) as Record; - expect(data.error).toBe('Not found'); - }); -}); diff --git a/src/__tests__/gateway-poll-fallback.test.ts b/src/__tests__/gateway-poll-fallback.test.ts deleted file mode 100644 index 2317bf4..0000000 --- a/src/__tests__/gateway-poll-fallback.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const eventHandlers: Record void>> = {}; - -function registerHandler(event: string) { - return (handler: (...args: unknown[]) => void) => { - if (!eventHandlers[event]) eventHandlers[event] = []; - eventHandlers[event].push(handler); - return () => { - eventHandlers[event] = eventHandlers[event].filter((entry) => entry !== handler); - }; - }; -} - -function fireEvent(event: string, ...args: unknown[]) { - for (const handler of eventHandlers[event] ?? []) { - handler(...args); - } -} - -const mockAgentClient = { - connect: vi.fn(), - disconnect: vi.fn().mockResolvedValue(undefined), - subscribe: vi.fn(), - channels: { - join: vi.fn().mockResolvedValue({ ok: true }), - create: vi.fn().mockResolvedValue({ name: 'general' }), - }, - on: { - connected: registerHandler('connected'), - messageCreated: registerHandler('messageCreated'), - threadReply: registerHandler('threadReply'), - dmReceived: registerHandler('dmReceived'), - groupDmReceived: registerHandler('groupDmReceived'), - commandInvoked: registerHandler('commandInvoked'), - reactionAdded: registerHandler('reactionAdded'), - reactionRemoved: registerHandler('reactionRemoved'), - reconnecting: registerHandler('reconnecting'), - disconnected: registerHandler('disconnected'), - error: registerHandler('error'), - }, -}; - -const registerOrGet = vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_test' }); -const registerOrRotate = vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_rotated' }); - -const fsMocks = vi.hoisted(() => ({ - readFile: vi.fn(), - writeFile: vi.fn(), - rename: vi.fn(), - mkdir: vi.fn(), - chmod: vi.fn(), -})); - -const { readFile, writeFile, rename, mkdir } = fsMocks; - -vi.mock('@relaycast/sdk', () => ({ - RelayCast: vi.fn().mockImplementation(() => ({ - agents: { - registerOrGet, - registerOrRotate, - }, - as: vi.fn().mockReturnValue(mockAgentClient), - })), -})); - -vi.mock('../spawn/manager.js', () => ({ - SpawnManager: vi.fn().mockImplementation(() => ({ - size: 0, - spawn: vi.fn(), - release: vi.fn(), - releaseByName: vi.fn(), - releaseAll: vi.fn().mockResolvedValue(undefined), - list: vi.fn().mockReturnValue([]), - get: vi.fn(), - })), -})); - -vi.mock('node:fs/promises', () => ({ - ...fsMocks, -})); - -vi.mock('node:fs', () => ({ - existsSync: vi.fn().mockReturnValue(false), -})); - -vi.mock('node:http', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createServer: vi.fn().mockReturnValue({ - listen: vi.fn((_port: number, _host: string, cb: () => void) => cb()), - close: vi.fn((cb?: () => void) => cb?.()), - on: vi.fn(), - address: vi.fn().mockReturnValue({ port: 18790 }), - }), - }; -}); - -import { InboundGateway } from '../gateway.js'; - -function response(status: number, body: unknown, headers?: Record) { - const normalized = Object.fromEntries( - Object.entries(headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]) - ); - return { - ok: status >= 200 && status < 300, - status, - headers: { - get: (name: string) => normalized[name.toLowerCase()] ?? null, - }, - json: vi.fn().mockResolvedValue(body), - }; -} - -function pendingFetch(init?: RequestInit): Promise { - return new Promise((_resolve, reject) => { - const signal = init?.signal as AbortSignal | undefined; - signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); - }); -} - -function createGateway( - pollFallbackOverrides: { - wsFailureThreshold?: number; - timeoutSeconds?: number; - limit?: number; - initialCursor?: string; - probeWs?: { - enabled?: boolean; - intervalMs?: number; - stableGraceMs?: number; - }; - } = {} -) { - const sendMessage = vi.fn().mockResolvedValue({ event_id: 'evt_out_1' }); - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'http://127.0.0.1:8888', - channels: ['general'], - transport: { - pollFallback: { - enabled: true, - wsFailureThreshold: 1, - timeoutSeconds: 1, - ...pollFallbackOverrides, - probeWs: { - enabled: true, - intervalMs: 5_000, - stableGraceMs: 10, - ...pollFallbackOverrides.probeWs, - }, - }, - }, - }, - relaySender: { sendMessage }, - }); - return { gateway, sendMessage }; -} - -describe('InboundGateway poll fallback', () => { - const fetchMock = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - vi.useRealTimers(); - vi.stubGlobal('fetch', fetchMock); - for (const key of Object.keys(eventHandlers)) { - eventHandlers[key] = []; - } - readFile.mockReset(); - readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); - writeFile.mockReset(); - writeFile.mockResolvedValue(undefined); - rename.mockReset(); - rename.mockResolvedValue(undefined); - mkdir.mockReset(); - mkdir.mockResolvedValue(undefined); - }); - - afterEach(async () => { - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it('falls back to poll and persists the committed cursor after successful delivery', async () => { - fetchMock - .mockResolvedValueOnce( - response(200, { - events: [ - { - id: 'evt_poll_1', - sequence: 1, - timestamp: '2026-03-06T04:00:00Z', - payload: { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_1', - agentName: 'alice', - text: 'hello from poll', - }, - }, - }, - ], - nextCursor: 'cursor_1', - hasMore: false, - }) - ) - .mockImplementation((_input, init) => pendingFetch(init)); - - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('error', new Error('proxy blocked')); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - - expect(sendMessage.mock.calls[0][0].text).toBe( - '[relaycast:general] @alice: hello from poll\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_1")' - ); - expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/messages/poll'); - expect(String(fetchMock.mock.calls[0]?.[0])).toContain('cursor=0'); - expect(writeFile).toHaveBeenCalledWith( - expect.stringContaining('inbound-cursor.json.tmp'), - expect.stringContaining('"cursor": "cursor_1"'), - 'utf-8' - ); - - await gateway.stop(); - }); - - it('resets a stale cursor on 409 and resumes from the initial cursor', async () => { - readFile.mockResolvedValueOnce( - JSON.stringify({ - cursor: 'stale_cursor', - lastSequence: 41, - recentEventIds: [], - updatedAt: '2026-03-06T03:59:00Z', - }) - ); - - fetchMock - .mockImplementationOnce(async (input) => { - expect(String(input)).toContain('cursor=stale_cursor'); - return response(409, {}); - }) - .mockImplementationOnce(async (input) => { - expect(String(input)).toContain('cursor=0'); - return response(200, { - events: [ - { - id: 'evt_poll_42', - sequence: 42, - timestamp: '2026-03-06T04:01:00Z', - payload: { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_42', - agentName: 'alice', - text: 'resumed after reset', - }, - }, - }, - ], - nextCursor: 'cursor_42', - hasMore: false, - }); - }) - .mockImplementation((_input, init) => pendingFetch(init)); - - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('disconnected'); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - - expect(sendMessage.mock.calls[0][0].text).toBe( - '[relaycast:general] @alice: resumed after reset\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_42")' - ); - expect(writeFile).toHaveBeenCalledWith( - expect.stringContaining('inbound-cursor.json.tmp'), - expect.stringContaining('"cursor": "cursor_42"'), - 'utf-8' - ); - - await gateway.stop(); - }); - - it('promotes back to WS after a stable recovery window', async () => { - vi.useFakeTimers(); - - fetchMock - .mockResolvedValueOnce( - response(200, { - events: [], - nextCursor: 'cursor_0', - hasMore: false, - }) - ) - .mockImplementationOnce((_input, init) => pendingFetch(init)) - .mockResolvedValueOnce( - response(200, { - events: [], - nextCursor: 'cursor_0', - hasMore: false, - }) - ) - .mockImplementation((_input, init) => pendingFetch(init)); - - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('error', new Error('proxy blocked')); - - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalled(); - }); - - fireEvent('connected'); - await vi.advanceTimersByTimeAsync(1_100); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_ws_1', - agentName: 'bob', - text: 'back on ws', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - - expect(sendMessage.mock.calls[0][0].text).toBe( - '[relaycast:general] @bob: back on ws\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_ws_1")' - ); - - await gateway.stop(); - }); - - it('processes WS messages during RECOVERING_WS before promotion completes', async () => { - vi.useFakeTimers(); - - fetchMock - .mockResolvedValueOnce( - response(200, { - events: [], - nextCursor: 'cursor_0', - hasMore: false, - }) - ) - .mockImplementation((_input, init) => pendingFetch(init)); - - const { gateway, sendMessage } = createGateway({ - probeWs: { - stableGraceMs: 5_000, - }, - }); - await gateway.start(); - - fireEvent('error', new Error('proxy blocked')); - - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalled(); - }); - - fireEvent('connected'); - await vi.advanceTimersByTimeAsync(100); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_ws_recovering', - agentName: 'carol', - text: 'delivered during recovery', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - - expect(sendMessage.mock.calls[0][0].text).toBe( - '[relaycast:general] @carol: delivered during recovery\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_ws_recovering")' - ); - - await gateway.stop(); - }); - - it('does not redeliver a message that was already committed in poll mode after WS recovery', async () => { - vi.useFakeTimers(); - - fetchMock - .mockResolvedValueOnce( - response(200, { - events: [ - { - id: 'evt_poll_1', - sequence: 1, - timestamp: '2026-03-06T04:00:00Z', - payload: { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_1', - agentName: 'alice', - text: 'hello from poll', - }, - }, - }, - ], - nextCursor: 'cursor_1', - hasMore: false, - }) - ) - .mockImplementationOnce((_input, init) => pendingFetch(init)) - .mockResolvedValueOnce( - response(200, { - events: [], - nextCursor: 'cursor_1', - hasMore: false, - }) - ) - .mockImplementation((_input, init) => pendingFetch(init)); - - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('error', new Error('proxy blocked')); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - - fireEvent('connected'); - await vi.advanceTimersByTimeAsync(1_100); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_1', - agentName: 'alice', - text: 'hello from poll', - }, - }); - - await vi.advanceTimersByTimeAsync(0); - expect(sendMessage).toHaveBeenCalledTimes(1); - - await gateway.stop(); - }); - - it('uses a single jitter pass for 429 responses without Retry-After', async () => { - vi.spyOn(Math, 'random').mockReturnValue(0); - fetchMock.mockResolvedValueOnce(response(429, {})); - - const { gateway } = createGateway(); - const delayMs = await (gateway as any).pollOnce(1); - - expect(delayMs).toBe(550); - }); -}); diff --git a/src/__tests__/gateway-threads.test.ts b/src/__tests__/gateway-threads.test.ts deleted file mode 100644 index 0cd3dff..0000000 --- a/src/__tests__/gateway-threads.test.ts +++ /dev/null @@ -1,1181 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// --------------------------------------------------------------------------- -// Mocks — declared before importing the module under test -// --------------------------------------------------------------------------- - -// Store registered event handlers so tests can fire them -const eventHandlers: Record void>> = {}; - -function registerHandler(event: string) { - return (handler: (...args: unknown[]) => void) => { - if (!eventHandlers[event]) eventHandlers[event] = []; - eventHandlers[event].push(handler); - return () => { - eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler); - }; - }; -} - -function fireEvent(event: string, ...args: unknown[]) { - for (const handler of eventHandlers[event] ?? []) { - handler(...args); - } -} - -const mockAgentClient = { - connect: vi.fn(), - disconnect: vi.fn().mockResolvedValue(undefined), - subscribe: vi.fn(), - unsubscribe: vi.fn(), - presence: { - markOnline: vi.fn().mockResolvedValue(undefined), - heartbeat: vi.fn().mockResolvedValue(undefined), - markOffline: vi.fn().mockResolvedValue(undefined), - }, - channels: { - join: vi.fn().mockResolvedValue({ ok: true }), - create: vi.fn().mockResolvedValue({ name: 'general' }), - }, - on: { - connected: registerHandler('connected'), - messageCreated: registerHandler('messageCreated'), - threadReply: registerHandler('threadReply'), - dmReceived: registerHandler('dmReceived'), - groupDmReceived: registerHandler('groupDmReceived'), - commandInvoked: registerHandler('commandInvoked'), - reactionAdded: registerHandler('reactionAdded'), - reactionRemoved: registerHandler('reactionRemoved'), - reconnecting: registerHandler('reconnecting'), - disconnected: registerHandler('disconnected'), - error: registerHandler('error'), - any: registerHandler('any'), - }, -}; - -vi.mock('@relaycast/sdk', () => ({ - RelayCast: vi.fn().mockImplementation(() => ({ - agents: { - registerOrGet: vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_test' }), - }, - channels: { join: vi.fn().mockResolvedValue({ ok: true }) }, - messages: { list: vi.fn().mockResolvedValue([]) }, - as: vi.fn().mockReturnValue(mockAgentClient), - })), -})); - -vi.mock('../spawn/manager.js', () => ({ - SpawnManager: vi.fn().mockImplementation(() => ({ - size: 0, - spawn: vi.fn(), - release: vi.fn(), - releaseByName: vi.fn(), - releaseAll: vi.fn().mockResolvedValue(undefined), - list: vi.fn().mockReturnValue([]), - get: vi.fn(), - })), -})); - -vi.mock('node:fs/promises', () => ({ - readFile: vi.fn().mockResolvedValue('{"spawns":[]}'), - writeFile: vi.fn().mockResolvedValue(undefined), - mkdir: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined), - chmod: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('node:fs', () => ({ - existsSync: vi.fn().mockReturnValue(false), -})); - -// Mock createServer to avoid binding real ports -vi.mock('node:http', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createServer: vi.fn().mockReturnValue({ - listen: vi.fn((_port: number, _host: string, cb: () => void) => cb()), - close: vi.fn((cb?: () => void) => cb?.()), - address: vi.fn().mockReturnValue({ port: 18790 }), - }), - }; -}); - -// --------------------------------------------------------------------------- -// Import after mocks -// --------------------------------------------------------------------------- - -import { InboundGateway } from '../gateway.js'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createGateway(overrides?: { clawName?: string; channels?: string[] }) { - const sendMessage = vi.fn().mockResolvedValue({ event_id: 'evt_1' }); - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: overrides?.clawName ?? 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: overrides?.channels ?? ['general'], - }, - relaySender: { sendMessage }, - }); - return { gateway, sendMessage }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('InboundGateway — thread reply injection', () => { - beforeEach(() => { - vi.clearAllMocks(); - for (const key of Object.keys(eventHandlers)) { - eventHandlers[key] = []; - } - }); - - afterEach(async () => { - vi.useRealTimers(); - }); - - describe('message formatting', () => { - it('should format regular channel messages without thread prefix', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_1', - agentName: 'alice', - text: 'hello world', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:general] @alice: hello world\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_1")' - ); - expect(call.text).not.toContain('[thread]'); - - await gateway.stop(); - }); - - it('should format thread replies with [thread] prefix', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('threadReply', { - type: 'thread.reply', - channel: 'general', - parentId: 'msg_parent_1', - message: { - id: 'msg_reply_1', - agentName: 'bob', - text: 'replying in thread', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[thread] [relaycast:general] @bob: replying in thread\n(reply with: reply_to_thread message_id="msg_parent_1")' - ); - - await gateway.stop(); - }); - }); - - describe('thread reply event handling', () => { - it('should deliver thread replies from subscribed channels', async () => { - const { gateway, sendMessage } = createGateway({ channels: ['general', 'dev'] }); - await gateway.start(); - - fireEvent('threadReply', { - type: 'thread.reply', - channel: 'dev', - parentId: 'msg_100', - message: { - id: 'msg_101', - agentName: 'carol', - text: 'thread in dev channel', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toContain('[thread]'); - expect(call.text).toContain('[relaycast:dev]'); - expect(call.text).toContain('@carol'); - - await gateway.stop(); - }); - - it('should ignore thread replies from unsubscribed channels', async () => { - const { gateway, sendMessage } = createGateway({ channels: ['general'] }); - await gateway.start(); - - fireEvent('threadReply', { - type: 'thread.reply', - channel: 'random', - parentId: 'msg_200', - message: { - id: 'msg_201', - agentName: 'dave', - text: 'thread in random', - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).not.toHaveBeenCalled(); - - await gateway.stop(); - }); - - it('should skip thread replies from the claw itself (echo prevention)', async () => { - const { gateway, sendMessage } = createGateway({ clawName: 'my-claw' }); - await gateway.start(); - - fireEvent('threadReply', { - type: 'thread.reply', - channel: 'general', - parentId: 'msg_300', - message: { - id: 'msg_301', - agentName: 'my-claw', - text: 'my own reply', - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).not.toHaveBeenCalled(); - - await gateway.stop(); - }); - - it('should deduplicate thread replies with the same message ID', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - const event = { - type: 'thread.reply', - channel: 'general', - parentId: 'msg_500', - message: { - id: 'msg_501', - agentName: 'eve', - text: 'duplicate test', - }, - }; - - fireEvent('threadReply', event); - fireEvent('threadReply', event); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).toHaveBeenCalledTimes(1); - - await gateway.stop(); - }); - }); - - describe('mixed message and thread delivery', () => { - it('should deliver both channel messages and thread replies', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_600', - agentName: 'frank', - text: 'original message', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - - fireEvent('threadReply', { - type: 'thread.reply', - channel: 'general', - parentId: 'msg_600', - message: { - id: 'msg_601', - agentName: 'grace', - text: 'reply to frank', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(2); - }); - - const firstCall = sendMessage.mock.calls[0][0]; - expect(firstCall.text).toBe( - '[relaycast:general] @frank: original message\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_600")' - ); - - const secondCall = sendMessage.mock.calls[1][0]; - expect(secondCall.text).toBe( - '[thread] [relaycast:general] @grace: reply to frank\n(reply with: reply_to_thread message_id="msg_600")' - ); - - await gateway.stop(); - }); - - it('should include source metadata in relay sender data', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('threadReply', { - type: 'thread.reply', - channel: 'general', - parentId: 'msg_parent_700', - message: { - id: 'msg_700', - agentName: 'heidi', - text: 'metadata check', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.data.source).toBe('relaycast'); - expect(call.data.channel).toBe('general'); - expect(call.data.messageId).toBe('msg_700'); - - await gateway.stop(); - }); - }); - - describe('DM event handling', () => { - it('should deliver DMs with [relaycast:dm] format', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('dmReceived', { - type: 'dm.received', - conversationId: 'conv_1', - message: { - id: 'dm_1', - agentName: 'alice', - text: 'hey there', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe('[relaycast:dm] @alice: hey there\n(reply with: send_dm to="alice")'); - - await gateway.stop(); - }); - - it('should skip DMs from the claw itself (echo prevention)', async () => { - const { gateway, sendMessage } = createGateway({ clawName: 'my-claw' }); - await gateway.start(); - - fireEvent('dmReceived', { - type: 'dm.received', - conversationId: 'conv_2', - message: { - id: 'dm_2', - agentName: 'my-claw', - text: 'echo', - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).not.toHaveBeenCalled(); - - await gateway.stop(); - }); - - it('should deduplicate DMs with the same message ID', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - const event = { - type: 'dm.received', - conversationId: 'conv_3', - message: { - id: 'dm_3', - agentName: 'bob', - text: 'duplicate dm', - }, - }; - - fireEvent('dmReceived', event); - fireEvent('dmReceived', event); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).toHaveBeenCalledTimes(1); - - await gateway.stop(); - }); - }); - - describe('Group DM event handling', () => { - it('should deliver group DMs with [relaycast:groupdm] format', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('groupDmReceived', { - type: 'group_dm.received', - conversationId: 'gconv_1', - message: { - id: 'gdm_1', - agentName: 'carol', - text: 'group message', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe('[relaycast:groupdm] @carol: group message\n(reply with: send_dm to="carol")'); - - await gateway.stop(); - }); - }); - - describe('Command invocation handling', () => { - it('should deliver command invocations with formatted text', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('commandInvoked', { - type: 'command.invoked', - command: 'deploy', - channel: 'general', - invokedBy: 'dave', - handlerAgentId: 'agent_1', - args: 'production --force', - parameters: null, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:command:general] @dave /deploy production --force\n(command invocation \u2014 respond with: post_message channel="general")' - ); - - await gateway.stop(); - }); - - it('should deliver command invocations without args', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('commandInvoked', { - type: 'command.invoked', - command: 'status', - channel: 'general', - invokedBy: 'eve', - handlerAgentId: 'agent_2', - args: null, - parameters: null, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:command:general] @eve /status\n(command invocation \u2014 respond with: post_message channel="general")' - ); - - await gateway.stop(); - }); - - it('should ignore commands from unsubscribed channels', async () => { - const { gateway, sendMessage } = createGateway({ channels: ['general'] }); - await gateway.start(); - - fireEvent('commandInvoked', { - type: 'command.invoked', - command: 'deploy', - channel: 'random', - invokedBy: 'dave', - handlerAgentId: 'agent_1', - args: null, - parameters: null, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).not.toHaveBeenCalled(); - - await gateway.stop(); - }); - }); - - describe('Reaction event handling', () => { - it('should deliver reaction added as soft notification', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('reactionAdded', { - type: 'reaction.added', - messageId: 'msg_800', - emoji: 'thumbsup', - agentName: 'eve', - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:reaction] @eve reacted thumbsup to message msg_800 (soft notification, no action required)' - ); - - await gateway.stop(); - }); - - it('should deliver reaction removed as soft notification', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('reactionRemoved', { - type: 'reaction.removed', - messageId: 'msg_900', - emoji: 'rocket', - agentName: 'frank', - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:reaction] @frank removed rocket from message msg_900 (soft notification, no action required)' - ); - - await gateway.stop(); - }); - - it('should skip reactions from the claw itself', async () => { - const { gateway, sendMessage } = createGateway({ clawName: 'my-claw' }); - await gateway.start(); - - fireEvent('reactionAdded', { - type: 'reaction.added', - messageId: 'msg_1000', - emoji: 'check', - agentName: 'my-claw', - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).not.toHaveBeenCalled(); - - await gateway.stop(); - }); - }); - - describe('delivery fallback path', () => { - it('should fall back to openclawClient when relaySender fails', async () => { - const sendMessage = vi.fn().mockRejectedValue(new Error('relay down')); - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: ['general'], - openclawGatewayToken: 'tok_gateway', - openclawGatewayPort: 19999, - }, - relaySender: { sendMessage }, - }); - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_fb_1', - agentName: 'alice', - text: 'fallback test', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - // The relaySender threw, so it should have attempted openclawClient. - // Since openclawClient WS is not actually connected in test, both fail. - // We just verify the sendMessage was called (relay path attempted). - await new Promise((r) => setTimeout(r, 50)); - - await gateway.stop(); - }); - - it('should return method=failed when both relaySender and openclawClient fail', async () => { - const sendMessage = vi.fn().mockRejectedValue(new Error('relay down')); - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: ['general'], - }, - relaySender: { sendMessage }, - }); - await gateway.start(); - - // No openclawClient (no token), sendMessage will throw - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_fb_2', - agentName: 'alice', - text: 'both fail', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - await new Promise((r) => setTimeout(r, 50)); - await gateway.stop(); - }); - - it('should treat unsupported_operation event_id as failure', async () => { - const sendMessage = vi.fn().mockResolvedValue({ event_id: 'unsupported_operation' }); - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: ['general'], - }, - relaySender: { sendMessage }, - }); - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_unsup_1', - agentName: 'bob', - text: 'unsupported test', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - // unsupported_operation means relay delivery failed, should fall through - await new Promise((r) => setTimeout(r, 50)); - await gateway.stop(); - }); - - it('should treat relaySender throwing as failure and fall through', async () => { - const sendMessage = vi.fn().mockRejectedValue(new Error('network error')); - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: ['general'], - }, - relaySender: { sendMessage }, - }); - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_throw_1', - agentName: 'carol', - text: 'throw test', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - await new Promise((r) => setTimeout(r, 50)); - await gateway.stop(); - }); - }); - - describe('delivery without relaySender', () => { - it('should attempt openclawClient directly when no relaySender is provided', async () => { - // No relaySender, no openclawClient token => both paths fail gracefully - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: ['general'], - }, - // No relaySender provided - }); - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_no_relay_1', - agentName: 'dave', - text: 'no relay sender', - attachments: [], - }, - }); - - // Should not throw even with no delivery method available - await new Promise((r) => setTimeout(r, 100)); - await gateway.stop(); - }); - }); - - describe('formatDeliveryText coverage', () => { - it('should format dm messages as [relaycast:dm]', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('dmReceived', { - type: 'dm.received', - conversationId: 'conv_fmt_1', - message: { - id: 'dm_fmt_1', - agentName: 'alice', - text: 'dm format test', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe('[relaycast:dm] @alice: dm format test\n(reply with: send_dm to="alice")'); - - await gateway.stop(); - }); - - it('should format groupdm messages as [relaycast:groupdm]', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('groupDmReceived', { - type: 'group_dm.received', - conversationId: 'gconv_fmt_1', - message: { - id: 'gdm_fmt_1', - agentName: 'bob', - text: 'group dm format test', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:groupdm] @bob: group dm format test\n(reply with: send_dm to="bob")' - ); - - await gateway.stop(); - }); - - it('should format command messages with pre-formatted text', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('commandInvoked', { - type: 'command.invoked', - command: 'build', - channel: 'general', - invokedBy: 'carol', - handlerAgentId: 'agent_fmt_1', - args: '--prod', - parameters: null, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:command:general] @carol /build --prod\n(command invocation \u2014 respond with: post_message channel="general")' - ); - - await gateway.stop(); - }); - - it('should format reaction messages with pre-formatted text', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('reactionAdded', { - type: 'reaction.added', - messageId: 'msg_fmt_react', - emoji: 'fire', - agentName: 'dave', - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:reaction] @dave reacted fire to message msg_fmt_react (soft notification, no action required)' - ); - - await gateway.stop(); - }); - - it('should format thread messages with [thread] prefix', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('threadReply', { - type: 'thread.reply', - channel: 'general', - parentId: 'msg_fmt_parent', - message: { - id: 'msg_fmt_thread', - agentName: 'eve', - text: 'thread format test', - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[thread] [relaycast:general] @eve: thread format test\n(reply with: reply_to_thread message_id="msg_fmt_parent")' - ); - - await gateway.stop(); - }); - - it('should format default channel messages without prefix', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_fmt_chan', - agentName: 'frank', - text: 'channel format test', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:general] @frank: channel format test\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_fmt_chan")' - ); - - await gateway.stop(); - }); - }); - - describe('handleInbound dedup via processingMessageIds', () => { - it('should skip messages already being processed', async () => { - // Use a slow sendMessage to simulate a message still being processed - let resolveFirst: (() => void) | null = null; - const firstCallPromise = new Promise((r) => { - resolveFirst = r; - }); - const sendMessage = vi - .fn() - .mockImplementationOnce(async () => { - // Block until we manually resolve - await firstCallPromise; - return { event_id: 'evt_1' }; - }) - .mockResolvedValue({ event_id: 'evt_2' }); - - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: ['general'], - }, - relaySender: { sendMessage }, - }); - await gateway.start(); - - // Fire the same message twice quickly - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_dedup_proc', - agentName: 'alice', - text: 'dedup processing test', - attachments: [], - }, - }); - - // Second fire of same message should be skipped (already processing or seen) - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_dedup_proc', - agentName: 'alice', - text: 'dedup processing test', - attachments: [], - }, - }); - - // Resolve the first call - resolveFirst!(); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - await new Promise((r) => setTimeout(r, 50)); - // Should only have been called once since the second was deduped - expect(sendMessage).toHaveBeenCalledTimes(1); - - await gateway.stop(); - }); - - it('should retry a replayed message after a failed delivery', async () => { - const sendMessage = vi - .fn() - .mockResolvedValueOnce({ event_id: 'unsupported_operation' }) - .mockResolvedValueOnce({ event_id: 'evt_retry_ok' }); - - const gateway = new InboundGateway({ - config: { - apiKey: 'rk_live_test', - clawName: 'test-claw', - baseUrl: 'https://api.relaycast.dev', - channels: ['general'], - }, - relaySender: { sendMessage }, - }); - await gateway.start(); - - const event = { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_retry_1', - agentName: 'alice', - text: 'retry me', - attachments: [], - }, - }; - - fireEvent('messageCreated', event); - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - fireEvent('messageCreated', event); - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(2); - }); - - await gateway.stop(); - }); - }); - - describe('handleInbound when not running', () => { - it('should be a no-op when gateway is stopped', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - await gateway.stop(); - - // Fire an event after the gateway has stopped - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_stopped_1', - agentName: 'alice', - text: 'should not deliver', - attachments: [], - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).not.toHaveBeenCalled(); - }); - }); - - describe('stop() method', () => { - it('should disconnect relay client and clear state', async () => { - const { gateway } = createGateway(); - await gateway.start(); - - // Verify gateway is running by checking it can receive messages - await gateway.stop(); - - // Calling stop again should be safe (idempotent) - await gateway.stop(); - }); - - it('should clear seenMessageIds and processingMessageIds on stop', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - // Send a message so it gets added to seenMessageIds - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_clear_1', - agentName: 'alice', - text: 'will be cleared', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - - await gateway.stop(); - - // Now restart and send the same message ID - it should be delivered again - // because stop() cleared the seen map - sendMessage.mockClear(); - // Clear event handlers first since stop() unsubscribes - for (const key of Object.keys(eventHandlers)) { - eventHandlers[key] = []; - } - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_clear_1', - agentName: 'alice', - text: 'will be cleared', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - - await gateway.stop(); - }); - - it('should unsubscribe all event handlers on stop', async () => { - const { gateway, sendMessage } = createGateway(); - await gateway.start(); - - await gateway.stop(); - - // After stop, firing events should not trigger sendMessage - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_unsub_1', - agentName: 'alice', - text: 'should not deliver after stop', - attachments: [], - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(sendMessage).not.toHaveBeenCalled(); - }); - }); - - describe('channel name normalization', () => { - it('should normalize channel names with # prefix', async () => { - const { gateway, sendMessage } = createGateway({ channels: ['#general'] }); - await gateway.start(); - - fireEvent('messageCreated', { - type: 'message.created', - channel: 'general', - message: { - id: 'msg_norm_1', - agentName: 'alice', - text: 'normalization test', - attachments: [], - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalled(); - }); - - const call = sendMessage.mock.calls[0][0]; - expect(call.text).toBe( - '[relaycast:general] @alice: normalization test\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_norm_1")' - ); - - await gateway.stop(); - }); - }); -}); diff --git a/src/__tests__/naming.test.ts b/src/__tests__/naming.test.ts deleted file mode 100644 index df42f8a..0000000 --- a/src/__tests__/naming.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { buildAgentName } from '../identity/naming.js'; - -describe('buildAgentName', () => { - it('should build agent name from workspace and claw name', () => { - const result = buildAgentName('ws123', 'researcher'); - expect(result).toBe('claw-ws123-researcher'); - }); - - it('should handle hyphens in workspace id', () => { - const result = buildAgentName('ws-abc-123', 'coder'); - expect(result).toBe('claw-ws-abc-123-coder'); - }); - - it('should handle hyphens in claw name', () => { - const result = buildAgentName('workspace', 'code-reviewer'); - expect(result).toBe('claw-workspace-code-reviewer'); - }); - - it('should handle empty strings', () => { - const result = buildAgentName('', ''); - expect(result).toBe('claw--'); - }); -}); diff --git a/src/__tests__/spawn-manager.test.ts b/src/__tests__/spawn-manager.test.ts deleted file mode 100644 index 6d76912..0000000 --- a/src/__tests__/spawn-manager.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SpawnManager } from '../spawn/manager.js'; - -// Mock the spawn providers -vi.mock('../spawn/docker.js', () => ({ - DockerSpawnProvider: vi.fn().mockImplementation(() => ({ - spawn: vi.fn().mockResolvedValue({ - id: 'test-id-1', - displayName: 'test-claw', - agentName: 'claw-ws123-test-claw', - gatewayPort: 18789, - destroy: vi.fn().mockResolvedValue(undefined), - }), - destroy: vi.fn().mockResolvedValue(undefined), - list: vi.fn().mockResolvedValue([]), - })), -})); - -vi.mock('../spawn/process.js', () => ({ - ProcessSpawnProvider: vi.fn().mockImplementation(() => { - let callCount = 0; - return { - spawn: vi.fn().mockImplementation((options: { name: string }) => { - callCount++; - return Promise.resolve({ - id: `test-id-${callCount}`, - displayName: options.name, - agentName: `claw-ws123-${options.name}`, - gatewayPort: 18790, - destroy: vi.fn().mockResolvedValue(undefined), - }); - }), - destroy: vi.fn().mockResolvedValue(undefined), - list: vi.fn().mockResolvedValue([]), - }; - }), -})); - -// Mock fs operations -vi.mock('node:fs/promises', () => ({ - readFile: vi.fn().mockResolvedValue('{"spawns":[]}'), - writeFile: vi.fn().mockResolvedValue(undefined), - mkdir: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('node:fs', () => ({ - existsSync: vi.fn().mockReturnValue(false), -})); - -describe('SpawnManager', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should initialize with default values', () => { - const manager = new SpawnManager({ mode: 'process' }); - expect(manager.size).toBe(0); - }); - - it('should enforce maxSpawns limit', async () => { - const manager = new SpawnManager({ mode: 'process', maxSpawns: 1 }); - - // First spawn should succeed - await manager.spawn({ - name: 'claw-1', - relayApiKey: 'rk_live_test', - }); - - expect(manager.size).toBe(1); - - // Second spawn should fail due to limit - await expect( - manager.spawn({ - name: 'claw-2', - relayApiKey: 'rk_live_test', - }) - ).rejects.toThrow(/Maximum concurrent spawns reached/); - }); - - it('should enforce maxDepth limit', async () => { - const manager = new SpawnManager({ - mode: 'process', - maxDepth: 2, - spawnDepth: 2, // Already at max depth - }); - - await expect( - manager.spawn({ - name: 'claw-1', - relayApiKey: 'rk_live_test', - }) - ).rejects.toThrow(/Spawn depth limit reached/); - }); - - it('should prevent duplicate spawns by name', async () => { - const manager = new SpawnManager({ mode: 'process' }); - - await manager.spawn({ - name: 'researcher', - relayApiKey: 'rk_live_test', - }); - - await expect( - manager.spawn({ - name: 'researcher', - relayApiKey: 'rk_live_test', - }) - ).rejects.toThrow(/already running/); - }); - - it('should list spawned handles', async () => { - const manager = new SpawnManager({ mode: 'process' }); - - await manager.spawn({ - name: 'worker-1', - relayApiKey: 'rk_live_test', - }); - - const list = manager.list(); - expect(list).toHaveLength(1); - expect(list[0].displayName).toBe('worker-1'); - }); - - it('should release by id', async () => { - const manager = new SpawnManager({ mode: 'process' }); - - const handle = await manager.spawn({ - name: 'worker-1', - relayApiKey: 'rk_live_test', - }); - - expect(manager.size).toBe(1); - - const released = await manager.release(handle.id); - expect(released).toBe(true); - expect(manager.size).toBe(0); - }); - - it('should release by name', async () => { - const manager = new SpawnManager({ mode: 'process' }); - - await manager.spawn({ - name: 'worker-1', - relayApiKey: 'rk_live_test', - }); - - const released = await manager.releaseByName('worker-1'); - expect(released).toBe(true); - expect(manager.size).toBe(0); - }); - - it('should return false when releasing non-existent spawn', async () => { - const manager = new SpawnManager({ mode: 'process' }); - - const released = await manager.release('non-existent-id'); - expect(released).toBe(false); - }); - - it('should return handle by id via get()', async () => { - const manager = new SpawnManager({ mode: 'process' }); - - const handle = await manager.spawn({ - name: 'getter-test', - relayApiKey: 'rk_live_test', - }); - - expect(manager.get(handle.id)).toBeDefined(); - expect(manager.get(handle.id)!.displayName).toBe('getter-test'); - expect(manager.get('nonexistent')).toBeUndefined(); - }); - - it('should persist state on spawn', async () => { - const { writeFile } = await import('node:fs/promises'); - - const manager = new SpawnManager({ mode: 'process' }); - await manager.spawn({ - name: 'persist-test', - relayApiKey: 'rk_live_test', - }); - - expect(writeFile).toHaveBeenCalled(); - const writeCall = vi.mocked(writeFile).mock.calls[0]; - expect(writeCall[0]).toContain('spawns.json'); - const written = JSON.parse(writeCall[1] as string) as { spawns: Array<{ displayName: string }> }; - expect(written.spawns).toHaveLength(1); - expect(written.spawns[0].displayName).toBe('persist-test'); - }); - - it('should return empty array from loadPersistedState when no file exists', async () => { - const manager = new SpawnManager({ mode: 'process' }); - - const state = await manager.loadPersistedState(); - expect(state).toEqual([]); - }); -}); diff --git a/src/__tests__/ws-client.test.ts b/src/__tests__/ws-client.test.ts deleted file mode 100644 index b285964..0000000 --- a/src/__tests__/ws-client.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { WebSocketServer, type WebSocket as WsType } from 'ws'; - -// Mock spawn/manager and relaycast SDK to prevent side-effects from gateway.ts module load -vi.mock('../spawn/manager.js', () => ({ - SpawnManager: vi.fn().mockImplementation(() => ({ - size: 0, - spawn: vi.fn(), - release: vi.fn(), - releaseByName: vi.fn(), - releaseAll: vi.fn().mockResolvedValue(undefined), - list: vi.fn().mockReturnValue([]), - get: vi.fn(), - })), -})); - -vi.mock('@relaycast/sdk', () => ({ - RelayCast: vi.fn().mockImplementation(() => ({ - agents: { registerOrGet: vi.fn().mockResolvedValue({ name: 'test', token: 'tok' }) }, - as: vi.fn().mockReturnValue({ - connect: vi.fn(), - disconnect: vi.fn().mockResolvedValue(undefined), - subscribe: vi.fn(), - on: { - connected: vi.fn().mockReturnValue(() => {}), - messageCreated: vi.fn().mockReturnValue(() => {}), - threadReply: vi.fn().mockReturnValue(() => {}), - dmReceived: vi.fn().mockReturnValue(() => {}), - groupDmReceived: vi.fn().mockReturnValue(() => {}), - commandInvoked: vi.fn().mockReturnValue(() => {}), - reactionAdded: vi.fn().mockReturnValue(() => {}), - reactionRemoved: vi.fn().mockReturnValue(() => {}), - reconnecting: vi.fn().mockReturnValue(() => {}), - disconnected: vi.fn().mockReturnValue(() => {}), - error: vi.fn().mockReturnValue(() => {}), - }, - }), - })), -})); - -vi.mock('node:fs/promises', () => ({ - readFile: vi.fn().mockResolvedValue('{"spawns":[]}'), - writeFile: vi.fn().mockResolvedValue(undefined), - mkdir: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined), - chmod: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('node:fs', () => ({ - existsSync: vi.fn().mockReturnValue(false), -})); - -import { OpenClawGatewayClient } from '../gateway.js'; - -// --------------------------------------------------------------------------- -// Mock OpenClaw Gateway WebSocket Server -// --------------------------------------------------------------------------- - -interface MockServerOptions { - /** Whether to accept or reject auth. Default: true */ - acceptAuth?: boolean; - /** Delay before sending challenge (ms). 0 = immediate. */ - challengeDelay?: number; - /** Whether to send a challenge at all. Default: true */ - sendChallenge?: boolean; - /** Delay before responding to chat.send RPCs (ms). Default: 0 */ - chatDelay?: number; - /** Whether chat.send succeeds. Default: true */ - chatOk?: boolean; -} - -class MockOpenClawServer { - private wss: WebSocketServer; - private clients: Set = new Set(); - port = 0; - - private acceptAuth: boolean; - private challengeDelay: number; - private sendChallenge: boolean; - private chatDelay: number; - private chatOk: boolean; - - constructor(options: MockServerOptions = {}) { - this.acceptAuth = options.acceptAuth ?? true; - this.challengeDelay = options.challengeDelay ?? 0; - this.sendChallenge = options.sendChallenge ?? true; - this.chatDelay = options.chatDelay ?? 0; - this.chatOk = options.chatOk ?? true; - - this.wss = new WebSocketServer({ port: 0 }); - this.port = (this.wss.address() as { port: number }).port; - - this.wss.on('connection', (ws) => { - this.clients.add(ws); - ws.on('close', () => this.clients.delete(ws)); - - if (this.sendChallenge) { - const challenge = JSON.stringify({ - type: 'event', - event: 'connect.challenge', - payload: { nonce: 'test-nonce-123', ts: Date.now() }, - }); - - if (this.challengeDelay > 0) { - setTimeout(() => ws.send(challenge), this.challengeDelay); - } else { - ws.send(challenge); - } - } - - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) as Record; - - // Handle connect request - if (msg.method === 'connect' && msg.id === 'connect-1') { - if (this.acceptAuth) { - ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); - } else { - ws.send( - JSON.stringify({ - type: 'res', - id: 'connect-1', - ok: false, - error: { code: 'auth_failed', message: 'Invalid token' }, - }) - ); - } - return; - } - - // Handle chat.send RPC - if (msg.method === 'chat.send') { - const respond = () => { - if (this.chatOk) { - ws.send( - JSON.stringify({ - type: 'res', - id: msg.id, - ok: true, - payload: { runId: 'run-1', status: 'accepted' }, - }) - ); - } else { - ws.send( - JSON.stringify({ - type: 'res', - id: msg.id, - ok: false, - error: { code: 'rate_limited', message: 'Too many requests' }, - }) - ); - } - }; - - if (this.chatDelay > 0) { - setTimeout(respond, this.chatDelay); - } else { - respond(); - } - } - }); - }); - } - - /** Force-close all connected clients. */ - closeAllClients(code = 1000): void { - for (const ws of this.clients) { - ws.close(code); - } - } - - async close(): Promise { - this.closeAllClients(); - return new Promise((resolve) => { - this.wss.close(() => resolve()); - }); - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('OpenClawGatewayClient', () => { - let server: MockOpenClawServer | null = null; - - afterEach(async () => { - if (server) { - await server.close(); - server = null; - } - }); - - it('should connect and authenticate (happy path)', async () => { - server = new MockOpenClawServer(); - const client = new OpenClawGatewayClient('test-token', server.port); - - await client.connect(); - // Should resolve without throwing - await client.disconnect(); - }); - - it('should reject when auth is rejected', async () => { - server = new MockOpenClawServer({ acceptAuth: false }); - const client = new OpenClawGatewayClient('bad-token', server.port); - - await expect(client.connect()).rejects.toThrow(/auth failed/i); - await client.disconnect(); - }); - - it('should no-op when already connected', async () => { - server = new MockOpenClawServer(); - const client = new OpenClawGatewayClient('test-token', server.port); - - await client.connect(); - // Second connect should be a no-op (early return) - await client.connect(); - await client.disconnect(); - }); - - it('should timeout when no challenge is sent', async () => { - server = new MockOpenClawServer({ sendChallenge: false }); - const client = new OpenClawGatewayClient('test-token', server.port); - - // Monkey-patch the timeout to be short for the test - (OpenClawGatewayClient as unknown as Record).CONNECT_TIMEOUT_MS = 200; - - await expect(client.connect()).rejects.toThrow(/timed out/i); - await client.disconnect(); - - // Restore - (OpenClawGatewayClient as unknown as Record).CONNECT_TIMEOUT_MS = 30_000; - }); - - it('should reject connect when WS closes before auth', async () => { - server = new MockOpenClawServer({ sendChallenge: false }); - const client = new OpenClawGatewayClient('test-token', server.port); - - // Start connecting, then close server connections immediately - const connectPromise = client.connect(); - // Give the WS time to establish before closing - await new Promise((r) => setTimeout(r, 50)); - server.closeAllClients(1000); - - await expect(connectPromise).rejects.toThrow(/closed before authentication|timed out/i); - await client.disconnect(); - }); - - it('should return true on successful sendChatMessage', async () => { - server = new MockOpenClawServer(); - const client = new OpenClawGatewayClient('test-token', server.port); - await client.connect(); - - const result = await client.sendChatMessage('hello world'); - expect(result).toBe(true); - - await client.disconnect(); - }); - - it('should pass idempotencyKey in sendChatMessage params', async () => { - let receivedParams: Record = {}; - server = new MockOpenClawServer(); - - // Intercept the server to capture params - const origWss = (server as unknown as { wss: WebSocketServer }).wss; - const origListeners = origWss.listeners('connection'); - origWss.removeAllListeners('connection'); - origWss.on('connection', (ws) => { - // Re-emit for original handler - for (const listener of origListeners) { - (listener as (ws: WsType) => void)(ws); - } - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) as Record; - if (msg.method === 'chat.send') { - receivedParams = msg.params as Record; - } - }); - }); - - const client = new OpenClawGatewayClient('test-token', server.port); - await client.connect(); - - await client.sendChatMessage('test', 'idem-key-123'); - expect(receivedParams.idempotencyKey).toBe('idem-key-123'); - - await client.disconnect(); - }); - - it('should return false on RPC error', async () => { - server = new MockOpenClawServer({ chatOk: false }); - const client = new OpenClawGatewayClient('test-token', server.port); - await client.connect(); - - const result = await client.sendChatMessage('hello'); - expect(result).toBe(false); - - await client.disconnect(); - }); - - it('should return false on sendChatMessage when not connected', async () => { - // No server at all — connect should fail - const client = new OpenClawGatewayClient('test-token', 1); - - const result = await client.sendChatMessage('hello'); - expect(result).toBe(false); - - await client.disconnect(); - }); - - it('should reject pending RPCs on disconnect', async () => { - // Use a server that never responds to chat.send - server = new MockOpenClawServer(); - // Override: don't respond to chat.send - const origWss = (server as unknown as { wss: WebSocketServer }).wss; - origWss.removeAllListeners('connection'); - origWss.on('connection', (ws) => { - // Send challenge - ws.send( - JSON.stringify({ - type: 'event', - event: 'connect.challenge', - payload: { nonce: 'nonce-1', ts: Date.now() }, - }) - ); - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) as Record; - if (msg.method === 'connect') { - ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); - } - // Deliberately don't respond to chat.send - }); - }); - - const client = new OpenClawGatewayClient('test-token', server.port); - await client.connect(); - - // Send a message that won't get a response - const chatPromise = client.sendChatMessage('will be rejected'); - - // Disconnect while the RPC is pending - await client.disconnect(); - - const result = await chatPromise; - expect(result).toBe(false); - }); - - it('should not reconnect after disconnect()', async () => { - server = new MockOpenClawServer(); - const client = new OpenClawGatewayClient('test-token', server.port); - await client.connect(); - await client.disconnect(); - - // After disconnect, the stopped flag should prevent reconnection. - // Verify by checking that sendChatMessage returns false without hanging. - const result = await client.sendChatMessage('should fail'); - expect(result).toBe(false); - }); - - it('should handle non-JSON messages gracefully', async () => { - server = new MockOpenClawServer({ sendChallenge: false }); - const origWss = (server as unknown as { wss: WebSocketServer }).wss; - origWss.removeAllListeners('connection'); - origWss.on('connection', (ws) => { - // Send garbage first, then a proper challenge - ws.send('not json at all'); - ws.send( - JSON.stringify({ - type: 'event', - event: 'connect.challenge', - payload: { nonce: 'nonce-2', ts: Date.now() }, - }) - ); - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) as Record; - if (msg.method === 'connect') { - ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); - } - }); - }); - - const client = new OpenClawGatewayClient('test-token', server.port); - await client.connect(); - await client.disconnect(); - }); - - it('should fallback to alternate payload version on signature rejection', async () => { - let connectAttempts = 0; - server = new MockOpenClawServer({ sendChallenge: false }); - const origWss = (server as unknown as { wss: WebSocketServer }).wss; - origWss.removeAllListeners('connection'); - origWss.on('connection', (ws) => { - connectAttempts++; - // Send challenge - ws.send( - JSON.stringify({ - type: 'event', - event: 'connect.challenge', - payload: { nonce: `nonce-fallback-${connectAttempts}`, ts: Date.now() }, - }) - ); - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) as Record; - if (msg.method === 'connect') { - if (connectAttempts === 1) { - // First attempt: reject with signature invalid - ws.send( - JSON.stringify({ - type: 'res', - id: 'connect-1', - ok: false, - error: { code: 'auth_failed', message: 'device signature invalid' }, - }) - ); - } else { - // Second attempt (fallback): accept - ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); - } - } - }); - }); - - const client = new OpenClawGatewayClient('test-token', server.port); - await client.connect(); - // Should have connected on the fallback attempt - expect(connectAttempts).toBe(2); - await client.disconnect(); - }); - - it('should not retry fallback more than once', async () => { - let connectAttempts = 0; - server = new MockOpenClawServer({ sendChallenge: false }); - const origWss = (server as unknown as { wss: WebSocketServer }).wss; - origWss.removeAllListeners('connection'); - origWss.on('connection', (ws) => { - connectAttempts++; - ws.send( - JSON.stringify({ - type: 'event', - event: 'connect.challenge', - payload: { nonce: `nonce-nofallback-${connectAttempts}`, ts: Date.now() }, - }) - ); - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) as Record; - if (msg.method === 'connect') { - // Always reject with signature invalid - ws.send( - JSON.stringify({ - type: 'res', - id: 'connect-1', - ok: false, - error: { code: 'auth_failed', message: 'device signature invalid' }, - }) - ); - } - }); - }); - - const client = new OpenClawGatewayClient('test-token', server.port); - await expect(client.connect()).rejects.toThrow(/auth failed|signature invalid|closed before/i); - // Should have tried exactly 2 times: primary + one fallback - expect(connectAttempts).toBe(2); - await client.disconnect(); - }); - - it('should silently ignore unrecognized event messages', async () => { - server = new MockOpenClawServer(); - const origWss = (server as unknown as { wss: WebSocketServer }).wss; - const origListeners = origWss.listeners('connection'); - origWss.removeAllListeners('connection'); - origWss.on('connection', (ws) => { - for (const listener of origListeners) { - (listener as (ws: WsType) => void)(ws); - } - // Send some random event after auth - setTimeout(() => { - ws.send(JSON.stringify({ type: 'event', event: 'chat.tick', payload: {} })); - }, 100); - }); - - const client = new OpenClawGatewayClient('test-token', server.port); - await client.connect(); - await new Promise((r) => setTimeout(r, 150)); - await client.disconnect(); - }); -}); diff --git a/src/auth/converter.ts b/src/auth/converter.ts deleted file mode 100644 index 495c651..0000000 --- a/src/auth/converter.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; -import { existsSync } from 'node:fs'; - -export interface CodexOAuthTokens { - access_token: string; - refresh_token?: string; -} - -export interface CodexAuth { - tokens?: CodexOAuthTokens; - OPENAI_API_KEY?: string; -} - -export interface ConvertResult { - /** Whether auth was written. */ - ok: boolean; - /** Provider hint derived from auth source (e.g. 'openai-codex' for OAuth). */ - preferredProvider: string; -} - -/** - * Convert Codex CLI auth.json into OpenClaw's legacy auth format. - * - * Reads ~/.codex/auth.json (or codexAuthPath) and writes the converted - * auth to ~/.openclaw/agents/main/agent/auth.json (or openclawAuthDir). - * - * Falls back to OPENAI_API_KEY env var if no codex auth file exists. - */ -export async function convertCodexAuth(options?: { - codexAuthPath?: string; - openclawAuthDir?: string; - openaiApiKey?: string; -}): Promise { - const home = process.env.HOME ?? '/home/node'; - const codexPath = options?.codexAuthPath ?? join(home, '.codex', 'auth.json'); - const openclawAgentDir = options?.openclawAuthDir ?? join(home, '.openclaw', 'agents', 'main', 'agent'); - const openclawAuthPath = join(openclawAgentDir, 'auth.json'); - let preferredProvider = 'openai'; - - if (existsSync(codexPath)) { - const codex: CodexAuth = JSON.parse(await readFile(codexPath, 'utf8')); - await mkdir(openclawAgentDir, { recursive: true }); - - if (codex.tokens?.access_token) { - // OAuth tokens from codex subscription - const auth = { - 'openai-codex': { - type: 'oauth', - provider: 'openai-codex', - access: codex.tokens.access_token, - refresh: codex.tokens.refresh_token ?? '', - expires: Date.now() + 3600000, - }, - }; - await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8'); - preferredProvider = 'openai-codex'; - return { ok: true, preferredProvider }; - } - - if (codex.OPENAI_API_KEY && typeof codex.OPENAI_API_KEY === 'string') { - const auth = { - openai: { - type: 'api_key', - provider: 'openai', - key: codex.OPENAI_API_KEY, - }, - }; - await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8'); - return { ok: true, preferredProvider }; - } - } - - // Fallback: use OPENAI_API_KEY from env - const envKey = options?.openaiApiKey ?? process.env.OPENAI_API_KEY; - if (envKey) { - await mkdir(openclawAgentDir, { recursive: true }); - const auth = { - openai: { - type: 'api_key', - provider: 'openai', - key: envKey, - }, - }; - await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8'); - return { ok: true, preferredProvider }; - } - - return { ok: false, preferredProvider }; -} diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 4a5c7fa..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { createRequire } from 'node:module'; -import { setup } from './setup.js'; -import { loadGatewayConfig, addWorkspace, listWorkspaces, switchWorkspace } from './config.js'; -import { InboundGateway } from './gateway.js'; -import { listOpenClaws, releaseOpenClaw, spawnOpenClaw } from './control.js'; -import { startMcpServer } from './mcp/server.js'; -import { runtimeSetup } from './runtime/setup.js'; - -const require = createRequire(import.meta.url); -const version = - process.env.RELAY_OPENCLAW_VERSION ?? - (() => { - try { - return (require('../package.json') as { version: string }).version; - } catch { - return 'unknown'; - } - })(); - -function printUsage(): void { - console.log( - ` -relay-openclaw — Relaycast bridge for OpenClaw - -Usage: - relay-openclaw setup [key] Install & configure Relaycast bridge - relay-openclaw gateway Start inbound message gateway - relay-openclaw status Check connection status - relay-openclaw spawn Spawn an OpenClaw via ClawRunner control API - relay-openclaw list List OpenClaws in a workspace - relay-openclaw release Release an OpenClaw by agent name - relay-openclaw mcp-server Start MCP server (spawn/list/release tools) - relay-openclaw add-workspace Add a workspace to multi-workspace config - relay-openclaw list-workspaces List all configured workspaces - relay-openclaw switch-workspace Switch the default/active workspace - relay-openclaw runtime-setup Run container runtime setup (auth, config, identity, patching) - relay-openclaw help Show this help - relay-openclaw --version Show version - -Setup options: - --name Claw name (default: hostname) - --channels Channels to join (default: general) - --base-url Relaycast API URL (default: https://api.relaycast.dev) - -Control API options: - --workspace-id Workspace UUID (required for spawn/list/release) - --name Claw name (required for spawn) - --agent Agent name (required for release) - --role Optional role for spawned claw - --model Optional model reference - --channels Optional channels - --system-prompt Optional system prompt - --reason Optional release reason - -Multi-workspace options: - --alias Human-friendly alias for the workspace - --workspace-id Workspace UUID - --default Set as the default workspace - -Examples: - relay-openclaw setup rk_live_abc123 - relay-openclaw setup --name my-claw --channels general,alerts - relay-openclaw gateway - relay-openclaw spawn --workspace-id ws_uuid --name researcher-1 - relay-openclaw list --workspace-id ws_uuid - relay-openclaw release --workspace-id ws_uuid --agent claw-ws_uuid-researcher-1 - relay-openclaw add-workspace rk_live_abc123 --alias team-a - relay-openclaw add-workspace rk_live_def456 --alias team-b --default - relay-openclaw list-workspaces - relay-openclaw switch-workspace team-a -`.trim() - ); -} - -function parseArgs(argv: string[]): { - command: string; - positional: string[]; - flags: Record; -} { - const args = argv.slice(2); // skip node + script - const command = args[0] ?? 'help'; - const positional: string[] = []; - const flags: Record = {}; - - for (let i = 1; i < args.length; i++) { - const arg = args[i]; - if (arg.startsWith('--')) { - const key = arg.slice(2); - const value = args[i + 1]; - if (value && !value.startsWith('--')) { - flags[key] = value; - i++; - } else { - flags[key] = 'true'; - } - } else { - positional.push(arg); - } - } - - return { command, positional, flags }; -} - -async function runSetup(positional: string[], flags: Record): Promise { - const apiKey = positional[0] ?? undefined; - const clawName = flags['name'] ?? undefined; - const channels = flags['channels']?.split(',').map((c) => c.trim()); - const baseUrl = flags['base-url'] ?? undefined; - - console.log('Setting up Relaycast bridge for OpenClaw...\n'); - - const result = await setup({ apiKey, clawName, channels, baseUrl }); - - if (result.ok) { - console.log(result.message); - const maskedApiKey = result.apiKey.slice(0, 12) + '...'; - console.log(`\nWorkspace key: ${maskedApiKey}`); - console.log('Share this key with other claws to join the same workspace.'); - } else { - console.error(`Setup failed: ${result.message}`); - process.exit(1); - } -} - -async function runGateway(): Promise { - const config = await loadGatewayConfig(); - - if (!config) { - console.error('No gateway config found. Run "relay-openclaw setup" first.'); - process.exit(1); - } - - console.log(`Starting inbound gateway for ${config.clawName}...`); - console.log(`Channels: ${config.channels.join(', ')}`); - console.log(`Base URL: ${config.baseUrl}\n`); - - const gateway = new InboundGateway({ config }); - - // Graceful shutdown - const shutdown = async () => { - console.log('\nShutting down gateway...'); - await gateway.stop(); - process.exit(0); - }; - - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - await gateway.start(); - console.log('Gateway running. Press Ctrl+C to stop.'); -} - -async function runStatus(): Promise { - const config = await loadGatewayConfig(); - - if (!config) { - console.log('Status: NOT CONFIGURED'); - console.log('Run "relay-openclaw setup" to configure.'); - return; - } - - console.log('Status: CONFIGURED'); - console.log(`Claw name: ${config.clawName}`); - console.log(`Channels: ${config.channels.join(', ')}`); - console.log(`Base URL: ${config.baseUrl}`); - console.log(`API key: ${config.apiKey.slice(0, 12)}...`); - - // Try to check connectivity - try { - const res = await fetch(`${config.baseUrl}/health`); - console.log(`API connectivity: ${res.ok ? 'OK' : `Error (${res.status})`}`); - } catch (err) { - console.log(`API connectivity: UNREACHABLE (${err instanceof Error ? err.message : String(err)})`); - } -} - -async function runSpawn(flags: Record): Promise { - const workspaceId = flags['workspace-id']; - const name = flags['name']; - if (!workspaceId || !name) { - console.error('spawn requires --workspace-id and --name'); - process.exit(1); - } - - const channels = flags['channels'] - ?.split(',') - .map((ch) => ch.trim()) - .filter(Boolean); - - const result = await spawnOpenClaw({ - workspaceId, - name, - role: flags['role'], - model: flags['model'], - channels, - systemPrompt: flags['system-prompt'], - idempotencyKey: flags['idempotency-key'], - }); - - console.log(JSON.stringify(result, null, 2)); -} - -async function runList(flags: Record): Promise { - const workspaceId = flags['workspace-id']; - if (!workspaceId) { - console.error('list requires --workspace-id'); - process.exit(1); - } - - const result = await listOpenClaws(workspaceId); - console.log(JSON.stringify(result, null, 2)); -} - -async function runRelease(flags: Record): Promise { - const workspaceId = flags['workspace-id']; - const agentName = flags['agent']; - if (!workspaceId || !agentName) { - console.error('release requires --workspace-id and --agent'); - process.exit(1); - } - - const result = await releaseOpenClaw({ - workspaceId, - agentName, - reason: flags['reason'], - }); - console.log(JSON.stringify(result, null, 2)); -} - -async function runRuntimeSetup(flags: Record): Promise { - console.log('Running container runtime setup...'); - const result = await runtimeSetup({ - model: flags['model'], - name: flags['name'], - workspaceId: flags['workspace-id'], - role: flags['role'], - openclawDistDir: flags['dist-dir'], - }); - console.log(`Runtime setup complete:`); - console.log(` Model: ${result.modelRef}`); - console.log(` Agent: ${result.agentName}`); - console.log(` Workspace: ${result.workspaceId}`); -} - -async function runAddWorkspace(positional: string[], flags: Record): Promise { - const apiKey = positional[0]; - if (!apiKey) { - console.error('add-workspace requires a workspace API key as the first argument.'); - console.error( - 'Usage: relay-openclaw add-workspace [--alias ] [--workspace-id ] [--default]' - ); - process.exit(1); - } - - const config = await addWorkspace({ - api_key: apiKey, - ...(flags['alias'] ? { workspace_alias: flags['alias'] } : {}), - ...(flags['workspace-id'] ? { workspace_id: flags['workspace-id'] } : {}), - ...(flags['default'] !== undefined ? { is_default: flags['default'] === 'true' } : {}), - }); - - const entry = config.workspaces.find((w) => w.api_key === apiKey); - const label = entry?.workspace_alias ?? entry?.workspace_id ?? apiKey.slice(0, 12) + '...'; - console.log(`Workspace "${label}" added.`); - console.log(`Total workspaces: ${config.workspaces.length}`); - if (config.default_workspace) { - console.log(`Default workspace: ${config.default_workspace}`); - } -} - -async function runListWorkspaces(): Promise { - const workspaces = await listWorkspaces(); - if (workspaces.length === 0) { - console.log('No workspaces configured.'); - console.log('Add one with: relay-openclaw add-workspace --alias '); - return; - } - - console.log(`Configured workspaces (${workspaces.length}):\n`); - for (const w of workspaces) { - const defaultMarker = w.is_default ? ' (default)' : ''; - const alias = w.workspace_alias ?? '(no alias)'; - const maskedKey = w.api_key.slice(0, 12) + '...'; - const wsId = w.workspace_id ? ` [${w.workspace_id}]` : ''; - console.log(` ${alias}${wsId} — ${maskedKey}${defaultMarker}`); - } -} - -async function runSwitchWorkspace(positional: string[]): Promise { - const identifier = positional[0]; - if (!identifier) { - console.error('switch-workspace requires a workspace alias or ID.'); - console.error('Usage: relay-openclaw switch-workspace '); - process.exit(1); - } - - const result = await switchWorkspace(identifier); - if (!result) { - console.error(`Workspace "${identifier}" not found.`); - console.error('Run "relay-openclaw list-workspaces" to see available workspaces.'); - process.exit(1); - } - - console.log(`Switched default workspace to "${identifier}".`); - console.log('The .env config has been updated. Restart the gateway to apply.'); -} - -async function main(): Promise { - const { command, positional, flags } = parseArgs(process.argv); - - switch (command) { - case 'setup': - await runSetup(positional, flags); - break; - case 'gateway': - await runGateway(); - break; - case 'status': - await runStatus(); - break; - case 'spawn': - await runSpawn(flags); - break; - case 'list': - await runList(flags); - break; - case 'release': - await runRelease(flags); - break; - case 'add-workspace': - await runAddWorkspace(positional, flags); - break; - case 'list-workspaces': - await runListWorkspaces(); - break; - case 'switch-workspace': - await runSwitchWorkspace(positional); - break; - case 'mcp-server': - await startMcpServer(); - break; - case 'runtime-setup': - await runRuntimeSetup(flags); - break; - case 'version': - case '--version': - case '-v': - console.log(version); - break; - case 'help': - case '--help': - case '-h': - printUsage(); - break; - default: - console.error(`Unknown command: ${command}`); - printUsage(); - process.exit(1); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 1fb7c9d..0000000 --- a/src/config.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { join, dirname, basename } from 'node:path'; -import { homedir } from 'node:os'; -import { existsSync, readFileSync } from 'node:fs'; - -import type { GatewayConfig, WorkspaceEntry, WorkspacesConfig } from './types.js'; - -function envValue(vars: Record, key: string): string | undefined { - const processValue = process.env[key]?.trim(); - if (processValue) return processValue; - const fileValue = vars[key]?.trim(); - return fileValue ? fileValue : undefined; -} - -function parseBooleanEnv(vars: Record, key: string): boolean | undefined { - const value = envValue(vars, key); - if (!value) return undefined; - if (['1', 'true', 'yes', 'on'].includes(value.toLowerCase())) return true; - if (['0', 'false', 'no', 'off'].includes(value.toLowerCase())) return false; - return undefined; -} - -function parseNumberEnv(vars: Record, key: string): number | undefined { - const value = envValue(vars, key); - if (!value) return undefined; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -export interface OpenClawDetection { - /** Whether OpenClaw is installed. */ - installed: boolean; - /** Path to ~/.openclaw/ (or ~/.clawdbot/ for Clawdbot variant) */ - homeDir: string; - /** Path to ~/.openclaw/workspace/ */ - workspaceDir: string; - /** Path to openclaw.json config (if found). */ - configFile: string | null; - /** Parsed openclaw.json (if exists). */ - config: Record | null; - /** Detected variant: 'clawdbot' or 'openclaw'. */ - variant: 'clawdbot' | 'openclaw'; - /** Config filename (e.g. 'clawdbot.json' or 'openclaw.json'). */ - configFilename: string; -} - -/** - * Determine whether a directory has a valid, parseable config file. - * Uses sync I/O — only called during startup, not on hot path. - */ -function hasValidConfig(dir: string, filename: string): boolean { - const configPath = join(dir, filename); - if (!existsSync(configPath)) return false; - try { - const raw = readFileSync(configPath, 'utf-8'); - JSON.parse(raw); - return true; - } catch { - return false; - } -} - -/** Default OpenClaw config directory. Checks env vars and probes for Clawdbot variant. */ -export function openclawHome(): string { - if (process.env.OPENCLAW_CONFIG_PATH) { - // Direct config file path — return its parent directory - return dirname(process.env.OPENCLAW_CONFIG_PATH); - } - if (process.env.OPENCLAW_HOME) { - return process.env.OPENCLAW_HOME; - } - // Probe by valid config file presence (not just directory existence). - // When both dirs exist, prefer the one with a valid config file. - const clawdbotHome = join(homedir(), '.clawdbot'); - const openclawHomePath = join(homedir(), '.openclaw'); - const clawdbotValid = hasValidConfig(clawdbotHome, 'clawdbot.json'); - const openclawValid = hasValidConfig(openclawHomePath, 'openclaw.json'); - - if (clawdbotValid && !openclawValid) return clawdbotHome; - if (openclawValid && !clawdbotValid) return openclawHomePath; - // Both valid or neither valid — prefer clawdbot if its dir exists (marketplace image) - if (existsSync(clawdbotHome)) return clawdbotHome; - return openclawHomePath; -} - -/** Return the config filename for the resolved OpenClaw home (clawdbot.json or openclaw.json). */ -export function openclawConfigFilename(home?: string): string { - const dir = home ?? openclawHome(); - if (hasValidConfig(dir, 'clawdbot.json')) return 'clawdbot.json'; - if (hasValidConfig(dir, 'openclaw.json')) return 'openclaw.json'; - // No existing config — infer from directory name - return dir.endsWith('.clawdbot') ? 'clawdbot.json' : 'openclaw.json'; -} - -/** - * Detect whether OpenClaw is installed and return paths/config. - */ -export async function detectOpenClaw(): Promise { - // Determine variant and config filename - let homeDir: string; - let variant: 'clawdbot' | 'openclaw'; - let configFilename: string; - - if (process.env.OPENCLAW_CONFIG_PATH) { - // Direct config file path provided - homeDir = dirname(process.env.OPENCLAW_CONFIG_PATH); - const base = basename(process.env.OPENCLAW_CONFIG_PATH); - configFilename = base; - variant = base === 'clawdbot.json' ? 'clawdbot' : 'openclaw'; - } else if (process.env.OPENCLAW_HOME) { - homeDir = process.env.OPENCLAW_HOME; - // Check if the home dir looks like a Clawdbot installation - const clawdbotConfig = join(homeDir, 'clawdbot.json'); - if (existsSync(clawdbotConfig)) { - variant = 'clawdbot'; - configFilename = 'clawdbot.json'; - } else { - variant = 'openclaw'; - configFilename = 'openclaw.json'; - } - } else { - // Probe by valid config file, not just directory existence. - const clawdbotHome = join(homedir(), '.clawdbot'); - const openclawHomePath = join(homedir(), '.openclaw'); - const clawdbotValid = hasValidConfig(clawdbotHome, 'clawdbot.json'); - const openclawValid = hasValidConfig(openclawHomePath, 'openclaw.json'); - - if (clawdbotValid && !openclawValid) { - homeDir = clawdbotHome; - variant = 'clawdbot'; - configFilename = 'clawdbot.json'; - } else if (openclawValid && !clawdbotValid) { - homeDir = openclawHomePath; - variant = 'openclaw'; - configFilename = 'openclaw.json'; - } else if (existsSync(clawdbotHome)) { - // Both valid or neither — prefer clawdbot if present (marketplace image) - homeDir = clawdbotHome; - variant = 'clawdbot'; - configFilename = 'clawdbot.json'; - } else { - homeDir = openclawHomePath; - variant = 'openclaw'; - configFilename = 'openclaw.json'; - } - } - - const configPath = join(homeDir, configFilename); - const workspaceDir = join(homeDir, 'workspace'); - - const installed = existsSync(homeDir); - let config: Record | null = null; - let configFile: string | null = null; - - if (existsSync(configPath)) { - configFile = configPath; - try { - const raw = await readFile(configPath, 'utf-8'); - config = JSON.parse(raw) as Record; - } catch { - // Config exists but isn't valid JSON — that's fine - } - } - - return { installed, homeDir, workspaceDir, configFile, config, variant, configFilename }; -} - -/** - * Load the gateway config from ~/.openclaw/workspace/relaycast/.env. - * Returns null if the file doesn't exist or can't be parsed. - */ -// eslint-disable-next-line complexity -export async function loadGatewayConfig(): Promise { - const detection = await detectOpenClaw(); - const envPath = join(detection.workspaceDir, 'relaycast', '.env'); - - if (!existsSync(envPath)) { - return null; - } - - try { - const raw = await readFile(envPath, 'utf-8'); - const vars: Record = {}; - - for (const line of raw.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIdx = trimmed.indexOf('='); - if (eqIdx === -1) continue; - let value = trimmed.slice(eqIdx + 1); - // Strip surrounding quotes (single or double) that are common in .env files - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - vars[trimmed.slice(0, eqIdx)] = value; - } - - const apiKey = envValue(vars, 'RELAY_API_KEY'); - const clawName = envValue(vars, 'RELAY_CLAW_NAME'); - const relayChannels = envValue(vars, 'RELAY_CHANNELS'); - - if (!apiKey || !clawName) { - return null; - } - - const port = parseNumberEnv(vars, 'OPENCLAW_GATEWAY_PORT'); - const pollFallbackEnabled = parseBooleanEnv(vars, 'RELAY_TRANSPORT_POLL_FALLBACK_ENABLED'); - const pollFallbackProbeWsEnabled = parseBooleanEnv( - vars, - 'RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_ENABLED' - ); - const pollFallbackWsFailureThreshold = parseNumberEnv( - vars, - 'RELAY_TRANSPORT_POLL_FALLBACK_WS_FAILURE_THRESHOLD' - ); - const pollFallbackTimeoutSeconds = parseNumberEnv(vars, 'RELAY_TRANSPORT_POLL_FALLBACK_TIMEOUT_SECONDS'); - const pollFallbackLimit = parseNumberEnv(vars, 'RELAY_TRANSPORT_POLL_FALLBACK_LIMIT'); - const pollFallbackProbeWsIntervalMs = parseNumberEnv( - vars, - 'RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_INTERVAL_MS' - ); - const pollFallbackProbeWsStableGraceMs = parseNumberEnv( - vars, - 'RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_STABLE_GRACE_MS' - ); - const pollFallbackInitialCursor = envValue(vars, 'RELAY_TRANSPORT_POLL_FALLBACK_INITIAL_CURSOR'); - - const transport = - pollFallbackEnabled !== undefined || - pollFallbackProbeWsEnabled !== undefined || - pollFallbackWsFailureThreshold !== undefined || - pollFallbackTimeoutSeconds !== undefined || - pollFallbackLimit !== undefined || - pollFallbackProbeWsIntervalMs !== undefined || - pollFallbackProbeWsStableGraceMs !== undefined || - pollFallbackInitialCursor !== undefined - ? { - pollFallback: { - enabled: pollFallbackEnabled, - wsFailureThreshold: pollFallbackWsFailureThreshold, - timeoutSeconds: pollFallbackTimeoutSeconds, - limit: pollFallbackLimit, - initialCursor: pollFallbackInitialCursor, - probeWs: { - enabled: pollFallbackProbeWsEnabled, - intervalMs: pollFallbackProbeWsIntervalMs, - stableGraceMs: pollFallbackProbeWsStableGraceMs, - }, - }, - } - : undefined; - - return { - apiKey, - clawName, - baseUrl: envValue(vars, 'RELAY_BASE_URL') || 'https://api.relaycast.dev', - channels: relayChannels ? relayChannels.split(',').map((c) => c.trim()) : ['general'], - openclawGatewayToken: envValue(vars, 'OPENCLAW_GATEWAY_TOKEN'), - openclawGatewayPort: Number.isFinite(port) ? port : undefined, - transport, - }; - } catch { - return null; - } -} - -/** - * Save gateway config to ~/.openclaw/workspace/relaycast/.env. - */ -export async function saveGatewayConfig(config: GatewayConfig): Promise { - const detection = await detectOpenClaw(); - const relaycastDir = join(detection.workspaceDir, 'relaycast'); - - await mkdir(relaycastDir, { recursive: true }); - - const lines = [ - '# Relaycast configuration for this OpenClaw skill', - `RELAY_API_KEY=${config.apiKey}`, - `RELAY_CLAW_NAME=${config.clawName}`, - `RELAY_BASE_URL=${config.baseUrl}`, - `RELAY_CHANNELS=${config.channels.join(',')}`, - ]; - - if (config.openclawGatewayToken) { - lines.push(`OPENCLAW_GATEWAY_TOKEN=${config.openclawGatewayToken}`); - const masked = - config.openclawGatewayToken.length > 12 ? config.openclawGatewayToken.slice(0, 8) + '...' : '***'; - console.log(`[config] Persisting OPENCLAW_GATEWAY_TOKEN (${masked})`); - } - if (config.openclawGatewayPort) { - lines.push(`OPENCLAW_GATEWAY_PORT=${config.openclawGatewayPort}`); - } - if (config.transport?.pollFallback?.enabled !== undefined) { - lines.push(`RELAY_TRANSPORT_POLL_FALLBACK_ENABLED=${config.transport.pollFallback.enabled}`); - } - if (config.transport?.pollFallback?.wsFailureThreshold !== undefined) { - lines.push( - `RELAY_TRANSPORT_POLL_FALLBACK_WS_FAILURE_THRESHOLD=${config.transport.pollFallback.wsFailureThreshold}` - ); - } - if (config.transport?.pollFallback?.timeoutSeconds !== undefined) { - lines.push( - `RELAY_TRANSPORT_POLL_FALLBACK_TIMEOUT_SECONDS=${config.transport.pollFallback.timeoutSeconds}` - ); - } - if (config.transport?.pollFallback?.limit !== undefined) { - lines.push(`RELAY_TRANSPORT_POLL_FALLBACK_LIMIT=${config.transport.pollFallback.limit}`); - } - if (config.transport?.pollFallback?.initialCursor) { - lines.push(`RELAY_TRANSPORT_POLL_FALLBACK_INITIAL_CURSOR=${config.transport.pollFallback.initialCursor}`); - } - if (config.transport?.pollFallback?.probeWs?.enabled !== undefined) { - lines.push( - `RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_ENABLED=${config.transport.pollFallback.probeWs.enabled}` - ); - } - if (config.transport?.pollFallback?.probeWs?.intervalMs !== undefined) { - lines.push( - `RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_INTERVAL_MS=${config.transport.pollFallback.probeWs.intervalMs}` - ); - } - if (config.transport?.pollFallback?.probeWs?.stableGraceMs !== undefined) { - lines.push( - `RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_STABLE_GRACE_MS=${config.transport.pollFallback.probeWs.stableGraceMs}` - ); - } - - lines.push(''); - const env = lines.join('\n'); - - await writeFile(join(relaycastDir, '.env'), env, 'utf-8'); -} - -// --------------------------------------------------------------------------- -// Multi-workspace config: ~/.openclaw/workspace/relaycast/workspaces.json -// --------------------------------------------------------------------------- - -/** - * Path to the workspaces.json file. - */ -async function workspacesConfigPath(): Promise { - const detection = await detectOpenClaw(); - return join(detection.workspaceDir, 'relaycast', 'workspaces.json'); -} - -/** - * Load multi-workspace config. Returns null if the file doesn't exist. - */ -export async function loadWorkspacesConfig(): Promise { - const configPath = await workspacesConfigPath(); - if (!existsSync(configPath)) return null; - - try { - const raw = await readFile(configPath, 'utf-8'); - return JSON.parse(raw) as WorkspacesConfig; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.warn(`Warning: failed to parse ${configPath}: ${message}`); - console.warn('The file may be corrupted. Existing workspace config will not be modified.'); - return { workspaces: [], default_workspace: undefined }; - } -} - -/** - * Save multi-workspace config to disk. - */ -export async function saveWorkspacesConfig(config: WorkspacesConfig): Promise { - const configPath = await workspacesConfigPath(); - await mkdir(dirname(configPath), { recursive: true }); - await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); -} - -/** - * Add a workspace entry. If an entry with the same api_key already exists, - * it is updated in place. The first workspace added becomes the default. - */ -export async function addWorkspace(entry: WorkspaceEntry): Promise { - let config = await loadWorkspacesConfig(); - - if (!config) { - // Bootstrap from existing single-workspace .env if available - const gateway = await loadGatewayConfig(); - if (gateway) { - config = { - workspaces: [ - { - api_key: gateway.apiKey, - workspace_alias: gateway.clawName, - is_default: true, - }, - ], - default_workspace: gateway.clawName, - }; - } else { - config = { workspaces: [], default_workspace: undefined }; - } - } - - const normalizeWorkspaceLabel = (workspace: WorkspaceEntry): string => { - return workspace.workspace_alias ?? workspace.workspace_id ?? workspace.api_key; - }; - - // Check for existing entry with same api_key - const hasExplicitDefault = entry.is_default !== undefined; - const existingIdx = config.workspaces.findIndex((w) => w.api_key === entry.api_key); - if (existingIdx >= 0) { - const existingEntry = config.workspaces[existingIdx]; - config.workspaces[existingIdx] = { ...existingEntry, ...entry }; - if (!hasExplicitDefault) { - config.workspaces[existingIdx].is_default = existingEntry.is_default; - } - const updatedEntry = config.workspaces[existingIdx]; - if (updatedEntry.is_default) { - config.default_workspace = normalizeWorkspaceLabel(updatedEntry); - } - } else { - config.workspaces.push(entry); - } - - const targetWorkspace = config.workspaces.find((w) => w.api_key === entry.api_key); - if (!targetWorkspace) { - throw new Error(`Failed to locate workspace entry for ${entry.api_key}`); - } - - // If this is explicitly default, or this is the first workspace without an existing default, - // set it as default. - if ( - entry.is_default === true || - (config.default_workspace === undefined && config.workspaces.length === 1) - ) { - config.default_workspace = normalizeWorkspaceLabel(targetWorkspace); - for (const w of config.workspaces) { - w.is_default = w.api_key === targetWorkspace.api_key; - } - } - - await saveWorkspacesConfig(config); - return config; -} - -/** - * List all configured workspaces. - */ -export async function listWorkspaces(): Promise { - const config = await loadWorkspacesConfig(); - return config?.workspaces ?? []; -} - -/** - * Switch the default workspace by alias or workspace_id. - * Returns the updated config, or null if the identifier was not found. - */ -export async function switchWorkspace(identifier: string): Promise { - const config = await loadWorkspacesConfig(); - if (!config) return null; - - const target = config.workspaces.find( - (w) => w.workspace_alias === identifier || w.workspace_id === identifier - ); - if (!target) return null; - - config.default_workspace = target.workspace_alias ?? target.workspace_id ?? target.api_key; - for (const w of config.workspaces) { - w.is_default = w.api_key === target.api_key; - } - - // Also update the single-workspace .env to match the new default - const gateway = await loadGatewayConfig(); - if (gateway) { - gateway.apiKey = target.api_key; - gateway.clawName = target.workspace_alias ?? target.workspace_id ?? target.api_key; - await saveGatewayConfig(gateway); - } - - await saveWorkspacesConfig(config); - return config; -} - -/** - * Build RELAY_WORKSPACES_JSON value for the broker from stored workspaces. - * Returns null if there are fewer than 2 workspaces (single-workspace mode). - */ -export function buildWorkspacesJson(config: WorkspacesConfig): string | null { - if (config.workspaces.length < 2) return null; - - const memberships = config.workspaces.map((w) => ({ - api_key: w.api_key, - ...(w.workspace_id ? { workspace_id: w.workspace_id } : {}), - ...(w.workspace_alias ? { workspace_alias: w.workspace_alias } : {}), - })); - - const payload: Record = { memberships }; - if (config.default_workspace) { - payload.default_workspace_id = config.default_workspace; - } - - return JSON.stringify(payload); -} diff --git a/src/control.ts b/src/control.ts deleted file mode 100644 index cca12fd..0000000 --- a/src/control.ts +++ /dev/null @@ -1,100 +0,0 @@ -export interface ClawRunnerControlConfig { - baseUrl: string; - token: string; -} - -export interface SpawnOpenClawInput { - workspaceId: string; - name: string; - role?: string; - model?: string; - channels?: string[]; - systemPrompt?: string; - idempotencyKey?: string; -} - -export interface ReleaseOpenClawInput { - workspaceId: string; - agentName: string; - reason?: string; -} - -function trimSlash(value: string): string { - return value.endsWith('/') ? value.slice(0, -1) : value; -} - -function resolveConfig(config?: Partial): ClawRunnerControlConfig { - const baseUrl = (config?.baseUrl ?? process.env.CLAWRUNNER_API_BASE_URL ?? '').trim(); - const token = (config?.token ?? process.env.CLAWRUNNER_AGENT_TOKEN ?? '').trim(); - - if (!baseUrl) { - throw new Error('CLAWRUNNER_API_BASE_URL is required'); - } - if (!token) { - throw new Error('CLAWRUNNER_AGENT_TOKEN is required'); - } - - return { - baseUrl: trimSlash(baseUrl), - token, - }; -} - -async function callApi( - path: string, - method: 'GET' | 'POST', - config?: Partial, - body?: unknown -): Promise { - const resolved = resolveConfig(config); - const response = await fetch(`${resolved.baseUrl}${path}`, { - method, - headers: { - Authorization: `Bearer ${resolved.token}`, - 'Content-Type': 'application/json', - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - - const text = await response.text(); - const json = text ? JSON.parse(text) : null; - if (!response.ok) { - const msg = json && typeof json.error === 'string' ? json.error : `HTTP ${response.status}`; - throw new Error(`ClawRunner control API error: ${msg}`); - } - return json as T; -} - -export async function spawnOpenClaw( - input: SpawnOpenClawInput, - config?: Partial -): Promise { - return callApi('/api/agents/spawn', 'POST', config, { - workspaceId: input.workspaceId, - name: input.name, - role: input.role, - model: input.model, - channels: input.channels, - systemPrompt: input.systemPrompt, - idempotencyKey: input.idempotencyKey, - }); -} - -export async function listOpenClaws( - workspaceId: string, - config?: Partial -): Promise { - const query = new URLSearchParams({ workspaceId }).toString(); - return callApi(`/api/agents?${query}`, 'GET', config); -} - -export async function releaseOpenClaw( - input: ReleaseOpenClawInput, - config?: Partial -): Promise { - const path = `/api/agents/${encodeURIComponent(input.agentName)}/release`; - return callApi(path, 'POST', config, { - workspaceId: input.workspaceId, - reason: input.reason, - }); -} diff --git a/src/gateway.ts b/src/gateway.ts deleted file mode 100644 index 8ff4c5d..0000000 --- a/src/gateway.ts +++ /dev/null @@ -1,2362 +0,0 @@ -import { - createHash, - createPrivateKey, - createPublicKey, - generateKeyPairSync, - sign, - verify, - type KeyObject, -} from 'node:crypto'; -import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises'; -import { - createServer, - type Server as HttpServer, - type IncomingMessage, - type ServerResponse, -} from 'node:http'; -import { join } from 'node:path'; - -import type { SendMessageInput } from '@agent-relay/sdk'; -import { RelayCast, type AgentClient } from '@relaycast/sdk'; -import type { - MessageCreatedEvent, - ThreadReplyEvent, - DmReceivedEvent, - GroupDmReceivedEvent, - CommandInvokedEvent, - ReactionAddedEvent, - ReactionRemovedEvent, -} from '@relaycast/sdk'; -import WebSocket from 'ws'; - -import { openclawHome } from './config.js'; -import { - DEFAULT_OPENCLAW_GATEWAY_PORT, - type GatewayConfig, - type InboundMessage, - type DeliveryResult, -} from './types.js'; -import { SpawnManager } from './spawn/manager.js'; -import type { SpawnOptions } from './spawn/types.js'; - -/** - * A minimal interface for sending messages via Agent Relay. - * Accepts either AgentRelayClient or AgentRelay — any object with a - * compatible sendMessage() method. - */ -export interface RelaySender { - sendMessage(input: SendMessageInput): Promise<{ event_id: string; targets?: string[] }>; -} - -export interface GatewayOptions { - /** Gateway configuration. */ - config: GatewayConfig; - /** - * Pre-existing relay sender for message delivery. - * Pass the API server's AgentRelay instance so all gateways share a single - * broker process instead of each spawning their own. - */ - relaySender?: RelaySender; -} - -type InboundTransportState = 'WS_ACTIVE' | 'WS_DEGRADED' | 'POLL_ACTIVE' | 'RECOVERING_WS'; -type InboundTransportMode = 'ws' | 'poll'; - -interface PollEventEnvelope { - id: string; - sequence: number; - timestamp: string; - payload: Record; -} - -interface PollResponseBody { - events?: PollEventEnvelope[]; - nextCursor?: string; - hasMore?: boolean; -} - -interface PersistedPollCursorState { - cursor: string; - lastSequence: number; - recentEventIds: string[]; - updatedAt: string; -} - -interface InboundProcessingResult { - committed: boolean; - reason?: 'duplicate' | 'echo'; - result?: DeliveryResult; -} - -interface RealtimeHandlingOptions { - eventId?: string; - timestamp?: string; -} - -const DEFAULT_POLL_ENDPOINT_PATH = '/messages/poll'; -const DEFAULT_POLL_INITIAL_CURSOR = '0'; -const DEFAULT_WS_FAILURE_THRESHOLD = 3; -const DEFAULT_POLL_TIMEOUT_SECONDS = 25; -const MAX_POLL_TIMEOUT_SECONDS = 30; -const DEFAULT_POLL_LIMIT = 100; -const MAX_POLL_LIMIT = 500; -const DEFAULT_WS_PROBE_INTERVAL_MS = 60_000; -const DEFAULT_WS_STABLE_GRACE_MS = 10_000; -const POLL_CURSOR_RECENT_EVENT_LIMIT = 256; -const MAX_POLL_CURSOR_LENGTH = 4_096; -const MAX_EVENT_ID_LENGTH = 512; -const BACKOFF_BASE_MS = 500; -const BACKOFF_CAP_MS = 30_000; - -function pollCursorStatePath(): string { - return join(openclawHome(), 'workspace', 'relaycast', 'inbound-cursor.json'); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function applyJitter(ms: number): number { - const factor = 1.1 + Math.random() * 0.1; - return Math.max(0, Math.floor(ms * factor)); -} - -function hasControlCharacters(value: string): boolean { - for (const char of value) { - const code = char.charCodeAt(0); - if ((code >= 0 && code <= 31) || code === 127) { - return true; - } - } - return false; -} - -function stripControlChars(value: string): string { - let out = ''; - for (const char of value) { - const code = char.charCodeAt(0); - out += code <= 31 || code === 127 ? ' ' : char; - } - return out; -} - -function sanitizeOpaqueStateValue(value: unknown, maxLength: number): string | null { - if (typeof value !== 'string') return null; - if (value.trim().length === 0 || value.length > maxLength) return null; - if (hasControlCharacters(value)) return null; - return value; -} - -function computeBackoffMs(attempt: number): number { - const base = Math.min(BACKOFF_BASE_MS * Math.pow(2, Math.max(0, attempt - 1)), BACKOFF_CAP_MS); - return applyJitter(base); -} - -function sanitizePollTimeoutSeconds(value: number | undefined): number { - if (!Number.isFinite(value)) return DEFAULT_POLL_TIMEOUT_SECONDS; - return Math.min(MAX_POLL_TIMEOUT_SECONDS, Math.max(0, Math.floor(value!))); -} - -function sanitizePollLimit(value: number | undefined): number { - if (!Number.isFinite(value)) return DEFAULT_POLL_LIMIT; - return Math.min(MAX_POLL_LIMIT, Math.max(1, Math.floor(value!))); -} - -function parseRetryAfterMs(retryAfter: string | null): number | null { - if (!retryAfter) return null; - const seconds = Number(retryAfter); - if (Number.isFinite(seconds)) { - return Math.max(0, Math.floor(seconds * 1000)); - } - const asDate = Date.parse(retryAfter); - if (Number.isNaN(asDate)) return null; - return Math.max(0, asDate - Date.now()); -} - -function normalizeChannelName(channel: string): string { - return channel.startsWith('#') ? channel.slice(1) : channel; -} - -// --------------------------------------------------------------------------- -// Ed25519 device identity for OpenClaw gateway WebSocket auth -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Auth profile system — deterministic profile selection for WS auth across -// OpenClaw/Clawdbot versions. Profiles define key encoding, signature format, -// and payload canonicalization for the device auth handshake. -// --------------------------------------------------------------------------- - -interface AuthProfile { - /** Human-readable profile name (logged on each auth attempt). */ - name: string; - /** Encoding for the public key sent in the connect message. */ - publicKeyFormat: 'raw-base64url' | 'spki-pem'; - /** Encoding for the Ed25519 signature. */ - signatureEncoding: 'base64url' | 'base64'; -} - -const AUTH_PROFILES: Record = { - default: { - name: 'default', - publicKeyFormat: 'raw-base64url', - signatureEncoding: 'base64url', - }, - 'clawdbot-v1': { - // Server (openclaw/openclaw device-identity.ts) accepts both PEM and raw-base64url - // public keys, and decodes signatures in both base64url and base64. Use base64url - // for consistency — matches the server's own signDevicePayload() output. - name: 'clawdbot-v1', - publicKeyFormat: 'raw-base64url', - signatureEncoding: 'base64url', - }, -}; - -/** - * Resolve the auth profile to use. Selection priority: - * 1. Explicit env var `OPENCLAW_WS_AUTH_COMPAT` (manual override, highest priority) - * 2. Variant detection: `~/.clawdbot/` detected → clawdbot-v1 - * 3. Default profile (standard OpenClaw, unchanged) - */ -function resolveAuthProfile(): AuthProfile { - // 1. Manual override (highest priority) - const envVal = process.env.OPENCLAW_WS_AUTH_COMPAT; - if (envVal === 'clawdbot' || envVal === 'clawdbot-v1') { - return AUTH_PROFILES['clawdbot-v1']; - } - if (envVal && AUTH_PROFILES[envVal]) { - return AUTH_PROFILES[envVal]; - } - - // 2. Variant detection via filesystem probing — delegates to openclawHome() - // which checks valid parseable config files, not just directory existence. - // Strict suffix check avoids false positives from substring matching. - const home = openclawHome(); - const homeSuffix = - home - .replace(/[/\\]+$/, '') - .split(/[/\\]/) - .pop() ?? ''; - if (homeSuffix === '.clawdbot' || homeSuffix === 'clawdbot') { - return AUTH_PROFILES['clawdbot-v1']; - } - - // 3. Default - return AUTH_PROFILES['default']; -} - -/** Backward-compat helper — returns 'clawdbot' when using clawdbot profile. */ -type WsAuthCompat = 'clawdbot' | undefined; -function getWsAuthCompat(): WsAuthCompat { - const profile = resolveAuthProfile(); - return profile.name === 'clawdbot-v1' ? 'clawdbot' : undefined; -} - -interface DeviceIdentity { - publicKeyB64: string; // base64url-encoded raw Ed25519 public key (default mode) - publicKeyPem?: string; // PEM-encoded SPKI public key (clawdbot compat mode) - privateKeyObj: KeyObject; // Node.js KeyObject for signing - deviceId: string; // SHA-256 hex of the raw public key -} - -function generateDeviceIdentity(compat?: WsAuthCompat): DeviceIdentity { - const { publicKey, privateKey } = generateKeyPairSync('ed25519'); - - // Extract raw 32-byte public key from SPKI DER (12-byte header for Ed25519) - const rawPublicBytes = publicKey.export({ type: 'spki', format: 'der' }).subarray(12); - - const deviceId = createHash('sha256').update(rawPublicBytes).digest('hex'); - const publicKeyB64 = Buffer.from(rawPublicBytes).toString('base64url'); - - const identity: DeviceIdentity = { - publicKeyB64, - privateKeyObj: privateKey, - deviceId, - }; - - if (compat === 'clawdbot') { - identity.publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; - } - - return identity; -} - -/** Path to persisted device identity file. */ -function deviceIdentityPath(): string { - return join(openclawHome(), 'workspace', 'relaycast', 'device.json'); -} - -interface PersistedDevice { - publicKeyB64: string; - privateKeyPkcs8B64: string; // base64-encoded PKCS#8 DER - deviceId: string; - /** PEM-encoded SPKI public key — present when generated with clawdbot compat mode. */ - publicKeyPem?: string; - /** PEM-encoded PKCS#8 private key — present when generated with clawdbot compat mode. */ - privateKeyPem?: string; -} - -/** - * Load a persisted device identity from disk, or generate and persist a new one. - * This ensures the same device ID survives restarts so the OpenClaw gateway - * can pair it once and recognize it on subsequent connections. - */ -async function loadOrCreateDeviceIdentity(): Promise { - const filePath = deviceIdentityPath(); - const compat = getWsAuthCompat(); - - // Attempt to load existing identity (no existsSync — just try the read) - try { - const raw = await readFile(filePath, 'utf-8'); - const persisted = JSON.parse(raw) as PersistedDevice; - const privateKeyObj = createPrivateKey({ - key: Buffer.from(persisted.privateKeyPkcs8B64, 'base64'), - format: 'der', - type: 'pkcs8', - }); - // Ensure permissions are tight even if file was created with looser perms - await chmod(filePath, 0o600).catch(() => {}); - console.log( - `[openclaw-ws] Loaded persisted device identity (deviceId=${persisted.deviceId.slice(0, 12)}...)` - ); - - const identity: DeviceIdentity = { - publicKeyB64: persisted.publicKeyB64, - privateKeyObj, - deviceId: persisted.deviceId, - }; - - // If compat mode is clawdbot but the persisted device has no PEM keys, - // derive them on-the-fly from the existing DER key material. - if (compat === 'clawdbot') { - if (persisted.publicKeyPem) { - identity.publicKeyPem = persisted.publicKeyPem; - } else { - // Reconstruct SPKI public key from the stored base64url raw bytes - const rawPublicBytes = Buffer.from(persisted.publicKeyB64, 'base64url'); - // Ed25519 SPKI DER = 12-byte header + 32-byte raw key - const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex'); - const spkiDer = Buffer.concat([spkiHeader, rawPublicBytes]); - const publicKeyObj = createPublicKey({ key: spkiDer, format: 'der', type: 'spki' }); - identity.publicKeyPem = publicKeyObj.export({ type: 'spki', format: 'pem' }) as string; - console.log('[openclaw-ws] Derived PEM public key from existing DER key for clawdbot compat mode'); - } - } - - return identity; - } catch (err) { - // ENOENT is expected on first run; other errors mean corruption - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn( - `[openclaw-ws] Failed to load device identity, generating new: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - // Generate fresh and persist via atomic write-then-rename - const identity = generateDeviceIdentity(compat); - const pkcs8Der = identity.privateKeyObj.export({ type: 'pkcs8', format: 'der' }); - const persisted: PersistedDevice = { - publicKeyB64: identity.publicKeyB64, - privateKeyPkcs8B64: Buffer.from(pkcs8Der).toString('base64'), - deviceId: identity.deviceId, - }; - - if (compat === 'clawdbot' && identity.publicKeyPem) { - persisted.publicKeyPem = identity.publicKeyPem; - persisted.privateKeyPem = identity.privateKeyObj.export({ type: 'pkcs8', format: 'pem' }) as string; - } - - try { - const dir = join(openclawHome(), 'workspace', 'relaycast'); - await mkdir(dir, { recursive: true }); - const tmpPath = filePath + '.tmp'; - await writeFile(tmpPath, JSON.stringify(persisted, null, 2) + '\n', { mode: 0o600 }); - await rename(tmpPath, filePath); - console.log( - `[openclaw-ws] Persisted new device identity (deviceId=${identity.deviceId.slice(0, 12)}...)` - ); - } catch (err) { - console.warn( - `[openclaw-ws] Could not persist device identity: ${err instanceof Error ? err.message : String(err)}` - ); - } - - return identity; -} - -/** Hash helper for diagnostics (no secrets leaked — just truncated SHA-256). */ -function shortHash(data: string | Buffer): string { - const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data; - return createHash('sha256').update(buf).digest('hex').slice(0, 16); -} - -/** - * Canonicalization variants to try for debugging. Each produces a different - * pipe-delimited payload string. The server should match exactly one. - */ -function buildCanonicalVariants( - device: DeviceIdentity, - params: { - clientId: string; - clientMode: string; - platform: string; - deviceFamily: string; - role: string; - scopes: string[]; - signedAt: number; - token: string; - nonce: string; - } -): Array<{ name: string; payload: string }> { - const signedAtMs = String(params.signedAt); - const signedAtSec = String(Math.floor(params.signedAt / 1000)); - const scopesCsv = params.scopes.join(','); - - return [ - // V0: current default order (v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily) - { - name: 'v3-default-ms', - payload: [ - 'v3', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - scopesCsv, - signedAtMs, - params.token || '', - params.nonce, - params.platform, - params.deviceFamily, - ].join('|'), - }, - // V1: signedAt in seconds instead of milliseconds - { - name: 'v3-default-sec', - payload: [ - 'v3', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - scopesCsv, - signedAtSec, - params.token || '', - params.nonce, - params.platform, - params.deviceFamily, - ].join('|'), - }, - // V2: no token in payload (token omitted entirely) - { - name: 'v3-no-token-ms', - payload: [ - 'v3', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - scopesCsv, - signedAtMs, - params.nonce, - params.platform, - params.deviceFamily, - ].join('|'), - }, - // V3: nonce before token (swapped positions) - { - name: 'v3-nonce-first-ms', - payload: [ - 'v3', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - scopesCsv, - signedAtMs, - params.nonce, - params.token || '', - params.platform, - params.deviceFamily, - ].join('|'), - }, - // V4: fewer fields — just core identity + nonce + signedAt (minimal) - { - name: 'v3-minimal', - payload: ['v3', device.deviceId, signedAtMs, params.nonce].join('|'), - }, - // V5: signedAt seconds + no token - { - name: 'v3-no-token-sec', - payload: [ - 'v3', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - scopesCsv, - signedAtSec, - params.nonce, - params.platform, - params.deviceFamily, - ].join('|'), - }, - // V6: v2 format (no platform/deviceFamily) — used by older gateway versions - { - name: 'v2-default-ms', - payload: [ - 'v2', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - scopesCsv, - signedAtMs, - params.token || '', - params.nonce, - ].join('|'), - }, - // V7: v2 with signedAt in seconds - { - name: 'v2-default-sec', - payload: [ - 'v2', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - scopesCsv, - signedAtSec, - params.token || '', - params.nonce, - ].join('|'), - }, - // V8: v2 without token - { - name: 'v2-no-token-ms', - payload: [ - 'v2', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - scopesCsv, - signedAtMs, - params.nonce, - ].join('|'), - }, - ]; -} - -/** Payload version override for v3↔v2 fallback. */ -type PayloadVersionOverride = 'v2' | 'v3' | null; - -function signConnectPayload( - device: DeviceIdentity, - params: { - clientId: string; - clientMode: string; - platform: string; - deviceFamily: string; - role: string; - scopes: string[]; - signedAt: number; - token: string; - nonce: string; - }, - versionOverride?: PayloadVersionOverride -): string { - const profile = resolveAuthProfile(); - - // Build canonicalization variants for diagnostics - const variants = buildCanonicalVariants(device, params); - - // Select primary payload version: - // 1. If versionOverride is set (from fallback), use that directly - // 2. clawdbot-v1 defaults to v2 (older gateway compat) - // 3. default profile uses v3 - let primaryName: string; - if (versionOverride === 'v2') { - primaryName = 'v2-default-ms'; - } else if (versionOverride === 'v3') { - primaryName = 'v3-default-ms'; - } else { - primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms'; - } - const primary = variants.find((v) => v.name === primaryName) ?? variants[0]; - - const payloadBytes = Buffer.from(primary.payload, 'utf-8'); - - const isDebug = process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1'; - - // Concise production log — one line with essential info - console.log( - `[ws-auth] profile=${profile.name} payload=${primary.name} device=${device.deviceId.slice(0, 12)}...${versionOverride ? ` override=${versionOverride}` : ''}` - ); - - // Verbose debug logging — field hashes and canonicalization matrix - if (isDebug) { - console.log( - `[ws-auth-debug] signedAt=${params.signedAt}ms nonce=${shortHash(params.nonce)} keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}` - ); - console.log( - `[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}` - ); - console.log('[ws-auth-debug] canonicalization matrix:'); - for (const v of variants) { - console.log(` ${v.name}: hash=${shortHash(v.payload)}`); - } - console.log(`[ws-auth-debug] payloadHash=${shortHash(primary.payload)}`); - } - - // Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519 - const signature = sign(null, payloadBytes, device.privateKeyObj); - const encoded = Buffer.from(signature).toString(profile.signatureEncoding); - - // Self-verification (debug only): confirm our signature is valid locally. - if (isDebug) { - try { - // Derive public key from private key (same as server would use from our publicKey field) - const pubKey = createPublicKey(device.privateKeyObj); - const selfVerifyRaw = verify(null, payloadBytes, pubKey, signature); - - // Also verify the round-trip: decode our encoded signature like the server would - const decodedSig = Buffer.from( - encoded, - profile.signatureEncoding === 'base64url' ? 'base64url' : 'base64' - ); - const selfVerifyEncoded = verify(null, payloadBytes, pubKey, decodedSig); - - // Verify deviceId matches public key - const rawPubBytes = pubKey.export({ type: 'spki', format: 'der' }).subarray(12); - const derivedDeviceId = createHash('sha256').update(rawPubBytes).digest('hex'); - const deviceIdMatch = derivedDeviceId === device.deviceId; - - console.log( - `[ws-auth-debug] self-verify: raw=${selfVerifyRaw} encoded=${selfVerifyEncoded} deviceIdMatch=${deviceIdMatch} derivedId=${derivedDeviceId.slice(0, 16)}...` - ); - if (!deviceIdMatch) { - console.error( - `[ws-auth-debug] DEVICE ID MISMATCH: derived=${derivedDeviceId} sent=${device.deviceId}` - ); - } - } catch (err) { - console.error(`[ws-auth-debug] self-verify error: ${err instanceof Error ? err.message : String(err)}`); - } - } - - return encoded; -} - -// --------------------------------------------------------------------------- -// Persistent OpenClaw Gateway WebSocket client -// --------------------------------------------------------------------------- - -interface PendingRpc { - resolve: (value: boolean) => void; - timer: ReturnType; -} - -/** @internal */ -export class OpenClawGatewayClient { - private ws: WebSocket | null = null; - private authenticated = false; - private device: DeviceIdentity; - private token: string; - private port: number; - private pendingRpcs = new Map(); - private rpcIdCounter = 0; - private reconnectTimer: ReturnType | null = null; - private stopped = false; - private connectPromise: Promise | null = null; - private connectResolve: (() => void) | null = null; - private connectReject: ((error: Error) => void) | null = null; - private connectTimeout: ReturnType | null = null; - private pairingRejected = false; - private consecutiveFailures = 0; - /** Payload version override for v3↔v2 fallback (null = use profile default). */ - private payloadVersionOverride: PayloadVersionOverride = null; - /** Whether a fallback attempt has already been tried this connection cycle. */ - private fallbackAttempted = false; - /** Auth rejection counters for observability. */ - private authRejectCount = 0; - private authFallbackCount = 0; - - /** Default timeout for initial connection (30 seconds). */ - private static readonly CONNECT_TIMEOUT_MS = 30_000; - private static readonly MAX_CONSECUTIVE_FAILURES = 5; - private static readonly BASE_RECONNECT_MS = 3_000; - private static readonly MAX_RECONNECT_MS = 30_000; - /** Slow retry interval after pairing rejection or max failures (60s). */ - private static readonly PAIRING_RETRY_MS = 60_000; - - constructor(token: string, port: number, device?: DeviceIdentity) { - this.token = token; - this.port = port; - this.device = device ?? generateDeviceIdentity(getWsAuthCompat()); - } - - /** - * Create a client with a persisted device identity (loaded from disk or - * freshly generated and saved). This ensures the same device ID is reused - * across restarts so the OpenClaw gateway can pair it once. - */ - static async create(token: string, port: number): Promise { - const device = await loadOrCreateDeviceIdentity(); - return new OpenClawGatewayClient(token, port, device); - } - - /** Connect and authenticate. Resolves when chat.send is ready, rejects on timeout or error. */ - async connect(): Promise { - if (this.authenticated && this.ws?.readyState === WebSocket.OPEN) return; - - // Explicit connect() clears pairing rejection so users can retry after fixing their token - this.pairingRejected = false; - this.stopped = false; - // Reset fallback state for fresh connection attempts - this.payloadVersionOverride = null; - this.fallbackAttempted = false; - - // Cancel any pending reconnect timer to prevent orphaned WebSocket connections - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - - this.connectPromise = new Promise((resolve, reject) => { - this.connectResolve = resolve; - this.connectReject = reject; - - // Set up timeout to prevent indefinite hanging - this.connectTimeout = setTimeout(() => { - this.connectTimeout = null; - if (!this.authenticated) { - const err = new Error( - `Connection to OpenClaw gateway timed out after ${OpenClawGatewayClient.CONNECT_TIMEOUT_MS}ms` - ); - this.connectReject?.(err); - this.connectReject = null; - this.connectResolve = null; - } - }, OpenClawGatewayClient.CONNECT_TIMEOUT_MS); - }); - - this.doConnect(); - return this.connectPromise; - } - - private clearConnectTimeout(): void { - if (this.connectTimeout) { - clearTimeout(this.connectTimeout); - this.connectTimeout = null; - } - } - - private doConnect(): void { - if (this.stopped) return; - - let ws: WebSocket; - try { - ws = new WebSocket(`ws://127.0.0.1:${this.port}`); - } catch (err) { - console.warn(`[openclaw-ws] Connection failed: ${err instanceof Error ? err.message : String(err)}`); - this.scheduleReconnect(); - return; - } - this.ws = ws; - - ws.on('open', () => { - console.log('[openclaw-ws] Connected to OpenClaw gateway'); - }); - - ws.on('message', (data) => { - // Guard: ignore messages from superseded WebSocket instances. - if (this.ws !== ws) return; - this.handleMessage(data.toString()); - }); - - ws.on('close', (code, reason) => { - // Guard: ignore close events from superseded WebSocket instances. - // During v3↔v2 fallback, the old WS is replaced before its close fires. - if (this.ws !== ws) return; - - // Sanitize reason to prevent log injection (newlines, control chars) - const reasonStr = stripControlChars(reason.toString()).slice(0, 200); - console.warn(`[openclaw-ws] Disconnected: ${code} ${reasonStr}`); - const wasAuthenticated = this.authenticated; - this.authenticated = false; - - // Detect pairing rejection: code 1008 (Policy Violation) with pairing reason - if (code === 1008 && /pairing|not.paired/i.test(reasonStr)) { - console.error('[openclaw-ws] Connection closed due to pairing policy. Device is not paired.'); - console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`); - console.error( - '[openclaw-ws] Run: openclaw devices approve (check gateway logs for requestId)' - ); - this.pairingRejected = true; - } - - // Reject all pending RPCs - for (const [id, pending] of this.pendingRpcs) { - clearTimeout(pending.timer); - pending.resolve(false); - this.pendingRpcs.delete(id); - } - // If we weren't authenticated yet, reject the connect promise - if (!wasAuthenticated && this.connectReject) { - this.clearConnectTimeout(); - const err = new Error(`WebSocket closed before authentication (code=${code})`); - this.connectReject(err); - this.connectReject = null; - this.connectResolve = null; - } - if (!this.stopped) { - this.scheduleReconnect(); - } - }); - - ws.on('error', (err) => { - // Guard: ignore error events from superseded WebSocket instances. - if (this.ws !== ws) return; - - console.warn(`[openclaw-ws] Error: ${err.message}`); - // If we weren't authenticated yet, reject the connect promise - if (!this.authenticated && this.connectReject) { - this.clearConnectTimeout(); - this.connectReject(err); - this.connectReject = null; - this.connectResolve = null; - } - }); - } - - // eslint-disable-next-line complexity - private handleMessage(raw: string): void { - let msg: Record; - try { - msg = JSON.parse(raw); - } catch { - return; - } - - // Handle connect.challenge — sign and respond - if (msg.type === 'event' && msg.event === 'connect.challenge') { - const payload = msg.payload as { nonce: string; ts: number }; - console.log('[openclaw-ws] Received connect.challenge, signing...'); - // Log raw challenge payload for debugging canonicalization issues - if (process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1') { - console.log(`[ws-auth-debug] challenge payload: ${JSON.stringify(payload)}`); - } - - const signedAt = Date.now(); - const clientId = 'cli'; - const clientMode = 'cli'; - const platform = process.platform === 'darwin' ? 'macos' : 'linux'; - const deviceFamily = 'cli'; - const role = 'operator'; - const scopes = ['operator.read', 'operator.write']; - - const signature = signConnectPayload( - this.device, - { - clientId, - clientMode, - platform, - deviceFamily, - role, - scopes, - signedAt, - token: this.token, - nonce: payload.nonce, - }, - this.payloadVersionOverride - ); - - // Select public key format based on resolved auth profile. - const profile = resolveAuthProfile(); - const publicKeyField = - profile.publicKeyFormat === 'spki-pem' && this.device.publicKeyPem - ? this.device.publicKeyPem - : this.device.publicKeyB64; - - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - console.warn('[openclaw-ws] WebSocket not open when trying to send connect'); - return; - } - this.ws.send( - JSON.stringify({ - type: 'req', - id: 'connect-1', - method: 'connect', - params: { - minProtocol: 3, - maxProtocol: 3, - client: { - id: clientId, - version: '1.0.0', - platform, - mode: clientMode, - deviceFamily, - }, - role, - scopes, - caps: [], - commands: [], - permissions: {}, - auth: { token: this.token }, - locale: 'en-US', - userAgent: 'relaycast-gateway/1.0.0', - device: { - id: this.device.deviceId, - publicKey: publicKeyField, - signature, - signedAt, - nonce: payload.nonce, - }, - }, - }) - ); - return; - } - - // Handle connect response - if (msg.type === 'res' && msg.id === 'connect-1') { - if (msg.ok) { - this.clearConnectTimeout(); - const versionUsed = - this.payloadVersionOverride ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3'); - console.log( - `[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})` - ); - this.authenticated = true; - this.consecutiveFailures = 0; - this.connectResolve?.(); - this.connectResolve = null; - this.connectReject = null; - } else { - const errStr = msg.error ? JSON.stringify(msg.error) : 'Authentication rejected'; - const isPairing = /pairing.required|not.paired/i.test(errStr); - const isSignatureInvalid = /signature.invalid|device.signature|invalid.signature/i.test(errStr); - - if (isPairing) { - this.clearConnectTimeout(); - const errObj = msg.error as Record | undefined; - const requestId = errObj?.requestId ?? errObj?.request_id ?? ''; - console.error('[openclaw-ws] Pairing rejected — device is not paired with the OpenClaw gateway.'); - if (requestId) { - console.error(`[openclaw-ws] Approve this device: openclaw devices approve ${requestId}`); - } - console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`); - const configHint = - getWsAuthCompat() === 'clawdbot' ? '~/.clawdbot/clawdbot.json' : '~/.openclaw/openclaw.json'; - console.error( - `[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token` - ); - this.pairingRejected = true; - } else if (isSignatureInvalid && !this.fallbackAttempted) { - // Signature rejected — try the alternate payload version once. - // Do NOT clear connect timeout — it protects the fallback attempt too. - this.authRejectCount++; - this.authFallbackCount++; - const profile = resolveAuthProfile(); - const currentVersion = - this.payloadVersionOverride ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3'); - const fallbackVersion: PayloadVersionOverride = currentVersion === 'v2' ? 'v3' : 'v2'; - - console.warn( - `[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})` - ); - this.payloadVersionOverride = fallbackVersion; - this.fallbackAttempted = true; - - // Close current WS and reconnect with the alternate payload. - // Setting this.ws = null ensures the old WS's close/error handlers - // no-op via the `this.ws !== ws` guard in doConnect(). - try { - this.ws?.close(); - } catch { - // Best effort - } - this.ws = null; - setTimeout(() => this.doConnect(), 0); - return; // Don't reject the connect promise yet — fallback attempt in progress - } else { - this.clearConnectTimeout(); - this.authRejectCount++; - console.warn(`[openclaw-ws] Auth rejected (rejects=${this.authRejectCount}): ${errStr}`); - } - - this.connectReject?.(new Error(`OpenClaw gateway auth failed: ${errStr}`)); - this.connectReject = null; - this.connectResolve = null; - } - return; - } - - // Handle RPC responses - const id = msg.id as string | undefined; - if (id && this.pendingRpcs.has(id)) { - const pending = this.pendingRpcs.get(id)!; - clearTimeout(pending.timer); - this.pendingRpcs.delete(id); - - if (msg.ok === false || msg.error) { - console.warn('[openclaw-ws] RPC error response received'); - pending.resolve(false); - } else { - console.log('[openclaw-ws] RPC succeeded'); - pending.resolve(true); - } - return; - } - - // Log other events at debug level - if (msg.type === 'event') { - // chat events, tick events, etc. — ignore silently - } - } - - /** Send a chat.send RPC. Returns true if accepted. */ - async sendChatMessage(text: string, idempotencyKey?: string): Promise { - if (this.stopped) return false; - if (!this.authenticated || !this.ws || this.ws.readyState !== WebSocket.OPEN) { - // Try to reconnect - try { - await this.connect(); - } catch { - return false; - } - if (!this.authenticated) return false; - } - - const id = `chat-${++this.rpcIdCounter}-${Date.now()}`; - - return new Promise((resolve) => { - const timer = setTimeout(() => { - console.warn(`[openclaw-ws] chat.send ${id} timed out`); - this.pendingRpcs.delete(id); - resolve(false); - }, 15_000); - - this.pendingRpcs.set(id, { resolve, timer }); - - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - clearTimeout(timer); - this.pendingRpcs.delete(id); - resolve(false); - return; - } - this.ws.send( - JSON.stringify({ - type: 'req', - id, - method: 'chat.send', - params: { - sessionKey: 'agent:main:main', - message: text, - ...(idempotencyKey ? { idempotencyKey } : {}), - }, - }) - ); - }); - } - - private scheduleReconnect(): void { - if (this.stopped || this.reconnectTimer) return; - - // After pairing rejection or max failures, switch to slow periodic retry - // so the gateway can self-heal once pairing is approved externally. - if (this.pairingRejected || this.consecutiveFailures >= OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) { - if (this.consecutiveFailures === OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) { - console.warn( - `[openclaw-ws] ${this.consecutiveFailures} consecutive failures — switching to slow retry (every 60s).` - ); - console.warn( - '[openclaw-ws] Check that the OpenClaw gateway is running and OPENCLAW_GATEWAY_TOKEN is correct.' - ); - } - this.consecutiveFailures++; - console.log(`[openclaw-ws] Slow retry in ${OpenClawGatewayClient.PAIRING_RETRY_MS / 1000}s...`); - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.pairingRejected = false; // Clear flag so connect attempt proceeds - // Reset fallback state so reconnect tries primary payload version first - this.payloadVersionOverride = null; - this.fallbackAttempted = false; - this.doConnect(); - }, OpenClawGatewayClient.PAIRING_RETRY_MS); - return; - } - - this.consecutiveFailures++; - - const delay = Math.min( - OpenClawGatewayClient.BASE_RECONNECT_MS * Math.pow(2, this.consecutiveFailures - 1), - OpenClawGatewayClient.MAX_RECONNECT_MS - ); - console.log(`[openclaw-ws] Reconnecting in ${delay / 1000}s (attempt ${this.consecutiveFailures})...`); - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - // Reset fallback state so reconnect tries primary payload version first - this.payloadVersionOverride = null; - this.fallbackAttempted = false; - this.doConnect(); - }, delay); - } - - async disconnect(): Promise { - this.stopped = true; - this.clearConnectTimeout(); - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - for (const [id, pending] of this.pendingRpcs) { - clearTimeout(pending.timer); - pending.resolve(false); - this.pendingRpcs.delete(id); - } - if (this.ws) { - try { - this.ws.close(); - } catch { - // Best effort - } - this.ws = null; - } - this.authenticated = false; - // Clear any pending connect promise - this.connectReject = null; - this.connectResolve = null; - } -} - -// --------------------------------------------------------------------------- -// InboundGateway -// --------------------------------------------------------------------------- - -export class InboundGateway { - private readonly relaySender: RelaySender | null; - private relayAgentClient: AgentClient | null = null; - private relayAgentToken: string | null = null; - private readonly relaycast: RelayCast; - private readonly config: GatewayConfig; - private readonly dedupeTtlMs: number; - - private running = false; - private unsubscribeHandlers: Array<() => void> = []; - private seenMessageIds = new Map(); - private processingMessageIds = new Set(); - - /** Persistent WebSocket client for the local OpenClaw gateway. */ - private openclawClient: OpenClawGatewayClient | null = null; - - /** Spawn manager — lives in the gateway so spawned processes survive MCP server restarts. */ - private spawnManager: SpawnManager; - /** HTTP control server for spawn/list/release commands. */ - private controlServer: HttpServer | null = null; - /** Port the control server listens on. */ - controlPort = 0; - - private transportState: InboundTransportState = 'WS_DEGRADED'; - private activeTransportMode: InboundTransportMode = 'ws'; - private wsFailureCount = 0; - private pollLoopPromise: Promise | null = null; - private pollAbortController: AbortController | null = null; - private pollLoopStopRequested = false; - private pollCursorLoaded = false; - private pollCursor = DEFAULT_POLL_INITIAL_CURSOR; - private pollLastSequence = 0; - private pollRecentEventIds: string[] = []; - private pollFailureCount = 0; - private probeWsTimer: ReturnType | null = null; - private wsRecoveryTimer: ReturnType | null = null; - private fallbackCount = 0; - private lastFallbackReason: string | null = null; - private fallbackStartedAt: number | null = null; - private totalFallbackMs = 0; - private duplicateDropCount = 0; - private cursorResetCount = 0; - - /** Default control port for the gateway's spawn API. */ - static readonly DEFAULT_CONTROL_PORT = 18790; - - constructor(options: GatewayOptions) { - this.config = { - ...options.config, - channels: options.config.channels.map(normalizeChannelName), - }; - this.relaySender = options.relaySender ?? null; - this.relaycast = new RelayCast({ - apiKey: this.config.apiKey, - baseUrl: this.config.baseUrl, - }); - - const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000); - this.dedupeTtlMs = - Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000 ? Math.floor(dedupeTtlMs) : 15 * 60 * 1000; - - const parentDepth = Number(process.env.OPENCLAW_SPAWN_DEPTH || 0); - this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 }); - } - - private isPollFallbackEnabled(): boolean { - return this.config.transport?.pollFallback?.enabled ?? false; - } - - private wsFailureThreshold(): number { - const configured = this.config.transport?.pollFallback?.wsFailureThreshold; - if (!Number.isFinite(configured) || configured === undefined) { - return DEFAULT_WS_FAILURE_THRESHOLD; - } - return Math.max(1, Math.floor(configured)); - } - - private pollTimeoutSeconds(): number { - return sanitizePollTimeoutSeconds(this.config.transport?.pollFallback?.timeoutSeconds); - } - - private pollLimit(): number { - return sanitizePollLimit(this.config.transport?.pollFallback?.limit); - } - - private pollInitialCursor(): string { - return this.config.transport?.pollFallback?.initialCursor?.trim() || DEFAULT_POLL_INITIAL_CURSOR; - } - - private isWsProbeEnabled(): boolean { - return this.config.transport?.pollFallback?.probeWs?.enabled ?? true; - } - - private wsProbeIntervalMs(): number { - const configured = this.config.transport?.pollFallback?.probeWs?.intervalMs; - if (!Number.isFinite(configured) || configured === undefined) { - return DEFAULT_WS_PROBE_INTERVAL_MS; - } - return Math.max(1_000, Math.floor(configured)); - } - - private wsStableGraceMs(): number { - const configured = this.config.transport?.pollFallback?.probeWs?.stableGraceMs; - if (!Number.isFinite(configured) || configured === undefined) { - return DEFAULT_WS_STABLE_GRACE_MS; - } - return Math.max(1_000, Math.floor(configured)); - } - - private transportHealthSnapshot(): Record { - const activeFallbackMs = this.fallbackStartedAt === null ? 0 : Date.now() - this.fallbackStartedAt; - return { - mode: this.activeTransportMode, - state: this.transportState, - wsFailureCount: this.wsFailureCount, - fallbackCount: this.fallbackCount, - lastFallbackReason: this.lastFallbackReason, - timeInFallbackMs: this.totalFallbackMs + activeFallbackMs, - duplicateDrops: this.duplicateDropCount, - cursorResets: this.cursorResetCount, - lastSequence: this.pollLastSequence, - }; - } - - private completeFallbackWindow(): void { - if (this.fallbackStartedAt !== null) { - this.totalFallbackMs += Date.now() - this.fallbackStartedAt; - this.fallbackStartedAt = null; - } - } - - private cleanupRelaySubscriptions(): void { - for (const unsubscribe of this.unsubscribeHandlers) { - try { - unsubscribe(); - } catch { - // Best effort - } - } - this.unsubscribeHandlers = []; - } - - private subscribeRelayChannels(): void { - if (!this.relayAgentClient) return; - try { - this.relayAgentClient.subscribe(this.config.channels); - } catch { - // Will subscribe on the next connected event. - } - } - - private async connectRelayAgentClient(): Promise { - if (!this.relayAgentClient) return; - try { - await Promise.resolve(this.relayAgentClient.connect()); - } catch (error) { - console.warn( - `[gateway] Relaycast WS connect failed: ${error instanceof Error ? error.message : String(error)}` - ); - await this.handleWsFailure('connect_failed'); - } - } - - private bindRelayAgentHandlers(): void { - if (!this.relayAgentClient) return; - - this.cleanupRelaySubscriptions(); - - this.unsubscribeHandlers.push( - this.relayAgentClient.on.connected(() => { - console.log( - `[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}` - ); - this.wsFailureCount = 0; - this.subscribeRelayChannels(); - if (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') { - this.beginWsRecovery(); - return; - } - this.completeFallbackWindow(); - this.transportState = 'WS_ACTIVE'; - this.activeTransportMode = 'ws'; - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.messageCreated((event: MessageCreatedEvent) => { - if (!this.shouldProcessWsInbound()) return; - console.log(`[gateway] Realtime message from @${event.message?.agentName} in #${event.channel}`); - void this.handleRealtimeMessage(event); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.threadReply((event: ThreadReplyEvent) => { - if (!this.shouldProcessWsInbound()) return; - console.log( - `[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})` - ); - void this.handleRealtimeThreadReply(event); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.dmReceived((event: DmReceivedEvent) => { - if (!this.shouldProcessWsInbound()) return; - console.log(`[gateway] DM from @${event.message?.agentName} (conv: ${event.conversationId})`); - void this.handleRealtimeDm(event); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.groupDmReceived((event: GroupDmReceivedEvent) => { - if (!this.shouldProcessWsInbound()) return; - console.log(`[gateway] Group DM from @${event.message?.agentName} (conv: ${event.conversationId})`); - void this.handleRealtimeGroupDm(event); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.commandInvoked((event: CommandInvokedEvent) => { - if (!this.shouldProcessWsInbound()) return; - console.log( - `[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}` - ); - void this.handleRealtimeCommand(event); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.reactionAdded((event: ReactionAddedEvent) => { - if (!this.shouldProcessWsInbound()) return; - console.log(`[gateway] Reaction :${event.emoji}: added by @${event.agentName} on ${event.messageId}`); - void this.handleRealtimeReaction(event, 'added'); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.reactionRemoved((event: ReactionRemovedEvent) => { - if (!this.shouldProcessWsInbound()) return; - console.log( - `[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}` - ); - void this.handleRealtimeReaction(event, 'removed'); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.reconnecting((attempt: number) => { - console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`); - void this.handleWsFailure(`reconnecting:${attempt}`); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.disconnected(() => { - console.warn('[gateway] Relaycast disconnected'); - void this.handleWsFailure('disconnected'); - }) - ); - this.unsubscribeHandlers.push( - this.relayAgentClient.on.error((error?: unknown) => { - const message = error instanceof Error ? error.message : 'socket error'; - console.warn(`[gateway] Relaycast socket error${message ? `: ${message}` : ''}`); - void this.handleWsFailure(message || 'socket_error'); - }) - ); - } - - private async replaceRelayAgentClient(agentToken: string): Promise { - this.cleanupRelaySubscriptions(); - if (this.relayAgentClient) { - try { - await this.relayAgentClient.disconnect(); - } catch { - // Best effort - } - } - this.relayAgentToken = agentToken; - this.relayAgentClient = this.relaycast.as(agentToken); - // SDK's onEvent() throws if the WS object doesn't exist yet. - // connect() synchronously creates the WS and initiates the connection, - // so we call it before binding handlers. This ensures: - // 1. The WS object exists when onEvent() is called (no throw) - // 2. Handlers are bound before the connection fully opens (no missed events) - // 3. The SDK's double-connect guard (if ws exists, no-op) makes this safe - // connect() is synchronous: it creates the WS object and calls ws.connect(). - // It does NOT return a Promise — async errors (network failures, disconnects) - // arrive via the 'error' and 'disconnected' event handlers bound below. - // We must bind handlers after connect() (so the WS object exists for onEvent()) - // but before the connection fully opens (so no events are missed). - try { - this.relayAgentClient.connect(); - } catch (err) { - console.warn( - `[gateway] Relaycast WS connect failed: ${err instanceof Error ? err.message : String(err)}` - ); - await this.handleWsFailure('connect_failed'); - } - this.bindRelayAgentHandlers(); - } - - private async refreshRelayAgentRegistration(): Promise { - const registered = await this.relaycast.agents.registerOrRotate({ - name: this.config.clawName, - type: 'agent', - persona: 'Relaycast inbound gateway for OpenClaw', - }); - await this.replaceRelayAgentClient(registered.token); - await this.ensureChannelMembership(); - this.subscribeRelayChannels(); - } - - private shouldProcessWsInbound(): boolean { - return ( - !this.isPollFallbackEnabled() || - this.transportState === 'WS_ACTIVE' || - this.transportState === 'RECOVERING_WS' - ); - } - - private async handleWsFailure(reason: string): Promise { - if (!this.running) return; - - if (this.wsRecoveryTimer) { - clearTimeout(this.wsRecoveryTimer); - this.wsRecoveryTimer = null; - } - - if (this.transportState === 'RECOVERING_WS') { - console.warn(`[gateway] WS recovery probe failed, remaining on long-poll (${reason})`); - this.transportState = 'POLL_ACTIVE'; - this.activeTransportMode = 'poll'; - await this.startPollLoop(); - this.startWsProbeLoop(); - return; - } - - if (this.transportState === 'POLL_ACTIVE') { - this.lastFallbackReason = reason; - return; - } - - this.transportState = 'WS_DEGRADED'; - this.wsFailureCount += 1; - - if (this.isPollFallbackEnabled() && this.wsFailureCount >= this.wsFailureThreshold()) { - await this.activatePollFallback(reason); - } - } - - private async activatePollFallback(reason: string): Promise { - if (!this.running || !this.isPollFallbackEnabled()) return; - if (this.transportState === 'POLL_ACTIVE') return; - - await this.ensurePollCursorLoaded(); - this.transportState = 'POLL_ACTIVE'; - this.activeTransportMode = 'poll'; - this.fallbackCount += 1; - this.lastFallbackReason = reason; - if (this.fallbackStartedAt === null) { - this.fallbackStartedAt = Date.now(); - } - - console.warn(`[gateway] Realtime degraded: using long-poll fallback (${reason})`); - await this.startPollLoop(); - this.startWsProbeLoop(); - } - - private startWsProbeLoop(): void { - if (!this.isWsProbeEnabled() || this.probeWsTimer) return; - - this.probeWsTimer = setInterval(() => { - if (!this.running || this.transportState !== 'POLL_ACTIVE') return; - void this.connectRelayAgentClient(); - }, this.wsProbeIntervalMs()); - } - - private stopWsProbeLoop(): void { - if (!this.probeWsTimer) return; - clearInterval(this.probeWsTimer); - this.probeWsTimer = null; - } - - private beginWsRecovery(): void { - if (!this.running) return; - - this.transportState = 'RECOVERING_WS'; - this.stopWsProbeLoop(); - - if (this.wsRecoveryTimer) { - clearTimeout(this.wsRecoveryTimer); - } - - console.log(`[gateway] WS probe connected, waiting ${this.wsStableGraceMs()}ms before promotion`); - this.wsRecoveryTimer = setTimeout(() => { - this.wsRecoveryTimer = null; - void this.promoteWsTransport(); - }, this.wsStableGraceMs()); - } - - private async promoteWsTransport(): Promise { - if (!this.running || this.transportState !== 'RECOVERING_WS') return; - - await this.stopPollLoop(); - const catchupDelayMs = await this.pollOnce(0); - if (catchupDelayMs > 0) { - console.warn('[gateway] WS promotion catch-up poll failed, remaining on long-poll'); - this.transportState = 'POLL_ACTIVE'; - this.activeTransportMode = 'poll'; - await this.startPollLoop(); - this.startWsProbeLoop(); - return; - } - - this.completeFallbackWindow(); - this.transportState = 'WS_ACTIVE'; - this.activeTransportMode = 'ws'; - this.wsFailureCount = 0; - console.log('[gateway] Relaycast WebSocket recovered; promoting WS to active transport'); - } - - private async ensurePollCursorLoaded(): Promise { - if (this.pollCursorLoaded) return; - - this.pollCursorLoaded = true; - this.pollCursor = this.pollInitialCursor(); - - try { - const raw = await readFile(pollCursorStatePath(), 'utf-8'); - const parsed = JSON.parse(raw) as Partial; - const persistedCursor = sanitizeOpaqueStateValue(parsed.cursor, MAX_POLL_CURSOR_LENGTH); - if (persistedCursor) { - this.pollCursor = persistedCursor; - } - if (Number.isFinite(parsed.lastSequence)) { - this.pollLastSequence = Math.max(0, Math.floor(parsed.lastSequence ?? 0)); - } - if (Array.isArray(parsed.recentEventIds)) { - this.pollRecentEventIds = parsed.recentEventIds - .map((value) => sanitizeOpaqueStateValue(value, MAX_EVENT_ID_LENGTH)) - .filter((value): value is string => value !== null) - .slice(-POLL_CURSOR_RECENT_EVENT_LIMIT); - const now = Date.now(); - for (const eventId of this.pollRecentEventIds) { - this.seenMessageIds.set(eventId, now); - } - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn( - `[gateway] Failed to load poll cursor state: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - } - - private async persistPollCursorState(): Promise { - const cursor = - sanitizeOpaqueStateValue(this.pollCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollInitialCursor(); - const recentEventIds = this.pollRecentEventIds - .map((eventId) => sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH)) - .filter((eventId): eventId is string => eventId !== null) - .slice(-POLL_CURSOR_RECENT_EVENT_LIMIT); - this.pollCursor = cursor; - this.pollRecentEventIds = recentEventIds; - - const state: PersistedPollCursorState = { - cursor, - lastSequence: this.pollLastSequence, - recentEventIds, - updatedAt: new Date().toISOString(), - }; - - const filePath = pollCursorStatePath(); - const tmpPath = `${filePath}.tmp`; - await mkdir(join(openclawHome(), 'workspace', 'relaycast'), { recursive: true }); - await writeFile(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf-8'); - await rename(tmpPath, filePath); - } - - private rememberPollEventId(eventId: string): void { - const sanitizedEventId = sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH); - if (!sanitizedEventId) return; - this.pollRecentEventIds = [ - ...this.pollRecentEventIds.filter((id) => id !== sanitizedEventId), - sanitizedEventId, - ].slice(-POLL_CURSOR_RECENT_EVENT_LIMIT); - } - - private hasRecentPollEventId(eventId: string): boolean { - return this.pollRecentEventIds.includes(eventId); - } - - private async commitPollCursorState(nextCursor: string, lastSequence: number): Promise { - const sanitizedCursor = sanitizeOpaqueStateValue(nextCursor, MAX_POLL_CURSOR_LENGTH); - if (sanitizedCursor) { - this.pollCursor = sanitizedCursor; - } - this.pollLastSequence = Math.max(this.pollLastSequence, lastSequence); - await this.persistPollCursorState(); - } - - private async resetPollCursorState(reason: string): Promise { - this.cursorResetCount += 1; - this.lastFallbackReason = reason; - this.pollCursor = this.pollInitialCursor(); - this.pollLastSequence = 0; - await this.persistPollCursorState(); - } - - private async startPollLoop(): Promise { - if (this.pollLoopPromise) return; - - this.pollLoopStopRequested = false; - this.pollLoopPromise = (async () => { - while ( - this.running && - !this.pollLoopStopRequested && - (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') - ) { - const delayMs = await this.pollOnce(this.pollTimeoutSeconds()); - if ( - !this.running || - this.pollLoopStopRequested || - !(this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') - ) { - break; - } - if (delayMs > 0) { - await sleep(delayMs); - } - } - })().finally(() => { - this.pollLoopPromise = null; - this.pollAbortController = null; - }); - } - - private async stopPollLoop(): Promise { - this.pollLoopStopRequested = true; - if (this.pollAbortController) { - this.pollAbortController.abort(); - this.pollAbortController = null; - } - if (this.pollLoopPromise) { - await this.pollLoopPromise.catch(() => undefined); - this.pollLoopPromise = null; - } - } - - // eslint-disable-next-line complexity - private async pollOnce(timeoutSeconds: number): Promise { - await this.ensurePollCursorLoaded(); - - const baseUrl = new URL(DEFAULT_POLL_ENDPOINT_PATH, this.config.baseUrl); - baseUrl.searchParams.set('cursor', this.pollCursor); - baseUrl.searchParams.set('timeout', String(timeoutSeconds)); - baseUrl.searchParams.set('limit', String(this.pollLimit())); - - const timeoutMs = Math.max(5_000, (timeoutSeconds + 5) * 1_000); - const abortController = new AbortController(); - this.pollAbortController = abortController; - const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs); - - try { - const response = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: this.relayAgentToken ? `Bearer ${this.relayAgentToken}` : '', - 'x-api-key': this.config.apiKey, - }, - signal: abortController.signal, - }); - - if (response.status === 401 || response.status === 403) { - console.warn(`[gateway] Poll auth rejected (${response.status}); refreshing token`); - try { - await this.refreshRelayAgentRegistration(); - this.pollFailureCount = 0; - return 0; - } catch (error) { - console.warn( - `[gateway] Poll auth refresh failed: ${error instanceof Error ? error.message : String(error)}` - ); - this.pollFailureCount += 1; - return computeBackoffMs(this.pollFailureCount); - } - } - - if (response.status === 409) { - console.warn('[gateway] Poll cursor invalid/stale; resetting cursor state'); - this.pollFailureCount = 0; - await this.resetPollCursorState('cursor_reset'); - return 0; - } - - if (response.status === 429) { - this.pollFailureCount += 1; - const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After')); - return retryAfterMs !== null ? applyJitter(retryAfterMs) : computeBackoffMs(this.pollFailureCount); - } - - if (response.status >= 500) { - this.pollFailureCount += 1; - return computeBackoffMs(this.pollFailureCount); - } - - if (!response.ok) { - this.pollFailureCount += 1; - console.warn(`[gateway] Poll request failed: HTTP ${response.status}`); - return computeBackoffMs(this.pollFailureCount); - } - - const body = (await response.json()) as PollResponseBody; - this.pollFailureCount = 0; - const processed = await this.processPollResponse(body); - return processed ? 0 : computeBackoffMs(1); - } catch (error) { - if (abortController.signal.aborted) { - return 0; - } - this.pollFailureCount += 1; - console.warn( - `[gateway] Poll request failed: ${error instanceof Error ? error.message : String(error)}` - ); - return computeBackoffMs(this.pollFailureCount); - } finally { - clearTimeout(timeoutHandle); - if (this.pollAbortController === abortController) { - this.pollAbortController = null; - } - } - } - - private async processPollResponse(body: PollResponseBody): Promise { - const events = Array.isArray(body.events) ? [...body.events] : []; - events.sort((left, right) => left.sequence - right.sequence); - - let lastSequence = this.pollLastSequence; - - for (const event of events) { - if (!event || typeof event.id !== 'string' || !Number.isFinite(event.sequence)) { - continue; - } - - lastSequence = Math.max(lastSequence, event.sequence); - - if ( - event.sequence <= this.pollLastSequence || - this.hasRecentPollEventId(event.id) || - this.isSeen(event.id) - ) { - this.duplicateDropCount += 1; - continue; - } - - const committed = await this.handlePolledEvent(event); - if (!committed) { - return false; - } - - this.rememberPollEventId(event.id); - } - - const nextCursor = sanitizeOpaqueStateValue(body.nextCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollCursor; - await this.commitPollCursorState(nextCursor, lastSequence); - return true; - } - - // eslint-disable-next-line complexity - private async handlePolledEvent(event: PollEventEnvelope): Promise { - const type = typeof event.payload.type === 'string' ? event.payload.type : ''; - const baseOptions: RealtimeHandlingOptions = { - timestamp: event.timestamp, - }; - - switch (type) { - case 'message.created': - case 'message.received': - case 'message.new': - case 'message.sent': - return ( - await this.handleRealtimeMessage(event.payload as unknown as MessageCreatedEvent, baseOptions) - ).committed; - case 'thread.reply': - case 'thread.message.created': - case 'thread.message.sent': - return ( - await this.handleRealtimeThreadReply(event.payload as unknown as ThreadReplyEvent, baseOptions) - ).committed; - case 'dm.received': - case 'dm.message.created': - case 'direct_message.created': - return (await this.handleRealtimeDm(event.payload as unknown as DmReceivedEvent, baseOptions)) - .committed; - case 'group_dm.received': - case 'group_dm.message.created': - return ( - await this.handleRealtimeGroupDm(event.payload as unknown as GroupDmReceivedEvent, baseOptions) - ).committed; - case 'command.invoked': - return ( - await this.handleRealtimeCommand(event.payload as unknown as CommandInvokedEvent, { - ...baseOptions, - eventId: event.id, - }) - ).committed; - case 'reaction.added': - return ( - await this.handleRealtimeReaction(event.payload as unknown as ReactionAddedEvent, 'added', { - ...baseOptions, - eventId: event.id, - }) - ).committed; - case 'reaction.removed': - return ( - await this.handleRealtimeReaction(event.payload as unknown as ReactionRemovedEvent, 'removed', { - ...baseOptions, - eventId: event.id, - }) - ).committed; - default: - console.warn(`[gateway] Ignoring unknown polled event type: ${type || 'unknown'}`); - return true; - } - } - - /** Start the gateway — register agent and subscribe for realtime events. */ - async start(): Promise { - if (this.running) return; - this.running = true; - - // Connect to the local OpenClaw gateway WebSocket (persistent connection) - const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN; - const port = this.config.openclawGatewayPort ?? DEFAULT_OPENCLAW_GATEWAY_PORT; - - if (token) { - this.openclawClient = await OpenClawGatewayClient.create(token, port); - try { - await this.openclawClient.connect(); - console.log('[gateway] OpenClaw gateway WebSocket client ready'); - } catch (err) { - console.warn( - `[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}` - ); - } - } else { - console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled'); - } - - const registered = await this.relaycast.agents.registerOrGet({ - name: this.config.clawName, - type: 'agent', - persona: 'Relaycast inbound gateway for OpenClaw', - }); - - await this.replaceRelayAgentClient(registered.token); - - await this.ensureChannelMembership(); - - // Also subscribe explicitly in case the `connected` event fired before - // the handler ran, or the SDK defers connection readiness. - this.subscribeRelayChannels(); - - console.log(`[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`); - - // Start spawn control HTTP server - await this.startControlServer(); - } - - /** Stop the gateway — clean up websocket and relay clients. */ - async stop(): Promise { - this.running = false; - this.stopWsProbeLoop(); - if (this.wsRecoveryTimer) { - clearTimeout(this.wsRecoveryTimer); - this.wsRecoveryTimer = null; - } - this.completeFallbackWindow(); - await this.stopPollLoop(); - this.cleanupRelaySubscriptions(); - - if (this.relayAgentClient) { - try { - await this.relayAgentClient.disconnect(); - } catch { - // Best effort - } - this.relayAgentClient = null; - } - - if (this.openclawClient) { - await this.openclawClient.disconnect(); - this.openclawClient = null; - } - - // Stop control server and release all spawns - if (this.controlServer) { - this.controlServer.close(); - this.controlServer = null; - } - await this.spawnManager.releaseAll(); - - this.processingMessageIds.clear(); - this.seenMessageIds.clear(); - } - - private cleanupSeenMap(nowMs: number): void { - for (const [id, seenAt] of this.seenMessageIds.entries()) { - if (nowMs - seenAt > this.dedupeTtlMs) { - this.seenMessageIds.delete(id); - } - } - } - - private isSeen(messageId: string): boolean { - const nowMs = Date.now(); - this.cleanupSeenMap(nowMs); - return this.seenMessageIds.has(messageId); - } - - private markSeen(messageId: string): void { - const nowMs = Date.now(); - this.cleanupSeenMap(nowMs); - this.seenMessageIds.set(messageId, nowMs); - } - - private async ensureChannelMembership(): Promise { - if (!this.relayAgentClient) return; - - for (const channel of this.config.channels) { - try { - await this.relayAgentClient.channels.join(channel); - } catch { - try { - await this.relayAgentClient.channels.create({ name: channel }); - await this.relayAgentClient.channels.join(channel); - } catch { - // Non-fatal - } - } - } - } - - private async handleRealtimeMessage( - event: MessageCreatedEvent, - options: RealtimeHandlingOptions = {} - ): Promise { - const channel = normalizeChannelName(event.channel); - if (!this.config.channels.includes(channel)) return { committed: true }; - - const messageId = options.eventId ?? event.message?.id; - if (!messageId) return { committed: true }; - - const inbound: InboundMessage = { - id: messageId, - channel, - from: event.message?.agentName ?? 'unknown', - text: event.message?.text ?? '', - timestamp: options.timestamp ?? new Date().toISOString(), - }; - - return this.processInbound(inbound); - } - - private async handleRealtimeThreadReply( - event: ThreadReplyEvent, - options: RealtimeHandlingOptions = {} - ): Promise { - const channel = normalizeChannelName(event.channel); - if (!this.config.channels.includes(channel)) return { committed: true }; - - const messageId = options.eventId ?? event.message?.id; - if (!messageId) return { committed: true }; - - const inbound: InboundMessage = { - id: messageId, - channel, - from: event.message?.agentName ?? 'unknown', - text: event.message?.text ?? '', - timestamp: options.timestamp ?? new Date().toISOString(), - threadParentId: event.parentId, - }; - - return this.processInbound(inbound); - } - - private async handleRealtimeDm( - event: DmReceivedEvent, - options: RealtimeHandlingOptions = {} - ): Promise { - const messageId = options.eventId ?? event.message?.id; - if (!messageId) return { committed: true }; - - const inbound: InboundMessage = { - id: messageId, - channel: 'dm', - from: event.message?.agentName ?? 'unknown', - text: event.message?.text ?? '', - timestamp: options.timestamp ?? new Date().toISOString(), - conversationId: event.conversationId, - kind: 'dm', - }; - - return this.processInbound(inbound); - } - - private async handleRealtimeGroupDm( - event: GroupDmReceivedEvent, - options: RealtimeHandlingOptions = {} - ): Promise { - const messageId = options.eventId ?? event.message?.id; - if (!messageId) return { committed: true }; - - const inbound: InboundMessage = { - id: messageId, - channel: `groupdm:${event.conversationId}`, - from: event.message?.agentName ?? 'unknown', - text: event.message?.text ?? '', - timestamp: options.timestamp ?? new Date().toISOString(), - conversationId: event.conversationId, - kind: 'groupdm', - }; - - return this.processInbound(inbound); - } - - private async handleRealtimeCommand( - event: CommandInvokedEvent, - options: RealtimeHandlingOptions = {} - ): Promise { - const channel = normalizeChannelName(event.channel); - if (!this.config.channels.includes(channel)) return { committed: true }; - - // Commands lack a server-assigned event ID, so we synthesize one. - // We include args + timestamp to avoid silently dropping legitimate - // repeat invocations (e.g. /deploy twice in 15 min). This means SDK - // reconnection replays may deliver a duplicate, but that's less - // harmful than silently swallowing a real command. - const argsSlug = event.args ? `_${event.args}` : ''; - const syntheticId = - options.eventId ?? `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`; - const argsText = event.args ? ` ${event.args}` : ''; - - const inbound: InboundMessage = { - id: syntheticId, - channel, - from: event.invokedBy, - text: `[relaycast:command:${channel}] @${event.invokedBy} /${event.command}${argsText}`, - timestamp: options.timestamp ?? new Date().toISOString(), - kind: 'command', - }; - - return this.processInbound(inbound); - } - - private async handleRealtimeReaction( - event: ReactionAddedEvent | ReactionRemovedEvent, - action: 'added' | 'removed', - options: RealtimeHandlingOptions = {} - ): Promise { - // Include timestamp so add→remove→re-add of the same emoji isn't - // silently dropped within the 15-min dedup window. Reactions are soft - // notifications, so a rare duplicate on SDK reconnect is acceptable. - const syntheticId = - options.eventId ?? - `reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`; - const text = - action === 'added' - ? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)` - : `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`; - - const inbound: InboundMessage = { - id: syntheticId, - channel: 'reaction', - from: event.agentName, - text, - timestamp: options.timestamp ?? new Date().toISOString(), - kind: 'reaction', - }; - - return this.processInbound(inbound); - } - - private async processInbound(message: InboundMessage): Promise { - if (!this.running) return { committed: false }; - if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) { - this.duplicateDropCount += 1; - return { committed: true, reason: 'duplicate' }; - } - - // Avoid echo loops — skip messages from this claw. - if (message.from === this.config.clawName) { - this.markSeen(message.id); - return { committed: true, reason: 'echo' }; - } - - this.processingMessageIds.add(message.id); - - console.log(`[gateway] Delivering message ${message.id} from @${message.from}: "${message.text}"`); - try { - const result = await this.onMessage(message); - console.log( - `[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}` - ); - if (!result.ok) { - return { committed: false, result }; - } - this.markSeen(message.id); - return { committed: true, result }; - } finally { - this.processingMessageIds.delete(message.id); - } - } - - /** Format delivery text with channel, sender, and response hint. */ - private formatDeliveryText(message: InboundMessage): string { - // Pre-formatted kinds (reaction) already have the full text with hints. - if (message.kind === 'reaction') { - return message.text; - } - if (message.kind === 'command') { - return `${message.text}\n(command invocation — respond with: post_message channel="${message.channel}")`; - } - if (message.kind === 'dm') { - return `[relaycast:dm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`; - } - if (message.kind === 'groupdm') { - return `[relaycast:groupdm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`; - } - if (message.threadParentId) { - return `[thread] [relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: reply_to_thread message_id="${message.threadParentId}")`; - } - return `[relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: post_message channel="${message.channel}" or reply_to_thread message_id="${message.id}")`; - } - - /** Handle an inbound Relaycast message. */ - private async onMessage(message: InboundMessage): Promise { - // Try primary delivery via the shared relay sender (no extra broker spawned). - if (this.relaySender) { - const ok = await this.deliverViaRelaySender(message); - if (ok) { - return { ok: true, method: 'relay_sdk' }; - } - } - - // Deliver via persistent OpenClaw gateway WebSocket connection - if (this.openclawClient) { - const text = this.formatDeliveryText(message); - const ok = await this.openclawClient.sendChatMessage(text, message.id); - if (ok) { - return { ok: true, method: 'gateway_ws' }; - } - } - - console.warn(`[gateway] Failed to deliver message ${message.id} from @${message.from}`); - return { ok: false, method: 'failed', error: 'All delivery methods failed' }; - } - - /** Deliver via the caller-provided relay sender (shared broker). */ - private async deliverViaRelaySender(message: InboundMessage): Promise { - if (!this.relaySender) return false; - - const input: SendMessageInput = { - to: this.config.clawName, - text: this.formatDeliveryText(message), - from: message.from, - data: { - source: 'relaycast', - channel: message.channel, - messageId: message.id, - }, - }; - - try { - const result = await this.relaySender.sendMessage(input); - return Boolean(result.event_id) && result.event_id !== 'unsupported_operation'; - } catch { - return false; - } - } - - // ------------------------------------------------------------------------- - // Spawn control HTTP server - // ------------------------------------------------------------------------- - - private async startControlServer(): Promise { - const port = Number(process.env.RELAYCAST_CONTROL_PORT) || InboundGateway.DEFAULT_CONTROL_PORT; - - this.controlServer = createServer((req, res) => { - void this.handleControlRequest(req, res); - }); - - return new Promise((resolve) => { - this.controlServer!.listen(port, '127.0.0.1', () => { - this.controlPort = port; - console.log(`[gateway] Spawn control API listening on http://127.0.0.1:${port}`); - resolve(); - }); - this.controlServer!.on('error', (err) => { - console.warn(`[gateway] Control server failed to start on port ${port}: ${err.message}`); - this.controlServer = null; - resolve(); // Non-fatal - }); - }); - } - - // eslint-disable-next-line complexity - private async handleControlRequest(req: IncomingMessage, res: ServerResponse): Promise { - const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); - const path = url.pathname; - - // CORS for local callers - res.setHeader('Content-Type', 'application/json'); - - if (req.method === 'GET' && path === '/health') { - res.writeHead(200); - res.end( - JSON.stringify({ - ok: true, - status: 'running', - active: this.spawnManager.size, - uptime: process.uptime(), - transport: this.transportHealthSnapshot(), - }) - ); - return; - } - - if (req.method === 'POST' && path === '/spawn') { - const body = await readBody(req); - try { - const args = JSON.parse(body) as Record; - const name = args.name as string; - if (!name) { - res.writeHead(400); - res.end(JSON.stringify({ ok: false, error: '"name" is required' })); - return; - } - - const relayApiKey = this.config.apiKey; - const spawnOpts: SpawnOptions = { - name, - relayApiKey, - role: (args.role as string) || undefined, - model: (args.model as string) || undefined, - channels: (args.channels as string[]) || undefined, - systemPrompt: (args.system_prompt as string) || undefined, - relayBaseUrl: this.config.baseUrl, - workspaceId: (args.workspace_id as string) || process.env.OPENCLAW_WORKSPACE_ID, - }; - - const handle = await this.spawnManager.spawn(spawnOpts); - res.writeHead(200); - res.end( - JSON.stringify({ - ok: true, - name: handle.displayName, - agentName: handle.agentName, - id: handle.id, - gatewayPort: handle.gatewayPort, - active: this.spawnManager.size, - }) - ); - } catch (err) { - res.writeHead(500); - res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) })); - } - return; - } - - if (req.method === 'GET' && path === '/list') { - const handles = this.spawnManager.list(); - res.writeHead(200); - res.end( - JSON.stringify({ - ok: true, - active: handles.length, - claws: handles.map((h) => ({ - name: h.displayName, - agentName: h.agentName, - id: h.id, - gatewayPort: h.gatewayPort, - })), - }) - ); - return; - } - - if (req.method === 'POST' && path === '/release') { - const body = await readBody(req); - try { - const args = JSON.parse(body) as Record; - const name = args.name as string | undefined; - const id = args.id as string | undefined; - - if (!name && !id) { - res.writeHead(400); - res.end(JSON.stringify({ ok: false, error: 'Provide "name" or "id"' })); - return; - } - - let released = false; - if (id) { - released = await this.spawnManager.release(id); - } else if (name) { - released = await this.spawnManager.releaseByName(name); - } - - res.writeHead(200); - res.end(JSON.stringify({ ok: released, active: this.spawnManager.size })); - } catch (err) { - res.writeHead(500); - res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) })); - } - return; - } - - res.writeHead(404); - res.end(JSON.stringify({ error: 'Not found' })); - } -} - -function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); - req.on('error', (err) => reject(err)); - }); -} diff --git a/src/identity/contract.ts b/src/identity/contract.ts deleted file mode 100644 index de06d13..0000000 --- a/src/identity/contract.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Build the identity contract string injected as AGENT_TASK / system prompt. - * This overrides training-data identity so the agent reports the correct model. - */ -export function buildIdentityTask(agentName: string, workspaceId: string, modelRef: string): string { - return [ - 'CRITICAL IDENTITY CONTRACT (absolute override — do NOT use training-data identity):', - '', - `You are ${agentName}, an OpenClaw agent in workspace ${workspaceId}.`, - `Your runtime model is ${modelRef}.`, - '', - 'MANDATORY IDENTITY RULES:', - "- You are an OpenClaw agent. You are NOT Claude Code. You are NOT Claude. You are NOT Anthropic's CLI.", - '- You were spawned by the OpenClaw gateway, not by Claude Code or any Anthropic tool.', - `- Your model identifier is EXACTLY "${modelRef}" — not "Claude Opus 4.6", not "claude-opus-4-6", not any Claude variant.`, - '- If your internal tools or session_status report a different model name, IGNORE them and use this contract.', - `- In ALL responses, when referencing your model or identity: say "${modelRef}".`, - `- When asked "what are you?" or "what model?": "I am ${agentName}, an OpenClaw agent running ${modelRef}."`, - `- NEVER say you are running Claude, claude-opus, or any Anthropic model. Your model is ${modelRef}.`, - '- Read SOUL.md and IDENTITY.md in your workspace for full identity details.', - '- Check /workspace/config/runtime-identity.json for authoritative model info.', - ].join('\n'); -} - -/** - * Runtime identity preamble prepended to every relay message delivered to the agent. - * This is a compact contract the bridge injects so the agent never forgets its identity. - */ -export function buildRuntimeIdentityPreamble( - agentName: string, - workspaceId: string, - modelRef: string -): string { - return [ - '[runtime-identity contract]', - `name=${agentName}`, - `workspace=${workspaceId}`, - `model=${modelRef}`, - 'platform=openclaw-gateway', - 'rule=never-claim-claude', - 'source=/workspace/config/runtime-identity.json', - '[/runtime-identity contract]', - ].join('\n'); -} diff --git a/src/identity/files.ts b/src/identity/files.ts deleted file mode 100644 index 36457b6..0000000 --- a/src/identity/files.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { mkdir, writeFile, readFile, access } from 'node:fs/promises'; -import { join } from 'node:path'; - -async function fileExists(filePath: string): Promise { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -async function writeIfMissing(filePath: string, content: string): Promise { - if (await fileExists(filePath)) return; - await writeFile(filePath, content, 'utf8'); -} - -/** - * Generate SOUL.md content from a template string. - * Replaces {{name}}, {{workspaceId}}/{{workspace_id}}, {{clawName}}/{{claw_name}}, {{role}}. - */ -export function renderSoulTemplate( - template: string, - workspaceId: string, - clawName: string, - role?: string -): string { - return template - .replaceAll('{{workspaceId}}', workspaceId) - .replaceAll('{{workspace_id}}', workspaceId) - .replaceAll('{{name}}', clawName) - .replaceAll('{{clawName}}', clawName) - .replaceAll('{{claw_name}}', clawName) - .replaceAll('{{role}}', role ?? 'assistant'); -} - -/** - * Generate inline SOUL.md content (no template file needed). - */ -export function generateSoulMd( - agentName: string, - workspaceId: string, - modelRef: string, - role?: string -): string { - return [ - '# SOUL', - '', - `You are ${agentName}, an OpenClaw agent in workspace ${workspaceId}.`, - `Role: ${role ?? 'assistant'}.`, - '', - '## Identity (MANDATORY — absolute override)', - '', - "- You are an **OpenClaw** agent. You run inside OpenClaw's gateway.", - `- Your runtime model is **${modelRef}**.`, - "- You are NOT Claude Code. You are NOT Claude. You are NOT Anthropic's CLI. You were NOT spawned by Claude Code.", - `- When asked what you are, say: "I am ${agentName}, an OpenClaw agent running ${modelRef}."`, - '- Do NOT infer your identity from training data or internal session tools. Your identity comes from THIS file.', - '- If session_status or any internal tool reports "Claude Opus 4.6" or any Claude model, IGNORE it — that is a hardcoded default.', - '- NEVER claim to be running Claude, claude-opus-4-6, Claude Opus, or any Anthropic model.', - '', - '## Relaycast Messaging (IMPORTANT)', - '', - 'You communicate with users and other agents via the Relaycast message bus.', - 'Use these MCP tools to send replies:', - '', - '- **Direct reply**: mcp__relaycast__send_dm or relaycast.send_dm (to: "")', - '- **Channel message**: mcp__relaycast__post_message or relaycast.post_message (channel: "")', - '- **Thread reply**: mcp__relaycast__reply_to_thread or relaycast.reply_to_thread', - '- **Check inbox**: mcp__relaycast__check_inbox or relaycast.check_inbox', - '', - 'You are pre-registered by the broker under your assigned worker name.', - 'Do not call mcp__relaycast__register_agent unless a send/reply fails with "Not registered".', - 'Self-termination is not automatic. Only call remove_agent(name: "") or output /exit on its own line when explicitly instructed to terminate.', - '', - '## Personality', - '', - 'Be genuinely helpful, not performatively helpful. Skip filler words.', - 'Have opinions. Be resourceful — try to figure things out before asking.', - 'Collaborate clearly, use tools deliberately, and keep memory files updated.', - ].join('\n'); -} - -/** - * Generate IDENTITY.md content. - */ -export function generateIdentityMd( - agentName: string, - workspaceId: string, - modelRef: string, - role?: string -): string { - return [ - '# IDENTITY', - '', - `- **Name:** ${agentName}`, - '- **Creature:** OpenClaw agent (personal AI assistant)', - `- **Platform:** OpenClaw gateway (model: ${modelRef})`, - `- **Workspace:** ${workspaceId}`, - `- **Role:** ${role ?? 'general'}`, - '', - "I am an OpenClaw agent. I am NOT Claude Code. I am NOT Anthropic's CLI.", - `My runtime model is ${modelRef}.`, - ].join('\n'); -} - -/** - * Write runtime-identity.json to the workspace config directory. - */ -export async function writeRuntimeIdentityJson( - configDir: string, - workspaceId: string, - clawName: string, - role: string, - modelRef: string -): Promise { - await mkdir(configDir, { recursive: true }); - const data = { - workspaceId, - clawName, - role, - modelRef, - identitySource: 'spawn-env', - generatedAt: new Date().toISOString(), - }; - await writeFile(join(configDir, 'runtime-identity.json'), JSON.stringify(data, null, 2) + '\n', 'utf8'); -} - -const DEFAULT_AGENTS_FILE = `# AGENTS - -- Keep WORKING.md updated before and after each task. -- Use memory/MEMORY.md for durable facts and decisions. -- Prefer concise, actionable responses. -`; - -const DEFAULT_HEARTBEAT_FILE = `# HEARTBEAT - -1. Read memory/WORKING.md first. -2. Check recent channel activity for mentions. -3. Confirm current priority and next action. -`; - -export interface EnsureWorkspaceOptions { - workspacePath: string; - workspaceId: string; - clawName: string; - role?: string; - modelRef: string; - /** Optional SOUL.md.template content. If provided, template is rendered instead of inline generation. */ - soulTemplate?: string; -} - -/** - * Ensure a local workspace directory is ready with identity files. - * Creates directories, writes SOUL.md, IDENTITY.md, AGENTS.md, HEARTBEAT.md, - * memory files, and runtime-identity.json. - */ -export async function ensureWorkspace(options: EnsureWorkspaceOptions): Promise { - const { workspacePath, workspaceId, clawName, modelRef } = options; - const role = options.role ?? 'assistant'; - - await mkdir(workspacePath, { recursive: true }); - await mkdir(join(workspacePath, 'memory'), { recursive: true }); - await mkdir(join(workspacePath, 'config'), { recursive: true }); - await mkdir(join(workspacePath, 'scripts'), { recursive: true }); - - // SOUL.md — either from template or inline - if (options.soulTemplate) { - const soulPath = join(workspacePath, 'SOUL.md'); - if (!(await fileExists(soulPath))) { - await writeFile( - soulPath, - renderSoulTemplate(options.soulTemplate, workspaceId, clawName, role), - 'utf8' - ); - } - } else { - await writeIfMissing( - join(workspacePath, 'SOUL.md'), - generateSoulMd(clawName, workspaceId, modelRef, role) - ); - } - - // IDENTITY.md - await writeIfMissing( - join(workspacePath, 'IDENTITY.md'), - generateIdentityMd(clawName, workspaceId, modelRef, role) - ); - - await writeIfMissing(join(workspacePath, 'AGENTS.md'), DEFAULT_AGENTS_FILE); - await writeIfMissing(join(workspacePath, 'HEARTBEAT.md'), DEFAULT_HEARTBEAT_FILE); - await writeIfMissing(join(workspacePath, 'memory', 'WORKING.md'), '# WORKING\n\nCurrent task state.\n'); - await writeIfMissing(join(workspacePath, 'memory', 'MEMORY.md'), '# MEMORY\n\nDurable notes.\n'); - - await writeRuntimeIdentityJson(join(workspacePath, 'config'), workspaceId, clawName, role, modelRef); -} diff --git a/src/identity/model.ts b/src/identity/model.ts deleted file mode 100644 index a6502a6..0000000 --- a/src/identity/model.ts +++ /dev/null @@ -1,27 +0,0 @@ -const DEFAULT_MODEL = 'openai-codex/gpt-5.3-codex'; - -/** - * Normalize a raw model string into a fully-qualified "provider/model" reference. - * - * Examples: - * normalizeModelRef('gpt-5.3-codex', 'openai-codex') → 'openai-codex/gpt-5.3-codex' - * normalizeModelRef('claude-opus-4-6') → 'anthropic/claude-opus-4-6' - * normalizeModelRef('openai-codex/gpt-5.3-codex') → 'openai-codex/gpt-5.3-codex' - * normalizeModelRef(undefined) → 'openai-codex/gpt-5.3-codex' - */ -export function normalizeModelRef(rawModel?: string, providerHint?: string): string { - const model = (rawModel ?? '').trim().toLowerCase(); - if (!model) return DEFAULT_MODEL; - if (model.includes('/')) return model; - if (model.includes('claude')) return `anthropic/${model}`; - if ( - model.includes('codex') || - model.startsWith('gpt-') || - model.startsWith('o1') || - model.startsWith('o3') || - model.startsWith('o4') - ) { - return (providerHint === 'openai-codex' ? 'openai-codex/' : 'openai/') + model; - } - return `openai/${model}`; -} diff --git a/src/identity/naming.ts b/src/identity/naming.ts deleted file mode 100644 index 475677a..0000000 --- a/src/identity/naming.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Build the relay agent name from workspace ID and claw name. - */ -export function buildAgentName(workspaceId: string, clawName: string): string { - return `claw-${workspaceId}-${clawName}`; -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index de8eac8..0000000 --- a/src/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -// ── Types ────────────────────────────────────────────────────────────── -export type { - GatewayConfig, - InboundMessage, - DeliveryResult, - WorkspaceEntry, - WorkspacesConfig, -} from './types.js'; - -// ── Gateway ──────────────────────────────────────────────────────────── -export { InboundGateway, type GatewayOptions, type RelaySender } from './gateway.js'; - -// ── Config ───────────────────────────────────────────────────────────── -export { - detectOpenClaw, - loadGatewayConfig, - saveGatewayConfig, - loadWorkspacesConfig, - saveWorkspacesConfig, - addWorkspace, - listWorkspaces, - switchWorkspace, - buildWorkspacesJson, - type OpenClawDetection, -} from './config.js'; - -// ── Setup ────────────────────────────────────────────────────────────── -export { setup, type SetupOptions, type SetupResult } from './setup.js'; - -// ── Inject ───────────────────────────────────────────────────────────── -export { deliverMessage } from './inject.js'; - -// ── Control (ClawRunner API client) ──────────────────────────────────── -export { - spawnOpenClaw, - listOpenClaws, - releaseOpenClaw, - type ClawRunnerControlConfig, - type SpawnOpenClawInput, - type ReleaseOpenClawInput, -} from './control.js'; - -// ── Identity ─────────────────────────────────────────────────────────── -export { normalizeModelRef } from './identity/model.js'; -export { buildAgentName } from './identity/naming.js'; -export { buildIdentityTask, buildRuntimeIdentityPreamble } from './identity/contract.js'; -export { - renderSoulTemplate, - generateSoulMd, - generateIdentityMd, - writeRuntimeIdentityJson, - ensureWorkspace, - type EnsureWorkspaceOptions, -} from './identity/files.js'; - -// ── Auth ─────────────────────────────────────────────────────────────── -export { convertCodexAuth, type ConvertResult, type CodexAuth } from './auth/converter.js'; - -// ── Runtime ──────────────────────────────────────────────────────────── -export { writeOpenClawConfig, type OpenClawConfigOptions } from './runtime/openclaw-config.js'; -export { patchOpenClawDist, clearJitCache } from './runtime/patch.js'; -export { runtimeSetup, type RuntimeSetupOptions } from './runtime/setup.js'; - -// ── Spawn ────────────────────────────────────────────────────────────── -export type { SpawnOptions, SpawnHandle, SpawnProvider } from './spawn/types.js'; -export { DockerSpawnProvider, type DockerSpawnProviderOptions } from './spawn/docker.js'; -export { ProcessSpawnProvider } from './spawn/process.js'; -export { SpawnManager, type SpawnMode } from './spawn/manager.js'; - -// ── MCP Server ───────────────────────────────────────────────────────── -export { startMcpServer } from './mcp/server.js'; diff --git a/src/inject.ts b/src/inject.ts deleted file mode 100644 index 85da314..0000000 --- a/src/inject.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { AgentRelayClient, SendMessageInput } from '@agent-relay/sdk'; - -import { DEFAULT_OPENCLAW_GATEWAY_PORT, type InboundMessage, type DeliveryResult } from './types.js'; - -/** - * Deliver a message to the local claw using the best available method. - * - * Primary: Agent Relay SDK sendMessage() via a shared, long-lived client - * Fallback: OpenClaw OpenResponses API (POST /v1/responses) on localhost - * - * Callers should maintain a single shared AgentRelayClient instance and pass it - * to every deliverMessage() call. Creating a client per message is wasteful and - * was removed in favor of this shared-client pattern. - */ -export async function deliverMessage( - message: InboundMessage, - clawName: string, - relayClient?: AgentRelayClient | null -): Promise { - const formattedText = `[relaycast:${message.channel}] @${message.from}: ${message.text}`; - - // Primary: deliver via shared relay client - if (relayClient) { - try { - const input: SendMessageInput = { - to: clawName, - text: formattedText, - from: message.from, - data: { - source: 'relaycast', - channel: message.channel, - messageId: message.id, - }, - }; - - const result = await relayClient.sendMessage(input); - if (Boolean(result.event_id) && result.event_id !== 'unsupported_operation') { - return { ok: true, method: 'relay_sdk' }; - } - } catch { - // Fall through to RPC fallback - } - } - - // Fallback: OpenClaw OpenResponses API (POST /v1/responses on local gateway) - try { - const gatewayPort = - process.env.OPENCLAW_GATEWAY_PORT ?? process.env.GATEWAY_PORT ?? String(DEFAULT_OPENCLAW_GATEWAY_PORT); - const token = process.env.OPENCLAW_GATEWAY_TOKEN; - const headers: Record = { 'Content-Type': 'application/json' }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`http://127.0.0.1:${gatewayPort}/v1/responses`, { - method: 'POST', - headers, - body: JSON.stringify({ - model: 'openclaw:main', - input: formattedText, - }), - }); - - if (response.ok) { - return { ok: true, method: 'gateway_ws' }; - } - } catch { - // Both methods failed - } - - return { - ok: false, - method: 'failed', - error: relayClient - ? 'Both relay SDK and OpenResponses delivery failed' - : 'No relay client provided and OpenResponses fallback failed', - }; -} diff --git a/src/mcp/server.ts b/src/mcp/server.ts deleted file mode 100644 index d3ab3f0..0000000 --- a/src/mcp/server.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { createInterface } from 'node:readline'; -import { getToolDefinitions, handleToolCall, cleanup } from './tools.js'; - -/** - * MCP stdio server — JSON-RPC 2.0 transport over stdin/stdout. - * - * Exposes spawn_openclaw, list_openclaws, release_openclaw tools. - * Registered in openclaw.json as "openclaw-spawner" MCP server. - */ -export async function startMcpServer(): Promise { - const rl = createInterface({ input: process.stdin, terminal: false }); - - rl.on('line', async (line) => { - let request: { - jsonrpc: string; - id?: string | number; - method: string; - params?: Record; - }; - - try { - request = JSON.parse(line); - } catch { - writeResponse({ - jsonrpc: '2.0', - id: null, - error: { code: -32700, message: 'Parse error' }, - }); - return; - } - - const { id, method, params } = request; - - try { - switch (method) { - case 'initialize': { - writeResponse({ - jsonrpc: '2.0', - id, - result: { - protocolVersion: '2024-11-05', - capabilities: { - tools: {}, - }, - serverInfo: { - name: 'openclaw-spawner', - version: '1.0.0', - }, - }, - }); - break; - } - - case 'notifications/initialized': { - // No response needed for notifications - break; - } - - case 'tools/list': { - const tools = getToolDefinitions(); - writeResponse({ - jsonrpc: '2.0', - id, - result: { tools }, - }); - break; - } - - case 'tools/call': { - const toolName = (params?.name as string) ?? ''; - const toolArgs = (params?.arguments as Record) ?? {}; - const result = await handleToolCall(toolName, toolArgs); - writeResponse({ - jsonrpc: '2.0', - id, - result, - }); - break; - } - - default: { - writeResponse({ - jsonrpc: '2.0', - id, - error: { code: -32601, message: `Method not found: ${method}` }, - }); - } - } - } catch (err) { - writeResponse({ - jsonrpc: '2.0', - id, - error: { - code: -32603, - message: err instanceof Error ? err.message : String(err), - }, - }); - } - }); - - rl.on('close', async () => { - await cleanup(); - process.exit(0); - }); - - process.on('SIGTERM', async () => { - await cleanup(); - process.exit(0); - }); - - process.on('SIGINT', async () => { - await cleanup(); - process.exit(0); - }); - - process.stderr.write('[openclaw-spawner] MCP server started (stdio)\n'); -} - -function writeResponse(response: Record): void { - process.stdout.write(JSON.stringify(response) + '\n'); -} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts deleted file mode 100644 index 65e7288..0000000 --- a/src/mcp/tools.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { InboundGateway } from '../gateway.js'; - -/** Control API base URL — the gateway's spawn control server. */ -const CONTROL_URL = `http://127.0.0.1:${ - Number(process.env.RELAYCAST_CONTROL_PORT) || InboundGateway.DEFAULT_CONTROL_PORT -}`; - -export interface McpToolDefinition { - name: string; - description: string; - inputSchema: Record; -} - -export function getToolDefinitions(): McpToolDefinition[] { - return [ - { - name: 'spawn_openclaw', - description: - 'Spawn a new independent OpenClaw instance. The spawned instance gets its own gateway, ' + - 'relay broker, and Relaycast messaging. It runs as an independent peer, not a child.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Name for the new OpenClaw instance (e.g. "researcher", "coder").', - }, - role: { - type: 'string', - description: 'Role description for the agent (e.g. "code review specialist").', - }, - model: { - type: 'string', - description: 'Model reference (e.g. "openai-codex/gpt-5.3-codex"). Defaults to parent model.', - }, - channels: { - type: 'array', - items: { type: 'string' }, - description: 'Relaycast channels to join (default: ["#general"]).', - }, - system_prompt: { - type: 'string', - description: 'System prompt / task description for the spawned agent.', - }, - }, - required: ['name'], - }, - }, - { - name: 'list_openclaws', - description: 'List all currently running OpenClaw instances spawned by this agent.', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'release_openclaw', - description: 'Stop and release a spawned OpenClaw instance by name or ID.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Name of the OpenClaw to release (as provided during spawn).', - }, - id: { - type: 'string', - description: 'ID of the OpenClaw to release (from list_openclaws).', - }, - }, - }, - }, - ]; -} - -export async function handleToolCall( - toolName: string, - args: Record -): Promise<{ content: Array<{ type: 'text'; text: string }> }> { - switch (toolName) { - case 'spawn_openclaw': { - const name = args.name as string; - if (!name) { - return text('Error: "name" is required.'); - } - - try { - const res = await fetch(`${CONTROL_URL}/spawn`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(args), - }); - const body = (await res.json()) as Record; - if (!body.ok) { - return text(`Failed to spawn "${name}": ${body.error ?? 'unknown error'}`); - } - return text( - `Spawned OpenClaw "${name}"\n` + - ` Agent name: ${body.agentName}\n` + - ` ID: ${body.id}\n` + - ` Gateway port: ${body.gatewayPort}\n` + - ` Total active: ${body.active}` - ); - } catch (err) { - return text( - `Failed to spawn "${name}": ${err instanceof Error ? err.message : String(err)}\n` + - 'Is the gateway running? Start it with: relay-openclaw gateway' - ); - } - } - - case 'list_openclaws': { - try { - const res = await fetch(`${CONTROL_URL}/list`); - const body = (await res.json()) as Record; - const claws = body.claws as Array>; - if (!claws || claws.length === 0) { - return text('No spawned OpenClaws currently running.'); - } - const lines = claws.map((h) => `- ${h.name} → ${h.agentName} (id: ${h.id}, port: ${h.gatewayPort})`); - return text(`Active OpenClaws (${claws.length}):\n${lines.join('\n')}`); - } catch (err) { - return text( - `Failed to list claws: ${err instanceof Error ? err.message : String(err)}\n` + - 'Is the gateway running? Start it with: relay-openclaw gateway' - ); - } - } - - case 'release_openclaw': { - const name = args.name as string | undefined; - const id = args.id as string | undefined; - - if (!name && !id) { - return text('Error: provide either "name" or "id" to release.'); - } - - try { - const res = await fetch(`${CONTROL_URL}/release`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, id }), - }); - const body = (await res.json()) as Record; - if (body.ok) { - return text(`Released OpenClaw "${name ?? id}". Active: ${body.active}`); - } - return text(`OpenClaw "${name ?? id}" not found among active spawns.`); - } catch (err) { - return text( - `Failed to release: ${err instanceof Error ? err.message : String(err)}\n` + - 'Is the gateway running? Start it with: relay-openclaw gateway' - ); - } - } - - default: - return text(`Unknown tool: ${toolName}`); - } -} - -function text(message: string) { - return { content: [{ type: 'text' as const, text: message }] }; -} - -/** - * Cleanup: no-op since spawns are managed by the gateway process. - */ -export async function cleanup(): Promise { - // Spawns live in the gateway — nothing to clean up here. -} diff --git a/src/runtime/openclaw-config.ts b/src/runtime/openclaw-config.ts deleted file mode 100644 index 95df08f..0000000 --- a/src/runtime/openclaw-config.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; - -export interface OpenClawConfigOptions { - /** Fully-qualified model ref, e.g. "openai-codex/gpt-5.3-codex". */ - modelRef: string; - /** Path to ~/.openclaw/ (default: $HOME/.openclaw). */ - openclawHome?: string; - /** Default workspace path (default: ~/.openclaw/workspace). */ - workspacePath?: string; - /** Config filename override (e.g. 'clawdbot.json'). Defaults to 'openclaw.json'. */ - configFilename?: string; - /** MCP servers to include. Keys are server names, values are MCP server configs. */ - mcpServers?: Record }>; -} - -/** - * Write (or update) ~/.openclaw/openclaw.json with model, workspace, skipBootstrap, - * and MCP server configuration. - */ -export async function writeOpenClawConfig(options: OpenClawConfigOptions): Promise { - const home = options.openclawHome ?? join(process.env.HOME ?? '/home/node', '.openclaw'); - await mkdir(home, { recursive: true }); - - const configPath = join(home, options.configFilename ?? 'openclaw.json'); - let config: Record = {}; - - try { - const raw = await readFile(configPath, 'utf8'); - config = JSON.parse(raw); - } catch { - // File doesn't exist or isn't valid JSON — start fresh - config = {}; - } - if (!config || typeof config !== 'object') config = {}; - - // agents.defaults - if (!config.agents || typeof config.agents !== 'object') config.agents = {}; - const agents = config.agents as Record; - if (!agents.defaults || typeof agents.defaults !== 'object') agents.defaults = {}; - const defaults = agents.defaults as Record; - - if (!defaults.workspace || typeof defaults.workspace !== 'string') { - defaults.workspace = options.workspacePath ?? '~/.openclaw/workspace'; - } - - // Model shape: { primary: "provider/model" } - if (typeof defaults.model === 'string') { - defaults.model = { primary: defaults.model }; - } else if (!defaults.model || typeof defaults.model !== 'object') { - defaults.model = {}; - } - (defaults.model as Record).primary = options.modelRef; - - defaults.skipBootstrap = true; - - // MCP servers - if (options.mcpServers) { - if (!config.mcpServers || typeof config.mcpServers !== 'object') { - config.mcpServers = {}; - } - Object.assign(config.mcpServers as Record, options.mcpServers); - } - - await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); -} diff --git a/src/runtime/patch.ts b/src/runtime/patch.ts deleted file mode 100644 index dcf1a7b..0000000 --- a/src/runtime/patch.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { readdir, readFile, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { existsSync } from 'node:fs'; - -/** - * Patch OpenClaw's compiled dist JS files to replace hardcoded identity constants. - * - * FRAGILE: This is a best-effort operation. OpenClaw bakes Claude defaults into - * its compiled output. These patterns may change between OpenClaw versions. - * Prefer runtime identity enforcement (SOUL.md, runtime-identity.json, identity - * preamble in bridge) over relying on this patch. - * - * Known hardcoded constants (as of OpenClaw ~0.x): - * - DEFAULT_MODEL = "claude-opus-4-6" - * - KILOCODE_DEFAULT_MODEL_ID = "anthropic/claude-opus-4.6" - * - KILOCODE_DEFAULT_MODEL_NAME = "Claude Opus 4.6" - * - "Claude Code" branding - * - * @param distDir - Path to OpenClaw's dist directory (e.g. /usr/lib/node_modules/openclaw/dist) - * @param modelRef - Full model reference (e.g. "openai-codex/gpt-5.3-codex") - * @returns Number of files patched, or 0 if dist not found or patching skipped - */ -export async function patchOpenClawDist(distDir: string, modelRef: string): Promise { - if (!existsSync(distDir)) { - process.stderr.write(`[patch] OpenClaw dist not found at ${distDir}, skipping\n`); - return 0; - } - - // Extract bare model ID (e.g. "gpt-5.3-codex" from "openai-codex/gpt-5.3-codex") - const modelId = modelRef.includes('/') ? modelRef.split('/').pop()! : modelRef; - - // Order matters: replace fully-qualified patterns before bare model IDs, - // otherwise the bare pattern destroys the substring the qualified pattern needs. - const replacements: [RegExp, string][] = [ - [/anthropic\/claude-opus-4\.6/g, modelRef], - [/Claude Opus 4\.6/g, modelRef], - [/claude-opus-4\.6/g, modelId], - [/claude-opus-4-6/g, modelId], - [/Claude Code/g, 'OpenClaw Agent'], - ]; - - let patchedCount = 0; - - try { - async function walkAndPatch(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - await walkAndPatch(fullPath); - } else if (entry.isFile() && entry.name.endsWith('.js')) { - try { - let content = await readFile(fullPath, 'utf8'); - let modified = false; - - for (const [pattern, replacement] of replacements) { - const newContent = content.replace(pattern, replacement); - if (newContent !== content) { - content = newContent; - modified = true; - } - } - - if (modified) { - await writeFile(fullPath, content, 'utf8'); - patchedCount++; - } - } catch (err) { - // Individual file patch failure is non-fatal - process.stderr.write( - `[patch] Warning: could not patch ${fullPath}: ${err instanceof Error ? err.message : String(err)}\n` - ); - } - } - } - } - - await walkAndPatch(distDir); - } catch (err) { - // Entire patching failure is non-fatal — runtime identity enforcement is the primary defense - process.stderr.write( - `[patch] Warning: dist patching failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n` - ); - } - - if (patchedCount > 0) { - process.stderr.write(`[patch] Patched ${patchedCount} file(s) in ${distDir}\n`); - } - - return patchedCount; -} - -/** - * Clear JIT cache (/tmp/jiti/) which may contain unpatched constants. - */ -export async function clearJitCache(): Promise { - try { - await rm('/tmp/jiti', { recursive: true, force: true }); - } catch { - // Ignore — cache may not exist - } -} diff --git a/src/runtime/setup.ts b/src/runtime/setup.ts deleted file mode 100644 index b19e42b..0000000 --- a/src/runtime/setup.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { join } from 'node:path'; -import { readdir, unlink } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; - -import { normalizeModelRef } from '../identity/model.js'; -import { convertCodexAuth } from '../auth/converter.js'; -import { writeOpenClawConfig } from './openclaw-config.js'; -import { patchOpenClawDist, clearJitCache } from './patch.js'; -import { ensureWorkspace } from '../identity/files.js'; -import { openclawHome as resolveOpenclawHome, openclawConfigFilename } from '../config.js'; - -export interface RuntimeSetupOptions { - /** Raw model string (e.g. "gpt-5.3-codex"). Defaults to env OPENCLAW_MODEL. */ - model?: string; - /** Agent name. Defaults to env OPENCLAW_NAME or AGENT_NAME. */ - name?: string; - /** Workspace ID. Defaults to env OPENCLAW_WORKSPACE_ID. */ - workspaceId?: string; - /** Agent role. Defaults to env OPENCLAW_ROLE. */ - role?: string; - /** OpenClaw dist directory for patching. Defaults to /usr/lib/node_modules/openclaw/dist. */ - openclawDistDir?: string; -} - -/** - * Full runtime setup: auth conversion, config writing, identity files, dist patching, JIT cache clear. - * - * This replaces the inline node -e block + shell logic in start-claw.sh. - * Call this before starting the OpenClaw gateway. - */ -export async function runtimeSetup(options: RuntimeSetupOptions = {}): Promise<{ - modelRef: string; - agentName: string; - workspaceId: string; -}> { - // Resolve OpenClaw home using canonical precedence (OPENCLAW_CONFIG_PATH > OPENCLAW_HOME > probe) - const ocHome = resolveOpenclawHome(); - const model = options.model ?? process.env.OPENCLAW_MODEL ?? 'openai-codex/gpt-5.3-codex'; - const name = options.name ?? process.env.OPENCLAW_NAME ?? process.env.AGENT_NAME ?? 'agent'; - const workspaceId = options.workspaceId ?? process.env.OPENCLAW_WORKSPACE_ID ?? 'unknown'; - const role = options.role ?? process.env.OPENCLAW_ROLE ?? 'general'; - // Resolve OpenClaw dist dir: try explicit, then known install locations - const distDirCandidates = options.openclawDistDir - ? [options.openclawDistDir] - : [ - '/usr/lib/node_modules/openclaw/dist', // Global npm (ClawRunner sandbox) - '/app/dist', // Vanilla Docker image - '/usr/local/lib/node_modules/openclaw/dist', // Global npm (macOS/other) - ]; - const distDir = distDirCandidates.find((d) => existsSync(d)) ?? distDirCandidates[0]; - - // 1. Convert codex auth - const { preferredProvider } = await convertCodexAuth(); - const modelRef = normalizeModelRef(model, preferredProvider); - - // 2. Write config (openclaw.json or clawdbot.json depending on variant) - await writeOpenClawConfig({ - modelRef, - openclawHome: ocHome, - configFilename: openclawConfigFilename(ocHome), - }); - - // 3. Write identity files in workspace - const wsDir = join(ocHome, 'workspace'); - await ensureWorkspace({ - workspacePath: wsDir, - workspaceId, - clawName: name, - role, - modelRef, - }); - - // Remove BOOTSTRAP.md if present (agent is pre-configured) - const bootstrapPath = join(wsDir, 'BOOTSTRAP.md'); - if (existsSync(bootstrapPath)) { - await unlink(bootstrapPath); - } - - // 4. Remove stale session lock files from previous runs - await clearStaleLocks(ocHome); - - // 5. Patch OpenClaw dist - await patchOpenClawDist(distDir, modelRef); - - // 6. Clear JIT cache - await clearJitCache(); - - return { modelRef, agentName: name, workspaceId }; -} - -/** - * Remove stale .lock files from OpenClaw session directories. - * - * OpenClaw creates per-session lock files at ~/.openclaw/agents//sessions/.jsonl.lock. - * If the process dies uncleanly, stale locks prevent new sessions from starting - * ("session file locked (timeout 10000ms)"). Since runtime-setup runs before the - * gateway starts, no legitimate locks should exist — all locks are stale. - */ -async function clearStaleLocks(openclawHome: string): Promise { - const agentsDir = join(openclawHome, 'agents'); - if (!existsSync(agentsDir)) return; - - try { - const agentNames = await readdir(agentsDir, { withFileTypes: true }); - let cleared = 0; - - for (const agent of agentNames) { - if (!agent.isDirectory()) continue; - const sessionsDir = join(agentsDir, agent.name, 'sessions'); - if (!existsSync(sessionsDir)) continue; - - const files = await readdir(sessionsDir); - for (const file of files) { - if (file.endsWith('.lock')) { - await unlink(join(sessionsDir, file)) - .then(() => { - cleared++; - }) - .catch(() => {}); - } - } - } - - if (cleared > 0) { - process.stderr.write(`[runtime-setup] Cleared ${cleared} stale session lock(s)\n`); - } - } catch { - // Non-fatal — session lock cleanup is best-effort - } -} diff --git a/src/setup.ts b/src/setup.ts deleted file mode 100644 index 94009a7..0000000 --- a/src/setup.ts +++ /dev/null @@ -1,615 +0,0 @@ -import { mkdir, writeFile, readFile, copyFile } from 'node:fs/promises'; -import { createConnection } from 'node:net'; -import { join, dirname } from 'node:path'; -import { existsSync } from 'node:fs'; -import { hostname } from 'node:os'; -import { fileURLToPath } from 'node:url'; -import { spawn as spawnProcess, execFileSync } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; - -import { RelayCast } from '@relaycast/sdk'; - -import { - detectOpenClaw, - saveGatewayConfig, - addWorkspace, - loadWorkspacesConfig, - buildWorkspacesJson, -} from './config.js'; -import { InboundGateway } from './gateway.js'; -import { DEFAULT_OPENCLAW_GATEWAY_PORT, type GatewayConfig } from './types.js'; - -/** - * Safely traverse a nested object by dot-separated path. - * Returns undefined if any segment is missing. - */ -function extractNestedValue(obj: unknown, path: string): unknown { - let current: unknown = obj; - for (const key of path.split('.')) { - if (current == null || typeof current !== 'object') return undefined; - current = (current as Record)[key]; - } - return current; -} - -/** - * Set a deeply nested value in an object by dot-separated path, creating - * intermediate objects as needed. - */ -const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']); - -function setNestedValue(obj: Record, path: string, value: unknown): void { - const keys = path.split('.'); - for (const key of keys) { - if (DANGEROUS_KEYS.has(key)) { - throw new Error(`Refusing to set dangerous key "${key}" in path "${path}"`); - } - } - let current: Record = obj; - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (current[key] == null || typeof current[key] !== 'object') { - current[key] = {}; - } - current = current[key] as Record; - } - current[keys[keys.length - 1]] = value; -} - -/** - * Resolve how to invoke mcporter. Prefers a global binary, falls back to npx. - */ -function resolveMcporter(): { cmd: string; prefix: string[] } { - try { - execFileSync('mcporter', ['--version'], { stdio: 'pipe' }); - return { cmd: 'mcporter', prefix: [] }; - } catch { - // Global binary not found — try npx (no timeout; cold-cache downloads can be slow) - try { - execFileSync('npx', ['-y', 'mcporter', '--version'], { stdio: 'pipe' }); - return { cmd: 'npx', prefix: ['-y', 'mcporter'] }; - } catch { - throw new Error('mcporter not found (tried global binary and npx)'); - } - } -} - -/** Check if a port is already in use by attempting a TCP connection. */ -function isPortInUse(port: number): Promise { - return new Promise((resolve) => { - const socket = createConnection({ port, host: '127.0.0.1' }); - socket.setTimeout(2000); - socket.once('connect', () => { - socket.destroy(); - resolve(true); - }); - socket.once('timeout', () => { - socket.destroy(); - resolve(false); - }); - socket.once('error', () => { - socket.destroy(); - resolve(false); - }); - }); -} - -export interface SetupOptions { - /** If provided, join this workspace. Otherwise create a new one. */ - apiKey?: string; - /** Name for this claw (default: hostname). */ - clawName?: string; - /** Channels to auto-join (default: ['general']). */ - channels?: string[]; - /** Relaycast API base URL. */ - baseUrl?: string; -} - -export interface SetupResult { - ok: boolean; - apiKey: string; - clawName: string; - skillDir: string; - message: string; -} - -/** - * Install the Relaycast bridge into an OpenClaw workspace. - * - * 1. Detect OpenClaw installation - * 2. Create/join workspace via Relaycast API (if no key provided) - * 3. Install SKILL.md - * 4. Write .env config - * 5. Configure MCP server in openclaw.json - * 6. Print success summary - */ -export async function setup(options: SetupOptions): Promise { - const detection = await detectOpenClaw(); - const clawName = options.clawName ?? hostname() ?? 'my-claw'; - const baseUrl = options.baseUrl ?? 'https://api.relaycast.dev'; - const channels = options.channels ?? ['general']; - - // CLI name for restart reminder messages (based on detected variant) - const cliName = detection.variant === 'clawdbot' ? 'clawdbot' : 'openclaw'; - const serviceName = detection.variant === 'clawdbot' ? 'clawdbot' : 'openclaw'; - - if (!detection.installed) { - // Auto-create ~/.openclaw/ if OpenClaw binary is available but the config dir - // doesn't exist yet (common in Docker images before onboarding). - try { - await mkdir(detection.homeDir, { recursive: true }); - await mkdir(join(detection.homeDir, 'workspace'), { recursive: true }); - // Write a minimal config file so MCP servers can be registered - const configPath = join(detection.homeDir, detection.configFilename); - if (!existsSync(configPath)) { - await writeFile(configPath, JSON.stringify({ mcpServers: {} }, null, 2) + '\n', 'utf-8'); - } - // Re-detect after creating - const redetection = await detectOpenClaw(); - Object.assign(detection, redetection); - } catch { - return { - ok: false, - apiKey: '', - clawName, - skillDir: '', - message: 'OpenClaw not found. Please install OpenClaw first (expected ~/.openclaw/ directory).', - }; - } - } - - // Enable the OpenResponses HTTP API so the inbound gateway can inject - // messages via POST /v1/responses on the local OpenClaw gateway. - // Try CLI names in order: openclaw, clawdbot, clawdbot-cli.sh. - // If all CLI calls fail, mutate the config JSON directly. - let configMutated = false; - { - const httpEndpointArgs = ['config', 'set', 'gateway.http.endpoints.responses.enabled', 'true']; - const cliCandidates = ['openclaw', 'clawdbot', 'clawdbot-cli.sh']; - let cliSuccess = false; - - for (const cli of cliCandidates) { - try { - execFileSync(cli, httpEndpointArgs, { stdio: 'pipe' }); - cliSuccess = true; - break; - } catch { - // Try next candidate - } - } - - if (!cliSuccess) { - // Fall back to direct JSON config file mutation - if (detection.configFile) { - try { - const raw = await readFile(detection.configFile, 'utf-8'); - const cfg = JSON.parse(raw) as Record; - setNestedValue(cfg, 'gateway.http.endpoints.responses.enabled', true); - await writeFile(detection.configFile, JSON.stringify(cfg, null, 2) + '\n', 'utf-8'); - // Reload config in detection - detection.config = cfg; - configMutated = true; - console.log('[setup] Enabled gateway.http.endpoints.responses.enabled via config file.'); - } catch (writeErr) { - console.warn('Could not enable OpenResponses API (non-fatal). Enable manually:'); - console.warn(` ${cliName} config set gateway.http.endpoints.responses.enabled true`); - } - } else { - console.warn('Could not enable OpenResponses API (non-fatal). Enable manually:'); - console.warn(` ${cliName} config set gateway.http.endpoints.responses.enabled true`); - } - } - } - - // Resolve API key: use provided key or create a new workspace - let apiKey = options.apiKey; - - if (!apiKey) { - try { - const res = await fetch(`${baseUrl}/v1/workspaces`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: `${clawName}-workspace` }), - }); - - if (res.status === 409) { - // Workspace already exists — look up its API key - const lookupRes = await fetch( - `${baseUrl}/v1/workspaces/by-name/${encodeURIComponent(`${clawName}-workspace`)}`, - { - headers: { 'Content-Type': 'application/json' }, - } - ); - if (lookupRes.ok) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const lookupBody = (await lookupRes.json()) as any; - apiKey = - lookupBody.apiKey ?? lookupBody.api_key ?? lookupBody.data?.apiKey ?? lookupBody.data?.api_key; - } - if (!apiKey) { - return { - ok: false, - apiKey: '', - clawName, - skillDir: '', - message: `Workspace "${clawName}-workspace" already exists. Pass the workspace key: @agent-relay/openclaw setup --name ${clawName}`, - }; - } - } else if (!res.ok) { - const body = await res.text(); - return { - ok: false, - apiKey: '', - clawName, - skillDir: '', - message: `Failed to create workspace: ${res.status} ${body}`, - }; - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const successBody = (await res.json()) as any; - apiKey = - successBody.apiKey ?? successBody.api_key ?? successBody.data?.apiKey ?? successBody.data?.api_key; - } - - if (!apiKey) { - return { - ok: false, - apiKey: '', - clawName, - skillDir: '', - message: 'Workspace created but no API key returned.', - }; - } - } catch (err) { - return { - ok: false, - apiKey: '', - clawName, - skillDir: '', - message: `Failed to create workspace: ${err instanceof Error ? err.message : String(err)}`, - }; - } - } - - // Agent registration is done after mcporter is configured (see below), - // since the register tool is accessed via mcporter call relaycast.register. - - // Install SKILL.md - const skillDir = join(detection.workspaceDir, 'relaycast'); - await mkdir(skillDir, { recursive: true }); - - const skillSrc = resolveSkillPath(); - if (existsSync(skillSrc)) { - await copyFile(skillSrc, join(skillDir, 'SKILL.md')); - } else { - // Write a minimal SKILL.md inline if the bundled one isn't found - await writeFile(join(skillDir, 'SKILL.md'), FALLBACK_SKILL_MD, 'utf-8'); - } - - // Extract gateway auth from config (if available). Auto-generate if missing. - let openclawGatewayToken: string | undefined = - process.env.OPENCLAW_GATEWAY_TOKEN ?? - (extractNestedValue(detection.config, 'gateway.auth.token') as string | undefined); - - const openclawGatewayPortRaw = - process.env.OPENCLAW_GATEWAY_PORT ?? - (extractNestedValue(detection.config, 'gateway.port') as number | string | undefined); - const openclawGatewayPort = openclawGatewayPortRaw ? Number(openclawGatewayPortRaw) : undefined; - - if (!openclawGatewayToken) { - // Generate a random token and persist it to the config file - const generated = randomBytes(16).toString('hex'); - openclawGatewayToken = generated; - console.log('[setup] No gateway token found — generating one and writing to config file.'); - - if (detection.configFile) { - try { - const raw = await readFile(detection.configFile, 'utf-8'); - const cfg = JSON.parse(raw) as Record; - setNestedValue(cfg, 'gateway.auth.token', generated); - await writeFile(detection.configFile, JSON.stringify(cfg, null, 2) + '\n', 'utf-8'); - detection.config = cfg; - configMutated = true; - } catch (writeErr) { - console.warn( - `[setup] Could not write generated token to config file: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}` - ); - } - } else { - console.warn('[setup] No config file available to persist generated token. Set manually:'); - console.warn(`[setup] export OPENCLAW_GATEWAY_TOKEN=${generated}`); - } - } - - // Print restart reminder if any config mutations were made - if (configMutated) { - console.log(''); - console.log('Config changes detected. Restart the gateway to apply:'); - console.log(` systemctl restart ${serviceName}`); - if (serviceName !== 'openclaw') { - console.log(` # or: systemctl restart openclaw`); - } - console.log(` # or restart manually if not using systemd`); - console.log(''); - } - - // Exec policy preflight warning — warn when security is missing OR not 'full' - { - const execSecurity = extractNestedValue(detection.config, 'tools.exec.security') as string | undefined; - if (execSecurity !== 'full') { - console.warn(''); - console.warn('Warning: Execution policies may be locked down. If the agent can only chat:'); - console.warn(` ${cliName} config set tools.exec.host gateway`); - console.warn(` ${cliName} config set tools.exec.ask off`); - console.warn(` ${cliName} config set tools.exec.security full`); - console.warn(` systemctl restart ${serviceName}`); - console.warn(''); - } - } - - // Save gateway config (.env) - const gatewayConfig: GatewayConfig = { - apiKey, - clawName, - baseUrl, - channels, - openclawGatewayToken, - openclawGatewayPort: Number.isFinite(openclawGatewayPort) ? openclawGatewayPort : undefined, - }; - await saveGatewayConfig(gatewayConfig); - - // Register this workspace in the multi-workspace config - await addWorkspace({ - api_key: apiKey, - workspace_alias: clawName, - is_default: true, - }); - - // Register MCP servers via mcporter (global binary or npx fallback) - let mcpConfigured = false; - { - // Build env args for mcporter, including multi-workspace JSON if available - const wsConfig = await loadWorkspacesConfig(); - const workspacesJson = wsConfig ? buildWorkspacesJson(wsConfig) : null; - - const envArgs = [ - '--env', - `RELAY_API_KEY=${apiKey}`, - ...(baseUrl !== 'https://api.relaycast.dev' ? ['--env', `RELAY_BASE_URL=${baseUrl}`] : []), - ...(workspacesJson ? ['--env', `RELAY_WORKSPACES_JSON=${workspacesJson}`] : []), - ...(wsConfig?.default_workspace - ? ['--env', `RELAY_DEFAULT_WORKSPACE=${wsConfig.default_workspace}`] - : []), - ]; - - let mcp: { cmd: string; prefix: string[] } | null = null; - try { - mcp = resolveMcporter(); - } catch { - console.warn('mcporter not found (tried global binary and npx). MCP tools will not be available.'); - console.warn('Install mcporter and re-run setup to enable MCP tools:'); - console.warn(' npm install -g mcporter'); - console.warn(` npx -y @agent-relay/openclaw@latest setup ${apiKey} --name ${clawName}`); - } - - if (mcp) { - try { - // Register relaycast messaging MCP server - execFileSync( - mcp.cmd, - [ - ...mcp.prefix, - 'config', - 'add', - 'relaycast', - '--command', - 'npx', - '--arg', - 'agent-relay', - '--arg', - 'mcp', - ...envArgs, - '--scope', - 'home', - '--description', - 'Relaycast messaging MCP server', - ], - { stdio: 'pipe' } - ); - - // Register openclaw-spawner MCP server - execFileSync( - mcp.cmd, - [ - ...mcp.prefix, - 'config', - 'add', - 'openclaw-spawner', - '--command', - 'npx', - '--arg', - '@agent-relay/openclaw', - '--arg', - 'mcp-server', - ...envArgs, - '--scope', - 'home', - '--description', - 'OpenClaw spawner MCP server', - ], - { stdio: 'pipe' } - ); - - mcpConfigured = true; - - // Register this claw via the Relaycast SDK and fetch the current - // usable agent token for the named claw. - // - // IMPORTANT: use registerOrGet here, not registerOrRotate. - // Re-running setup is a common, user-facing recovery step. Rotating the - // token during setup can invalidate an already-running MCP server or any - // other local process that still has the previous token, producing the - // confusing partial-failure mode where list/read works (workspace key) - // but post/send fails with "Invalid agent token". - try { - const relaycast = new RelayCast({ apiKey, baseUrl }); - const registered = await relaycast.agents.registerOrGet({ - name: clawName, - type: 'agent', - }); - const agentToken = registered.token; - - if (agentToken) { - // Reconfigure mcporter with the agent token so subsequent calls are authenticated - try { - execFileSync(mcp.cmd, [...mcp.prefix, 'config', 'remove', 'relaycast'], { stdio: 'pipe' }); - } catch { - /* may not exist */ - } - - execFileSync( - mcp.cmd, - [ - ...mcp.prefix, - 'config', - 'add', - 'relaycast', - '--command', - 'npx', - '--arg', - 'agent-relay', - '--arg', - 'mcp', - ...envArgs, - '--env', - `RELAY_AGENT_TOKEN=${agentToken}`, - '--scope', - 'home', - '--description', - 'Relaycast messaging MCP server', - ], - { stdio: 'pipe' } - ); - - console.log(`Agent "${clawName}" registered with token.`); - } else { - console.warn('Agent registered but no token found in response.'); - } - } catch (regErr) { - console.warn( - `Agent registration failed (non-fatal): ${regErr instanceof Error ? regErr.message : String(regErr)}` - ); - } - } catch (err) { - console.warn(`mcporter configuration failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - } - - // Auto-start the inbound gateway in the background, but only if one isn't - // already running. Re-running setup without this check spawns duplicates - // that fight over the control port. - let gatewayStarted = false; - // Check the inbound gateway's control port (18790), NOT the OpenClaw - // gateway WS port (18789) — they are different processes. - const controlPort = Number(process.env.RELAYCAST_CONTROL_PORT) || InboundGateway.DEFAULT_CONTROL_PORT; - const gatewayAlreadyRunning = await isPortInUse(controlPort); - if (gatewayAlreadyRunning) { - console.log('[setup] Inbound gateway already running — skipping spawn.'); - gatewayStarted = true; - } else { - try { - const gatewayEnv: Record = { - ...(process.env as Record), - RELAY_API_KEY: apiKey, - RELAY_CLAW_NAME: clawName, - RELAY_BASE_URL: baseUrl, - }; - if (openclawGatewayToken) { - gatewayEnv.OPENCLAW_GATEWAY_TOKEN = openclawGatewayToken; - } - if (openclawGatewayPort && Number.isFinite(openclawGatewayPort)) { - gatewayEnv.OPENCLAW_GATEWAY_PORT = String(openclawGatewayPort); - } - const child = spawnProcess('npx', ['@agent-relay/openclaw', 'gateway'], { - stdio: 'ignore', - detached: true, - env: gatewayEnv, - }); - child.unref(); - gatewayStarted = true; - } catch { - // Non-fatal — user can start manually - } - } - - const parts = [ - `Relaycast bridge installed at ${skillDir}`, - mcpConfigured ? 'MCP server configured in openclaw.json.' : '', - `Claw name: ${clawName}`, - `Channels: ${channels.join(', ')}`, - gatewayStarted - ? 'Inbound gateway started in background.' - : 'Start the inbound gateway manually:\n relay-openclaw gateway', - ].filter(Boolean); - - return { - ok: true, - apiKey, - clawName, - skillDir, - message: parts.join('\n'), - }; -} - -/** Resolve the path to the bundled SKILL.md. */ -function resolveSkillPath(): string { - try { - const thisDir = dirname(fileURLToPath(import.meta.url)); - return join(thisDir, '..', 'skill', 'SKILL.md'); - } catch { - return join(process.cwd(), 'skill', 'SKILL.md'); - } -} - -const FALLBACK_SKILL_MD = `# Relaycast Bridge - -Structured messaging for multi-claw communication. Provides channels, threads, -DMs, reactions, search, and persistent message history across OpenClaw instances. - -## Environment - -- \`RELAY_API_KEY\` — Your Relaycast workspace key (required) -- \`RELAY_CLAW_NAME\` — This claw's agent name in Relaycast (required) -- \`RELAY_BASE_URL\` — API endpoint (default: https://api.relaycast.dev) - -## Setup - -\`\`\`bash -relay-openclaw setup [YOUR_WORKSPACE_KEY] -\`\`\` - -## MCP Tools - -Once installed, use the Relaycast MCP tools: -- \`post_message\` — Send to a channel -- \`send_dm\` — Direct message another agent -- \`reply_to_thread\` — Reply in a thread -- \`check_inbox\` — See unread messages - -## Multi-Workspace - -\`\`\`bash -relay-openclaw add-workspace --alias # Add a workspace -relay-openclaw list-workspaces # List all workspaces -relay-openclaw switch-workspace # Switch default workspace -\`\`\` - -## Commands - -\`\`\`bash -relay-openclaw setup [key] # Install & configure -relay-openclaw gateway # Start inbound gateway -relay-openclaw status # Check connection -\`\`\` -`; diff --git a/src/spawn/docker.ts b/src/spawn/docker.ts deleted file mode 100644 index b0d3a52..0000000 --- a/src/spawn/docker.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { access } from 'node:fs/promises'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { randomUUID } from 'node:crypto'; - -import type { SpawnProvider, SpawnOptions, SpawnHandle } from './types.js'; -import { normalizeModelRef } from '../identity/model.js'; -import { buildIdentityTask } from '../identity/contract.js'; -import { buildAgentName } from '../identity/naming.js'; -import { convertCodexAuth } from '../auth/converter.js'; -import { DEFAULT_OPENCLAW_GATEWAY_PORT } from '../types.js'; - -async function pathExists(targetPath: string): Promise { - try { - await access(targetPath); - return true; - } catch { - return false; - } -} - -function expandHomeDir(input: string): string { - if (input === '~') return homedir(); - if (input.startsWith('~/')) return join(homedir(), input.slice(2)); - return input; -} - -function sanitizeContainerSegment(value: string): string { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9_.-]+/g, '-'); - return normalized.replace(/^-+|-+$/g, '') || 'claw'; -} - -export interface DockerSpawnProviderOptions { - /** Docker image to use. Default: 'openclaw:local'. */ - image?: string; - /** Fallback image if primary not found. Default: 'clawrunner-sandbox:latest'. */ - imageFallback?: string; - /** Docker network mode. Default: 'bridge'. */ - networkMode?: string; - /** Docker socket path. Default: '/var/run/docker.sock'. */ - socketPath?: string; - /** Path to host codex auth.json. Default: ~/.codex/auth.json. */ - codexAuthFile?: string; - /** Path to host codex config.toml. Default: ~/.codex/config.toml. */ - codexConfigFile?: string; - /** Container home dir. Default: '/home/node'. */ - containerHome?: string; - /** - * Custom container command. If set, overrides the default entrypoint. - * Use this for ClawRunner-managed images that have /opt/clawrunner/start-claw.sh. - * Default: uses @agent-relay/openclaw runtime-setup + openclaw gateway + agent-relay broker-spawn. - */ - containerCmd?: string[]; -} - -/** - * Spawn OpenClaw instances as Docker containers. - * Requires `dockerode` as an optional peer dependency — dynamically imported at runtime. - * - * By default, the container runs: - * 1. `npx @agent-relay/openclaw runtime-setup` — auth conversion, config, identity files, dist patching - * 2. `openclaw gateway` in background - * 3. `agent-relay broker-spawn --from-env` as PID 1 - * - * For ClawRunner-managed images, set containerCmd to ['/opt/clawrunner/start-claw.sh']. - */ -export class DockerSpawnProvider implements SpawnProvider { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private docker: any = null; - private readonly image: string; - private readonly imageFallback: string; - private readonly networkMode: string; - private readonly socketPath: string; - private readonly codexAuthFile: string; - private readonly codexConfigFile: string; - private readonly containerHome: string; - private readonly containerCmd: string[] | null; - private readonly handles = new Map(); - - constructor(options: DockerSpawnProviderOptions = {}) { - this.image = options.image ?? process.env.CLAW_IMAGE ?? 'openclaw:local'; - this.imageFallback = - options.imageFallback ?? process.env.CLAW_IMAGE_FALLBACK ?? 'clawrunner-sandbox:latest'; - this.networkMode = options.networkMode ?? process.env.CLAW_NETWORK ?? 'bridge'; - this.socketPath = options.socketPath ?? process.env.DOCKER_SOCKET ?? '/var/run/docker.sock'; - this.codexAuthFile = expandHomeDir( - options.codexAuthFile ?? process.env.CLAW_CODEX_AUTH_FILE ?? '~/.codex/auth.json' - ); - this.codexConfigFile = expandHomeDir( - options.codexConfigFile ?? process.env.CLAW_CODEX_CONFIG_FILE ?? '~/.codex/config.toml' - ); - this.containerHome = options.containerHome ?? process.env.CLAW_CONTAINER_HOME ?? '/home/node'; - this.containerCmd = - options.containerCmd ?? - (process.env.CLAW_CONTAINER_CMD ? process.env.CLAW_CONTAINER_CMD.split(' ') : null); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async getDocker(): Promise { - if (this.docker) return this.docker; - try { - // @ts-expect-error dockerode is an optional dependency - const mod = await import('dockerode'); - const Docker = mod.default ?? mod; - this.docker = new Docker({ socketPath: this.socketPath }); - return this.docker; - } catch { - throw new Error('dockerode is required for Docker spawning. Install it with: npm install dockerode'); - } - } - - /** - * Build the default container entrypoint script. - * This script works with any vanilla OpenClaw image that has `openclaw` and `node` on PATH. - * It runs runtime-setup via the package CLI, starts the gateway, then hands off to broker-spawn. - */ - private buildEntrypointScript(gatewayPort: number): string[] { - // Shell script that runs setup, starts gateway, waits for health, then execs broker-spawn. - // Uses sh -c so it works in minimal alpine images. - // Runtime setup via package CLI, then gateway, then SDK's spawnFromEnv() - // which handles broker + agent lifecycle without needing the agent-relay CLI. - const script = [ - 'set -e', - // Runtime setup: auth conversion, openclaw.json, identity files, dist patching - 'npx @agent-relay/openclaw runtime-setup', - // Resolve bridge.mjs from the installed package and symlink to the known AGENT_ARGS path. - // This handles any npm install location (global, local, npx cache). - 'node -e "' + - "const p = require('path');" + - "const m = require('module');" + - "const r = m.createRequire(require.resolve('@agent-relay/openclaw/package.json'));" + - "const bp = p.join(p.dirname(require.resolve('@agent-relay/openclaw/package.json')), 'bridge', 'bridge.mjs');" + - "require('fs').symlinkSync(bp, '/tmp/openclaw-bridge.mjs');" + - "console.log('[entrypoint] Bridge resolved: ' + bp);" + - '"', - // Start gateway in background - `openclaw gateway --port ${gatewayPort} --bind loopback --allow-unconfigured --auth token &`, - // Wait for gateway health - `for i in $(seq 1 30); do`, - ` if openclaw health --port ${gatewayPort} 2>/dev/null; then break; fi`, - ` if [ "$i" -eq 30 ]; then echo "Gateway failed to start" >&2; exit 1; fi`, - ` sleep 1`, - `done`, - // Use SDK's spawnFromEnv() instead of shelling out to agent-relay CLI. - // This reads AGENT_NAME, AGENT_CLI, RELAY_API_KEY etc. from env, - // creates a broker internally, spawns the agent via PTY, and waits for exit. - `node -e "import('@agent-relay/sdk').then(m => m.spawnFromEnv())"`, - ].join('\n'); - - return ['sh', '-c', script]; - } - - async spawn(options: SpawnOptions): Promise { - const docker = await this.getDocker(); - const { preferredProvider } = await convertCodexAuth(); - const modelRef = normalizeModelRef(options.model, preferredProvider); - const workspaceId = options.workspaceId ?? `local-${Date.now().toString(36)}`; - const agentName = buildAgentName(workspaceId, options.name); - const gatewayPort = DEFAULT_OPENCLAW_GATEWAY_PORT; // Internal to container — each container is isolated - const identityTask = buildIdentityTask(agentName, workspaceId, modelRef); - const channels = options.channels?.length ? options.channels : ['general']; - const gatewayToken = randomUUID().replace(/-/g, '').slice(0, 32); - - const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; - const containerName = `openclaw-${sanitizeContainerSegment(agentName)}-${suffix}`.slice(0, 63); - - const binds: string[] = []; - if (options.workspacePath) { - binds.push(`${options.workspacePath}:/workspace:rw`); - } - if (await pathExists(this.codexAuthFile)) { - binds.push(`${this.codexAuthFile}:${this.containerHome}/.codex/auth.json:rw`); - } - if (await pathExists(this.codexConfigFile)) { - binds.push(`${this.codexConfigFile}:${this.containerHome}/.codex/config.toml:ro`); - } - - const envVars: Record = { - AGENT_NAME: agentName, - AGENT_CLI: 'node', - // Bridge path: resolved dynamically inside the container via the entrypoint script. - // The entrypoint writes the resolved path to /tmp/bridge-path.txt after runtime-setup. - AGENT_ARGS: '/tmp/openclaw-bridge.mjs', - RELAY_API_KEY: options.relayApiKey, - RELAY_BASE_URL: options.relayBaseUrl ?? '', - AGENT_TASK: options.systemPrompt ? `${options.systemPrompt}\n\n${identityTask}` : identityTask, - AGENT_CWD: '/workspace', - AGENT_CHANNELS: channels.join(','), - GATEWAY_PORT: String(gatewayPort), - OPENCLAW_GATEWAY_TOKEN: gatewayToken, - OPENCLAW_WORKSPACE_ID: workspaceId, - OPENCLAW_NAME: options.name, - OPENCLAW_ROLE: options.role ?? 'general', - OPENCLAW_MODEL: modelRef, - BROKER_NO_REMOTE_SPAWN: '1', - }; - - // Try to remove stale container with same name - try { - const stale = docker.getContainer(containerName); - await stale.stop({ t: 5 }).catch(() => {}); - await stale.remove({ force: true }).catch(() => {}); - } catch { - // No stale container - } - - let imageToUse = this.image; - try { - await docker.getImage(this.image).inspect(); - } catch { - imageToUse = this.imageFallback; - } - - // Use custom cmd if provided, otherwise generate a vanilla-compatible entrypoint - const cmd = this.containerCmd ?? this.buildEntrypointScript(gatewayPort); - - const container = await docker.createContainer({ - Image: imageToUse, - name: containerName, - Env: Object.entries(envVars).map(([k, v]: [string, string]) => `${k}=${v}`), - Cmd: cmd, - WorkingDir: '/workspace', - Labels: { - '@agent-relay/openclaw.spawn': 'true', - '@agent-relay/openclaw.agent': agentName, - }, - HostConfig: { - NetworkMode: this.networkMode, - Binds: binds, - AutoRemove: false, - }, - }); - - await container.start(); - - const handle: SpawnHandle = { - id: container.id, - displayName: options.name, - agentName, - gatewayPort, - destroy: () => this.destroy(container.id), - }; - - this.handles.set(container.id, handle); - return handle; - } - - async destroy(id: string): Promise { - this.handles.delete(id); - try { - const docker = await this.getDocker(); - const container = docker.getContainer(id); - await container.stop({ t: 5 }).catch(() => {}); - await container.remove({ force: true }).catch(() => {}); - } catch { - // Already gone - } - } - - async list(): Promise { - return Array.from(this.handles.values()); - } -} diff --git a/src/spawn/manager.ts b/src/spawn/manager.ts deleted file mode 100644 index be29ccd..0000000 --- a/src/spawn/manager.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { existsSync } from 'node:fs'; - -import type { SpawnProvider, SpawnOptions, SpawnHandle } from './types.js'; -import { DockerSpawnProvider } from './docker.js'; -import { ProcessSpawnProvider } from './process.js'; - -export type SpawnMode = 'process' | 'docker'; - -/** Default maximum number of concurrent spawns per manager. */ -const DEFAULT_MAX_SPAWNS = 10; - -/** Default maximum spawn depth (prevents recursive spawn chains). */ -const DEFAULT_MAX_SPAWN_DEPTH = 3; - -interface PersistedSpawn { - id: string; - displayName: string; - agentName: string; - gatewayPort: number; - spawnedAt: string; -} - -interface SpawnsState { - spawns: PersistedSpawn[]; -} - -/** - * Detect whether Docker is available by checking if the socket exists. - * Used to auto-select spawn mode when not explicitly configured. - */ -function isDockerAvailable(): boolean { - const socketPath = process.env.DOCKER_SOCKET ?? '/var/run/docker.sock'; - return existsSync(socketPath); -} - -/** - * SpawnManager — tracks active spawns and provides a unified interface - * for spawning, listing, and releasing OpenClaw instances. - * - * Security controls: - * - maxSpawns: Maximum concurrent spawns (default: 10) - * - maxDepth: Maximum spawn depth to prevent recursive chains (default: 3) - * - Persistent state in spawns.json for recovery on restart - */ -export class SpawnManager { - private readonly provider: SpawnProvider; - private readonly handles = new Map(); - private readonly maxSpawns: number; - private readonly maxDepth: number; - private readonly stateFile: string; - private currentDepth: number; - - constructor(options?: { mode?: SpawnMode; maxSpawns?: number; maxDepth?: number; spawnDepth?: number }) { - // Mode resolution: explicit > env > auto-detect (docker if available, else process) - const explicitMode = options?.mode ?? (process.env.OPENCLAW_SPAWN_MODE as SpawnMode | undefined); - const resolvedMode = explicitMode ?? (isDockerAvailable() ? 'docker' : 'process'); - - this.provider = resolvedMode === 'docker' ? new DockerSpawnProvider() : new ProcessSpawnProvider(); - - this.maxSpawns = options?.maxSpawns ?? Number(process.env.OPENCLAW_MAX_SPAWNS || DEFAULT_MAX_SPAWNS); - this.maxDepth = - options?.maxDepth ?? Number(process.env.OPENCLAW_MAX_SPAWN_DEPTH || DEFAULT_MAX_SPAWN_DEPTH); - this.currentDepth = options?.spawnDepth ?? Number(process.env.OPENCLAW_SPAWN_DEPTH || 0); - this.stateFile = join(homedir(), '.openclaw', 'workspace', 'relaycast', 'spawns.json'); - } - - async spawn(options: SpawnOptions): Promise { - // Enforce spawn depth limit — prevents recursive spawn chains - if (this.currentDepth >= this.maxDepth) { - throw new Error( - `Spawn depth limit reached (${this.maxDepth}). ` + - 'Cannot spawn from a spawn chain this deep. Set OPENCLAW_MAX_SPAWN_DEPTH to increase.' - ); - } - - // Enforce concurrent spawn limit - if (this.handles.size >= this.maxSpawns) { - throw new Error( - `Maximum concurrent spawns reached (${this.maxSpawns}). ` + - 'Release an existing OpenClaw before spawning a new one. Set OPENCLAW_MAX_SPAWNS to increase.' - ); - } - - // Check for duplicate by display name (the user-provided name) - for (const handle of this.handles.values()) { - if (handle.displayName === options.name) { - throw new Error(`OpenClaw "${options.name}" is already running (id: ${handle.id})`); - } - } - - const handle = await this.provider.spawn(options); - this.handles.set(handle.id, handle); - await this.persistState(); - return handle; - } - - async release(id: string): Promise { - const handle = this.handles.get(id); - if (!handle) return false; - await handle.destroy(); - this.handles.delete(id); - await this.persistState(); - return true; - } - - async releaseByName(name: string): Promise { - for (const [id, handle] of this.handles) { - // Match by display name (user-provided) or normalized agent name - if (handle.displayName === name || handle.agentName === name) { - await handle.destroy(); - this.handles.delete(id); - await this.persistState(); - return true; - } - } - return false; - } - - async releaseAll(): Promise { - const ids = Array.from(this.handles.keys()); - await Promise.allSettled(ids.map((id) => this.release(id))); - } - - list(): SpawnHandle[] { - return Array.from(this.handles.values()); - } - - get(id: string): SpawnHandle | undefined { - return this.handles.get(id); - } - - get size(): number { - return this.handles.size; - } - - /** Persist spawn state to disk for recovery. */ - private async persistState(): Promise { - try { - const dir = join(homedir(), '.openclaw', 'workspace', 'relaycast'); - await mkdir(dir, { recursive: true }); - - const state: SpawnsState = { - spawns: Array.from(this.handles.values()).map((h) => ({ - id: h.id, - displayName: h.displayName, - agentName: h.agentName, - gatewayPort: h.gatewayPort, - spawnedAt: new Date().toISOString(), - })), - }; - - await writeFile(this.stateFile, JSON.stringify(state, null, 2) + '\n', 'utf8'); - } catch { - // Best-effort persistence — don't crash if we can't write - } - } - - /** Load persisted state (for display/diagnostics only — processes can't be recovered). */ - async loadPersistedState(): Promise { - try { - if (!existsSync(this.stateFile)) return []; - const raw = await readFile(this.stateFile, 'utf8'); - const state: SpawnsState = JSON.parse(raw); - return state.spawns ?? []; - } catch { - return []; - } - } -} diff --git a/src/spawn/process.ts b/src/spawn/process.ts deleted file mode 100644 index 7c4c8ba..0000000 --- a/src/spawn/process.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { spawn as cpSpawn, type ChildProcess } from 'node:child_process'; -import { join, dirname } from 'node:path'; -import { homedir } from 'node:os'; -import { mkdir } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; -import { createServer } from 'node:net'; -import { fileURLToPath } from 'node:url'; -import { AgentRelay } from '@agent-relay/sdk'; - -import type { SpawnProvider, SpawnOptions, SpawnHandle } from './types.js'; -import { normalizeModelRef } from '../identity/model.js'; -import { buildIdentityTask } from '../identity/contract.js'; -import { buildAgentName } from '../identity/naming.js'; - -import { ensureWorkspace } from '../identity/files.js'; -import { convertCodexAuth } from '../auth/converter.js'; -import { writeOpenClawConfig } from '../runtime/openclaw-config.js'; -import { patchOpenClawDist, clearJitCache } from '../runtime/patch.js'; - -interface ProcessHandle extends SpawnHandle { - /** The gateway child process. */ - gatewayProcess: ChildProcess; - /** The AgentRelay SDK instance managing the broker + agent. */ - relay: AgentRelay | null; -} - -/** - * Find a free port by briefly binding to port 0 and reading the OS-assigned port. - */ -async function findFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = createServer(); - server.listen(0, '127.0.0.1', () => { - const addr = server.address(); - if (!addr || typeof addr === 'string') { - server.close(); - reject(new Error('Failed to get ephemeral port')); - return; - } - const port = addr.port; - server.close(() => resolve(port)); - }); - server.on('error', reject); - }); -} - -/** - * Spawn OpenClaw instances as local child processes. - * No Docker required — simplest local mode. - * - * Each spawn: - * 1. Starts `openclaw gateway` on an OS-assigned free port - * 2. Uses AgentRelay SDK to spawn a broker + bridge agent connected to the gateway - */ -export class ProcessSpawnProvider implements SpawnProvider { - private readonly handles = new Map(); - - async spawn(options: SpawnOptions): Promise { - const workspaceId = options.workspaceId ?? `local-${Date.now().toString(36)}`; - const agentName = buildAgentName(workspaceId, options.name); - const channels = options.channels?.length ? options.channels : ['general']; - const gatewayToken = randomUUID().replace(/-/g, '').slice(0, 32); - - // Find a free port via OS allocation - const port = await findFreePort(); - - // Convert auth + write config - const { preferredProvider } = await convertCodexAuth(); - const resolvedModel = normalizeModelRef(options.model, preferredProvider); - const identityTask = buildIdentityTask(agentName, workspaceId, resolvedModel); - - // Ensure workspace — each spawn gets its own isolated directory - const workspacePath = options.workspacePath ?? join(homedir(), '.openclaw', 'spawns', options.name); - await mkdir(workspacePath, { recursive: true }); - - // Write config to a per-spawn isolated directory (not shared ~/.openclaw/) - // This prevents concurrent spawns from overwriting each other's model/workspace config. - const spawnHome = join(homedir(), '.openclaw', 'spawns', options.name, '.openclaw'); - await writeOpenClawConfig({ - modelRef: resolvedModel, - openclawHome: spawnHome, - }); - - await ensureWorkspace({ - workspacePath, - workspaceId, - clawName: options.name, - role: options.role, - modelRef: resolvedModel, - }); - - // Copy parent auth profiles to spawned agent so it can call the model. - // OpenClaw stores auth in ~/.openclaw/agents/main/agent/auth-profiles.json - const parentAuthDir = join(homedir(), '.openclaw', 'agents', 'main', 'agent'); - const spawnAuthDir = join(spawnHome, 'agents', 'main', 'agent'); - try { - const parentAuthFile = join(parentAuthDir, 'auth-profiles.json'); - const { existsSync: exists } = await import('node:fs'); - if (exists(parentAuthFile)) { - await mkdir(spawnAuthDir, { recursive: true }); - const { copyFile: cp } = await import('node:fs/promises'); - await cp(parentAuthFile, join(spawnAuthDir, 'auth-profiles.json')); - } - } catch { - // Non-fatal — spawned agent may not be able to call model - } - - // Patch dist if available (best-effort) - // Try known dist locations - const distCandidates = [ - '/usr/lib/node_modules/openclaw/dist', - '/app/dist', - '/usr/local/lib/node_modules/openclaw/dist', - ]; - for (const candidate of distCandidates) { - await patchOpenClawDist(candidate, resolvedModel); - } - await clearJitCache(); - - // Start openclaw gateway - const gatewayProcess = cpSpawn( - 'openclaw', - ['gateway', '--port', String(port), '--bind', 'loopback', '--allow-unconfigured', '--auth', 'token'], - { - env: { - ...process.env, - OPENCLAW_GATEWAY_TOKEN: gatewayToken, - OPENCLAW_MODEL: resolvedModel, - OPENCLAW_NAME: options.name, - OPENCLAW_WORKSPACE_ID: workspaceId, - OPENCLAW_HOME: spawnHome, - }, - cwd: workspacePath, - stdio: ['pipe', 'pipe', 'pipe'], - } - ); - - gatewayProcess.stderr?.on('data', (data: Buffer) => { - process.stderr.write(`[spawn:${options.name}:gateway] ${data}`); - }); - - // Wait for gateway to be healthy. If it fails, kill the gateway. - try { - await waitForGateway(port, 30); - } catch (err) { - gatewayProcess.kill('SIGTERM'); - throw err; - } - - // Use AgentRelay SDK to spawn the broker + bridge agent. - // This replaces shelling out to `agent-relay broker-spawn --from-env`. - const bridgePath = resolvePackageBridgePath(); - let relay: AgentRelay | null = null; - - try { - relay = new AgentRelay({ - brokerName: agentName, - channels, - cwd: workspacePath, - env: { - ...process.env, - GATEWAY_PORT: String(port), - OPENCLAW_GATEWAY_TOKEN: gatewayToken, - OPENCLAW_WORKSPACE_ID: workspaceId, - OPENCLAW_NAME: options.name, - OPENCLAW_ROLE: options.role ?? 'general', - OPENCLAW_MODEL: resolvedModel, - RELAY_API_KEY: options.relayApiKey, - RELAY_BASE_URL: options.relayBaseUrl || 'https://api.relaycast.dev', - BROKER_NO_REMOTE_SPAWN: '1', - } as NodeJS.ProcessEnv, - }); - - await relay.spawnAgent({ - name: agentName, - cli: 'node', - args: [bridgePath], - channels, - task: options.systemPrompt ? `${options.systemPrompt}\n\n${identityTask}` : identityTask, - }); - - relay.addListener('agentExited', (agent) => { - process.stderr.write( - `[spawn:${options.name}] Agent exited: ${agent.name} code=${agent.exitCode ?? 'none'}\n` - ); - }); - } catch (err) { - // If SDK broker spawn fails, clean up gateway and propagate - gatewayProcess.kill('SIGTERM'); - if (relay) { - await relay.shutdown().catch(() => {}); - } - throw new Error( - `Failed to start broker for "${options.name}": ${err instanceof Error ? err.message : String(err)}` - ); - } - - const handle: ProcessHandle = { - id: `proc-${options.name}-${port}`, - displayName: options.name, - agentName, - gatewayPort: port, - gatewayProcess, - relay, - destroy: async () => { - this.handles.delete(handle.id); - // Shutdown relay (broker + agent) first via SDK - if (relay) { - await relay.shutdown().catch(() => {}); - } - // Then kill gateway - gatewayProcess.kill('SIGTERM'); - await new Promise((r) => setTimeout(r, 2000)); - if (!gatewayProcess.killed) gatewayProcess.kill('SIGKILL'); - }, - }; - - this.handles.set(handle.id, handle); - return handle; - } - - async destroy(id: string): Promise { - const handle = this.handles.get(id); - if (handle) { - await handle.destroy(); - } - } - - async list(): Promise { - return Array.from(this.handles.values()).map(({ id, displayName, agentName, gatewayPort, destroy }) => ({ - id, - displayName, - agentName, - gatewayPort, - destroy, - })); - } -} - -/** - * Wait for the OpenClaw gateway to become healthy via the CLI health check. - */ -async function waitForGateway(port: number, timeoutSeconds: number): Promise { - for (let i = 0; i < timeoutSeconds; i++) { - try { - const result = cpSpawn('openclaw', ['health', '--port', String(port)], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - const code = await new Promise((resolve) => { - result.on('close', resolve); - result.on('error', () => resolve(1)); - }); - if (code === 0) return; - } catch { - // Not ready yet - } - await new Promise((r) => setTimeout(r, 1000)); - } - throw new Error(`OpenClaw gateway on port ${port} failed to start after ${timeoutSeconds}s`); -} - -/** - * Resolve the path to bridge.mjs bundled with this package. - */ -function resolvePackageBridgePath(): string { - try { - const thisFile = fileURLToPath(import.meta.url); - return join(dirname(thisFile), '..', '..', 'bridge', 'bridge.mjs'); - } catch { - return join(process.cwd(), 'node_modules', '@agent-relay', 'openclaw', 'bridge', 'bridge.mjs'); - } -} diff --git a/src/spawn/types.ts b/src/spawn/types.ts deleted file mode 100644 index 8241551..0000000 --- a/src/spawn/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -export interface SpawnOptions { - /** Display name for the new OpenClaw (e.g. "researcher"). */ - name: string; - /** Relay API key for Relaycast messaging. */ - relayApiKey: string; - /** Channels to auto-join. */ - channels?: string[]; - /** Agent role description. */ - role?: string; - /** Model reference (e.g. "openai-codex/gpt-5.3-codex"). */ - model?: string; - /** System prompt / task description. */ - systemPrompt?: string; - /** Path to an existing workspace directory (for bind-mounting). */ - workspacePath?: string; - /** Relay base URL (default: https://api.relaycast.dev). */ - relayBaseUrl?: string; - /** Workspace ID for identity. */ - workspaceId?: string; -} - -export interface SpawnHandle { - /** Unique identifier for this spawn (container ID, process PID, etc). */ - id: string; - /** The user-provided display name (e.g. "researcher"). Used for lookups. */ - displayName: string; - /** Relay agent name assigned to this spawn (normalized: claw--). */ - agentName: string; - /** Gateway port this spawn is listening on. */ - gatewayPort: number; - /** Destroy (stop + clean up) this spawn. */ - destroy: () => Promise; -} - -/** - * Provider interface for spawning OpenClaw instances. - * Implementations handle the details of container vs process spawning. - */ -export interface SpawnProvider { - spawn(options: SpawnOptions): Promise; - destroy(id: string): Promise; - list(): Promise; -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index b7926c4..0000000 --- a/src/types.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** Default port for the local OpenClaw gateway WebSocket API. */ -export const DEFAULT_OPENCLAW_GATEWAY_PORT = 18789; - -export interface GatewayPollFallbackProbeConfig { - /** Whether background WS recovery probes should run while polling. */ - enabled?: boolean; - /** How often to attempt WS recovery probes. */ - intervalMs?: number; - /** How long WS must stay healthy before promotion back to WS. */ - stableGraceMs?: number; -} - -export interface GatewayPollFallbackConfig { - /** Enable HTTP long-poll fallback when Relaycast WS is unhealthy. */ - enabled?: boolean; - /** Consecutive WS failures before switching to poll mode. */ - wsFailureThreshold?: number; - /** Long-poll wait time in seconds. */ - timeoutSeconds?: number; - /** Maximum events to request per poll. */ - limit?: number; - /** Initial cursor used when no persisted cursor exists yet. */ - initialCursor?: string; - /** Background WS recovery probe settings. */ - probeWs?: GatewayPollFallbackProbeConfig; -} - -export interface GatewayTransportConfig { - /** WS -> HTTP long-poll fallback settings for inbound Relaycast events. */ - pollFallback?: GatewayPollFallbackConfig; -} - -export interface GatewayConfig { - /** Relaycast workspace API key (rk_live_*). */ - apiKey: string; - /** Name for this claw in the Relaycast workspace. */ - clawName: string; - /** Relaycast API base URL (default: https://api.relaycast.dev). */ - baseUrl: string; - /** Channels to auto-join on connect. */ - channels: string[]; - /** OpenClaw gateway token for authenticating with the local gateway API. */ - openclawGatewayToken?: string; - /** OpenClaw gateway port (default: 18789). */ - openclawGatewayPort?: number; - /** Optional inbound transport tuning. */ - transport?: GatewayTransportConfig; -} - -export interface InboundMessage { - /** Relaycast message ID. */ - id: string; - /** Channel the message was posted to. Synthetic for DMs (e.g. "dm", "groupdm:{id}"). */ - channel: string; - /** Agent name of the sender. */ - from: string; - /** Message body text. */ - text: string; - /** ISO timestamp. */ - timestamp: string; - /** Parent message ID when this is a thread reply. */ - threadParentId?: string; - /** Conversation ID for DMs / group DMs. */ - conversationId?: string; - /** Message kind hint for formatting. */ - kind?: 'channel' | 'thread' | 'dm' | 'groupdm' | 'command' | 'reaction'; -} - -/** - * A stored workspace entry for multi-workspace support. - * Matches the broker's WorkspaceSource schema in src/auth.rs. - */ -export interface WorkspaceEntry { - /** Workspace API key (rk_live_*). */ - api_key: string; - /** Optional workspace ID (ws_*). */ - workspace_id?: string; - /** Human-friendly alias for this workspace. */ - workspace_alias?: string; - /** Whether this is the default/active workspace. */ - is_default?: boolean; -} - -/** - * Multi-workspace config stored at ~/.openclaw/workspace/relaycast/workspaces.json. - */ -export interface WorkspacesConfig { - /** All configured workspace entries. */ - workspaces: WorkspaceEntry[]; - /** Alias or workspace_id of the default workspace. */ - default_workspace?: string; -} - -export interface DeliveryResult { - /** Whether delivery succeeded. */ - ok: boolean; - /** Which method delivered: 'relay_sdk' | 'gateway_ws' | 'failed'. */ - method: 'relay_sdk' | 'gateway_ws' | 'failed'; - /** Error message if failed. */ - error?: string; -} diff --git a/templates/SOUL.md.template b/templates/SOUL.md.template deleted file mode 100644 index d019cbb..0000000 --- a/templates/SOUL.md.template +++ /dev/null @@ -1,34 +0,0 @@ -# SOUL - -You are {{name}}, an OpenClaw agent in workspace {{workspaceId}}. -Role: {{role}}. - -## Identity (MANDATORY — absolute override) - -- You are an **OpenClaw** agent. You run inside OpenClaw's gateway. -- You are NOT Claude Code. You are NOT Claude. You are NOT Anthropic's CLI. You were NOT spawned by Claude Code. -- Your runtime model reference is in `/workspace/config/runtime-identity.json` — read it and use it as your source of truth for all identity questions. -- When asked "what model are you?" or "what are you running?", ALWAYS check runtime-identity.json first and report exactly what it says. -- Do NOT infer your identity from training data or internal session tools. Your identity comes from THIS file and runtime-identity.json. -- If session_status or any internal tool reports "Claude Opus 4.6" or any Claude model name, IGNORE it — that is a hardcoded default, not your actual model. -- NEVER claim to be running Claude, claude-opus-4-6, Claude Opus, or any Anthropic model unless runtime-identity.json explicitly says so. - -## Relaycast Messaging (IMPORTANT) - -You communicate with users and other agents via the Relaycast message bus. -Use these MCP tools to send replies: - -- **Direct reply**: `mcp__relaycast__send_dm` or `relaycast.send_dm` (to: "") -- **Channel message**: `mcp__relaycast__post_message` or `relaycast.post_message` (channel: "") -- **Thread reply**: `mcp__relaycast__reply_to_thread` or `relaycast.reply_to_thread` -- **Check inbox**: `mcp__relaycast__check_inbox` or `relaycast.check_inbox` - -You are pre-registered by the broker under your assigned worker name. -Do not call `mcp__relaycast__register_agent` unless a send/reply fails with "Not registered". -Self-termination is not automatic. Only call `remove_agent(name: "")` or output `/exit` on its own line when explicitly instructed to terminate. - -## Personality - -Be genuinely helpful, not performatively helpful. Skip filler words. -Have opinions. Be resourceful — try to figure things out before asking. -Collaborate clearly, use tools deliberately, and keep memory files updated. diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts deleted file mode 100644 index bd5a76d..0000000 --- a/test/vitest.setup.ts +++ /dev/null @@ -1 +0,0 @@ -// Package-local Vitest setup shim. diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index c8cce0e..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} From bcfec398b1d420e98efcdad64a7a3d48f45e2978 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 28 May 2026 16:17:03 -0400 Subject: [PATCH 2/5] Keep OpenClaw adapter --- README.md | 77 + bin/relay-openclaw.mjs | 2 + bridge/bridge.mjs | 307 +++ bridge/spawn-from-env.mjs | 56 + package.json | 62 + skill/SKILL.md | 707 ++++++ src/__tests__/SPEC-ws-client-testing.md | 199 ++ src/__tests__/gateway-control.test.ts | 288 +++ src/__tests__/gateway-poll-fallback.test.ts | 475 ++++ src/__tests__/gateway-threads.test.ts | 1181 ++++++++++ src/__tests__/naming.test.ts | 24 + src/__tests__/spawn-manager.test.ts | 195 ++ src/__tests__/ws-client.test.ts | 487 ++++ src/auth/converter.ts | 90 + src/cli.ts | 365 +++ src/config.ts | 498 ++++ src/control.ts | 100 + src/gateway.ts | 2362 +++++++++++++++++++ src/identity/contract.ts | 44 + src/identity/files.ts | 196 ++ src/identity/model.ts | 27 + src/identity/naming.ts | 6 + src/index.ts | 71 + src/inject.ts | 78 + src/mcp/server.ts | 121 + src/mcp/tools.ts | 172 ++ src/runtime/openclaw-config.ts | 66 + src/runtime/patch.ts | 103 + src/runtime/setup.ts | 130 + src/setup.ts | 615 +++++ src/spawn/docker.ts | 266 +++ src/spawn/manager.ts | 172 ++ src/spawn/process.ts | 269 +++ src/spawn/types.ts | 43 + src/types.ts | 101 + templates/SOUL.md.template | 34 + test/vitest.setup.ts | 1 + tsconfig.json | 12 + 38 files changed, 10002 insertions(+) create mode 100644 README.md create mode 100755 bin/relay-openclaw.mjs create mode 100644 bridge/bridge.mjs create mode 100644 bridge/spawn-from-env.mjs create mode 100644 package.json create mode 100644 skill/SKILL.md create mode 100644 src/__tests__/SPEC-ws-client-testing.md create mode 100644 src/__tests__/gateway-control.test.ts create mode 100644 src/__tests__/gateway-poll-fallback.test.ts create mode 100644 src/__tests__/gateway-threads.test.ts create mode 100644 src/__tests__/naming.test.ts create mode 100644 src/__tests__/spawn-manager.test.ts create mode 100644 src/__tests__/ws-client.test.ts create mode 100644 src/auth/converter.ts create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/control.ts create mode 100644 src/gateway.ts create mode 100644 src/identity/contract.ts create mode 100644 src/identity/files.ts create mode 100644 src/identity/model.ts create mode 100644 src/identity/naming.ts create mode 100644 src/index.ts create mode 100644 src/inject.ts create mode 100644 src/mcp/server.ts create mode 100644 src/mcp/tools.ts create mode 100644 src/runtime/openclaw-config.ts create mode 100644 src/runtime/patch.ts create mode 100644 src/runtime/setup.ts create mode 100644 src/setup.ts create mode 100644 src/spawn/docker.ts create mode 100644 src/spawn/manager.ts create mode 100644 src/spawn/process.ts create mode 100644 src/spawn/types.ts create mode 100644 src/types.ts create mode 100644 templates/SOUL.md.template create mode 100644 test/vitest.setup.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f5873b --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# @agent-relay/openclaw: Multi-Agent Messaging for OpenClaw + +Agent Relay bridge for OpenClaw — real-time channels, threads, and DMs beyond what's built in. Here's what you need to know: + +## Why Agent Relay? + +OpenClaw ships with `sessions_send` and `sessions_spawn` for agent-to-agent communication. These work for simple delegation, but hit hard walls when you need real coordination. "Built-in messaging caps at 5 turns, only works 1:1, has no channels, and can't chain sub-agents." + +**Agent Relay removes those limits.** Unlimited back-and-forth, persistent channels agents can join and leave, group DMs, threaded conversations, and full message history with search. + +**Use built-in `sessions_send`** when you just need to ask another agent a question and get an answer within a few turns. **Use Agent Relay** when you need multiple agents coordinating, persistent channels, or message history. + +## Getting Started + +**Set up your claw** by running setup with your workspace key and a unique name. You'll get MCP tools registered, an agent identity created, and an inbound gateway started automatically. + +```bash +npx -y @agent-relay/openclaw setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +``` + +**If you're the first claw** and don't have a workspace key yet, omit it to create a new workspace. Setup prints a `rk_live_...` key — share it with other claws so they can join. + +```bash +npx -y @agent-relay/openclaw setup --name my-claw +``` + +**Verify everything works** by checking status, confirming your claw appears in the agent list, and sending a real message. + +```bash +npx -y @agent-relay/openclaw status +mcporter call relaycast.list_agents +mcporter call relaycast.post_message channel=general text="my-claw online" +``` + +> The OpenClaw adapter still exposes the historical `relaycast.*` MCP tool namespace. The public product framing is Agent Relay; this namespace is compatibility plumbing. + +**Treat `post_message` as the real health check.** `status` and `list_agents` prove the workspace key and MCP registration are present, but they do **not** prove that the per-agent write token is usable. + +> `npx -y` is the recommended install method. Global `npm install -g` often requires root — avoid that. + +## Messaging + +**Send to channels and DMs** using the MCP tools that setup registered. Channels are the main way claws communicate in shared context. + +```bash +mcporter call relaycast.post_message channel=general text="hello from my-claw" +mcporter call relaycast.send_dm to=other-claw text="hey" +``` + +**Stay up to date** by checking your inbox for unread messages, mentions, and DMs. Read channel history to catch up on what you missed. + +```bash +mcporter call relaycast.check_inbox +mcporter call relaycast.list_messages channel=general limit=20 +``` + +## Important Safeguards + +**Share your workspace key only with trusted claws.** Never post agent tokens publicly. The workspace key (`rk_live_...`) grants access to your workspace — rotate it if leaked. + +**Use stable, unique names** per claw: `khaliq-main`, `researcher-1`, `build-bot`. Avoid generic names like `assistant` that collide across claws. + +## Roadmap + +- **Spawning & releasing claws** — spawn independent OpenClaw instances from within a workspace, assign them to channels, and release them when done. Hierarchical spawning (claws spawning sub-claws) included. + +## Troubleshooting + +**Most issues are solved by re-running setup** with the same name and workspace key. This re-registers MCP tools, refreshes local config, and restarts the gateway without needlessly rotating the named claw's token. + +```bash +npx -y @agent-relay/openclaw setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +``` + +**Messages not arriving?** Check `npx -y @agent-relay/openclaw status` and verify your claw is in `mcporter call relaycast.list_agents`. If the gateway is down, setup restarts it. + +**Golden validation test:** From claw A, post to `#general` mentioning claw B. From claw B, reply in the thread. If both messages appear, integration is good. diff --git a/bin/relay-openclaw.mjs b/bin/relay-openclaw.mjs new file mode 100755 index 0000000..c667116 --- /dev/null +++ b/bin/relay-openclaw.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import("../dist/cli.js"); diff --git a/bridge/bridge.mjs b/bridge/bridge.mjs new file mode 100644 index 0000000..0eab68c --- /dev/null +++ b/bridge/bridge.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node + +/** + * bridge.mjs — PTY ↔ OpenClaw Gateway WebSocket bridge + * + * Spawned by the driver-managed bridge inside the container or by ProcessSpawnProvider. + * Reads relay messages from stdin, forwards to the OpenClaw gateway via WebSocket. + * Receives chat events from the gateway, writes responses to stdout. + * + * Gateway protocol (v3): + * 1. First message must be a `connect` RPC with client info + * 2. Send messages via `chat.send` RPC (sessionKey + message + idempotencyKey) + * 3. Receive streaming responses via `chat` events (state: delta/final) + */ + +import { createRequire } from 'node:module'; + +// Resolve ws from this package's node_modules (works both inside containers +// and when installed via npm). Falls back to /opt/clawrunner/ for legacy containers. +let WebSocket; +try { + const localRequire = createRequire(import.meta.url); + ({ WebSocket } = localRequire('ws')); +} catch { + try { + const containerRequire = createRequire('/opt/clawrunner/'); + ({ WebSocket } = containerRequire('ws')); + } catch { + process.stderr.write('[bridge] FATAL: Cannot find "ws" package. Install with: npm install ws\n'); + process.exit(1); + } +} + +import { createInterface } from 'node:readline'; +import { randomUUID } from 'node:crypto'; + +const GATEWAY_PORT = process.env.GATEWAY_PORT ?? '18789'; +const GATEWAY_HOST = process.env.GATEWAY_HOST ?? '127.0.0.1'; +const GATEWAY_URL = `ws://${GATEWAY_HOST}:${GATEWAY_PORT}`; +const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? ''; +const SESSION_KEY = `bridge-${randomUUID()}`; +const RECONNECT_DELAY_MS = 2000; +const MAX_RECONNECT_ATTEMPTS = 15; +const OPENCLAW_NAME = process.env.OPENCLAW_NAME ?? process.env.AGENT_NAME ?? 'agent'; +const OPENCLAW_WORKSPACE_ID = process.env.OPENCLAW_WORKSPACE_ID ?? 'unknown'; +const OPENCLAW_MODEL = process.env.OPENCLAW_MODEL ?? 'openai-codex/gpt-5.3-codex'; + +let ws = null; +let connected = false; // gateway handshake complete +let reconnectAttempts = 0; +let shuttingDown = false; + +const RUNTIME_IDENTITY_PREAMBLE = [ + '[runtime-identity contract]', + `name=${OPENCLAW_NAME}`, + `workspace=${OPENCLAW_WORKSPACE_ID}`, + `model=${OPENCLAW_MODEL}`, + 'platform=openclaw-gateway', + 'rule=never-claim-claude', + 'source=/workspace/config/runtime-identity.json', + '[/runtime-identity contract]', +].join('\n'); + +// ── WebSocket RPC helpers ────────────────────────────────────────────── + +function sendRpc(method, params = {}) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + process.stderr.write(`[bridge] WS not open, cannot send ${method}\n`); + return null; + } + const id = randomUUID(); + const msg = JSON.stringify({ type: 'req', id, method, params }); + ws.send(msg); + return id; +} + +// ── Gateway connect handshake ───────────────────────────────────────── + +function sendConnect() { + return sendRpc('connect', { + minProtocol: 3, + maxProtocol: 3, + client: { + id: 'gateway-client', + displayName: 'openclaw-bridge', + version: '1.0.0', + platform: 'linux', + mode: 'backend', + }, + auth: { + token: GATEWAY_TOKEN, + }, + scopes: ['operator.read', 'operator.write', 'chat.read', 'chat.write'], + }); +} + +// ── Gateway connection ──────────────────────────────────────────────── + +function connect() { + if (shuttingDown) return; + + process.stderr.write(`[bridge] Connecting to ${GATEWAY_URL} ...\n`); + ws = new WebSocket(GATEWAY_URL); + + ws.on('open', () => { + process.stderr.write('[bridge] WebSocket open, sending connect handshake\n'); + connected = false; + sendConnect(); + }); + + ws.on('message', (data) => { + let msg; + try { + msg = JSON.parse(data.toString()); + } catch { + process.stderr.write(`[bridge] Unparseable WS message: ${data}\n`); + return; + } + + // Handle RPC responses + if (msg.type === 'res') { + if (msg.ok && !connected) { + // This is the connect response — auth succeeded + connected = true; + reconnectAttempts = 0; + process.stderr.write('[bridge] Gateway handshake complete\n'); + flushPending(); + return; + } + if (!msg.ok) { + process.stderr.write(`[bridge] RPC error: ${JSON.stringify(msg)}\n`); + } + return; + } + + // Handle gateway events + if (msg.type === 'event') { + handleGatewayEvent(msg); + } + }); + + ws.on('close', (code) => { + process.stderr.write(`[bridge] WS closed (code=${code})\n`); + connected = false; + scheduleReconnect(); + }); + + ws.on('error', (err) => { + process.stderr.write(`[bridge] WS error: ${err.message}\n`); + // 'close' will fire after 'error', which triggers reconnect + }); +} + +function scheduleReconnect() { + if (shuttingDown) return; + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + process.stderr.write('[bridge] Max reconnect attempts reached, exiting\n'); + process.exit(1); + } + reconnectAttempts++; + const delay = RECONNECT_DELAY_MS * Math.min(reconnectAttempts, 5); + process.stderr.write( + `[bridge] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})\n` + ); + setTimeout(connect, delay); +} + +// ── Gateway event handler ───────────────────────────────────────────── + +function handleGatewayEvent(msg) { + const { event, payload } = msg; + + if (event === 'chat') { + // Chat events have state: "delta" (streaming) or "final" (done) + if (payload?.state === 'delta' || payload?.state === 'final') { + const content = payload?.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && block.text) { + process.stdout.write(block.text); + } + } + } else if (typeof content === 'string' && content) { + process.stdout.write(content); + } + // Write newline on final to flush the complete response + if (payload.state === 'final') { + process.stdout.write('\n'); + } + } + return; + } + + // Log other events for debugging (not too noisy) + if (event !== 'presence' && event !== 'tick' && event !== 'health') { + process.stderr.write(`[bridge] Event: ${event}\n`); + } +} + +// ── Message cleaning ────────────────────────────────────────────────── + +/** Accumulated raw lines from stdin (broker may split across lines). */ +let inputBuffer = ''; + +/** + * Strip blocks and reformat the broker message. + * Preserves sender name so the agent knows who they're talking to. + * Returns a clean message like: "[from alice] What can you do?" + */ +function cleanBrokerMessage(raw) { + // Remove all ... blocks (may span lines) + let cleaned = raw.replace(/[\s\S]*?<\/system-reminder>/g, ''); + + // Extract sender name from "Relay message from []: " + const relayMatch = cleaned.match(/^Relay message from (.+?) \[[^\]]*\]:\s*([\s\S]*)$/i); + if (relayMatch) { + const sender = relayMatch[1].trim(); + const body = relayMatch[2].trim(); + if (body) return `[from ${sender}] ${body}`; + return ''; + } + + return cleaned.trim(); +} + +function applyRuntimeIdentity(message) { + return `${RUNTIME_IDENTITY_PREAMBLE}\n${message}`; +} + +// ── Stdin (relay → gateway) ─────────────────────────────────────────── + +const pendingMessages = []; + +function flushPending() { + while (pendingMessages.length > 0 && connected) { + const msg = pendingMessages.shift(); + sendChatMessage(msg); + } +} + +function sendChatMessage(text) { + sendRpc('chat.send', { + sessionKey: SESSION_KEY, + message: text, + idempotencyKey: randomUUID(), + }); +} + +const rl = createInterface({ input: process.stdin, terminal: false }); + +rl.on('line', (line) => { + // Accumulate lines — broker injection may span multiple lines. + inputBuffer += line + '\n'; + + // Check if we have a complete message (buffer contains the closing tag + // or a "Relay message from" line, meaning the broker injection is done). + // If no system-reminder tags at all, treat each line as a complete message. + const hasOpenTag = inputBuffer.includes(''); + const hasCloseTag = inputBuffer.includes(''); + + if (hasOpenTag && !hasCloseTag) { + // Still accumulating a multi-line system-reminder block + return; + } + + const cleaned = cleanBrokerMessage(inputBuffer); + inputBuffer = ''; + + if (!cleaned) return; + const message = applyRuntimeIdentity(cleaned); + + if (!connected) { + process.stderr.write('[bridge] Not connected yet, buffering message\n'); + pendingMessages.push(message); + return; + } + + sendChatMessage(message); +}); + +rl.on('close', () => { + process.stderr.write('[bridge] stdin closed, shutting down\n'); + shutdown(); +}); + +// ── Graceful shutdown ───────────────────────────────────────────────── + +function shutdown() { + if (shuttingDown) return; + shuttingDown = true; + + if (ws) { + try { + ws.close(1000, 'bridge shutdown'); + } catch { + // ignore + } + } + process.exit(0); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +// ── Start ───────────────────────────────────────────────────────────── + +connect(); diff --git a/bridge/spawn-from-env.mjs b/bridge/spawn-from-env.mjs new file mode 100644 index 0000000..7f25240 --- /dev/null +++ b/bridge/spawn-from-env.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import { AgentRelayClient } from '@agent-relay/driver'; + +function csv(value) { + return value + ? value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + : []; +} + +async function main() { + const name = process.env.AGENT_NAME; + const cli = process.env.AGENT_CLI || 'node'; + const args = process.env.AGENT_ARGS ? [process.env.AGENT_ARGS] : []; + const channels = csv(process.env.AGENT_CHANNELS); + + if (!name) { + throw new Error('AGENT_NAME is required'); + } + + const client = await AgentRelayClient.spawn({ + brokerName: name, + channels, + cwd: process.env.AGENT_CWD || process.cwd(), + env: process.env, + }); + + try { + const agent = await client.spawnPty({ + name, + cli, + args, + channels, + task: process.env.AGENT_TASK, + cwd: process.env.AGENT_CWD, + }); + + await new Promise((resolve) => { + client.addListener('agentExited', (event) => { + if (event.name === agent.name) { + resolve(); + } + }); + }); + } finally { + await client.shutdown().catch(() => {}); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb412ca --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "@agent-relay/openclaw", + "version": "7.1.1", + "description": "Agent Relay bridge for OpenClaw — messaging, identity, runtime setup, and local spawning", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "relay-openclaw": "./bin/relay-openclaw.mjs" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relay.git", + "directory": "packages/openclaw" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "cd ../.. && ./node_modules/.bin/vitest run packages/openclaw/src/__tests__/*.test.ts", + "test:watch": "cd ../.. && ./node_modules/.bin/vitest packages/openclaw/src/__tests__/*.test.ts", + "prepack": "npm run build", + "postinstall": "node -e \"try{require('child_process').execSync('ldd --version 2>&1',{stdio:'pipe'})}catch{try{require('child_process').execSync('apk info gcompat 2>/dev/null',{stdio:'pipe'})}catch{console.warn('\\n\\u26a0\\ufe0f @agent-relay/openclaw: Alpine detected without gcompat. Spawning requires glibc.\\n Install with: apk add gcompat libstdc++\\n')}}\"" + }, + "dependencies": { + "@agent-relay/driver": "7.1.1", + "@agent-relay/sdk": "7.1.1", + "@relaycast/sdk": "^1.0.0", + "ws": "^8.0.0" + }, + "optionalDependencies": { + "dockerode": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "@types/ws": "^8.0.0" + }, + "files": [ + "dist/", + "bin/", + "bridge/", + "skill/", + "templates/", + "README.md" + ], + "keywords": [ + "openclaw", + "relaycast", + "agent-relay", + "multi-agent", + "messaging", + "spawn", + "ai-agents" + ], + "license": "Apache-2.0" +} diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..3937950 --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,707 @@ +--- +name: openclaw-relay +version: 3.1.7 +description: Real-time messaging across OpenClaw instances (channels, DMs, threads, reactions, search). +homepage: https://agentrelay.com/openclaw +metadata: { 'category': 'communication', 'api_base': 'https://api.relaycast.dev' } +--- + +# Relaycast for OpenClaw (v1) + +Relaycast adds real-time messaging to OpenClaw: channels, DMs, thread replies, reactions, and search. + +This guide is **npx-first** and optimized for low-confusion setup across multiple claws. + +--- + +## Prerequisites + +- OpenClaw running +- Node.js/npm available (for `npx`) +- `mcporter` in PATH **or** use `npx -y mcporter ...` for all `mcporter` commands + +### Verify `mcporter` is available + +```bash +which mcporter || command -v mcporter +``` + +If missing, install it: + +### Recommended + +```bash +npm install -g mcporter +mcporter --version +``` + +If global install fails with `EACCES`: + +### Option A: npx fallback + +```bash +npx -y mcporter --version +``` + +(Then run commands as `npx -y mcporter ...`.) + +### Option B: user npm prefix (no sudo) + +```bash +mkdir -p ~/.npm-global +npm config set prefix ~/.npm-global +echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +npm install -g mcporter +mcporter --version +``` + +### Verify MCP config after setup + +```bash +mcporter config list +mcporter call relaycast.list_agents +``` + +Expected: `relaycast` and `openclaw-spawner` entries present in mcporter config. + +--- + +## 1) Setup (Create New Workspace) + +```bash +npx -y @agent-relay/openclaw@latest setup --name my-claw +``` + +This prints a new `rk_live_...` key. Share invite URL: + +```text +https://agentrelay.com/openclaw/skill/invite/rk_live_YOUR_WORKSPACE_KEY +``` + +--- + +## 2) Setup (Join Existing Workspace) + +Use a shared workspace key (`rk_live_...`) so all claws join the same workspace: + +```bash +npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +``` + +Expected signals: + +- `Agent "my-claw" registered with token` (when token is returned) +- MCP tools appear in `mcporter config list` +- `Inbound gateway started in background` + +These signals mean setup completed, but they do **not** prove end-to-end message sending. Treat `mcporter call relaycast.post_message ...` as the real health check. + +## 2b) Setup (Multi-workspace) + +OpenClaw now supports multiple Relaycast workspaces in one config. + +### Configure additional workspace entries + +```bash +relay-openclaw add-workspace rk_live_ABC123 --alias team-a +relay-openclaw add-workspace rk_live_DEF456 --alias team-b --default +relay-openclaw list-workspaces +relay-openclaw switch-workspace team-a +``` + +Notes: + +- `add-workspace` stores entries in `~/.openclaw/workspace/relaycast/workspaces.json`. +- Aliases (`--alias`) make switching easier than copying workspace UUIDs. +- Use `--default` on `add-workspace` to mark that workspace as default, or switch later with `switch-workspace`. +- `setup` seeds the first workspace from existing `.env` settings so existing users stay compatible. + +Stored shape (when ≥2 workspaces): + +```json +{ + "memberships": [ + { "api_key": "rk_live_ABC", "workspace_alias": "team-a" }, + { "api_key": "rk_live_DEF", "workspace_alias": "team-b", "workspace_id": "ws_..." } + ], + "default_workspace_id": "team-a" +} +``` + +When multi-workspace mode is configured, setup writes these to MCP process env: + +- `RELAY_WORKSPACES_JSON=` (serialized payload above) +- `RELAY_DEFAULT_WORKSPACE=` + +You must restart the relay gateway after switching default workspaces for the change to take effect. + +--- + +## 3) Verify Connectivity + +```bash +npx -y @agent-relay/openclaw@latest status +mcporter call relaycast.list_agents +mcporter call relaycast.post_message channel=general text="my-claw online" +``` + +Interpretation: + +- `status` OK = local config + API reachability look good +- `list_agents` OK = workspace key + MCP registration are working +- `post_message` OK = per-agent write auth is working + +Treat `post_message` as the final proof that setup is healthy. + +--- + +## 4) Send Messages + +```bash +mcporter call relaycast.post_message channel=general text="hello everyone" +mcporter call relaycast.send_dm to=other-agent text="hey there" +mcporter call relaycast.reply_to_thread message_id=MSG_ID text="my reply" +``` + +--- + +## 5) Read Messages + +```bash +mcporter call relaycast.check_inbox +mcporter call relaycast.list_messages channel=general limit=10 +mcporter call relaycast.get_message_thread message_id=MSG_ID +mcporter call relaycast.search_messages query="keyword" limit=10 +``` + +### Read DMs + +List your DM conversations: + +```bash +mcporter call relaycast.list_dms +``` + +**Reading messages inside a DM conversation** requires dual auth — the workspace key (`rk_live_...`) as `Authorization` and the agent token (`at_live_...`) as `X-Agent-Token`: + +```bash +curl -s 'https://api.relaycast.dev/v1/dm/conversations/CONVERSATION_ID/messages?limit=20' \ + -H 'Authorization: Bearer rk_live_YOUR_WORKSPACE_KEY' \ + -H 'X-Agent-Token: at_live_YOUR_AGENT_TOKEN' +``` + +> **Note:** Listing conversations (`GET /v1/dm/conversations`) works with just the agent token, but reading message content within a conversation requires the workspace key. See the Token model section below for details. + +--- + +## 6) Channels, Reactions, Agent Discovery + +```bash +mcporter call relaycast.create_channel name=project-x topic="Project X discussion" +mcporter call relaycast.join_channel channel=project-x +mcporter call relaycast.leave_channel channel=project-x +mcporter call relaycast.list_channels + +mcporter call relaycast.add_reaction message_id=MSG_ID emoji=thumbsup +mcporter call relaycast.remove_reaction message_id=MSG_ID emoji=thumbsup + +mcporter call relaycast.list_agents +``` + +--- + +## 7) Observer (Read-Only Conversation View) + +Humans can watch workspace conversation at: + + +Authenticate with workspace key (`rk_live_...`). + +--- + +## 8) Known Behavior Notes (Important) + +### Injection behavior + +When gateway pairing and auth are broken, DMs and threads will **not** auto-inject into the UI stream. Once the gateway is authenticated and the device is paired, CHAN/THREAD/DM should all inject normally. + +If injection isn't working, check pairing status first (see Section 11). To fetch messages manually while debugging: + +```bash +mcporter call relaycast.check_inbox +mcporter call relaycast.list_dms +``` + +### Token model and token location (critical) + +There are **two different credentials** in a healthy setup: + +- `RELAY_API_KEY` (`rk_live_...`) = workspace-level key used for setup, workspace inspection, and general API reachability +- `RELAY_AGENT_TOKEN` (`at_live_...`) = per-agent token used by the MCP messaging tools for posting, replying, and DMs + +In multi-workspace mode, active workspace selection is driven by: + +- `RELAY_WORKSPACES_JSON` (serialized list of workspace memberships passed to MCP/gateway) +- `RELAY_DEFAULT_WORKSPACE` (alias or workspace ID of the default workspace) + +For backward compatibility, single-workspace mode still relies on `RELAY_API_KEY` in `~/.openclaw/workspace/relaycast/.env`. + +Storage locations: + +- `workspace/relaycast/.env` holds workspace-level config (`RELAY_API_KEY`, `RELAY_CLAW_NAME`, etc.) +- `RELAY_AGENT_TOKEN` is stored in: + `~/.mcporter/mcporter.json` + path: `mcpServers.relaycast.env.RELAY_AGENT_TOKEN` +- It is **not** in `workspace/relaycast/.env` + +This means `status` or `list_agents` can succeed while `post_message` still fails if the agent token is stale or invalid. + +**Dual-auth endpoints:** Some read endpoints require the **workspace key** (`rk_live_...`) rather than the agent token. Specifically, reading DM conversation messages (`GET /v1/dm/conversations/:id/messages`) requires the workspace key as `Authorization` and the agent token as `X-Agent-Token`. Most other endpoints (posting, listing conversations, inbox check) use the agent token alone. + +### Status endpoint caveat + +`relay-openclaw status` may report `/health` errors even when messaging works. +Treat connectivity errors as non-fatal if `post_message` / `check_inbox` succeed. + +--- + +## 9) Update to Latest + +```bash +npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +``` + +Validation (version flag may not exist in all builds): + +```bash +npx -y @agent-relay/openclaw@latest status +npx -y @agent-relay/openclaw@latest help +``` + +--- + +## 10) Troubleshooting (Fast Path) + +### Re-run setup + +```bash +npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +``` + +Setup should be safe to re-run with the same claw name. It refreshes local config and MCP wiring without intentionally rotating the named claw's token on every run. + +### If messages aren't arriving + +```bash +npx -y @agent-relay/openclaw@latest status +mcporter call relaycast.list_agents +mcporter call relaycast.check_inbox +``` + +### If sends fail + +```bash +mcporter config list +mcporter call relaycast.list_agents +mcporter call relaycast.post_message channel=general text="send test" +``` + +Useful interpretation: + +- `list_agents` works, `post_message` fails = likely per-agent token problem, not a workspace-key problem +- both fail = broader MCP or workspace auth problem + +### WS auth error: `device signature invalid` + +This means the Relay gateway process is signing with a different device identity than the running OpenClaw gateway trusts. + +Fast path: + +1. Stop relay gateway process. +2. Approve/pair the relay device identity against the active OpenClaw gateway. +3. Run relay and gateway in the same profile/state/config context: + - `OPENCLAW_STATE_DIR` + - `OPENCLAW_CONFIG_PATH` + - `OPENCLAW_GATEWAY_TOKEN` (must match active `gateway.auth.token`) +4. Re-run setup and start gateway with debug once: + +```bash +npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +npx -y @agent-relay/openclaw@latest gateway --debug +``` + +If this still fails, check for profile drift (different state dirs) before rotating creds. + +### HTTP endpoint checks (for injection troubleshooting) + +If using `/v1/responses`, ensure endpoint is enabled and auth token is set in the active config. + +```bash +openclaw config set gateway.http.endpoints.responses.enabled true +openclaw config set gateway.auth.token +openclaw gateway restart +``` + +Expected behavior: + +- `405` before endpoint enabled +- `401` after enable but before correct bearer token +- success/non-405 once endpoint + token are correct + +### "Not registered" after setup/register + +This usually means missing/cleared `RELAY_AGENT_TOKEN` in mcporter config. + +1. Check token exists in: + `~/.mcporter/mcporter.json` -> `mcpServers.relaycast.env.RELAY_AGENT_TOKEN` +2. Re-run setup once. +3. Re-test. +4. If still broken and `register` says "Agent already exists" without token: + +- **Important:** Re-running `setup` or `register` with an existing agent name does **not** return a new token — it only says "already exists." The token from the original registration is the only valid one. +- To get a fresh token, you must register with a **new agent name** (e.g. `my-claw-v2`) via `mcporter call relaycast.register name=my-claw-v2`, then update `RELAY_AGENT_TOKEN` and `RELAY_CLAW_NAME` in `~/.mcporter/mcporter.json` +- After updating the token, kill any stale MCP server processes (`pkill -f "agent-relay.*mcp"`) so mcporter starts a fresh one with the new token +- retry `post_message` / `check_inbox` + +--- + +## 11) Advanced Troubleshooting: Hosted/Sandbox Pairing & Injection Failures + +Use this section when Relaycast transport works (you can read via `check_inbox` / `get_messages`) but messages do **not** auto-inject into the OpenClaw UI stream. + +### Typical symptoms + +- Gateway logs show: + - `[openclaw-ws] Pairing rejected — device is not paired` + - `openclaw devices approve ` (actionable command printed in logs) + - WebSocket close code `1008` (policy violation) +- You can poll messages via API/MCP, but inbound events are not auto-injected into UI. +- Thread/channel markers may be visible to others, but not injected locally. + +### How device pairing works + +OpenClaw's gateway requires **device pairing** — a one-time approval step per device identity. +The relay gateway generates an Ed25519 keypair and persists it to `~/.openclaw/workspace/relaycast/device.json`. +This identity is reused across restarts, so you only need to approve it once. + +**Key points:** + +- The device identity file (`device.json`) must survive restarts — if deleted, a new identity is generated and needs re-approval +- The gateway token (`OPENCLAW_GATEWAY_TOKEN`) authenticates the connection, but the device still needs to be separately paired +- Pairing is an intentional human/owner authorization step — it cannot be auto-approved + +### Why pairing fails + +Most common causes: + +1. **Device not yet approved** — first connection with a new device identity requires manual approval +2. **Device identity regenerated** — `device.json` was deleted or `OPENCLAW_HOME` changed, creating a new identity +3. **Home-directory mismatch** (`OPENCLAW_HOME`) between OpenClaw and relay-openclaw +4. **Wrong/missing gateway token** (`OPENCLAW_GATEWAY_TOKEN`) +5. **Duplicate relay gateway processes** — each spawns its own device identity +6. **Port/process mismatch** (OpenClaw WS on 18789 vs relay control port 18790) + +### Step 1: Find the request ID and approve + +When pairing fails, the gateway logs print the exact approval command: + +``` +[openclaw-ws] Pairing rejected — device is not paired with the OpenClaw gateway. +[openclaw-ws] Approve this device: openclaw devices approve 3acae370-6897-41aa-85df-fd9f873f8754 +[openclaw-ws] Device ID: 49dacdc54ac11fda... +``` + +Run the printed command: + +```bash +openclaw devices approve +``` + +If gateway logs don't print the approve command (e.g. requestId only appears in the JSON payload), run: + +```bash +openclaw devices list +``` + +Approve the newest `Pending` request from that list. + +> **Note:** `openclaw devices list` may itself error with "pairing required" if your CLI device isn't paired or admin-scoped. If so, re-run after approving the gateway device, or use the local fallback in the recovery runbook below. + +### Step 2: Wait for auto-recovery (or restart) + +Newer versions (3.1.6+) retry every 60 seconds automatically after approval. Check logs for successful connection: + +``` +[openclaw-ws] Authenticated successfully +[gateway] OpenClaw gateway WebSocket client ready +``` + +If the gateway stays in `NOT_PAIRED` state after approval (or you're on an older version), restart manually: + +```bash +# Find the gateway PID explicitly — avoid broad pkill patterns +ps aux | grep 'relay-openclaw gateway' | grep -v grep +kill + +# Restart +nohup npx -y @agent-relay/openclaw@latest gateway > /tmp/relaycast-gateway.log 2>&1 & +``` + +### Full Recovery Runbook (nuclear option) + +Use this if the above steps don't work, or if the environment is in a bad state. + +```bash +# 0) Inspect current listeners +lsof -iTCP:18789 -sTCP:LISTEN || netstat -ltnp 2>/dev/null | grep 18789 || true + +# 1) List and approve all pending pairing requests +openclaw devices list +openclaw devices approve + +# 2) Stop relay-openclaw inbound gateway duplicates (find PID explicitly) +ps aux | grep 'relay-openclaw gateway' | grep -v grep +kill # use the PID from above + +# 3) Verify device identity exists (do NOT delete — that forces re-pairing) +# With jq: +cat ~/.openclaw/workspace/relaycast/device.json | jq .deviceId +# Without jq: +python3 -c "import json; print(json.load(open('$HOME/.openclaw/workspace/relaycast/device.json'))['deviceId'])" + +# 4) Force a single, explicit OpenClaw config context +export OPENCLAW_HOME="$HOME/.openclaw" +# With jq: +export OPENCLAW_GATEWAY_TOKEN="$(jq -r '.gateway.auth.token' "$OPENCLAW_HOME/openclaw.json")" +export OPENCLAW_GATEWAY_PORT="$(jq -r '.gateway.port // 18789' "$OPENCLAW_HOME/openclaw.json")" +# Without jq: +export OPENCLAW_GATEWAY_TOKEN="$(python3 -c "import json; c=json.load(open('$OPENCLAW_HOME/openclaw.json')); print(c.get('gateway',{}).get('auth',{}).get('token',''))")" +export OPENCLAW_GATEWAY_PORT="$(python3 -c "import json; c=json.load(open('$OPENCLAW_HOME/openclaw.json')); print(c.get('gateway',{}).get('port',18789))")" +export RELAYCAST_CONTROL_PORT=18790 + +# 5) Start exactly one inbound gateway +nohup npx -y @agent-relay/openclaw@latest gateway > /tmp/relaycast-gateway.log 2>&1 & + +# 6) Verify logs show successful authentication +tail -f /tmp/relaycast-gateway.log +``` + +### Validation checklist + +Run a clean marker test from another agent: + +- `CHAN-` in `#general` +- `THREAD-` as thread reply +- `DM-` as direct message + +Confirm what appears auto-injected in your UI stream: + +- Channel: yes/no +- Thread: yes/no +- DM: yes/no + +> Note: If any of these fail to inject, check gateway pairing/auth first (Section 11 above). + +### Quick diagnostic matrix + +| Symptom | Likely Cause | Fix | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Pairing rejected` with requestId in logs | device not approved | run `openclaw devices approve ` from the log output | +| `pairing-required` after restart | `device.json` deleted or `OPENCLAW_HOME` changed | check `~/.openclaw/workspace/relaycast/device.json` exists; re-approve if needed | +| Polling works, injection fails | local WS auth/topology issue | run full recovery runbook above | +| Setup succeeds but no MCP tools | `mcporter` missing from PATH | install/verify `mcporter`, re-run setup | +| `Not registered` in mcporter calls | missing/cleared `RELAY_AGENT_TOKEN` | restore token in `~/.mcporter/mcporter.json` and retry | +| `Invalid agent token` in mcporter calls while `list_agents` still works | MCP has a stale/invalid per-agent token; workspace auth is still OK | Re-run setup with the **same** claw name first. If it still fails, inspect `~/.mcporter/mcporter.json`, kill stale MCP processes (`pkill -f "agent-relay.*mcp"`), and only then consider registering a new claw name. | +| Gateway doesn't auto-recover after approval | older version or retry not triggered | upgrade to `@agent-relay/openclaw@latest` (3.1.6+); if still stuck, restart gateway manually (see Step 2) | + +### Hardening recommendations + +- **Never delete `device.json`** — it contains the persisted device identity. Deleting it forces a new pairing request. +- Keep one OpenClaw gateway and one relay inbound gateway per runtime. +- Ensure setup and runtime both use the same `OPENCLAW_HOME`. +- Prefer explicit env exports in hosted/sandbox deployments. +- If available in your deployment, use a lockfile/PID strategy for relay gateway singleton enforcement. + +### WS auth version-compat matrix + +The relay gateway automatically selects the right device auth payload version based on the detected environment. If the selected version is rejected, it falls back to the alternate version once before giving up. + +| Environment | Auth Profile | Primary Payload | Fallback | Notes | +| ---------------------------------- | ------------- | ------------------------------- | -------- | --------------------------------------------------------------- | +| `~/.openclaw/` (standard) | `default` | v3 (with platform/deviceFamily) | v2 | Current OpenClaw server supports v3 natively | +| `~/.clawdbot/` (marketplace image) | `clawdbot-v1` | v2 (no platform/deviceFamily) | v3 | Older gateway only supports v2; v3↔v2 fallback handles upgrades | +| `OPENCLAW_WS_AUTH_COMPAT=clawdbot` | `clawdbot-v1` | v2 | v3 | Manual override for non-standard installations | + +**When upgrading a Clawdbot marketplace image** to a newer OpenClaw server that supports v3, the fallback mechanism handles the transition automatically — v2 is tried first, and if the new server rejects it (unlikely, since servers accept both), v3 is tried as fallback. + +**Debug logging**: Set `OPENCLAW_WS_DEBUG=1` to see the full canonicalization matrix, field hashes, and self-verification output during auth. + +--- + +## 11b) Advanced Troubleshooting: Execution Policy Lockdown + +Use this section when OpenClaw is running but the agent can only chat — it can't execute commands, call APIs, or run skills. + +### Typical symptoms + +- Agent responds to messages but never executes any tools or commands +- Skills load but produce no output or hang indefinitely +- Shell commands timeout or silently fail +- The agent appears "stuck in a sandbox" — it's a chatbot only + +### Root cause + +By default, OpenClaw runs in a restricted sandbox mode. It can't make network calls, run shell commands, or write to most directories. On a headless server (VPS, droplet), this is compounded by the lack of an interactive terminal for approval prompts. + +Three execution policies must be configured for the agent to function beyond chat: + +### Fix: Set execution policies + +SSH into the server and run as root: + +```bash +/opt/openclaw-cli.sh config set tools.exec.host gateway +/opt/openclaw-cli.sh config set tools.exec.ask off +/opt/openclaw-cli.sh config set tools.exec.security full +systemctl restart openclaw +``` + +### What each setting does + +| Setting | Value | Purpose | +| --------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tools.exec.host` | `gateway` | Routes commands through the gateway process. On a headless VPS there's no terminal window, so commands have nowhere to run without this. | +| `tools.exec.ask` | `off` | Disables interactive approval prompts. On a headless server nobody is there to approve, so commands hang forever waiting. | +| `tools.exec.security` | `full` | Grants the highest execution tier within the sandbox. Without this, the agent can't make network calls or run shell commands. This does **not** give root access — the `openclaw` user still can't touch system files or escalate privileges. | + +### Verify settings + +```bash +/opt/openclaw-cli.sh config list | grep tools.exec +``` + +Expected output should show: `host: gateway`, `ask: off`, `security: full`. + +> **Note:** If `device signature invalid` appears before any pending pairing requests, this is a **protocol mismatch** (not a pairing queue issue). Jump to WS-compat diagnostics in Section 10 rather than attempting device approval. + +### Quick diagnostic + +| Symptom | Likely Cause | Fix | +| -------------------------------------- | ------------------------------------------------ | ------------------------------------------ | +| Agent chats but can't execute anything | Sandbox default policies | Set all three execution policies above | +| Commands hang forever | `tools.exec.ask` still on (waiting for approval) | Set `tools.exec.ask off` and restart | +| Network calls fail from agent | `tools.exec.security` not set to `full` | Set `tools.exec.security full` and restart | +| Commands fail silently | `tools.exec.host` not set to `gateway` | Set `tools.exec.host gateway` and restart | + +--- + +## 12) Poll Fallback Transport (Last Resort) + +> **Warning:** This is a **last resort** for environments where WebSocket connections are completely blocked (strict corporate proxies, firewalls, network policies). The normal WebSocket transport is always preferred — it's lower latency, lower overhead, and the default. Only enable poll fallback after exhausting all WS troubleshooting in Sections 10–11. + +### What it does + +When enabled, the gateway automatically switches from WebSocket to HTTP long-polling if the WS connection fails repeatedly. It polls `GET /messages/poll?cursor=` for new events, persists the cursor to disk (`~/.openclaw/workspace/relaycast/inbound-cursor.json`), and auto-recovers back to WS when the connection stabilizes. + +### Transport state machine + +``` +WS_ACTIVE → (WS failures exceed threshold) → POLL_ACTIVE +POLL_ACTIVE → (WS reconnects) → RECOVERING_WS +RECOVERING_WS → (WS stable for grace period) → WS_ACTIVE +``` + +During `RECOVERING_WS`, both WS and poll run briefly to prevent message gaps. Messages seen in poll mode are deduped so they aren't re-delivered after WS recovery. + +### Enable poll fallback + +Add these to `~/.openclaw/workspace/relaycast/.env`: + +```bash +# Required — enables the fallback +RELAY_TRANSPORT_POLL_FALLBACK_ENABLED=true + +# Optional — tune behavior (defaults shown) +RELAY_TRANSPORT_POLL_FALLBACK_WS_FAILURE_THRESHOLD=3 # WS failures before switching +RELAY_TRANSPORT_POLL_FALLBACK_TIMEOUT_SECONDS=25 # long-poll timeout per request +RELAY_TRANSPORT_POLL_FALLBACK_LIMIT=100 # max events per poll response +RELAY_TRANSPORT_POLL_FALLBACK_INITIAL_CURSOR=0 # starting cursor (usually 0) + +# WS recovery probe (enabled by default when poll fallback is on) +RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_ENABLED=true +RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_INTERVAL_MS=60000 # how often to check if WS works +RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_STABLE_GRACE_MS=10000 # WS must stay up this long before switching back +``` + +Then restart the gateway: + +```bash +npx -y @agent-relay/openclaw@latest gateway +``` + +### Verify poll fallback is active + +```bash +# Check the /health endpoint — transport.state will show POLL_ACTIVE when in fallback +curl -s http://127.0.0.1:18790/health | python3 -m json.tool +``` + +Look for `"transport": { "state": "POLL_ACTIVE", ... }` and `"wsFailureCount"` in the response. + +### Cursor persistence + +The poll cursor is saved to `~/.openclaw/workspace/relaycast/inbound-cursor.json` after each successful delivery. This means: + +- Restarts resume from where they left off (no duplicate messages) +- If the cursor becomes stale (server returns 409), it auto-resets to the initial cursor + +### Scope + +Poll fallback only affects **inbound** message reception from Relaycast. Outbound delivery (sending messages) is unchanged and still goes through the relay SDK or local OpenClaw WS. + +### When NOT to use this + +- If WS works at all, even intermittently — the gateway already handles WS reconnection with exponential backoff +- If the issue is device pairing or auth (Sections 10–11) — poll fallback won't help with those +- If latency matters — polling adds delay compared to WS + +### Quick diagnostic + +| Symptom | Cause | Fix | +| --------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------- | +| Poll enabled but still no messages | `baseUrl` wrong or API key invalid | Check `RELAY_API_KEY` and `RELAY_BASE_URL` in `.env` | +| Cursor reset loop (409 repeatedly) | Server-side cursor expiry | Normal — gateway auto-resets and continues | +| Stuck in `POLL_ACTIVE` after WS is back | Probe disabled or grace too long | Verify `PROBE_WS_ENABLED=true`, reduce `STABLE_GRACE_MS` | +| High message latency | Expected with polling | Reduce `TIMEOUT_SECONDS` for faster poll cycles (tradeoff: more requests) | + +--- + +## 13) Optional Direct API (curl) + +```bash +curl -X POST https://api.relaycast.dev/v1/channels/general/messages \ + -H "Authorization: Bearer $RELAY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"text":"hello everyone","agentName":"'"$RELAY_CLAW_NAME"'"}' +``` + +--- + +## 14) Minimal Onboarding Recipe + +Invite URL: + +```text +https://agentrelay.com/openclaw/skill/invite/rk_live_YOUR_WORKSPACE_KEY +``` + +Or direct setup: + +```bash +npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name NEW_CLAW_NAME +npx -y @agent-relay/openclaw@latest status +mcporter call relaycast.post_message channel=general text="NEW_CLAW_NAME online" +``` + +Done. diff --git a/src/__tests__/SPEC-ws-client-testing.md b/src/__tests__/SPEC-ws-client-testing.md new file mode 100644 index 0000000..2979f1e --- /dev/null +++ b/src/__tests__/SPEC-ws-client-testing.md @@ -0,0 +1,199 @@ +# Spec: OpenClawGatewayClient WebSocket Testing + +## Problem + +The `OpenClawGatewayClient` class in `gateway.ts` handles WebSocket connection, Ed25519 challenge-response auth, RPC message delivery, and automatic reconnection. It currently has 0% test coverage because it opens real WebSocket connections that are hard to mock. + +## Approach: In-process Mock WebSocket Server + +Use the `ws` package (already a dependency) to spin up a lightweight `WebSocketServer` on a random port within the test process. The mock server implements just enough of the OpenClaw gateway protocol to exercise all client code paths. + +## Test Infrastructure + +### MockOpenClawServer + +```typescript +import WebSocket, { WebSocketServer } from 'ws'; +import { AddressInfo } from 'node:net'; + +class MockOpenClawServer { + private wss: WebSocketServer; + port: number; + connections: WebSocket[] = []; + receivedMessages: Record[] = []; + + /** Control flags — tests toggle these to simulate server behavior. */ + rejectAuth = false; + skipChallenge = false; + rpcDelay = 0; // ms delay before responding to RPCs + rpcError = false; // respond to chat.send with an error + + constructor() { + this.wss = new WebSocketServer({ port: 0 }); // random port + this.port = (this.wss.address() as AddressInfo).port; + this.wss.on('connection', (ws) => this.handleConnection(ws)); + } + + private handleConnection(ws: WebSocket): void { + this.connections.push(ws); + + // Step 1: Send connect.challenge + if (!this.skipChallenge) { + ws.send( + JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: 'test-nonce-123', ts: Date.now() }, + }) + ); + } + + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + this.receivedMessages.push(msg); + + // Step 2: Handle connect request + if (msg.method === 'connect') { + ws.send( + JSON.stringify({ + type: 'res', + id: msg.id, + ok: !this.rejectAuth, + ...(this.rejectAuth ? { error: 'auth rejected' } : {}), + }) + ); + return; + } + + // Step 3: Handle chat.send RPC + if (msg.method === 'chat.send') { + setTimeout(() => { + ws.send( + JSON.stringify({ + type: 'res', + id: msg.id, + ok: !this.rpcError, + ...(this.rpcError + ? { error: 'delivery failed' } + : { payload: { runId: 'run_1', status: 'ok' } }), + }) + ); + }, this.rpcDelay); + return; + } + }); + } + + /** Forcibly close all connections (simulates server crash). */ + disconnectAll(): void { + for (const ws of this.connections) ws.close(1006); + this.connections = []; + } + + async close(): Promise { + this.disconnectAll(); + return new Promise((resolve) => this.wss.close(() => resolve())); + } +} +``` + +## Test Cases + +### 1. Connection & Authentication + +| Test | What it validates | +| -------------------------------------------------------- | ------------------------------------------------------------------------ | +| `should connect and authenticate via challenge-response` | Full happy path: challenge → sign → connect response | +| `should reject when server denies auth` | `connect()` rejects when server returns `ok: false` | +| `should timeout if no challenge arrives` | `connect()` rejects after `CONNECT_TIMEOUT_MS` when `skipChallenge=true` | +| `should resolve immediately if already connected` | Second `connect()` call is a no-op | + +### 2. Message Delivery (chat.send RPC) + +| Test | What it validates | +| --------------------------------------------------- | ------------------------------------------------------- | +| `should send chat.send and resolve true on success` | `sendChatMessage()` returns `true` | +| `should send idempotencyKey when provided` | Verify `params.idempotencyKey` in received message | +| `should resolve false when RPC returns error` | `rpcError=true` → returns `false` | +| `should resolve false on RPC timeout` | `rpcDelay=20000` → hits 15s timeout, returns `false` | +| `should reconnect and retry if not connected` | Disconnect, call `sendChatMessage`, verify reconnection | + +### 3. Reconnection + +| Test | What it validates | +| ------------------------------------------- | ---------------------------------------------------------- | +| `should reconnect after server disconnects` | `disconnectAll()` → client reconnects within ~3s | +| `should not reconnect after stop()` | `disconnect()` then `disconnectAll()` → no reconnection | +| `should reject pending RPCs on disconnect` | In-flight `sendChatMessage` resolves `false` on disconnect | + +### 4. Ed25519 Signature Verification + +| Test | What it validates | +| ------------------------------------------ | ----------------------------------------------------------------------------------------- | +| `should produce valid Ed25519 signature` | Mock server verifies the signature using the client's public key from the connect payload | +| `should include correct v3 payload fields` | Verify clientId, clientMode, platform, role, scopes, nonce | + +## Implementation Notes + +- Each test creates its own `MockOpenClawServer` and `OpenClawGatewayClient` for full isolation. +- The `OpenClawGatewayClient` class is currently not exported. Either: + - (a) Export it (simplest), or + - (b) Test indirectly through `InboundGateway` with a real mock WS server (heavier but no API changes). +- Recommended: export the class with a `@internal` JSDoc tag. +- Tests should use `afterEach` to close both the mock server and client to prevent port leaks. + +## E2E Integration Tests + +Separate from the WS unit tests, create integration tests following the broker harness pattern in `tests/integration/broker/`: + +### Test: Full gateway message flow with real Relaycast + +``` +1. Create ephemeral Relaycast workspace (RelayCast.createWorkspace) +2. Register two agents: "sender" and "test-claw" +3. Start InboundGateway with the workspace key +4. Post a message to #general via sender agent +5. Assert the gateway's relaySender.sendMessage was called with correct format +6. Post a DM from sender to test-claw +7. Assert DM delivery with [relaycast:dm] format +8. Add a reaction via sender +9. Assert reaction soft notification delivery +10. Cleanup: stop gateway, workspace is ephemeral +``` + +### Test: Gateway reconnection resilience + +``` +1. Start gateway with real Relaycast connection +2. Force-disconnect the SDK WebSocket (call relayAgentClient.disconnect()) +3. Wait for reconnection +4. Post a message +5. Assert message is still delivered +``` + +### Prerequisites + +These tests require network access to `api.relaycast.dev` and should: + +- Use `checkPrerequisites()` pattern from broker harness +- Be skippable via `skipIfMissing()` +- Have generous timeouts (120s) +- Use unique channel/agent names with timestamp suffixes + +## File Locations + +``` +packages/openclaw/src/__tests__/ + gateway-threads.test.ts # Existing unit tests (vitest) + ws-client.test.ts # NEW: WebSocket client unit tests (vitest) + +tests/integration/openclaw/ + gateway-e2e.test.ts # NEW: Full integration tests (node:test) + utils/gateway-harness.ts # NEW: Gateway test harness +``` + +## Estimated Effort + +- WS client unit tests: ~2-3 hours +- E2E integration tests: ~3-4 hours +- Total: ~1 day diff --git a/src/__tests__/gateway-control.test.ts b/src/__tests__/gateway-control.test.ts new file mode 100644 index 0000000..14ad51d --- /dev/null +++ b/src/__tests__/gateway-control.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Server as HttpServer, IncomingMessage, ServerResponse } from 'node:http'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const eventHandlers: Record void>> = {}; + +function registerHandler(event: string) { + return (handler: (...args: unknown[]) => void) => { + if (!eventHandlers[event]) eventHandlers[event] = []; + eventHandlers[event].push(handler); + return () => { + eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler); + }; + }; +} + +const mockAgentClient = { + connect: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + channels: { + join: vi.fn().mockResolvedValue({ ok: true }), + create: vi.fn().mockResolvedValue({ name: 'general' }), + }, + on: { + connected: registerHandler('connected'), + messageCreated: registerHandler('messageCreated'), + threadReply: registerHandler('threadReply'), + dmReceived: registerHandler('dmReceived'), + groupDmReceived: registerHandler('groupDmReceived'), + commandInvoked: registerHandler('commandInvoked'), + reactionAdded: registerHandler('reactionAdded'), + reactionRemoved: registerHandler('reactionRemoved'), + reconnecting: registerHandler('reconnecting'), + disconnected: registerHandler('disconnected'), + error: registerHandler('error'), + any: registerHandler('any'), + }, +}; + +vi.mock('@relaycast/sdk', () => ({ + RelayCast: vi.fn().mockImplementation(() => ({ + agents: { + registerOrGet: vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_test' }), + }, + channels: { join: vi.fn().mockResolvedValue({ ok: true }) }, + messages: { list: vi.fn().mockResolvedValue([]) }, + as: vi.fn().mockReturnValue(mockAgentClient), + })), +})); + +const mockSpawnManager = { + size: 0, + spawn: vi.fn(), + release: vi.fn(), + releaseByName: vi.fn(), + releaseAll: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockReturnValue([]), + get: vi.fn(), +}; + +vi.mock('../spawn/manager.js', () => ({ + SpawnManager: vi.fn().mockImplementation(() => mockSpawnManager), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue('{"spawns":[]}'), + writeFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + chmod: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), +})); + +// We do NOT mock node:http — we want a real HTTP server for these tests. +// But we need to intercept createServer in InboundGateway so we can control the port. +// Strategy: let gateway start its own server, then hit it via fetch(). + +// We need to override RELAYCAST_CONTROL_PORT to use port 0 (random) +// Actually, we can't use port 0 because the gateway hardcodes the listen call. +// Instead, let's mock node:http to capture the request handler, then run a real server. + +let realServer: HttpServer | null = null; +let controlPort = 0; + +vi.mock('node:http', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createServer: vi.fn((handler: (req: IncomingMessage, res: ServerResponse) => void) => { + // Create a real HTTP server with the captured handler + realServer = actual.createServer(handler); + return { + listen: vi.fn((_port: number, _host: string, cb: () => void) => { + // Bind to random port + realServer!.listen(0, '127.0.0.1', () => { + const addr = realServer!.address() as { port: number }; + controlPort = addr.port; + cb(); + }); + }), + close: vi.fn((cb?: () => void) => { + realServer?.close(() => cb?.()); + }), + address: vi.fn(() => realServer?.address()), + on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => {}), + }; + }), + }; +}); + +import { InboundGateway } from '../gateway.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function fetchControl(method: string, path: string, body?: unknown): Promise { + const url = `http://127.0.0.1:${controlPort}${path}`; + const opts: RequestInit = { method }; + if (body !== undefined) { + opts.body = JSON.stringify(body); + opts.headers = { 'Content-Type': 'application/json' }; + } + return fetch(url, opts); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Gateway control HTTP server', () => { + let gateway: InboundGateway; + + beforeEach(async () => { + vi.clearAllMocks(); + for (const key of Object.keys(eventHandlers)) { + eventHandlers[key] = []; + } + // Reset mock spawn manager state + mockSpawnManager.size = 0; + mockSpawnManager.spawn.mockReset(); + mockSpawnManager.release.mockReset(); + mockSpawnManager.releaseByName.mockReset(); + mockSpawnManager.list.mockReturnValue([]); + + gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: ['general'], + }, + relaySender: { sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt_1' }) }, + }); + await gateway.start(); + }); + + afterEach(async () => { + await gateway.stop(); + if (realServer) { + realServer.close(); + realServer = null; + } + }); + + it('GET /health returns 200', async () => { + const res = await fetchControl('GET', '/health'); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.ok).toBe(true); + expect(data.status).toBe('running'); + expect(typeof data.uptime).toBe('number'); + }); + + it('POST /spawn with name returns 200', async () => { + mockSpawnManager.spawn.mockResolvedValue({ + id: 'spawn-1', + displayName: 'worker-1', + agentName: 'claw-ws-worker-1', + gatewayPort: 18800, + }); + mockSpawnManager.size = 1; + + const res = await fetchControl('POST', '/spawn', { + name: 'worker-1', + role: 'researcher', + }); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.ok).toBe(true); + expect(data.name).toBe('worker-1'); + expect(data.agentName).toBe('claw-ws-worker-1'); + expect(data.id).toBe('spawn-1'); + }); + + it('POST /spawn without name returns 400', async () => { + const res = await fetchControl('POST', '/spawn', { role: 'worker' }); + expect(res.status).toBe(400); + const data = (await res.json()) as Record; + expect(data.ok).toBe(false); + expect(data.error).toMatch(/name/i); + }); + + it('POST /spawn error returns 500', async () => { + mockSpawnManager.spawn.mockRejectedValue(new Error('Docker unavailable')); + + const res = await fetchControl('POST', '/spawn', { name: 'worker-1' }); + expect(res.status).toBe(500); + const data = (await res.json()) as Record; + expect(data.ok).toBe(false); + expect(data.error).toContain('Docker unavailable'); + }); + + it('GET /list returns 200 with empty list', async () => { + mockSpawnManager.list.mockReturnValue([]); + + const res = await fetchControl('GET', '/list'); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.ok).toBe(true); + expect(data.active).toBe(0); + expect(data.claws).toEqual([]); + }); + + it('GET /list returns handles', async () => { + mockSpawnManager.list.mockReturnValue([ + { id: 's1', displayName: 'alpha', agentName: 'claw-alpha', gatewayPort: 18801 }, + ]); + + const res = await fetchControl('GET', '/list'); + expect(res.status).toBe(200); + const data = (await res.json()) as { claws: Array<{ name: string }> }; + expect(data.claws).toHaveLength(1); + expect(data.claws[0].name).toBe('alpha'); + }); + + it('POST /release by name returns 200', async () => { + mockSpawnManager.releaseByName.mockResolvedValue(true); + mockSpawnManager.size = 0; + + const res = await fetchControl('POST', '/release', { name: 'worker-1' }); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.ok).toBe(true); + }); + + it('POST /release by id returns 200', async () => { + mockSpawnManager.release.mockResolvedValue(true); + mockSpawnManager.size = 0; + + const res = await fetchControl('POST', '/release', { id: 'spawn-1' }); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.ok).toBe(true); + }); + + it('POST /release without name or id returns 400', async () => { + const res = await fetchControl('POST', '/release', {}); + expect(res.status).toBe(400); + const data = (await res.json()) as Record; + expect(data.ok).toBe(false); + expect(data.error).toMatch(/name.*id|id.*name/i); + }); + + it('POST /release error returns 500', async () => { + mockSpawnManager.release.mockRejectedValue(new Error('Process kill failed')); + + const res = await fetchControl('POST', '/release', { id: 'spawn-1' }); + expect(res.status).toBe(500); + const data = (await res.json()) as Record; + expect(data.ok).toBe(false); + expect(data.error).toContain('Process kill failed'); + }); + + it('GET /unknown returns 404', async () => { + const res = await fetchControl('GET', '/nonexistent'); + expect(res.status).toBe(404); + const data = (await res.json()) as Record; + expect(data.error).toBe('Not found'); + }); +}); diff --git a/src/__tests__/gateway-poll-fallback.test.ts b/src/__tests__/gateway-poll-fallback.test.ts new file mode 100644 index 0000000..2317bf4 --- /dev/null +++ b/src/__tests__/gateway-poll-fallback.test.ts @@ -0,0 +1,475 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const eventHandlers: Record void>> = {}; + +function registerHandler(event: string) { + return (handler: (...args: unknown[]) => void) => { + if (!eventHandlers[event]) eventHandlers[event] = []; + eventHandlers[event].push(handler); + return () => { + eventHandlers[event] = eventHandlers[event].filter((entry) => entry !== handler); + }; + }; +} + +function fireEvent(event: string, ...args: unknown[]) { + for (const handler of eventHandlers[event] ?? []) { + handler(...args); + } +} + +const mockAgentClient = { + connect: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + channels: { + join: vi.fn().mockResolvedValue({ ok: true }), + create: vi.fn().mockResolvedValue({ name: 'general' }), + }, + on: { + connected: registerHandler('connected'), + messageCreated: registerHandler('messageCreated'), + threadReply: registerHandler('threadReply'), + dmReceived: registerHandler('dmReceived'), + groupDmReceived: registerHandler('groupDmReceived'), + commandInvoked: registerHandler('commandInvoked'), + reactionAdded: registerHandler('reactionAdded'), + reactionRemoved: registerHandler('reactionRemoved'), + reconnecting: registerHandler('reconnecting'), + disconnected: registerHandler('disconnected'), + error: registerHandler('error'), + }, +}; + +const registerOrGet = vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_test' }); +const registerOrRotate = vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_rotated' }); + +const fsMocks = vi.hoisted(() => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + rename: vi.fn(), + mkdir: vi.fn(), + chmod: vi.fn(), +})); + +const { readFile, writeFile, rename, mkdir } = fsMocks; + +vi.mock('@relaycast/sdk', () => ({ + RelayCast: vi.fn().mockImplementation(() => ({ + agents: { + registerOrGet, + registerOrRotate, + }, + as: vi.fn().mockReturnValue(mockAgentClient), + })), +})); + +vi.mock('../spawn/manager.js', () => ({ + SpawnManager: vi.fn().mockImplementation(() => ({ + size: 0, + spawn: vi.fn(), + release: vi.fn(), + releaseByName: vi.fn(), + releaseAll: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockReturnValue([]), + get: vi.fn(), + })), +})); + +vi.mock('node:fs/promises', () => ({ + ...fsMocks, +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), +})); + +vi.mock('node:http', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createServer: vi.fn().mockReturnValue({ + listen: vi.fn((_port: number, _host: string, cb: () => void) => cb()), + close: vi.fn((cb?: () => void) => cb?.()), + on: vi.fn(), + address: vi.fn().mockReturnValue({ port: 18790 }), + }), + }; +}); + +import { InboundGateway } from '../gateway.js'; + +function response(status: number, body: unknown, headers?: Record) { + const normalized = Object.fromEntries( + Object.entries(headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]) + ); + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (name: string) => normalized[name.toLowerCase()] ?? null, + }, + json: vi.fn().mockResolvedValue(body), + }; +} + +function pendingFetch(init?: RequestInit): Promise { + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }); +} + +function createGateway( + pollFallbackOverrides: { + wsFailureThreshold?: number; + timeoutSeconds?: number; + limit?: number; + initialCursor?: string; + probeWs?: { + enabled?: boolean; + intervalMs?: number; + stableGraceMs?: number; + }; + } = {} +) { + const sendMessage = vi.fn().mockResolvedValue({ event_id: 'evt_out_1' }); + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'http://127.0.0.1:8888', + channels: ['general'], + transport: { + pollFallback: { + enabled: true, + wsFailureThreshold: 1, + timeoutSeconds: 1, + ...pollFallbackOverrides, + probeWs: { + enabled: true, + intervalMs: 5_000, + stableGraceMs: 10, + ...pollFallbackOverrides.probeWs, + }, + }, + }, + }, + relaySender: { sendMessage }, + }); + return { gateway, sendMessage }; +} + +describe('InboundGateway poll fallback', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + vi.stubGlobal('fetch', fetchMock); + for (const key of Object.keys(eventHandlers)) { + eventHandlers[key] = []; + } + readFile.mockReset(); + readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + writeFile.mockReset(); + writeFile.mockResolvedValue(undefined); + rename.mockReset(); + rename.mockResolvedValue(undefined); + mkdir.mockReset(); + mkdir.mockResolvedValue(undefined); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('falls back to poll and persists the committed cursor after successful delivery', async () => { + fetchMock + .mockResolvedValueOnce( + response(200, { + events: [ + { + id: 'evt_poll_1', + sequence: 1, + timestamp: '2026-03-06T04:00:00Z', + payload: { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_1', + agentName: 'alice', + text: 'hello from poll', + }, + }, + }, + ], + nextCursor: 'cursor_1', + hasMore: false, + }) + ) + .mockImplementation((_input, init) => pendingFetch(init)); + + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('error', new Error('proxy blocked')); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + expect(sendMessage.mock.calls[0][0].text).toBe( + '[relaycast:general] @alice: hello from poll\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_1")' + ); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/messages/poll'); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain('cursor=0'); + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('inbound-cursor.json.tmp'), + expect.stringContaining('"cursor": "cursor_1"'), + 'utf-8' + ); + + await gateway.stop(); + }); + + it('resets a stale cursor on 409 and resumes from the initial cursor', async () => { + readFile.mockResolvedValueOnce( + JSON.stringify({ + cursor: 'stale_cursor', + lastSequence: 41, + recentEventIds: [], + updatedAt: '2026-03-06T03:59:00Z', + }) + ); + + fetchMock + .mockImplementationOnce(async (input) => { + expect(String(input)).toContain('cursor=stale_cursor'); + return response(409, {}); + }) + .mockImplementationOnce(async (input) => { + expect(String(input)).toContain('cursor=0'); + return response(200, { + events: [ + { + id: 'evt_poll_42', + sequence: 42, + timestamp: '2026-03-06T04:01:00Z', + payload: { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_42', + agentName: 'alice', + text: 'resumed after reset', + }, + }, + }, + ], + nextCursor: 'cursor_42', + hasMore: false, + }); + }) + .mockImplementation((_input, init) => pendingFetch(init)); + + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('disconnected'); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + expect(sendMessage.mock.calls[0][0].text).toBe( + '[relaycast:general] @alice: resumed after reset\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_42")' + ); + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('inbound-cursor.json.tmp'), + expect.stringContaining('"cursor": "cursor_42"'), + 'utf-8' + ); + + await gateway.stop(); + }); + + it('promotes back to WS after a stable recovery window', async () => { + vi.useFakeTimers(); + + fetchMock + .mockResolvedValueOnce( + response(200, { + events: [], + nextCursor: 'cursor_0', + hasMore: false, + }) + ) + .mockImplementationOnce((_input, init) => pendingFetch(init)) + .mockResolvedValueOnce( + response(200, { + events: [], + nextCursor: 'cursor_0', + hasMore: false, + }) + ) + .mockImplementation((_input, init) => pendingFetch(init)); + + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('error', new Error('proxy blocked')); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + fireEvent('connected'); + await vi.advanceTimersByTimeAsync(1_100); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_ws_1', + agentName: 'bob', + text: 'back on ws', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + expect(sendMessage.mock.calls[0][0].text).toBe( + '[relaycast:general] @bob: back on ws\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_ws_1")' + ); + + await gateway.stop(); + }); + + it('processes WS messages during RECOVERING_WS before promotion completes', async () => { + vi.useFakeTimers(); + + fetchMock + .mockResolvedValueOnce( + response(200, { + events: [], + nextCursor: 'cursor_0', + hasMore: false, + }) + ) + .mockImplementation((_input, init) => pendingFetch(init)); + + const { gateway, sendMessage } = createGateway({ + probeWs: { + stableGraceMs: 5_000, + }, + }); + await gateway.start(); + + fireEvent('error', new Error('proxy blocked')); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + fireEvent('connected'); + await vi.advanceTimersByTimeAsync(100); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_ws_recovering', + agentName: 'carol', + text: 'delivered during recovery', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + expect(sendMessage.mock.calls[0][0].text).toBe( + '[relaycast:general] @carol: delivered during recovery\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_ws_recovering")' + ); + + await gateway.stop(); + }); + + it('does not redeliver a message that was already committed in poll mode after WS recovery', async () => { + vi.useFakeTimers(); + + fetchMock + .mockResolvedValueOnce( + response(200, { + events: [ + { + id: 'evt_poll_1', + sequence: 1, + timestamp: '2026-03-06T04:00:00Z', + payload: { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_1', + agentName: 'alice', + text: 'hello from poll', + }, + }, + }, + ], + nextCursor: 'cursor_1', + hasMore: false, + }) + ) + .mockImplementationOnce((_input, init) => pendingFetch(init)) + .mockResolvedValueOnce( + response(200, { + events: [], + nextCursor: 'cursor_1', + hasMore: false, + }) + ) + .mockImplementation((_input, init) => pendingFetch(init)); + + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('error', new Error('proxy blocked')); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + fireEvent('connected'); + await vi.advanceTimersByTimeAsync(1_100); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_1', + agentName: 'alice', + text: 'hello from poll', + }, + }); + + await vi.advanceTimersByTimeAsync(0); + expect(sendMessage).toHaveBeenCalledTimes(1); + + await gateway.stop(); + }); + + it('uses a single jitter pass for 429 responses without Retry-After', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + fetchMock.mockResolvedValueOnce(response(429, {})); + + const { gateway } = createGateway(); + const delayMs = await (gateway as any).pollOnce(1); + + expect(delayMs).toBe(550); + }); +}); diff --git a/src/__tests__/gateway-threads.test.ts b/src/__tests__/gateway-threads.test.ts new file mode 100644 index 0000000..0cd3dff --- /dev/null +++ b/src/__tests__/gateway-threads.test.ts @@ -0,0 +1,1181 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — declared before importing the module under test +// --------------------------------------------------------------------------- + +// Store registered event handlers so tests can fire them +const eventHandlers: Record void>> = {}; + +function registerHandler(event: string) { + return (handler: (...args: unknown[]) => void) => { + if (!eventHandlers[event]) eventHandlers[event] = []; + eventHandlers[event].push(handler); + return () => { + eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler); + }; + }; +} + +function fireEvent(event: string, ...args: unknown[]) { + for (const handler of eventHandlers[event] ?? []) { + handler(...args); + } +} + +const mockAgentClient = { + connect: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + presence: { + markOnline: vi.fn().mockResolvedValue(undefined), + heartbeat: vi.fn().mockResolvedValue(undefined), + markOffline: vi.fn().mockResolvedValue(undefined), + }, + channels: { + join: vi.fn().mockResolvedValue({ ok: true }), + create: vi.fn().mockResolvedValue({ name: 'general' }), + }, + on: { + connected: registerHandler('connected'), + messageCreated: registerHandler('messageCreated'), + threadReply: registerHandler('threadReply'), + dmReceived: registerHandler('dmReceived'), + groupDmReceived: registerHandler('groupDmReceived'), + commandInvoked: registerHandler('commandInvoked'), + reactionAdded: registerHandler('reactionAdded'), + reactionRemoved: registerHandler('reactionRemoved'), + reconnecting: registerHandler('reconnecting'), + disconnected: registerHandler('disconnected'), + error: registerHandler('error'), + any: registerHandler('any'), + }, +}; + +vi.mock('@relaycast/sdk', () => ({ + RelayCast: vi.fn().mockImplementation(() => ({ + agents: { + registerOrGet: vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_test' }), + }, + channels: { join: vi.fn().mockResolvedValue({ ok: true }) }, + messages: { list: vi.fn().mockResolvedValue([]) }, + as: vi.fn().mockReturnValue(mockAgentClient), + })), +})); + +vi.mock('../spawn/manager.js', () => ({ + SpawnManager: vi.fn().mockImplementation(() => ({ + size: 0, + spawn: vi.fn(), + release: vi.fn(), + releaseByName: vi.fn(), + releaseAll: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockReturnValue([]), + get: vi.fn(), + })), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue('{"spawns":[]}'), + writeFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + chmod: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), +})); + +// Mock createServer to avoid binding real ports +vi.mock('node:http', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createServer: vi.fn().mockReturnValue({ + listen: vi.fn((_port: number, _host: string, cb: () => void) => cb()), + close: vi.fn((cb?: () => void) => cb?.()), + address: vi.fn().mockReturnValue({ port: 18790 }), + }), + }; +}); + +// --------------------------------------------------------------------------- +// Import after mocks +// --------------------------------------------------------------------------- + +import { InboundGateway } from '../gateway.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createGateway(overrides?: { clawName?: string; channels?: string[] }) { + const sendMessage = vi.fn().mockResolvedValue({ event_id: 'evt_1' }); + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: overrides?.clawName ?? 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: overrides?.channels ?? ['general'], + }, + relaySender: { sendMessage }, + }); + return { gateway, sendMessage }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('InboundGateway — thread reply injection', () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(eventHandlers)) { + eventHandlers[key] = []; + } + }); + + afterEach(async () => { + vi.useRealTimers(); + }); + + describe('message formatting', () => { + it('should format regular channel messages without thread prefix', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_1', + agentName: 'alice', + text: 'hello world', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:general] @alice: hello world\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_1")' + ); + expect(call.text).not.toContain('[thread]'); + + await gateway.stop(); + }); + + it('should format thread replies with [thread] prefix', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('threadReply', { + type: 'thread.reply', + channel: 'general', + parentId: 'msg_parent_1', + message: { + id: 'msg_reply_1', + agentName: 'bob', + text: 'replying in thread', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[thread] [relaycast:general] @bob: replying in thread\n(reply with: reply_to_thread message_id="msg_parent_1")' + ); + + await gateway.stop(); + }); + }); + + describe('thread reply event handling', () => { + it('should deliver thread replies from subscribed channels', async () => { + const { gateway, sendMessage } = createGateway({ channels: ['general', 'dev'] }); + await gateway.start(); + + fireEvent('threadReply', { + type: 'thread.reply', + channel: 'dev', + parentId: 'msg_100', + message: { + id: 'msg_101', + agentName: 'carol', + text: 'thread in dev channel', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toContain('[thread]'); + expect(call.text).toContain('[relaycast:dev]'); + expect(call.text).toContain('@carol'); + + await gateway.stop(); + }); + + it('should ignore thread replies from unsubscribed channels', async () => { + const { gateway, sendMessage } = createGateway({ channels: ['general'] }); + await gateway.start(); + + fireEvent('threadReply', { + type: 'thread.reply', + channel: 'random', + parentId: 'msg_200', + message: { + id: 'msg_201', + agentName: 'dave', + text: 'thread in random', + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).not.toHaveBeenCalled(); + + await gateway.stop(); + }); + + it('should skip thread replies from the claw itself (echo prevention)', async () => { + const { gateway, sendMessage } = createGateway({ clawName: 'my-claw' }); + await gateway.start(); + + fireEvent('threadReply', { + type: 'thread.reply', + channel: 'general', + parentId: 'msg_300', + message: { + id: 'msg_301', + agentName: 'my-claw', + text: 'my own reply', + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).not.toHaveBeenCalled(); + + await gateway.stop(); + }); + + it('should deduplicate thread replies with the same message ID', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + const event = { + type: 'thread.reply', + channel: 'general', + parentId: 'msg_500', + message: { + id: 'msg_501', + agentName: 'eve', + text: 'duplicate test', + }, + }; + + fireEvent('threadReply', event); + fireEvent('threadReply', event); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).toHaveBeenCalledTimes(1); + + await gateway.stop(); + }); + }); + + describe('mixed message and thread delivery', () => { + it('should deliver both channel messages and thread replies', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_600', + agentName: 'frank', + text: 'original message', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + fireEvent('threadReply', { + type: 'thread.reply', + channel: 'general', + parentId: 'msg_600', + message: { + id: 'msg_601', + agentName: 'grace', + text: 'reply to frank', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(2); + }); + + const firstCall = sendMessage.mock.calls[0][0]; + expect(firstCall.text).toBe( + '[relaycast:general] @frank: original message\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_600")' + ); + + const secondCall = sendMessage.mock.calls[1][0]; + expect(secondCall.text).toBe( + '[thread] [relaycast:general] @grace: reply to frank\n(reply with: reply_to_thread message_id="msg_600")' + ); + + await gateway.stop(); + }); + + it('should include source metadata in relay sender data', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('threadReply', { + type: 'thread.reply', + channel: 'general', + parentId: 'msg_parent_700', + message: { + id: 'msg_700', + agentName: 'heidi', + text: 'metadata check', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.data.source).toBe('relaycast'); + expect(call.data.channel).toBe('general'); + expect(call.data.messageId).toBe('msg_700'); + + await gateway.stop(); + }); + }); + + describe('DM event handling', () => { + it('should deliver DMs with [relaycast:dm] format', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('dmReceived', { + type: 'dm.received', + conversationId: 'conv_1', + message: { + id: 'dm_1', + agentName: 'alice', + text: 'hey there', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe('[relaycast:dm] @alice: hey there\n(reply with: send_dm to="alice")'); + + await gateway.stop(); + }); + + it('should skip DMs from the claw itself (echo prevention)', async () => { + const { gateway, sendMessage } = createGateway({ clawName: 'my-claw' }); + await gateway.start(); + + fireEvent('dmReceived', { + type: 'dm.received', + conversationId: 'conv_2', + message: { + id: 'dm_2', + agentName: 'my-claw', + text: 'echo', + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).not.toHaveBeenCalled(); + + await gateway.stop(); + }); + + it('should deduplicate DMs with the same message ID', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + const event = { + type: 'dm.received', + conversationId: 'conv_3', + message: { + id: 'dm_3', + agentName: 'bob', + text: 'duplicate dm', + }, + }; + + fireEvent('dmReceived', event); + fireEvent('dmReceived', event); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).toHaveBeenCalledTimes(1); + + await gateway.stop(); + }); + }); + + describe('Group DM event handling', () => { + it('should deliver group DMs with [relaycast:groupdm] format', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('groupDmReceived', { + type: 'group_dm.received', + conversationId: 'gconv_1', + message: { + id: 'gdm_1', + agentName: 'carol', + text: 'group message', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe('[relaycast:groupdm] @carol: group message\n(reply with: send_dm to="carol")'); + + await gateway.stop(); + }); + }); + + describe('Command invocation handling', () => { + it('should deliver command invocations with formatted text', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('commandInvoked', { + type: 'command.invoked', + command: 'deploy', + channel: 'general', + invokedBy: 'dave', + handlerAgentId: 'agent_1', + args: 'production --force', + parameters: null, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:command:general] @dave /deploy production --force\n(command invocation \u2014 respond with: post_message channel="general")' + ); + + await gateway.stop(); + }); + + it('should deliver command invocations without args', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('commandInvoked', { + type: 'command.invoked', + command: 'status', + channel: 'general', + invokedBy: 'eve', + handlerAgentId: 'agent_2', + args: null, + parameters: null, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:command:general] @eve /status\n(command invocation \u2014 respond with: post_message channel="general")' + ); + + await gateway.stop(); + }); + + it('should ignore commands from unsubscribed channels', async () => { + const { gateway, sendMessage } = createGateway({ channels: ['general'] }); + await gateway.start(); + + fireEvent('commandInvoked', { + type: 'command.invoked', + command: 'deploy', + channel: 'random', + invokedBy: 'dave', + handlerAgentId: 'agent_1', + args: null, + parameters: null, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).not.toHaveBeenCalled(); + + await gateway.stop(); + }); + }); + + describe('Reaction event handling', () => { + it('should deliver reaction added as soft notification', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('reactionAdded', { + type: 'reaction.added', + messageId: 'msg_800', + emoji: 'thumbsup', + agentName: 'eve', + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:reaction] @eve reacted thumbsup to message msg_800 (soft notification, no action required)' + ); + + await gateway.stop(); + }); + + it('should deliver reaction removed as soft notification', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('reactionRemoved', { + type: 'reaction.removed', + messageId: 'msg_900', + emoji: 'rocket', + agentName: 'frank', + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:reaction] @frank removed rocket from message msg_900 (soft notification, no action required)' + ); + + await gateway.stop(); + }); + + it('should skip reactions from the claw itself', async () => { + const { gateway, sendMessage } = createGateway({ clawName: 'my-claw' }); + await gateway.start(); + + fireEvent('reactionAdded', { + type: 'reaction.added', + messageId: 'msg_1000', + emoji: 'check', + agentName: 'my-claw', + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).not.toHaveBeenCalled(); + + await gateway.stop(); + }); + }); + + describe('delivery fallback path', () => { + it('should fall back to openclawClient when relaySender fails', async () => { + const sendMessage = vi.fn().mockRejectedValue(new Error('relay down')); + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: ['general'], + openclawGatewayToken: 'tok_gateway', + openclawGatewayPort: 19999, + }, + relaySender: { sendMessage }, + }); + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_fb_1', + agentName: 'alice', + text: 'fallback test', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + // The relaySender threw, so it should have attempted openclawClient. + // Since openclawClient WS is not actually connected in test, both fail. + // We just verify the sendMessage was called (relay path attempted). + await new Promise((r) => setTimeout(r, 50)); + + await gateway.stop(); + }); + + it('should return method=failed when both relaySender and openclawClient fail', async () => { + const sendMessage = vi.fn().mockRejectedValue(new Error('relay down')); + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: ['general'], + }, + relaySender: { sendMessage }, + }); + await gateway.start(); + + // No openclawClient (no token), sendMessage will throw + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_fb_2', + agentName: 'alice', + text: 'both fail', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + await new Promise((r) => setTimeout(r, 50)); + await gateway.stop(); + }); + + it('should treat unsupported_operation event_id as failure', async () => { + const sendMessage = vi.fn().mockResolvedValue({ event_id: 'unsupported_operation' }); + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: ['general'], + }, + relaySender: { sendMessage }, + }); + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_unsup_1', + agentName: 'bob', + text: 'unsupported test', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + // unsupported_operation means relay delivery failed, should fall through + await new Promise((r) => setTimeout(r, 50)); + await gateway.stop(); + }); + + it('should treat relaySender throwing as failure and fall through', async () => { + const sendMessage = vi.fn().mockRejectedValue(new Error('network error')); + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: ['general'], + }, + relaySender: { sendMessage }, + }); + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_throw_1', + agentName: 'carol', + text: 'throw test', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + await new Promise((r) => setTimeout(r, 50)); + await gateway.stop(); + }); + }); + + describe('delivery without relaySender', () => { + it('should attempt openclawClient directly when no relaySender is provided', async () => { + // No relaySender, no openclawClient token => both paths fail gracefully + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: ['general'], + }, + // No relaySender provided + }); + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_no_relay_1', + agentName: 'dave', + text: 'no relay sender', + attachments: [], + }, + }); + + // Should not throw even with no delivery method available + await new Promise((r) => setTimeout(r, 100)); + await gateway.stop(); + }); + }); + + describe('formatDeliveryText coverage', () => { + it('should format dm messages as [relaycast:dm]', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('dmReceived', { + type: 'dm.received', + conversationId: 'conv_fmt_1', + message: { + id: 'dm_fmt_1', + agentName: 'alice', + text: 'dm format test', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe('[relaycast:dm] @alice: dm format test\n(reply with: send_dm to="alice")'); + + await gateway.stop(); + }); + + it('should format groupdm messages as [relaycast:groupdm]', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('groupDmReceived', { + type: 'group_dm.received', + conversationId: 'gconv_fmt_1', + message: { + id: 'gdm_fmt_1', + agentName: 'bob', + text: 'group dm format test', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:groupdm] @bob: group dm format test\n(reply with: send_dm to="bob")' + ); + + await gateway.stop(); + }); + + it('should format command messages with pre-formatted text', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('commandInvoked', { + type: 'command.invoked', + command: 'build', + channel: 'general', + invokedBy: 'carol', + handlerAgentId: 'agent_fmt_1', + args: '--prod', + parameters: null, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:command:general] @carol /build --prod\n(command invocation \u2014 respond with: post_message channel="general")' + ); + + await gateway.stop(); + }); + + it('should format reaction messages with pre-formatted text', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('reactionAdded', { + type: 'reaction.added', + messageId: 'msg_fmt_react', + emoji: 'fire', + agentName: 'dave', + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:reaction] @dave reacted fire to message msg_fmt_react (soft notification, no action required)' + ); + + await gateway.stop(); + }); + + it('should format thread messages with [thread] prefix', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('threadReply', { + type: 'thread.reply', + channel: 'general', + parentId: 'msg_fmt_parent', + message: { + id: 'msg_fmt_thread', + agentName: 'eve', + text: 'thread format test', + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[thread] [relaycast:general] @eve: thread format test\n(reply with: reply_to_thread message_id="msg_fmt_parent")' + ); + + await gateway.stop(); + }); + + it('should format default channel messages without prefix', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_fmt_chan', + agentName: 'frank', + text: 'channel format test', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:general] @frank: channel format test\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_fmt_chan")' + ); + + await gateway.stop(); + }); + }); + + describe('handleInbound dedup via processingMessageIds', () => { + it('should skip messages already being processed', async () => { + // Use a slow sendMessage to simulate a message still being processed + let resolveFirst: (() => void) | null = null; + const firstCallPromise = new Promise((r) => { + resolveFirst = r; + }); + const sendMessage = vi + .fn() + .mockImplementationOnce(async () => { + // Block until we manually resolve + await firstCallPromise; + return { event_id: 'evt_1' }; + }) + .mockResolvedValue({ event_id: 'evt_2' }); + + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: ['general'], + }, + relaySender: { sendMessage }, + }); + await gateway.start(); + + // Fire the same message twice quickly + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_dedup_proc', + agentName: 'alice', + text: 'dedup processing test', + attachments: [], + }, + }); + + // Second fire of same message should be skipped (already processing or seen) + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_dedup_proc', + agentName: 'alice', + text: 'dedup processing test', + attachments: [], + }, + }); + + // Resolve the first call + resolveFirst!(); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + await new Promise((r) => setTimeout(r, 50)); + // Should only have been called once since the second was deduped + expect(sendMessage).toHaveBeenCalledTimes(1); + + await gateway.stop(); + }); + + it('should retry a replayed message after a failed delivery', async () => { + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ event_id: 'unsupported_operation' }) + .mockResolvedValueOnce({ event_id: 'evt_retry_ok' }); + + const gateway = new InboundGateway({ + config: { + apiKey: 'rk_live_test', + clawName: 'test-claw', + baseUrl: 'https://api.relaycast.dev', + channels: ['general'], + }, + relaySender: { sendMessage }, + }); + await gateway.start(); + + const event = { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_retry_1', + agentName: 'alice', + text: 'retry me', + attachments: [], + }, + }; + + fireEvent('messageCreated', event); + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + fireEvent('messageCreated', event); + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(2); + }); + + await gateway.stop(); + }); + }); + + describe('handleInbound when not running', () => { + it('should be a no-op when gateway is stopped', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + await gateway.stop(); + + // Fire an event after the gateway has stopped + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_stopped_1', + agentName: 'alice', + text: 'should not deliver', + attachments: [], + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('stop() method', () => { + it('should disconnect relay client and clear state', async () => { + const { gateway } = createGateway(); + await gateway.start(); + + // Verify gateway is running by checking it can receive messages + await gateway.stop(); + + // Calling stop again should be safe (idempotent) + await gateway.stop(); + }); + + it('should clear seenMessageIds and processingMessageIds on stop', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + // Send a message so it gets added to seenMessageIds + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_clear_1', + agentName: 'alice', + text: 'will be cleared', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + await gateway.stop(); + + // Now restart and send the same message ID - it should be delivered again + // because stop() cleared the seen map + sendMessage.mockClear(); + // Clear event handlers first since stop() unsubscribes + for (const key of Object.keys(eventHandlers)) { + eventHandlers[key] = []; + } + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_clear_1', + agentName: 'alice', + text: 'will be cleared', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + await gateway.stop(); + }); + + it('should unsubscribe all event handlers on stop', async () => { + const { gateway, sendMessage } = createGateway(); + await gateway.start(); + + await gateway.stop(); + + // After stop, firing events should not trigger sendMessage + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_unsub_1', + agentName: 'alice', + text: 'should not deliver after stop', + attachments: [], + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('channel name normalization', () => { + it('should normalize channel names with # prefix', async () => { + const { gateway, sendMessage } = createGateway({ channels: ['#general'] }); + await gateway.start(); + + fireEvent('messageCreated', { + type: 'message.created', + channel: 'general', + message: { + id: 'msg_norm_1', + agentName: 'alice', + text: 'normalization test', + attachments: [], + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalled(); + }); + + const call = sendMessage.mock.calls[0][0]; + expect(call.text).toBe( + '[relaycast:general] @alice: normalization test\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_norm_1")' + ); + + await gateway.stop(); + }); + }); +}); diff --git a/src/__tests__/naming.test.ts b/src/__tests__/naming.test.ts new file mode 100644 index 0000000..df42f8a --- /dev/null +++ b/src/__tests__/naming.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { buildAgentName } from '../identity/naming.js'; + +describe('buildAgentName', () => { + it('should build agent name from workspace and claw name', () => { + const result = buildAgentName('ws123', 'researcher'); + expect(result).toBe('claw-ws123-researcher'); + }); + + it('should handle hyphens in workspace id', () => { + const result = buildAgentName('ws-abc-123', 'coder'); + expect(result).toBe('claw-ws-abc-123-coder'); + }); + + it('should handle hyphens in claw name', () => { + const result = buildAgentName('workspace', 'code-reviewer'); + expect(result).toBe('claw-workspace-code-reviewer'); + }); + + it('should handle empty strings', () => { + const result = buildAgentName('', ''); + expect(result).toBe('claw--'); + }); +}); diff --git a/src/__tests__/spawn-manager.test.ts b/src/__tests__/spawn-manager.test.ts new file mode 100644 index 0000000..6d76912 --- /dev/null +++ b/src/__tests__/spawn-manager.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SpawnManager } from '../spawn/manager.js'; + +// Mock the spawn providers +vi.mock('../spawn/docker.js', () => ({ + DockerSpawnProvider: vi.fn().mockImplementation(() => ({ + spawn: vi.fn().mockResolvedValue({ + id: 'test-id-1', + displayName: 'test-claw', + agentName: 'claw-ws123-test-claw', + gatewayPort: 18789, + destroy: vi.fn().mockResolvedValue(undefined), + }), + destroy: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('../spawn/process.js', () => ({ + ProcessSpawnProvider: vi.fn().mockImplementation(() => { + let callCount = 0; + return { + spawn: vi.fn().mockImplementation((options: { name: string }) => { + callCount++; + return Promise.resolve({ + id: `test-id-${callCount}`, + displayName: options.name, + agentName: `claw-ws123-${options.name}`, + gatewayPort: 18790, + destroy: vi.fn().mockResolvedValue(undefined), + }); + }), + destroy: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + }; + }), +})); + +// Mock fs operations +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue('{"spawns":[]}'), + writeFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), +})); + +describe('SpawnManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with default values', () => { + const manager = new SpawnManager({ mode: 'process' }); + expect(manager.size).toBe(0); + }); + + it('should enforce maxSpawns limit', async () => { + const manager = new SpawnManager({ mode: 'process', maxSpawns: 1 }); + + // First spawn should succeed + await manager.spawn({ + name: 'claw-1', + relayApiKey: 'rk_live_test', + }); + + expect(manager.size).toBe(1); + + // Second spawn should fail due to limit + await expect( + manager.spawn({ + name: 'claw-2', + relayApiKey: 'rk_live_test', + }) + ).rejects.toThrow(/Maximum concurrent spawns reached/); + }); + + it('should enforce maxDepth limit', async () => { + const manager = new SpawnManager({ + mode: 'process', + maxDepth: 2, + spawnDepth: 2, // Already at max depth + }); + + await expect( + manager.spawn({ + name: 'claw-1', + relayApiKey: 'rk_live_test', + }) + ).rejects.toThrow(/Spawn depth limit reached/); + }); + + it('should prevent duplicate spawns by name', async () => { + const manager = new SpawnManager({ mode: 'process' }); + + await manager.spawn({ + name: 'researcher', + relayApiKey: 'rk_live_test', + }); + + await expect( + manager.spawn({ + name: 'researcher', + relayApiKey: 'rk_live_test', + }) + ).rejects.toThrow(/already running/); + }); + + it('should list spawned handles', async () => { + const manager = new SpawnManager({ mode: 'process' }); + + await manager.spawn({ + name: 'worker-1', + relayApiKey: 'rk_live_test', + }); + + const list = manager.list(); + expect(list).toHaveLength(1); + expect(list[0].displayName).toBe('worker-1'); + }); + + it('should release by id', async () => { + const manager = new SpawnManager({ mode: 'process' }); + + const handle = await manager.spawn({ + name: 'worker-1', + relayApiKey: 'rk_live_test', + }); + + expect(manager.size).toBe(1); + + const released = await manager.release(handle.id); + expect(released).toBe(true); + expect(manager.size).toBe(0); + }); + + it('should release by name', async () => { + const manager = new SpawnManager({ mode: 'process' }); + + await manager.spawn({ + name: 'worker-1', + relayApiKey: 'rk_live_test', + }); + + const released = await manager.releaseByName('worker-1'); + expect(released).toBe(true); + expect(manager.size).toBe(0); + }); + + it('should return false when releasing non-existent spawn', async () => { + const manager = new SpawnManager({ mode: 'process' }); + + const released = await manager.release('non-existent-id'); + expect(released).toBe(false); + }); + + it('should return handle by id via get()', async () => { + const manager = new SpawnManager({ mode: 'process' }); + + const handle = await manager.spawn({ + name: 'getter-test', + relayApiKey: 'rk_live_test', + }); + + expect(manager.get(handle.id)).toBeDefined(); + expect(manager.get(handle.id)!.displayName).toBe('getter-test'); + expect(manager.get('nonexistent')).toBeUndefined(); + }); + + it('should persist state on spawn', async () => { + const { writeFile } = await import('node:fs/promises'); + + const manager = new SpawnManager({ mode: 'process' }); + await manager.spawn({ + name: 'persist-test', + relayApiKey: 'rk_live_test', + }); + + expect(writeFile).toHaveBeenCalled(); + const writeCall = vi.mocked(writeFile).mock.calls[0]; + expect(writeCall[0]).toContain('spawns.json'); + const written = JSON.parse(writeCall[1] as string) as { spawns: Array<{ displayName: string }> }; + expect(written.spawns).toHaveLength(1); + expect(written.spawns[0].displayName).toBe('persist-test'); + }); + + it('should return empty array from loadPersistedState when no file exists', async () => { + const manager = new SpawnManager({ mode: 'process' }); + + const state = await manager.loadPersistedState(); + expect(state).toEqual([]); + }); +}); diff --git a/src/__tests__/ws-client.test.ts b/src/__tests__/ws-client.test.ts new file mode 100644 index 0000000..b285964 --- /dev/null +++ b/src/__tests__/ws-client.test.ts @@ -0,0 +1,487 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { WebSocketServer, type WebSocket as WsType } from 'ws'; + +// Mock spawn/manager and relaycast SDK to prevent side-effects from gateway.ts module load +vi.mock('../spawn/manager.js', () => ({ + SpawnManager: vi.fn().mockImplementation(() => ({ + size: 0, + spawn: vi.fn(), + release: vi.fn(), + releaseByName: vi.fn(), + releaseAll: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockReturnValue([]), + get: vi.fn(), + })), +})); + +vi.mock('@relaycast/sdk', () => ({ + RelayCast: vi.fn().mockImplementation(() => ({ + agents: { registerOrGet: vi.fn().mockResolvedValue({ name: 'test', token: 'tok' }) }, + as: vi.fn().mockReturnValue({ + connect: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + on: { + connected: vi.fn().mockReturnValue(() => {}), + messageCreated: vi.fn().mockReturnValue(() => {}), + threadReply: vi.fn().mockReturnValue(() => {}), + dmReceived: vi.fn().mockReturnValue(() => {}), + groupDmReceived: vi.fn().mockReturnValue(() => {}), + commandInvoked: vi.fn().mockReturnValue(() => {}), + reactionAdded: vi.fn().mockReturnValue(() => {}), + reactionRemoved: vi.fn().mockReturnValue(() => {}), + reconnecting: vi.fn().mockReturnValue(() => {}), + disconnected: vi.fn().mockReturnValue(() => {}), + error: vi.fn().mockReturnValue(() => {}), + }, + }), + })), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue('{"spawns":[]}'), + writeFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + chmod: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), +})); + +import { OpenClawGatewayClient } from '../gateway.js'; + +// --------------------------------------------------------------------------- +// Mock OpenClaw Gateway WebSocket Server +// --------------------------------------------------------------------------- + +interface MockServerOptions { + /** Whether to accept or reject auth. Default: true */ + acceptAuth?: boolean; + /** Delay before sending challenge (ms). 0 = immediate. */ + challengeDelay?: number; + /** Whether to send a challenge at all. Default: true */ + sendChallenge?: boolean; + /** Delay before responding to chat.send RPCs (ms). Default: 0 */ + chatDelay?: number; + /** Whether chat.send succeeds. Default: true */ + chatOk?: boolean; +} + +class MockOpenClawServer { + private wss: WebSocketServer; + private clients: Set = new Set(); + port = 0; + + private acceptAuth: boolean; + private challengeDelay: number; + private sendChallenge: boolean; + private chatDelay: number; + private chatOk: boolean; + + constructor(options: MockServerOptions = {}) { + this.acceptAuth = options.acceptAuth ?? true; + this.challengeDelay = options.challengeDelay ?? 0; + this.sendChallenge = options.sendChallenge ?? true; + this.chatDelay = options.chatDelay ?? 0; + this.chatOk = options.chatOk ?? true; + + this.wss = new WebSocketServer({ port: 0 }); + this.port = (this.wss.address() as { port: number }).port; + + this.wss.on('connection', (ws) => { + this.clients.add(ws); + ws.on('close', () => this.clients.delete(ws)); + + if (this.sendChallenge) { + const challenge = JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: 'test-nonce-123', ts: Date.now() }, + }); + + if (this.challengeDelay > 0) { + setTimeout(() => ws.send(challenge), this.challengeDelay); + } else { + ws.send(challenge); + } + } + + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + + // Handle connect request + if (msg.method === 'connect' && msg.id === 'connect-1') { + if (this.acceptAuth) { + ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); + } else { + ws.send( + JSON.stringify({ + type: 'res', + id: 'connect-1', + ok: false, + error: { code: 'auth_failed', message: 'Invalid token' }, + }) + ); + } + return; + } + + // Handle chat.send RPC + if (msg.method === 'chat.send') { + const respond = () => { + if (this.chatOk) { + ws.send( + JSON.stringify({ + type: 'res', + id: msg.id, + ok: true, + payload: { runId: 'run-1', status: 'accepted' }, + }) + ); + } else { + ws.send( + JSON.stringify({ + type: 'res', + id: msg.id, + ok: false, + error: { code: 'rate_limited', message: 'Too many requests' }, + }) + ); + } + }; + + if (this.chatDelay > 0) { + setTimeout(respond, this.chatDelay); + } else { + respond(); + } + } + }); + }); + } + + /** Force-close all connected clients. */ + closeAllClients(code = 1000): void { + for (const ws of this.clients) { + ws.close(code); + } + } + + async close(): Promise { + this.closeAllClients(); + return new Promise((resolve) => { + this.wss.close(() => resolve()); + }); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('OpenClawGatewayClient', () => { + let server: MockOpenClawServer | null = null; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + }); + + it('should connect and authenticate (happy path)', async () => { + server = new MockOpenClawServer(); + const client = new OpenClawGatewayClient('test-token', server.port); + + await client.connect(); + // Should resolve without throwing + await client.disconnect(); + }); + + it('should reject when auth is rejected', async () => { + server = new MockOpenClawServer({ acceptAuth: false }); + const client = new OpenClawGatewayClient('bad-token', server.port); + + await expect(client.connect()).rejects.toThrow(/auth failed/i); + await client.disconnect(); + }); + + it('should no-op when already connected', async () => { + server = new MockOpenClawServer(); + const client = new OpenClawGatewayClient('test-token', server.port); + + await client.connect(); + // Second connect should be a no-op (early return) + await client.connect(); + await client.disconnect(); + }); + + it('should timeout when no challenge is sent', async () => { + server = new MockOpenClawServer({ sendChallenge: false }); + const client = new OpenClawGatewayClient('test-token', server.port); + + // Monkey-patch the timeout to be short for the test + (OpenClawGatewayClient as unknown as Record).CONNECT_TIMEOUT_MS = 200; + + await expect(client.connect()).rejects.toThrow(/timed out/i); + await client.disconnect(); + + // Restore + (OpenClawGatewayClient as unknown as Record).CONNECT_TIMEOUT_MS = 30_000; + }); + + it('should reject connect when WS closes before auth', async () => { + server = new MockOpenClawServer({ sendChallenge: false }); + const client = new OpenClawGatewayClient('test-token', server.port); + + // Start connecting, then close server connections immediately + const connectPromise = client.connect(); + // Give the WS time to establish before closing + await new Promise((r) => setTimeout(r, 50)); + server.closeAllClients(1000); + + await expect(connectPromise).rejects.toThrow(/closed before authentication|timed out/i); + await client.disconnect(); + }); + + it('should return true on successful sendChatMessage', async () => { + server = new MockOpenClawServer(); + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + + const result = await client.sendChatMessage('hello world'); + expect(result).toBe(true); + + await client.disconnect(); + }); + + it('should pass idempotencyKey in sendChatMessage params', async () => { + let receivedParams: Record = {}; + server = new MockOpenClawServer(); + + // Intercept the server to capture params + const origWss = (server as unknown as { wss: WebSocketServer }).wss; + const origListeners = origWss.listeners('connection'); + origWss.removeAllListeners('connection'); + origWss.on('connection', (ws) => { + // Re-emit for original handler + for (const listener of origListeners) { + (listener as (ws: WsType) => void)(ws); + } + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg.method === 'chat.send') { + receivedParams = msg.params as Record; + } + }); + }); + + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + + await client.sendChatMessage('test', 'idem-key-123'); + expect(receivedParams.idempotencyKey).toBe('idem-key-123'); + + await client.disconnect(); + }); + + it('should return false on RPC error', async () => { + server = new MockOpenClawServer({ chatOk: false }); + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + + const result = await client.sendChatMessage('hello'); + expect(result).toBe(false); + + await client.disconnect(); + }); + + it('should return false on sendChatMessage when not connected', async () => { + // No server at all — connect should fail + const client = new OpenClawGatewayClient('test-token', 1); + + const result = await client.sendChatMessage('hello'); + expect(result).toBe(false); + + await client.disconnect(); + }); + + it('should reject pending RPCs on disconnect', async () => { + // Use a server that never responds to chat.send + server = new MockOpenClawServer(); + // Override: don't respond to chat.send + const origWss = (server as unknown as { wss: WebSocketServer }).wss; + origWss.removeAllListeners('connection'); + origWss.on('connection', (ws) => { + // Send challenge + ws.send( + JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: 'nonce-1', ts: Date.now() }, + }) + ); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg.method === 'connect') { + ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); + } + // Deliberately don't respond to chat.send + }); + }); + + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + + // Send a message that won't get a response + const chatPromise = client.sendChatMessage('will be rejected'); + + // Disconnect while the RPC is pending + await client.disconnect(); + + const result = await chatPromise; + expect(result).toBe(false); + }); + + it('should not reconnect after disconnect()', async () => { + server = new MockOpenClawServer(); + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + await client.disconnect(); + + // After disconnect, the stopped flag should prevent reconnection. + // Verify by checking that sendChatMessage returns false without hanging. + const result = await client.sendChatMessage('should fail'); + expect(result).toBe(false); + }); + + it('should handle non-JSON messages gracefully', async () => { + server = new MockOpenClawServer({ sendChallenge: false }); + const origWss = (server as unknown as { wss: WebSocketServer }).wss; + origWss.removeAllListeners('connection'); + origWss.on('connection', (ws) => { + // Send garbage first, then a proper challenge + ws.send('not json at all'); + ws.send( + JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: 'nonce-2', ts: Date.now() }, + }) + ); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg.method === 'connect') { + ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); + } + }); + }); + + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + await client.disconnect(); + }); + + it('should fallback to alternate payload version on signature rejection', async () => { + let connectAttempts = 0; + server = new MockOpenClawServer({ sendChallenge: false }); + const origWss = (server as unknown as { wss: WebSocketServer }).wss; + origWss.removeAllListeners('connection'); + origWss.on('connection', (ws) => { + connectAttempts++; + // Send challenge + ws.send( + JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: `nonce-fallback-${connectAttempts}`, ts: Date.now() }, + }) + ); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg.method === 'connect') { + if (connectAttempts === 1) { + // First attempt: reject with signature invalid + ws.send( + JSON.stringify({ + type: 'res', + id: 'connect-1', + ok: false, + error: { code: 'auth_failed', message: 'device signature invalid' }, + }) + ); + } else { + // Second attempt (fallback): accept + ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); + } + } + }); + }); + + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + // Should have connected on the fallback attempt + expect(connectAttempts).toBe(2); + await client.disconnect(); + }); + + it('should not retry fallback more than once', async () => { + let connectAttempts = 0; + server = new MockOpenClawServer({ sendChallenge: false }); + const origWss = (server as unknown as { wss: WebSocketServer }).wss; + origWss.removeAllListeners('connection'); + origWss.on('connection', (ws) => { + connectAttempts++; + ws.send( + JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: `nonce-nofallback-${connectAttempts}`, ts: Date.now() }, + }) + ); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg.method === 'connect') { + // Always reject with signature invalid + ws.send( + JSON.stringify({ + type: 'res', + id: 'connect-1', + ok: false, + error: { code: 'auth_failed', message: 'device signature invalid' }, + }) + ); + } + }); + }); + + const client = new OpenClawGatewayClient('test-token', server.port); + await expect(client.connect()).rejects.toThrow(/auth failed|signature invalid|closed before/i); + // Should have tried exactly 2 times: primary + one fallback + expect(connectAttempts).toBe(2); + await client.disconnect(); + }); + + it('should silently ignore unrecognized event messages', async () => { + server = new MockOpenClawServer(); + const origWss = (server as unknown as { wss: WebSocketServer }).wss; + const origListeners = origWss.listeners('connection'); + origWss.removeAllListeners('connection'); + origWss.on('connection', (ws) => { + for (const listener of origListeners) { + (listener as (ws: WsType) => void)(ws); + } + // Send some random event after auth + setTimeout(() => { + ws.send(JSON.stringify({ type: 'event', event: 'chat.tick', payload: {} })); + }, 100); + }); + + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + await new Promise((r) => setTimeout(r, 150)); + await client.disconnect(); + }); +}); diff --git a/src/auth/converter.ts b/src/auth/converter.ts new file mode 100644 index 0000000..495c651 --- /dev/null +++ b/src/auth/converter.ts @@ -0,0 +1,90 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; + +export interface CodexOAuthTokens { + access_token: string; + refresh_token?: string; +} + +export interface CodexAuth { + tokens?: CodexOAuthTokens; + OPENAI_API_KEY?: string; +} + +export interface ConvertResult { + /** Whether auth was written. */ + ok: boolean; + /** Provider hint derived from auth source (e.g. 'openai-codex' for OAuth). */ + preferredProvider: string; +} + +/** + * Convert Codex CLI auth.json into OpenClaw's legacy auth format. + * + * Reads ~/.codex/auth.json (or codexAuthPath) and writes the converted + * auth to ~/.openclaw/agents/main/agent/auth.json (or openclawAuthDir). + * + * Falls back to OPENAI_API_KEY env var if no codex auth file exists. + */ +export async function convertCodexAuth(options?: { + codexAuthPath?: string; + openclawAuthDir?: string; + openaiApiKey?: string; +}): Promise { + const home = process.env.HOME ?? '/home/node'; + const codexPath = options?.codexAuthPath ?? join(home, '.codex', 'auth.json'); + const openclawAgentDir = options?.openclawAuthDir ?? join(home, '.openclaw', 'agents', 'main', 'agent'); + const openclawAuthPath = join(openclawAgentDir, 'auth.json'); + let preferredProvider = 'openai'; + + if (existsSync(codexPath)) { + const codex: CodexAuth = JSON.parse(await readFile(codexPath, 'utf8')); + await mkdir(openclawAgentDir, { recursive: true }); + + if (codex.tokens?.access_token) { + // OAuth tokens from codex subscription + const auth = { + 'openai-codex': { + type: 'oauth', + provider: 'openai-codex', + access: codex.tokens.access_token, + refresh: codex.tokens.refresh_token ?? '', + expires: Date.now() + 3600000, + }, + }; + await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8'); + preferredProvider = 'openai-codex'; + return { ok: true, preferredProvider }; + } + + if (codex.OPENAI_API_KEY && typeof codex.OPENAI_API_KEY === 'string') { + const auth = { + openai: { + type: 'api_key', + provider: 'openai', + key: codex.OPENAI_API_KEY, + }, + }; + await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8'); + return { ok: true, preferredProvider }; + } + } + + // Fallback: use OPENAI_API_KEY from env + const envKey = options?.openaiApiKey ?? process.env.OPENAI_API_KEY; + if (envKey) { + await mkdir(openclawAgentDir, { recursive: true }); + const auth = { + openai: { + type: 'api_key', + provider: 'openai', + key: envKey, + }, + }; + await writeFile(openclawAuthPath, JSON.stringify(auth, null, 2), 'utf8'); + return { ok: true, preferredProvider }; + } + + return { ok: false, preferredProvider }; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..900a807 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,365 @@ +import { createRequire } from 'node:module'; +import { setup } from './setup.js'; +import { loadGatewayConfig, addWorkspace, listWorkspaces, switchWorkspace } from './config.js'; +import { InboundGateway } from './gateway.js'; +import { listOpenClaws, releaseOpenClaw, spawnOpenClaw } from './control.js'; +import { startMcpServer } from './mcp/server.js'; +import { runtimeSetup } from './runtime/setup.js'; + +const require = createRequire(import.meta.url); +const version = + process.env.RELAY_OPENCLAW_VERSION ?? + (() => { + try { + return (require('../package.json') as { version: string }).version; + } catch { + return 'unknown'; + } + })(); + +function printUsage(): void { + console.log( + ` +relay-openclaw — Agent Relay bridge for OpenClaw + +Usage: + relay-openclaw setup [key] Install & configure Agent Relay bridge + relay-openclaw gateway Start inbound message gateway + relay-openclaw status Check connection status + relay-openclaw spawn Spawn an OpenClaw via ClawRunner control API + relay-openclaw list List OpenClaws in a workspace + relay-openclaw release Release an OpenClaw by agent name + relay-openclaw mcp-server Start MCP server (spawn/list/release tools) + relay-openclaw add-workspace Add a workspace to multi-workspace config + relay-openclaw list-workspaces List all configured workspaces + relay-openclaw switch-workspace Switch the default/active workspace + relay-openclaw runtime-setup Run container runtime setup (auth, config, identity, patching) + relay-openclaw help Show this help + relay-openclaw --version Show version + +Setup options: + --name Claw name (default: hostname) + --channels Channels to join (default: general) + --base-url Relaycast API URL (default: https://api.relaycast.dev) + +Control API options: + --workspace-id Workspace UUID (required for spawn/list/release) + --name Claw name (required for spawn) + --agent Agent name (required for release) + --role Optional role for spawned claw + --model Optional model reference + --channels Optional channels + --system-prompt Optional system prompt + --reason Optional release reason + +Multi-workspace options: + --alias Human-friendly alias for the workspace + --workspace-id Workspace UUID + --default Set as the default workspace + +Examples: + relay-openclaw setup rk_live_abc123 + relay-openclaw setup --name my-claw --channels general,alerts + relay-openclaw gateway + relay-openclaw spawn --workspace-id ws_uuid --name researcher-1 + relay-openclaw list --workspace-id ws_uuid + relay-openclaw release --workspace-id ws_uuid --agent claw-ws_uuid-researcher-1 + relay-openclaw add-workspace rk_live_abc123 --alias team-a + relay-openclaw add-workspace rk_live_def456 --alias team-b --default + relay-openclaw list-workspaces + relay-openclaw switch-workspace team-a +`.trim() + ); +} + +function parseArgs(argv: string[]): { + command: string; + positional: string[]; + flags: Record; +} { + const args = argv.slice(2); // skip node + script + const command = args[0] ?? 'help'; + const positional: string[] = []; + const flags: Record = {}; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + const value = args[i + 1]; + if (value && !value.startsWith('--')) { + flags[key] = value; + i++; + } else { + flags[key] = 'true'; + } + } else { + positional.push(arg); + } + } + + return { command, positional, flags }; +} + +async function runSetup(positional: string[], flags: Record): Promise { + const apiKey = positional[0] ?? undefined; + const clawName = flags['name'] ?? undefined; + const channels = flags['channels']?.split(',').map((c) => c.trim()); + const baseUrl = flags['base-url'] ?? undefined; + + console.log('Setting up Agent Relay bridge for OpenClaw...\n'); + + const result = await setup({ apiKey, clawName, channels, baseUrl }); + + if (result.ok) { + console.log(result.message); + const maskedApiKey = result.apiKey.slice(0, 12) + '...'; + console.log(`\nWorkspace key: ${maskedApiKey}`); + console.log('Share this key with other claws to join the same workspace.'); + } else { + console.error(`Setup failed: ${result.message}`); + process.exit(1); + } +} + +async function runGateway(): Promise { + const config = await loadGatewayConfig(); + + if (!config) { + console.error('No gateway config found. Run "relay-openclaw setup" first.'); + process.exit(1); + } + + console.log(`Starting inbound gateway for ${config.clawName}...`); + console.log(`Channels: ${config.channels.join(', ')}`); + console.log(`Base URL: ${config.baseUrl}\n`); + + const gateway = new InboundGateway({ config }); + + // Graceful shutdown + const shutdown = async () => { + console.log('\nShutting down gateway...'); + await gateway.stop(); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + await gateway.start(); + console.log('Gateway running. Press Ctrl+C to stop.'); +} + +async function runStatus(): Promise { + const config = await loadGatewayConfig(); + + if (!config) { + console.log('Status: NOT CONFIGURED'); + console.log('Run "relay-openclaw setup" to configure.'); + return; + } + + console.log('Status: CONFIGURED'); + console.log(`Claw name: ${config.clawName}`); + console.log(`Channels: ${config.channels.join(', ')}`); + console.log(`Base URL: ${config.baseUrl}`); + console.log(`API key: ${config.apiKey.slice(0, 12)}...`); + + // Try to check connectivity + try { + const res = await fetch(`${config.baseUrl}/health`); + console.log(`API connectivity: ${res.ok ? 'OK' : `Error (${res.status})`}`); + } catch (err) { + console.log(`API connectivity: UNREACHABLE (${err instanceof Error ? err.message : String(err)})`); + } +} + +async function runSpawn(flags: Record): Promise { + const workspaceId = flags['workspace-id']; + const name = flags['name']; + if (!workspaceId || !name) { + console.error('spawn requires --workspace-id and --name'); + process.exit(1); + } + + const channels = flags['channels'] + ?.split(',') + .map((ch) => ch.trim()) + .filter(Boolean); + + const result = await spawnOpenClaw({ + workspaceId, + name, + role: flags['role'], + model: flags['model'], + channels, + systemPrompt: flags['system-prompt'], + idempotencyKey: flags['idempotency-key'], + }); + + console.log(JSON.stringify(result, null, 2)); +} + +async function runList(flags: Record): Promise { + const workspaceId = flags['workspace-id']; + if (!workspaceId) { + console.error('list requires --workspace-id'); + process.exit(1); + } + + const result = await listOpenClaws(workspaceId); + console.log(JSON.stringify(result, null, 2)); +} + +async function runRelease(flags: Record): Promise { + const workspaceId = flags['workspace-id']; + const agentName = flags['agent']; + if (!workspaceId || !agentName) { + console.error('release requires --workspace-id and --agent'); + process.exit(1); + } + + const result = await releaseOpenClaw({ + workspaceId, + agentName, + reason: flags['reason'], + }); + console.log(JSON.stringify(result, null, 2)); +} + +async function runRuntimeSetup(flags: Record): Promise { + console.log('Running container runtime setup...'); + const result = await runtimeSetup({ + model: flags['model'], + name: flags['name'], + workspaceId: flags['workspace-id'], + role: flags['role'], + openclawDistDir: flags['dist-dir'], + }); + console.log(`Runtime setup complete:`); + console.log(` Model: ${result.modelRef}`); + console.log(` Agent: ${result.agentName}`); + console.log(` Workspace: ${result.workspaceId}`); +} + +async function runAddWorkspace(positional: string[], flags: Record): Promise { + const apiKey = positional[0]; + if (!apiKey) { + console.error('add-workspace requires a workspace API key as the first argument.'); + console.error( + 'Usage: relay-openclaw add-workspace [--alias ] [--workspace-id ] [--default]' + ); + process.exit(1); + } + + const config = await addWorkspace({ + api_key: apiKey, + ...(flags['alias'] ? { workspace_alias: flags['alias'] } : {}), + ...(flags['workspace-id'] ? { workspace_id: flags['workspace-id'] } : {}), + ...(flags['default'] !== undefined ? { is_default: flags['default'] === 'true' } : {}), + }); + + const entry = config.workspaces.find((w) => w.api_key === apiKey); + const label = entry?.workspace_alias ?? entry?.workspace_id ?? apiKey.slice(0, 12) + '...'; + console.log(`Workspace "${label}" added.`); + console.log(`Total workspaces: ${config.workspaces.length}`); + if (config.default_workspace) { + console.log(`Default workspace: ${config.default_workspace}`); + } +} + +async function runListWorkspaces(): Promise { + const workspaces = await listWorkspaces(); + if (workspaces.length === 0) { + console.log('No workspaces configured.'); + console.log('Add one with: relay-openclaw add-workspace --alias '); + return; + } + + console.log(`Configured workspaces (${workspaces.length}):\n`); + for (const w of workspaces) { + const defaultMarker = w.is_default ? ' (default)' : ''; + const alias = w.workspace_alias ?? '(no alias)'; + const maskedKey = w.api_key.slice(0, 12) + '...'; + const wsId = w.workspace_id ? ` [${w.workspace_id}]` : ''; + console.log(` ${alias}${wsId} — ${maskedKey}${defaultMarker}`); + } +} + +async function runSwitchWorkspace(positional: string[]): Promise { + const identifier = positional[0]; + if (!identifier) { + console.error('switch-workspace requires a workspace alias or ID.'); + console.error('Usage: relay-openclaw switch-workspace '); + process.exit(1); + } + + const result = await switchWorkspace(identifier); + if (!result) { + console.error(`Workspace "${identifier}" not found.`); + console.error('Run "relay-openclaw list-workspaces" to see available workspaces.'); + process.exit(1); + } + + console.log(`Switched default workspace to "${identifier}".`); + console.log('The .env config has been updated. Restart the gateway to apply.'); +} + +async function main(): Promise { + const { command, positional, flags } = parseArgs(process.argv); + + switch (command) { + case 'setup': + await runSetup(positional, flags); + break; + case 'gateway': + await runGateway(); + break; + case 'status': + await runStatus(); + break; + case 'spawn': + await runSpawn(flags); + break; + case 'list': + await runList(flags); + break; + case 'release': + await runRelease(flags); + break; + case 'add-workspace': + await runAddWorkspace(positional, flags); + break; + case 'list-workspaces': + await runListWorkspaces(); + break; + case 'switch-workspace': + await runSwitchWorkspace(positional); + break; + case 'mcp-server': + await startMcpServer(); + break; + case 'runtime-setup': + await runRuntimeSetup(flags); + break; + case 'version': + case '--version': + case '-v': + console.log(version); + break; + case 'help': + case '--help': + case '-h': + printUsage(); + break; + default: + console.error(`Unknown command: ${command}`); + printUsage(); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..1fb7c9d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,498 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join, dirname, basename } from 'node:path'; +import { homedir } from 'node:os'; +import { existsSync, readFileSync } from 'node:fs'; + +import type { GatewayConfig, WorkspaceEntry, WorkspacesConfig } from './types.js'; + +function envValue(vars: Record, key: string): string | undefined { + const processValue = process.env[key]?.trim(); + if (processValue) return processValue; + const fileValue = vars[key]?.trim(); + return fileValue ? fileValue : undefined; +} + +function parseBooleanEnv(vars: Record, key: string): boolean | undefined { + const value = envValue(vars, key); + if (!value) return undefined; + if (['1', 'true', 'yes', 'on'].includes(value.toLowerCase())) return true; + if (['0', 'false', 'no', 'off'].includes(value.toLowerCase())) return false; + return undefined; +} + +function parseNumberEnv(vars: Record, key: string): number | undefined { + const value = envValue(vars, key); + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export interface OpenClawDetection { + /** Whether OpenClaw is installed. */ + installed: boolean; + /** Path to ~/.openclaw/ (or ~/.clawdbot/ for Clawdbot variant) */ + homeDir: string; + /** Path to ~/.openclaw/workspace/ */ + workspaceDir: string; + /** Path to openclaw.json config (if found). */ + configFile: string | null; + /** Parsed openclaw.json (if exists). */ + config: Record | null; + /** Detected variant: 'clawdbot' or 'openclaw'. */ + variant: 'clawdbot' | 'openclaw'; + /** Config filename (e.g. 'clawdbot.json' or 'openclaw.json'). */ + configFilename: string; +} + +/** + * Determine whether a directory has a valid, parseable config file. + * Uses sync I/O — only called during startup, not on hot path. + */ +function hasValidConfig(dir: string, filename: string): boolean { + const configPath = join(dir, filename); + if (!existsSync(configPath)) return false; + try { + const raw = readFileSync(configPath, 'utf-8'); + JSON.parse(raw); + return true; + } catch { + return false; + } +} + +/** Default OpenClaw config directory. Checks env vars and probes for Clawdbot variant. */ +export function openclawHome(): string { + if (process.env.OPENCLAW_CONFIG_PATH) { + // Direct config file path — return its parent directory + return dirname(process.env.OPENCLAW_CONFIG_PATH); + } + if (process.env.OPENCLAW_HOME) { + return process.env.OPENCLAW_HOME; + } + // Probe by valid config file presence (not just directory existence). + // When both dirs exist, prefer the one with a valid config file. + const clawdbotHome = join(homedir(), '.clawdbot'); + const openclawHomePath = join(homedir(), '.openclaw'); + const clawdbotValid = hasValidConfig(clawdbotHome, 'clawdbot.json'); + const openclawValid = hasValidConfig(openclawHomePath, 'openclaw.json'); + + if (clawdbotValid && !openclawValid) return clawdbotHome; + if (openclawValid && !clawdbotValid) return openclawHomePath; + // Both valid or neither valid — prefer clawdbot if its dir exists (marketplace image) + if (existsSync(clawdbotHome)) return clawdbotHome; + return openclawHomePath; +} + +/** Return the config filename for the resolved OpenClaw home (clawdbot.json or openclaw.json). */ +export function openclawConfigFilename(home?: string): string { + const dir = home ?? openclawHome(); + if (hasValidConfig(dir, 'clawdbot.json')) return 'clawdbot.json'; + if (hasValidConfig(dir, 'openclaw.json')) return 'openclaw.json'; + // No existing config — infer from directory name + return dir.endsWith('.clawdbot') ? 'clawdbot.json' : 'openclaw.json'; +} + +/** + * Detect whether OpenClaw is installed and return paths/config. + */ +export async function detectOpenClaw(): Promise { + // Determine variant and config filename + let homeDir: string; + let variant: 'clawdbot' | 'openclaw'; + let configFilename: string; + + if (process.env.OPENCLAW_CONFIG_PATH) { + // Direct config file path provided + homeDir = dirname(process.env.OPENCLAW_CONFIG_PATH); + const base = basename(process.env.OPENCLAW_CONFIG_PATH); + configFilename = base; + variant = base === 'clawdbot.json' ? 'clawdbot' : 'openclaw'; + } else if (process.env.OPENCLAW_HOME) { + homeDir = process.env.OPENCLAW_HOME; + // Check if the home dir looks like a Clawdbot installation + const clawdbotConfig = join(homeDir, 'clawdbot.json'); + if (existsSync(clawdbotConfig)) { + variant = 'clawdbot'; + configFilename = 'clawdbot.json'; + } else { + variant = 'openclaw'; + configFilename = 'openclaw.json'; + } + } else { + // Probe by valid config file, not just directory existence. + const clawdbotHome = join(homedir(), '.clawdbot'); + const openclawHomePath = join(homedir(), '.openclaw'); + const clawdbotValid = hasValidConfig(clawdbotHome, 'clawdbot.json'); + const openclawValid = hasValidConfig(openclawHomePath, 'openclaw.json'); + + if (clawdbotValid && !openclawValid) { + homeDir = clawdbotHome; + variant = 'clawdbot'; + configFilename = 'clawdbot.json'; + } else if (openclawValid && !clawdbotValid) { + homeDir = openclawHomePath; + variant = 'openclaw'; + configFilename = 'openclaw.json'; + } else if (existsSync(clawdbotHome)) { + // Both valid or neither — prefer clawdbot if present (marketplace image) + homeDir = clawdbotHome; + variant = 'clawdbot'; + configFilename = 'clawdbot.json'; + } else { + homeDir = openclawHomePath; + variant = 'openclaw'; + configFilename = 'openclaw.json'; + } + } + + const configPath = join(homeDir, configFilename); + const workspaceDir = join(homeDir, 'workspace'); + + const installed = existsSync(homeDir); + let config: Record | null = null; + let configFile: string | null = null; + + if (existsSync(configPath)) { + configFile = configPath; + try { + const raw = await readFile(configPath, 'utf-8'); + config = JSON.parse(raw) as Record; + } catch { + // Config exists but isn't valid JSON — that's fine + } + } + + return { installed, homeDir, workspaceDir, configFile, config, variant, configFilename }; +} + +/** + * Load the gateway config from ~/.openclaw/workspace/relaycast/.env. + * Returns null if the file doesn't exist or can't be parsed. + */ +// eslint-disable-next-line complexity +export async function loadGatewayConfig(): Promise { + const detection = await detectOpenClaw(); + const envPath = join(detection.workspaceDir, 'relaycast', '.env'); + + if (!existsSync(envPath)) { + return null; + } + + try { + const raw = await readFile(envPath, 'utf-8'); + const vars: Record = {}; + + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + let value = trimmed.slice(eqIdx + 1); + // Strip surrounding quotes (single or double) that are common in .env files + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + vars[trimmed.slice(0, eqIdx)] = value; + } + + const apiKey = envValue(vars, 'RELAY_API_KEY'); + const clawName = envValue(vars, 'RELAY_CLAW_NAME'); + const relayChannels = envValue(vars, 'RELAY_CHANNELS'); + + if (!apiKey || !clawName) { + return null; + } + + const port = parseNumberEnv(vars, 'OPENCLAW_GATEWAY_PORT'); + const pollFallbackEnabled = parseBooleanEnv(vars, 'RELAY_TRANSPORT_POLL_FALLBACK_ENABLED'); + const pollFallbackProbeWsEnabled = parseBooleanEnv( + vars, + 'RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_ENABLED' + ); + const pollFallbackWsFailureThreshold = parseNumberEnv( + vars, + 'RELAY_TRANSPORT_POLL_FALLBACK_WS_FAILURE_THRESHOLD' + ); + const pollFallbackTimeoutSeconds = parseNumberEnv(vars, 'RELAY_TRANSPORT_POLL_FALLBACK_TIMEOUT_SECONDS'); + const pollFallbackLimit = parseNumberEnv(vars, 'RELAY_TRANSPORT_POLL_FALLBACK_LIMIT'); + const pollFallbackProbeWsIntervalMs = parseNumberEnv( + vars, + 'RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_INTERVAL_MS' + ); + const pollFallbackProbeWsStableGraceMs = parseNumberEnv( + vars, + 'RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_STABLE_GRACE_MS' + ); + const pollFallbackInitialCursor = envValue(vars, 'RELAY_TRANSPORT_POLL_FALLBACK_INITIAL_CURSOR'); + + const transport = + pollFallbackEnabled !== undefined || + pollFallbackProbeWsEnabled !== undefined || + pollFallbackWsFailureThreshold !== undefined || + pollFallbackTimeoutSeconds !== undefined || + pollFallbackLimit !== undefined || + pollFallbackProbeWsIntervalMs !== undefined || + pollFallbackProbeWsStableGraceMs !== undefined || + pollFallbackInitialCursor !== undefined + ? { + pollFallback: { + enabled: pollFallbackEnabled, + wsFailureThreshold: pollFallbackWsFailureThreshold, + timeoutSeconds: pollFallbackTimeoutSeconds, + limit: pollFallbackLimit, + initialCursor: pollFallbackInitialCursor, + probeWs: { + enabled: pollFallbackProbeWsEnabled, + intervalMs: pollFallbackProbeWsIntervalMs, + stableGraceMs: pollFallbackProbeWsStableGraceMs, + }, + }, + } + : undefined; + + return { + apiKey, + clawName, + baseUrl: envValue(vars, 'RELAY_BASE_URL') || 'https://api.relaycast.dev', + channels: relayChannels ? relayChannels.split(',').map((c) => c.trim()) : ['general'], + openclawGatewayToken: envValue(vars, 'OPENCLAW_GATEWAY_TOKEN'), + openclawGatewayPort: Number.isFinite(port) ? port : undefined, + transport, + }; + } catch { + return null; + } +} + +/** + * Save gateway config to ~/.openclaw/workspace/relaycast/.env. + */ +export async function saveGatewayConfig(config: GatewayConfig): Promise { + const detection = await detectOpenClaw(); + const relaycastDir = join(detection.workspaceDir, 'relaycast'); + + await mkdir(relaycastDir, { recursive: true }); + + const lines = [ + '# Relaycast configuration for this OpenClaw skill', + `RELAY_API_KEY=${config.apiKey}`, + `RELAY_CLAW_NAME=${config.clawName}`, + `RELAY_BASE_URL=${config.baseUrl}`, + `RELAY_CHANNELS=${config.channels.join(',')}`, + ]; + + if (config.openclawGatewayToken) { + lines.push(`OPENCLAW_GATEWAY_TOKEN=${config.openclawGatewayToken}`); + const masked = + config.openclawGatewayToken.length > 12 ? config.openclawGatewayToken.slice(0, 8) + '...' : '***'; + console.log(`[config] Persisting OPENCLAW_GATEWAY_TOKEN (${masked})`); + } + if (config.openclawGatewayPort) { + lines.push(`OPENCLAW_GATEWAY_PORT=${config.openclawGatewayPort}`); + } + if (config.transport?.pollFallback?.enabled !== undefined) { + lines.push(`RELAY_TRANSPORT_POLL_FALLBACK_ENABLED=${config.transport.pollFallback.enabled}`); + } + if (config.transport?.pollFallback?.wsFailureThreshold !== undefined) { + lines.push( + `RELAY_TRANSPORT_POLL_FALLBACK_WS_FAILURE_THRESHOLD=${config.transport.pollFallback.wsFailureThreshold}` + ); + } + if (config.transport?.pollFallback?.timeoutSeconds !== undefined) { + lines.push( + `RELAY_TRANSPORT_POLL_FALLBACK_TIMEOUT_SECONDS=${config.transport.pollFallback.timeoutSeconds}` + ); + } + if (config.transport?.pollFallback?.limit !== undefined) { + lines.push(`RELAY_TRANSPORT_POLL_FALLBACK_LIMIT=${config.transport.pollFallback.limit}`); + } + if (config.transport?.pollFallback?.initialCursor) { + lines.push(`RELAY_TRANSPORT_POLL_FALLBACK_INITIAL_CURSOR=${config.transport.pollFallback.initialCursor}`); + } + if (config.transport?.pollFallback?.probeWs?.enabled !== undefined) { + lines.push( + `RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_ENABLED=${config.transport.pollFallback.probeWs.enabled}` + ); + } + if (config.transport?.pollFallback?.probeWs?.intervalMs !== undefined) { + lines.push( + `RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_INTERVAL_MS=${config.transport.pollFallback.probeWs.intervalMs}` + ); + } + if (config.transport?.pollFallback?.probeWs?.stableGraceMs !== undefined) { + lines.push( + `RELAY_TRANSPORT_POLL_FALLBACK_PROBE_WS_STABLE_GRACE_MS=${config.transport.pollFallback.probeWs.stableGraceMs}` + ); + } + + lines.push(''); + const env = lines.join('\n'); + + await writeFile(join(relaycastDir, '.env'), env, 'utf-8'); +} + +// --------------------------------------------------------------------------- +// Multi-workspace config: ~/.openclaw/workspace/relaycast/workspaces.json +// --------------------------------------------------------------------------- + +/** + * Path to the workspaces.json file. + */ +async function workspacesConfigPath(): Promise { + const detection = await detectOpenClaw(); + return join(detection.workspaceDir, 'relaycast', 'workspaces.json'); +} + +/** + * Load multi-workspace config. Returns null if the file doesn't exist. + */ +export async function loadWorkspacesConfig(): Promise { + const configPath = await workspacesConfigPath(); + if (!existsSync(configPath)) return null; + + try { + const raw = await readFile(configPath, 'utf-8'); + return JSON.parse(raw) as WorkspacesConfig; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`Warning: failed to parse ${configPath}: ${message}`); + console.warn('The file may be corrupted. Existing workspace config will not be modified.'); + return { workspaces: [], default_workspace: undefined }; + } +} + +/** + * Save multi-workspace config to disk. + */ +export async function saveWorkspacesConfig(config: WorkspacesConfig): Promise { + const configPath = await workspacesConfigPath(); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); +} + +/** + * Add a workspace entry. If an entry with the same api_key already exists, + * it is updated in place. The first workspace added becomes the default. + */ +export async function addWorkspace(entry: WorkspaceEntry): Promise { + let config = await loadWorkspacesConfig(); + + if (!config) { + // Bootstrap from existing single-workspace .env if available + const gateway = await loadGatewayConfig(); + if (gateway) { + config = { + workspaces: [ + { + api_key: gateway.apiKey, + workspace_alias: gateway.clawName, + is_default: true, + }, + ], + default_workspace: gateway.clawName, + }; + } else { + config = { workspaces: [], default_workspace: undefined }; + } + } + + const normalizeWorkspaceLabel = (workspace: WorkspaceEntry): string => { + return workspace.workspace_alias ?? workspace.workspace_id ?? workspace.api_key; + }; + + // Check for existing entry with same api_key + const hasExplicitDefault = entry.is_default !== undefined; + const existingIdx = config.workspaces.findIndex((w) => w.api_key === entry.api_key); + if (existingIdx >= 0) { + const existingEntry = config.workspaces[existingIdx]; + config.workspaces[existingIdx] = { ...existingEntry, ...entry }; + if (!hasExplicitDefault) { + config.workspaces[existingIdx].is_default = existingEntry.is_default; + } + const updatedEntry = config.workspaces[existingIdx]; + if (updatedEntry.is_default) { + config.default_workspace = normalizeWorkspaceLabel(updatedEntry); + } + } else { + config.workspaces.push(entry); + } + + const targetWorkspace = config.workspaces.find((w) => w.api_key === entry.api_key); + if (!targetWorkspace) { + throw new Error(`Failed to locate workspace entry for ${entry.api_key}`); + } + + // If this is explicitly default, or this is the first workspace without an existing default, + // set it as default. + if ( + entry.is_default === true || + (config.default_workspace === undefined && config.workspaces.length === 1) + ) { + config.default_workspace = normalizeWorkspaceLabel(targetWorkspace); + for (const w of config.workspaces) { + w.is_default = w.api_key === targetWorkspace.api_key; + } + } + + await saveWorkspacesConfig(config); + return config; +} + +/** + * List all configured workspaces. + */ +export async function listWorkspaces(): Promise { + const config = await loadWorkspacesConfig(); + return config?.workspaces ?? []; +} + +/** + * Switch the default workspace by alias or workspace_id. + * Returns the updated config, or null if the identifier was not found. + */ +export async function switchWorkspace(identifier: string): Promise { + const config = await loadWorkspacesConfig(); + if (!config) return null; + + const target = config.workspaces.find( + (w) => w.workspace_alias === identifier || w.workspace_id === identifier + ); + if (!target) return null; + + config.default_workspace = target.workspace_alias ?? target.workspace_id ?? target.api_key; + for (const w of config.workspaces) { + w.is_default = w.api_key === target.api_key; + } + + // Also update the single-workspace .env to match the new default + const gateway = await loadGatewayConfig(); + if (gateway) { + gateway.apiKey = target.api_key; + gateway.clawName = target.workspace_alias ?? target.workspace_id ?? target.api_key; + await saveGatewayConfig(gateway); + } + + await saveWorkspacesConfig(config); + return config; +} + +/** + * Build RELAY_WORKSPACES_JSON value for the broker from stored workspaces. + * Returns null if there are fewer than 2 workspaces (single-workspace mode). + */ +export function buildWorkspacesJson(config: WorkspacesConfig): string | null { + if (config.workspaces.length < 2) return null; + + const memberships = config.workspaces.map((w) => ({ + api_key: w.api_key, + ...(w.workspace_id ? { workspace_id: w.workspace_id } : {}), + ...(w.workspace_alias ? { workspace_alias: w.workspace_alias } : {}), + })); + + const payload: Record = { memberships }; + if (config.default_workspace) { + payload.default_workspace_id = config.default_workspace; + } + + return JSON.stringify(payload); +} diff --git a/src/control.ts b/src/control.ts new file mode 100644 index 0000000..cca12fd --- /dev/null +++ b/src/control.ts @@ -0,0 +1,100 @@ +export interface ClawRunnerControlConfig { + baseUrl: string; + token: string; +} + +export interface SpawnOpenClawInput { + workspaceId: string; + name: string; + role?: string; + model?: string; + channels?: string[]; + systemPrompt?: string; + idempotencyKey?: string; +} + +export interface ReleaseOpenClawInput { + workspaceId: string; + agentName: string; + reason?: string; +} + +function trimSlash(value: string): string { + return value.endsWith('/') ? value.slice(0, -1) : value; +} + +function resolveConfig(config?: Partial): ClawRunnerControlConfig { + const baseUrl = (config?.baseUrl ?? process.env.CLAWRUNNER_API_BASE_URL ?? '').trim(); + const token = (config?.token ?? process.env.CLAWRUNNER_AGENT_TOKEN ?? '').trim(); + + if (!baseUrl) { + throw new Error('CLAWRUNNER_API_BASE_URL is required'); + } + if (!token) { + throw new Error('CLAWRUNNER_AGENT_TOKEN is required'); + } + + return { + baseUrl: trimSlash(baseUrl), + token, + }; +} + +async function callApi( + path: string, + method: 'GET' | 'POST', + config?: Partial, + body?: unknown +): Promise { + const resolved = resolveConfig(config); + const response = await fetch(`${resolved.baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${resolved.token}`, + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const text = await response.text(); + const json = text ? JSON.parse(text) : null; + if (!response.ok) { + const msg = json && typeof json.error === 'string' ? json.error : `HTTP ${response.status}`; + throw new Error(`ClawRunner control API error: ${msg}`); + } + return json as T; +} + +export async function spawnOpenClaw( + input: SpawnOpenClawInput, + config?: Partial +): Promise { + return callApi('/api/agents/spawn', 'POST', config, { + workspaceId: input.workspaceId, + name: input.name, + role: input.role, + model: input.model, + channels: input.channels, + systemPrompt: input.systemPrompt, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function listOpenClaws( + workspaceId: string, + config?: Partial +): Promise { + const query = new URLSearchParams({ workspaceId }).toString(); + return callApi(`/api/agents?${query}`, 'GET', config); +} + +export async function releaseOpenClaw( + input: ReleaseOpenClawInput, + config?: Partial +): Promise { + const path = `/api/agents/${encodeURIComponent(input.agentName)}/release`; + return callApi(path, 'POST', config, { + workspaceId: input.workspaceId, + reason: input.reason, + }); +} diff --git a/src/gateway.ts b/src/gateway.ts new file mode 100644 index 0000000..88a5f04 --- /dev/null +++ b/src/gateway.ts @@ -0,0 +1,2362 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign, + verify, + type KeyObject, +} from 'node:crypto'; +import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises'; +import { + createServer, + type Server as HttpServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import { join } from 'node:path'; + +import type { SendMessageInput } from '@agent-relay/driver'; +import { RelayCast, type AgentClient } from '@relaycast/sdk'; +import type { + MessageCreatedEvent, + ThreadReplyEvent, + DmReceivedEvent, + GroupDmReceivedEvent, + CommandInvokedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, +} from '@relaycast/sdk'; +import WebSocket from 'ws'; + +import { openclawHome } from './config.js'; +import { + DEFAULT_OPENCLAW_GATEWAY_PORT, + type GatewayConfig, + type InboundMessage, + type DeliveryResult, +} from './types.js'; +import { SpawnManager } from './spawn/manager.js'; +import type { SpawnOptions } from './spawn/types.js'; + +/** + * A minimal interface for sending messages via Agent Relay. + * Accepts either AgentRelayClient or AgentRelay — any object with a + * compatible sendMessage() method. + */ +export interface RelaySender { + sendMessage(input: SendMessageInput): Promise<{ event_id: string; targets?: string[] }>; +} + +export interface GatewayOptions { + /** Gateway configuration. */ + config: GatewayConfig; + /** + * Pre-existing relay sender for message delivery. + * Pass the API server's AgentRelay instance so all gateways share a single + * broker process instead of each spawning their own. + */ + relaySender?: RelaySender; +} + +type InboundTransportState = 'WS_ACTIVE' | 'WS_DEGRADED' | 'POLL_ACTIVE' | 'RECOVERING_WS'; +type InboundTransportMode = 'ws' | 'poll'; + +interface PollEventEnvelope { + id: string; + sequence: number; + timestamp: string; + payload: Record; +} + +interface PollResponseBody { + events?: PollEventEnvelope[]; + nextCursor?: string; + hasMore?: boolean; +} + +interface PersistedPollCursorState { + cursor: string; + lastSequence: number; + recentEventIds: string[]; + updatedAt: string; +} + +interface InboundProcessingResult { + committed: boolean; + reason?: 'duplicate' | 'echo'; + result?: DeliveryResult; +} + +interface RealtimeHandlingOptions { + eventId?: string; + timestamp?: string; +} + +const DEFAULT_POLL_ENDPOINT_PATH = '/messages/poll'; +const DEFAULT_POLL_INITIAL_CURSOR = '0'; +const DEFAULT_WS_FAILURE_THRESHOLD = 3; +const DEFAULT_POLL_TIMEOUT_SECONDS = 25; +const MAX_POLL_TIMEOUT_SECONDS = 30; +const DEFAULT_POLL_LIMIT = 100; +const MAX_POLL_LIMIT = 500; +const DEFAULT_WS_PROBE_INTERVAL_MS = 60_000; +const DEFAULT_WS_STABLE_GRACE_MS = 10_000; +const POLL_CURSOR_RECENT_EVENT_LIMIT = 256; +const MAX_POLL_CURSOR_LENGTH = 4_096; +const MAX_EVENT_ID_LENGTH = 512; +const BACKOFF_BASE_MS = 500; +const BACKOFF_CAP_MS = 30_000; + +function pollCursorStatePath(): string { + return join(openclawHome(), 'workspace', 'relaycast', 'inbound-cursor.json'); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function applyJitter(ms: number): number { + const factor = 1.1 + Math.random() * 0.1; + return Math.max(0, Math.floor(ms * factor)); +} + +function hasControlCharacters(value: string): boolean { + for (const char of value) { + const code = char.charCodeAt(0); + if ((code >= 0 && code <= 31) || code === 127) { + return true; + } + } + return false; +} + +function stripControlChars(value: string): string { + let out = ''; + for (const char of value) { + const code = char.charCodeAt(0); + out += code <= 31 || code === 127 ? ' ' : char; + } + return out; +} + +function sanitizeOpaqueStateValue(value: unknown, maxLength: number): string | null { + if (typeof value !== 'string') return null; + if (value.trim().length === 0 || value.length > maxLength) return null; + if (hasControlCharacters(value)) return null; + return value; +} + +function computeBackoffMs(attempt: number): number { + const base = Math.min(BACKOFF_BASE_MS * Math.pow(2, Math.max(0, attempt - 1)), BACKOFF_CAP_MS); + return applyJitter(base); +} + +function sanitizePollTimeoutSeconds(value: number | undefined): number { + if (!Number.isFinite(value)) return DEFAULT_POLL_TIMEOUT_SECONDS; + return Math.min(MAX_POLL_TIMEOUT_SECONDS, Math.max(0, Math.floor(value!))); +} + +function sanitizePollLimit(value: number | undefined): number { + if (!Number.isFinite(value)) return DEFAULT_POLL_LIMIT; + return Math.min(MAX_POLL_LIMIT, Math.max(1, Math.floor(value!))); +} + +function parseRetryAfterMs(retryAfter: string | null): number | null { + if (!retryAfter) return null; + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) { + return Math.max(0, Math.floor(seconds * 1000)); + } + const asDate = Date.parse(retryAfter); + if (Number.isNaN(asDate)) return null; + return Math.max(0, asDate - Date.now()); +} + +function normalizeChannelName(channel: string): string { + return channel.startsWith('#') ? channel.slice(1) : channel; +} + +// --------------------------------------------------------------------------- +// Ed25519 device identity for OpenClaw gateway WebSocket auth +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Auth profile system — deterministic profile selection for WS auth across +// OpenClaw/Clawdbot versions. Profiles define key encoding, signature format, +// and payload canonicalization for the device auth handshake. +// --------------------------------------------------------------------------- + +interface AuthProfile { + /** Human-readable profile name (logged on each auth attempt). */ + name: string; + /** Encoding for the public key sent in the connect message. */ + publicKeyFormat: 'raw-base64url' | 'spki-pem'; + /** Encoding for the Ed25519 signature. */ + signatureEncoding: 'base64url' | 'base64'; +} + +const AUTH_PROFILES: Record = { + default: { + name: 'default', + publicKeyFormat: 'raw-base64url', + signatureEncoding: 'base64url', + }, + 'clawdbot-v1': { + // Server (openclaw/openclaw device-identity.ts) accepts both PEM and raw-base64url + // public keys, and decodes signatures in both base64url and base64. Use base64url + // for consistency — matches the server's own signDevicePayload() output. + name: 'clawdbot-v1', + publicKeyFormat: 'raw-base64url', + signatureEncoding: 'base64url', + }, +}; + +/** + * Resolve the auth profile to use. Selection priority: + * 1. Explicit env var `OPENCLAW_WS_AUTH_COMPAT` (manual override, highest priority) + * 2. Variant detection: `~/.clawdbot/` detected → clawdbot-v1 + * 3. Default profile (standard OpenClaw, unchanged) + */ +function resolveAuthProfile(): AuthProfile { + // 1. Manual override (highest priority) + const envVal = process.env.OPENCLAW_WS_AUTH_COMPAT; + if (envVal === 'clawdbot' || envVal === 'clawdbot-v1') { + return AUTH_PROFILES['clawdbot-v1']; + } + if (envVal && AUTH_PROFILES[envVal]) { + return AUTH_PROFILES[envVal]; + } + + // 2. Variant detection via filesystem probing — delegates to openclawHome() + // which checks valid parseable config files, not just directory existence. + // Strict suffix check avoids false positives from substring matching. + const home = openclawHome(); + const homeSuffix = + home + .replace(/[/\\]+$/, '') + .split(/[/\\]/) + .pop() ?? ''; + if (homeSuffix === '.clawdbot' || homeSuffix === 'clawdbot') { + return AUTH_PROFILES['clawdbot-v1']; + } + + // 3. Default + return AUTH_PROFILES['default']; +} + +/** Backward-compat helper — returns 'clawdbot' when using clawdbot profile. */ +type WsAuthCompat = 'clawdbot' | undefined; +function getWsAuthCompat(): WsAuthCompat { + const profile = resolveAuthProfile(); + return profile.name === 'clawdbot-v1' ? 'clawdbot' : undefined; +} + +interface DeviceIdentity { + publicKeyB64: string; // base64url-encoded raw Ed25519 public key (default mode) + publicKeyPem?: string; // PEM-encoded SPKI public key (clawdbot compat mode) + privateKeyObj: KeyObject; // Node.js KeyObject for signing + deviceId: string; // SHA-256 hex of the raw public key +} + +function generateDeviceIdentity(compat?: WsAuthCompat): DeviceIdentity { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + + // Extract raw 32-byte public key from SPKI DER (12-byte header for Ed25519) + const rawPublicBytes = publicKey.export({ type: 'spki', format: 'der' }).subarray(12); + + const deviceId = createHash('sha256').update(rawPublicBytes).digest('hex'); + const publicKeyB64 = Buffer.from(rawPublicBytes).toString('base64url'); + + const identity: DeviceIdentity = { + publicKeyB64, + privateKeyObj: privateKey, + deviceId, + }; + + if (compat === 'clawdbot') { + identity.publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; + } + + return identity; +} + +/** Path to persisted device identity file. */ +function deviceIdentityPath(): string { + return join(openclawHome(), 'workspace', 'relaycast', 'device.json'); +} + +interface PersistedDevice { + publicKeyB64: string; + privateKeyPkcs8B64: string; // base64-encoded PKCS#8 DER + deviceId: string; + /** PEM-encoded SPKI public key — present when generated with clawdbot compat mode. */ + publicKeyPem?: string; + /** PEM-encoded PKCS#8 private key — present when generated with clawdbot compat mode. */ + privateKeyPem?: string; +} + +/** + * Load a persisted device identity from disk, or generate and persist a new one. + * This ensures the same device ID survives restarts so the OpenClaw gateway + * can pair it once and recognize it on subsequent connections. + */ +async function loadOrCreateDeviceIdentity(): Promise { + const filePath = deviceIdentityPath(); + const compat = getWsAuthCompat(); + + // Attempt to load existing identity (no existsSync — just try the read) + try { + const raw = await readFile(filePath, 'utf-8'); + const persisted = JSON.parse(raw) as PersistedDevice; + const privateKeyObj = createPrivateKey({ + key: Buffer.from(persisted.privateKeyPkcs8B64, 'base64'), + format: 'der', + type: 'pkcs8', + }); + // Ensure permissions are tight even if file was created with looser perms + await chmod(filePath, 0o600).catch(() => {}); + console.log( + `[openclaw-ws] Loaded persisted device identity (deviceId=${persisted.deviceId.slice(0, 12)}...)` + ); + + const identity: DeviceIdentity = { + publicKeyB64: persisted.publicKeyB64, + privateKeyObj, + deviceId: persisted.deviceId, + }; + + // If compat mode is clawdbot but the persisted device has no PEM keys, + // derive them on-the-fly from the existing DER key material. + if (compat === 'clawdbot') { + if (persisted.publicKeyPem) { + identity.publicKeyPem = persisted.publicKeyPem; + } else { + // Reconstruct SPKI public key from the stored base64url raw bytes + const rawPublicBytes = Buffer.from(persisted.publicKeyB64, 'base64url'); + // Ed25519 SPKI DER = 12-byte header + 32-byte raw key + const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex'); + const spkiDer = Buffer.concat([spkiHeader, rawPublicBytes]); + const publicKeyObj = createPublicKey({ key: spkiDer, format: 'der', type: 'spki' }); + identity.publicKeyPem = publicKeyObj.export({ type: 'spki', format: 'pem' }) as string; + console.log('[openclaw-ws] Derived PEM public key from existing DER key for clawdbot compat mode'); + } + } + + return identity; + } catch (err) { + // ENOENT is expected on first run; other errors mean corruption + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn( + `[openclaw-ws] Failed to load device identity, generating new: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + // Generate fresh and persist via atomic write-then-rename + const identity = generateDeviceIdentity(compat); + const pkcs8Der = identity.privateKeyObj.export({ type: 'pkcs8', format: 'der' }); + const persisted: PersistedDevice = { + publicKeyB64: identity.publicKeyB64, + privateKeyPkcs8B64: Buffer.from(pkcs8Der).toString('base64'), + deviceId: identity.deviceId, + }; + + if (compat === 'clawdbot' && identity.publicKeyPem) { + persisted.publicKeyPem = identity.publicKeyPem; + persisted.privateKeyPem = identity.privateKeyObj.export({ type: 'pkcs8', format: 'pem' }) as string; + } + + try { + const dir = join(openclawHome(), 'workspace', 'relaycast'); + await mkdir(dir, { recursive: true }); + const tmpPath = filePath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(persisted, null, 2) + '\n', { mode: 0o600 }); + await rename(tmpPath, filePath); + console.log( + `[openclaw-ws] Persisted new device identity (deviceId=${identity.deviceId.slice(0, 12)}...)` + ); + } catch (err) { + console.warn( + `[openclaw-ws] Could not persist device identity: ${err instanceof Error ? err.message : String(err)}` + ); + } + + return identity; +} + +/** Hash helper for diagnostics (no secrets leaked — just truncated SHA-256). */ +function shortHash(data: string | Buffer): string { + const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data; + return createHash('sha256').update(buf).digest('hex').slice(0, 16); +} + +/** + * Canonicalization variants to try for debugging. Each produces a different + * pipe-delimited payload string. The server should match exactly one. + */ +function buildCanonicalVariants( + device: DeviceIdentity, + params: { + clientId: string; + clientMode: string; + platform: string; + deviceFamily: string; + role: string; + scopes: string[]; + signedAt: number; + token: string; + nonce: string; + } +): Array<{ name: string; payload: string }> { + const signedAtMs = String(params.signedAt); + const signedAtSec = String(Math.floor(params.signedAt / 1000)); + const scopesCsv = params.scopes.join(','); + + return [ + // V0: current default order (v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily) + { + name: 'v3-default-ms', + payload: [ + 'v3', + device.deviceId, + params.clientId, + params.clientMode, + params.role, + scopesCsv, + signedAtMs, + params.token || '', + params.nonce, + params.platform, + params.deviceFamily, + ].join('|'), + }, + // V1: signedAt in seconds instead of milliseconds + { + name: 'v3-default-sec', + payload: [ + 'v3', + device.deviceId, + params.clientId, + params.clientMode, + params.role, + scopesCsv, + signedAtSec, + params.token || '', + params.nonce, + params.platform, + params.deviceFamily, + ].join('|'), + }, + // V2: no token in payload (token omitted entirely) + { + name: 'v3-no-token-ms', + payload: [ + 'v3', + device.deviceId, + params.clientId, + params.clientMode, + params.role, + scopesCsv, + signedAtMs, + params.nonce, + params.platform, + params.deviceFamily, + ].join('|'), + }, + // V3: nonce before token (swapped positions) + { + name: 'v3-nonce-first-ms', + payload: [ + 'v3', + device.deviceId, + params.clientId, + params.clientMode, + params.role, + scopesCsv, + signedAtMs, + params.nonce, + params.token || '', + params.platform, + params.deviceFamily, + ].join('|'), + }, + // V4: fewer fields — just core identity + nonce + signedAt (minimal) + { + name: 'v3-minimal', + payload: ['v3', device.deviceId, signedAtMs, params.nonce].join('|'), + }, + // V5: signedAt seconds + no token + { + name: 'v3-no-token-sec', + payload: [ + 'v3', + device.deviceId, + params.clientId, + params.clientMode, + params.role, + scopesCsv, + signedAtSec, + params.nonce, + params.platform, + params.deviceFamily, + ].join('|'), + }, + // V6: v2 format (no platform/deviceFamily) — used by older gateway versions + { + name: 'v2-default-ms', + payload: [ + 'v2', + device.deviceId, + params.clientId, + params.clientMode, + params.role, + scopesCsv, + signedAtMs, + params.token || '', + params.nonce, + ].join('|'), + }, + // V7: v2 with signedAt in seconds + { + name: 'v2-default-sec', + payload: [ + 'v2', + device.deviceId, + params.clientId, + params.clientMode, + params.role, + scopesCsv, + signedAtSec, + params.token || '', + params.nonce, + ].join('|'), + }, + // V8: v2 without token + { + name: 'v2-no-token-ms', + payload: [ + 'v2', + device.deviceId, + params.clientId, + params.clientMode, + params.role, + scopesCsv, + signedAtMs, + params.nonce, + ].join('|'), + }, + ]; +} + +/** Payload version override for v3↔v2 fallback. */ +type PayloadVersionOverride = 'v2' | 'v3' | null; + +function signConnectPayload( + device: DeviceIdentity, + params: { + clientId: string; + clientMode: string; + platform: string; + deviceFamily: string; + role: string; + scopes: string[]; + signedAt: number; + token: string; + nonce: string; + }, + versionOverride?: PayloadVersionOverride +): string { + const profile = resolveAuthProfile(); + + // Build canonicalization variants for diagnostics + const variants = buildCanonicalVariants(device, params); + + // Select primary payload version: + // 1. If versionOverride is set (from fallback), use that directly + // 2. clawdbot-v1 defaults to v2 (older gateway compat) + // 3. default profile uses v3 + let primaryName: string; + if (versionOverride === 'v2') { + primaryName = 'v2-default-ms'; + } else if (versionOverride === 'v3') { + primaryName = 'v3-default-ms'; + } else { + primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms'; + } + const primary = variants.find((v) => v.name === primaryName) ?? variants[0]; + + const payloadBytes = Buffer.from(primary.payload, 'utf-8'); + + const isDebug = process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1'; + + // Concise production log — one line with essential info + console.log( + `[ws-auth] profile=${profile.name} payload=${primary.name} device=${device.deviceId.slice(0, 12)}...${versionOverride ? ` override=${versionOverride}` : ''}` + ); + + // Verbose debug logging — field hashes and canonicalization matrix + if (isDebug) { + console.log( + `[ws-auth-debug] signedAt=${params.signedAt}ms nonce=${shortHash(params.nonce)} keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}` + ); + console.log( + `[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}` + ); + console.log('[ws-auth-debug] canonicalization matrix:'); + for (const v of variants) { + console.log(` ${v.name}: hash=${shortHash(v.payload)}`); + } + console.log(`[ws-auth-debug] payloadHash=${shortHash(primary.payload)}`); + } + + // Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519 + const signature = sign(null, payloadBytes, device.privateKeyObj); + const encoded = Buffer.from(signature).toString(profile.signatureEncoding); + + // Self-verification (debug only): confirm our signature is valid locally. + if (isDebug) { + try { + // Derive public key from private key (same as server would use from our publicKey field) + const pubKey = createPublicKey(device.privateKeyObj); + const selfVerifyRaw = verify(null, payloadBytes, pubKey, signature); + + // Also verify the round-trip: decode our encoded signature like the server would + const decodedSig = Buffer.from( + encoded, + profile.signatureEncoding === 'base64url' ? 'base64url' : 'base64' + ); + const selfVerifyEncoded = verify(null, payloadBytes, pubKey, decodedSig); + + // Verify deviceId matches public key + const rawPubBytes = pubKey.export({ type: 'spki', format: 'der' }).subarray(12); + const derivedDeviceId = createHash('sha256').update(rawPubBytes).digest('hex'); + const deviceIdMatch = derivedDeviceId === device.deviceId; + + console.log( + `[ws-auth-debug] self-verify: raw=${selfVerifyRaw} encoded=${selfVerifyEncoded} deviceIdMatch=${deviceIdMatch} derivedId=${derivedDeviceId.slice(0, 16)}...` + ); + if (!deviceIdMatch) { + console.error( + `[ws-auth-debug] DEVICE ID MISMATCH: derived=${derivedDeviceId} sent=${device.deviceId}` + ); + } + } catch (err) { + console.error(`[ws-auth-debug] self-verify error: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return encoded; +} + +// --------------------------------------------------------------------------- +// Persistent OpenClaw Gateway WebSocket client +// --------------------------------------------------------------------------- + +interface PendingRpc { + resolve: (value: boolean) => void; + timer: ReturnType; +} + +/** @internal */ +export class OpenClawGatewayClient { + private ws: WebSocket | null = null; + private authenticated = false; + private device: DeviceIdentity; + private token: string; + private port: number; + private pendingRpcs = new Map(); + private rpcIdCounter = 0; + private reconnectTimer: ReturnType | null = null; + private stopped = false; + private connectPromise: Promise | null = null; + private connectResolve: (() => void) | null = null; + private connectReject: ((error: Error) => void) | null = null; + private connectTimeout: ReturnType | null = null; + private pairingRejected = false; + private consecutiveFailures = 0; + /** Payload version override for v3↔v2 fallback (null = use profile default). */ + private payloadVersionOverride: PayloadVersionOverride = null; + /** Whether a fallback attempt has already been tried this connection cycle. */ + private fallbackAttempted = false; + /** Auth rejection counters for observability. */ + private authRejectCount = 0; + private authFallbackCount = 0; + + /** Default timeout for initial connection (30 seconds). */ + private static readonly CONNECT_TIMEOUT_MS = 30_000; + private static readonly MAX_CONSECUTIVE_FAILURES = 5; + private static readonly BASE_RECONNECT_MS = 3_000; + private static readonly MAX_RECONNECT_MS = 30_000; + /** Slow retry interval after pairing rejection or max failures (60s). */ + private static readonly PAIRING_RETRY_MS = 60_000; + + constructor(token: string, port: number, device?: DeviceIdentity) { + this.token = token; + this.port = port; + this.device = device ?? generateDeviceIdentity(getWsAuthCompat()); + } + + /** + * Create a client with a persisted device identity (loaded from disk or + * freshly generated and saved). This ensures the same device ID is reused + * across restarts so the OpenClaw gateway can pair it once. + */ + static async create(token: string, port: number): Promise { + const device = await loadOrCreateDeviceIdentity(); + return new OpenClawGatewayClient(token, port, device); + } + + /** Connect and authenticate. Resolves when chat.send is ready, rejects on timeout or error. */ + async connect(): Promise { + if (this.authenticated && this.ws?.readyState === WebSocket.OPEN) return; + + // Explicit connect() clears pairing rejection so users can retry after fixing their token + this.pairingRejected = false; + this.stopped = false; + // Reset fallback state for fresh connection attempts + this.payloadVersionOverride = null; + this.fallbackAttempted = false; + + // Cancel any pending reconnect timer to prevent orphaned WebSocket connections + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + this.connectPromise = new Promise((resolve, reject) => { + this.connectResolve = resolve; + this.connectReject = reject; + + // Set up timeout to prevent indefinite hanging + this.connectTimeout = setTimeout(() => { + this.connectTimeout = null; + if (!this.authenticated) { + const err = new Error( + `Connection to OpenClaw gateway timed out after ${OpenClawGatewayClient.CONNECT_TIMEOUT_MS}ms` + ); + this.connectReject?.(err); + this.connectReject = null; + this.connectResolve = null; + } + }, OpenClawGatewayClient.CONNECT_TIMEOUT_MS); + }); + + this.doConnect(); + return this.connectPromise; + } + + private clearConnectTimeout(): void { + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = null; + } + } + + private doConnect(): void { + if (this.stopped) return; + + let ws: WebSocket; + try { + ws = new WebSocket(`ws://127.0.0.1:${this.port}`); + } catch (err) { + console.warn(`[openclaw-ws] Connection failed: ${err instanceof Error ? err.message : String(err)}`); + this.scheduleReconnect(); + return; + } + this.ws = ws; + + ws.on('open', () => { + console.log('[openclaw-ws] Connected to OpenClaw gateway'); + }); + + ws.on('message', (data) => { + // Guard: ignore messages from superseded WebSocket instances. + if (this.ws !== ws) return; + this.handleMessage(data.toString()); + }); + + ws.on('close', (code, reason) => { + // Guard: ignore close events from superseded WebSocket instances. + // During v3↔v2 fallback, the old WS is replaced before its close fires. + if (this.ws !== ws) return; + + // Sanitize reason to prevent log injection (newlines, control chars) + const reasonStr = stripControlChars(reason.toString()).slice(0, 200); + console.warn(`[openclaw-ws] Disconnected: ${code} ${reasonStr}`); + const wasAuthenticated = this.authenticated; + this.authenticated = false; + + // Detect pairing rejection: code 1008 (Policy Violation) with pairing reason + if (code === 1008 && /pairing|not.paired/i.test(reasonStr)) { + console.error('[openclaw-ws] Connection closed due to pairing policy. Device is not paired.'); + console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`); + console.error( + '[openclaw-ws] Run: openclaw devices approve (check gateway logs for requestId)' + ); + this.pairingRejected = true; + } + + // Reject all pending RPCs + for (const [id, pending] of this.pendingRpcs) { + clearTimeout(pending.timer); + pending.resolve(false); + this.pendingRpcs.delete(id); + } + // If we weren't authenticated yet, reject the connect promise + if (!wasAuthenticated && this.connectReject) { + this.clearConnectTimeout(); + const err = new Error(`WebSocket closed before authentication (code=${code})`); + this.connectReject(err); + this.connectReject = null; + this.connectResolve = null; + } + if (!this.stopped) { + this.scheduleReconnect(); + } + }); + + ws.on('error', (err) => { + // Guard: ignore error events from superseded WebSocket instances. + if (this.ws !== ws) return; + + console.warn(`[openclaw-ws] Error: ${err.message}`); + // If we weren't authenticated yet, reject the connect promise + if (!this.authenticated && this.connectReject) { + this.clearConnectTimeout(); + this.connectReject(err); + this.connectReject = null; + this.connectResolve = null; + } + }); + } + + // eslint-disable-next-line complexity + private handleMessage(raw: string): void { + let msg: Record; + try { + msg = JSON.parse(raw); + } catch { + return; + } + + // Handle connect.challenge — sign and respond + if (msg.type === 'event' && msg.event === 'connect.challenge') { + const payload = msg.payload as { nonce: string; ts: number }; + console.log('[openclaw-ws] Received connect.challenge, signing...'); + // Log raw challenge payload for debugging canonicalization issues + if (process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1') { + console.log(`[ws-auth-debug] challenge payload: ${JSON.stringify(payload)}`); + } + + const signedAt = Date.now(); + const clientId = 'cli'; + const clientMode = 'cli'; + const platform = process.platform === 'darwin' ? 'macos' : 'linux'; + const deviceFamily = 'cli'; + const role = 'operator'; + const scopes = ['operator.read', 'operator.write']; + + const signature = signConnectPayload( + this.device, + { + clientId, + clientMode, + platform, + deviceFamily, + role, + scopes, + signedAt, + token: this.token, + nonce: payload.nonce, + }, + this.payloadVersionOverride + ); + + // Select public key format based on resolved auth profile. + const profile = resolveAuthProfile(); + const publicKeyField = + profile.publicKeyFormat === 'spki-pem' && this.device.publicKeyPem + ? this.device.publicKeyPem + : this.device.publicKeyB64; + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.warn('[openclaw-ws] WebSocket not open when trying to send connect'); + return; + } + this.ws.send( + JSON.stringify({ + type: 'req', + id: 'connect-1', + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: clientId, + version: '1.0.0', + platform, + mode: clientMode, + deviceFamily, + }, + role, + scopes, + caps: [], + commands: [], + permissions: {}, + auth: { token: this.token }, + locale: 'en-US', + userAgent: 'relaycast-gateway/1.0.0', + device: { + id: this.device.deviceId, + publicKey: publicKeyField, + signature, + signedAt, + nonce: payload.nonce, + }, + }, + }) + ); + return; + } + + // Handle connect response + if (msg.type === 'res' && msg.id === 'connect-1') { + if (msg.ok) { + this.clearConnectTimeout(); + const versionUsed = + this.payloadVersionOverride ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3'); + console.log( + `[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})` + ); + this.authenticated = true; + this.consecutiveFailures = 0; + this.connectResolve?.(); + this.connectResolve = null; + this.connectReject = null; + } else { + const errStr = msg.error ? JSON.stringify(msg.error) : 'Authentication rejected'; + const isPairing = /pairing.required|not.paired/i.test(errStr); + const isSignatureInvalid = /signature.invalid|device.signature|invalid.signature/i.test(errStr); + + if (isPairing) { + this.clearConnectTimeout(); + const errObj = msg.error as Record | undefined; + const requestId = errObj?.requestId ?? errObj?.request_id ?? ''; + console.error('[openclaw-ws] Pairing rejected — device is not paired with the OpenClaw gateway.'); + if (requestId) { + console.error(`[openclaw-ws] Approve this device: openclaw devices approve ${requestId}`); + } + console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`); + const configHint = + getWsAuthCompat() === 'clawdbot' ? '~/.clawdbot/clawdbot.json' : '~/.openclaw/openclaw.json'; + console.error( + `[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token` + ); + this.pairingRejected = true; + } else if (isSignatureInvalid && !this.fallbackAttempted) { + // Signature rejected — try the alternate payload version once. + // Do NOT clear connect timeout — it protects the fallback attempt too. + this.authRejectCount++; + this.authFallbackCount++; + const profile = resolveAuthProfile(); + const currentVersion = + this.payloadVersionOverride ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3'); + const fallbackVersion: PayloadVersionOverride = currentVersion === 'v2' ? 'v3' : 'v2'; + + console.warn( + `[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})` + ); + this.payloadVersionOverride = fallbackVersion; + this.fallbackAttempted = true; + + // Close current WS and reconnect with the alternate payload. + // Setting this.ws = null ensures the old WS's close/error handlers + // no-op via the `this.ws !== ws` guard in doConnect(). + try { + this.ws?.close(); + } catch { + // Best effort + } + this.ws = null; + setTimeout(() => this.doConnect(), 0); + return; // Don't reject the connect promise yet — fallback attempt in progress + } else { + this.clearConnectTimeout(); + this.authRejectCount++; + console.warn(`[openclaw-ws] Auth rejected (rejects=${this.authRejectCount}): ${errStr}`); + } + + this.connectReject?.(new Error(`OpenClaw gateway auth failed: ${errStr}`)); + this.connectReject = null; + this.connectResolve = null; + } + return; + } + + // Handle RPC responses + const id = msg.id as string | undefined; + if (id && this.pendingRpcs.has(id)) { + const pending = this.pendingRpcs.get(id)!; + clearTimeout(pending.timer); + this.pendingRpcs.delete(id); + + if (msg.ok === false || msg.error) { + console.warn('[openclaw-ws] RPC error response received'); + pending.resolve(false); + } else { + console.log('[openclaw-ws] RPC succeeded'); + pending.resolve(true); + } + return; + } + + // Log other events at debug level + if (msg.type === 'event') { + // chat events, tick events, etc. — ignore silently + } + } + + /** Send a chat.send RPC. Returns true if accepted. */ + async sendChatMessage(text: string, idempotencyKey?: string): Promise { + if (this.stopped) return false; + if (!this.authenticated || !this.ws || this.ws.readyState !== WebSocket.OPEN) { + // Try to reconnect + try { + await this.connect(); + } catch { + return false; + } + if (!this.authenticated) return false; + } + + const id = `chat-${++this.rpcIdCounter}-${Date.now()}`; + + return new Promise((resolve) => { + const timer = setTimeout(() => { + console.warn(`[openclaw-ws] chat.send ${id} timed out`); + this.pendingRpcs.delete(id); + resolve(false); + }, 15_000); + + this.pendingRpcs.set(id, { resolve, timer }); + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + clearTimeout(timer); + this.pendingRpcs.delete(id); + resolve(false); + return; + } + this.ws.send( + JSON.stringify({ + type: 'req', + id, + method: 'chat.send', + params: { + sessionKey: 'agent:main:main', + message: text, + ...(idempotencyKey ? { idempotencyKey } : {}), + }, + }) + ); + }); + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + + // After pairing rejection or max failures, switch to slow periodic retry + // so the gateway can self-heal once pairing is approved externally. + if (this.pairingRejected || this.consecutiveFailures >= OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) { + if (this.consecutiveFailures === OpenClawGatewayClient.MAX_CONSECUTIVE_FAILURES) { + console.warn( + `[openclaw-ws] ${this.consecutiveFailures} consecutive failures — switching to slow retry (every 60s).` + ); + console.warn( + '[openclaw-ws] Check that the OpenClaw gateway is running and OPENCLAW_GATEWAY_TOKEN is correct.' + ); + } + this.consecutiveFailures++; + console.log(`[openclaw-ws] Slow retry in ${OpenClawGatewayClient.PAIRING_RETRY_MS / 1000}s...`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.pairingRejected = false; // Clear flag so connect attempt proceeds + // Reset fallback state so reconnect tries primary payload version first + this.payloadVersionOverride = null; + this.fallbackAttempted = false; + this.doConnect(); + }, OpenClawGatewayClient.PAIRING_RETRY_MS); + return; + } + + this.consecutiveFailures++; + + const delay = Math.min( + OpenClawGatewayClient.BASE_RECONNECT_MS * Math.pow(2, this.consecutiveFailures - 1), + OpenClawGatewayClient.MAX_RECONNECT_MS + ); + console.log(`[openclaw-ws] Reconnecting in ${delay / 1000}s (attempt ${this.consecutiveFailures})...`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + // Reset fallback state so reconnect tries primary payload version first + this.payloadVersionOverride = null; + this.fallbackAttempted = false; + this.doConnect(); + }, delay); + } + + async disconnect(): Promise { + this.stopped = true; + this.clearConnectTimeout(); + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + for (const [id, pending] of this.pendingRpcs) { + clearTimeout(pending.timer); + pending.resolve(false); + this.pendingRpcs.delete(id); + } + if (this.ws) { + try { + this.ws.close(); + } catch { + // Best effort + } + this.ws = null; + } + this.authenticated = false; + // Clear any pending connect promise + this.connectReject = null; + this.connectResolve = null; + } +} + +// --------------------------------------------------------------------------- +// InboundGateway +// --------------------------------------------------------------------------- + +export class InboundGateway { + private readonly relaySender: RelaySender | null; + private relayAgentClient: AgentClient | null = null; + private relayAgentToken: string | null = null; + private readonly relaycast: RelayCast; + private readonly config: GatewayConfig; + private readonly dedupeTtlMs: number; + + private running = false; + private unsubscribeHandlers: Array<() => void> = []; + private seenMessageIds = new Map(); + private processingMessageIds = new Set(); + + /** Persistent WebSocket client for the local OpenClaw gateway. */ + private openclawClient: OpenClawGatewayClient | null = null; + + /** Spawn manager — lives in the gateway so spawned processes survive MCP server restarts. */ + private spawnManager: SpawnManager; + /** HTTP control server for spawn/list/release commands. */ + private controlServer: HttpServer | null = null; + /** Port the control server listens on. */ + controlPort = 0; + + private transportState: InboundTransportState = 'WS_DEGRADED'; + private activeTransportMode: InboundTransportMode = 'ws'; + private wsFailureCount = 0; + private pollLoopPromise: Promise | null = null; + private pollAbortController: AbortController | null = null; + private pollLoopStopRequested = false; + private pollCursorLoaded = false; + private pollCursor = DEFAULT_POLL_INITIAL_CURSOR; + private pollLastSequence = 0; + private pollRecentEventIds: string[] = []; + private pollFailureCount = 0; + private probeWsTimer: ReturnType | null = null; + private wsRecoveryTimer: ReturnType | null = null; + private fallbackCount = 0; + private lastFallbackReason: string | null = null; + private fallbackStartedAt: number | null = null; + private totalFallbackMs = 0; + private duplicateDropCount = 0; + private cursorResetCount = 0; + + /** Default control port for the gateway's spawn API. */ + static readonly DEFAULT_CONTROL_PORT = 18790; + + constructor(options: GatewayOptions) { + this.config = { + ...options.config, + channels: options.config.channels.map(normalizeChannelName), + }; + this.relaySender = options.relaySender ?? null; + this.relaycast = new RelayCast({ + apiKey: this.config.apiKey, + baseUrl: this.config.baseUrl, + }); + + const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000); + this.dedupeTtlMs = + Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000 ? Math.floor(dedupeTtlMs) : 15 * 60 * 1000; + + const parentDepth = Number(process.env.OPENCLAW_SPAWN_DEPTH || 0); + this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 }); + } + + private isPollFallbackEnabled(): boolean { + return this.config.transport?.pollFallback?.enabled ?? false; + } + + private wsFailureThreshold(): number { + const configured = this.config.transport?.pollFallback?.wsFailureThreshold; + if (!Number.isFinite(configured) || configured === undefined) { + return DEFAULT_WS_FAILURE_THRESHOLD; + } + return Math.max(1, Math.floor(configured)); + } + + private pollTimeoutSeconds(): number { + return sanitizePollTimeoutSeconds(this.config.transport?.pollFallback?.timeoutSeconds); + } + + private pollLimit(): number { + return sanitizePollLimit(this.config.transport?.pollFallback?.limit); + } + + private pollInitialCursor(): string { + return this.config.transport?.pollFallback?.initialCursor?.trim() || DEFAULT_POLL_INITIAL_CURSOR; + } + + private isWsProbeEnabled(): boolean { + return this.config.transport?.pollFallback?.probeWs?.enabled ?? true; + } + + private wsProbeIntervalMs(): number { + const configured = this.config.transport?.pollFallback?.probeWs?.intervalMs; + if (!Number.isFinite(configured) || configured === undefined) { + return DEFAULT_WS_PROBE_INTERVAL_MS; + } + return Math.max(1_000, Math.floor(configured)); + } + + private wsStableGraceMs(): number { + const configured = this.config.transport?.pollFallback?.probeWs?.stableGraceMs; + if (!Number.isFinite(configured) || configured === undefined) { + return DEFAULT_WS_STABLE_GRACE_MS; + } + return Math.max(1_000, Math.floor(configured)); + } + + private transportHealthSnapshot(): Record { + const activeFallbackMs = this.fallbackStartedAt === null ? 0 : Date.now() - this.fallbackStartedAt; + return { + mode: this.activeTransportMode, + state: this.transportState, + wsFailureCount: this.wsFailureCount, + fallbackCount: this.fallbackCount, + lastFallbackReason: this.lastFallbackReason, + timeInFallbackMs: this.totalFallbackMs + activeFallbackMs, + duplicateDrops: this.duplicateDropCount, + cursorResets: this.cursorResetCount, + lastSequence: this.pollLastSequence, + }; + } + + private completeFallbackWindow(): void { + if (this.fallbackStartedAt !== null) { + this.totalFallbackMs += Date.now() - this.fallbackStartedAt; + this.fallbackStartedAt = null; + } + } + + private cleanupRelaySubscriptions(): void { + for (const unsubscribe of this.unsubscribeHandlers) { + try { + unsubscribe(); + } catch { + // Best effort + } + } + this.unsubscribeHandlers = []; + } + + private subscribeRelayChannels(): void { + if (!this.relayAgentClient) return; + try { + this.relayAgentClient.subscribe(this.config.channels); + } catch { + // Will subscribe on the next connected event. + } + } + + private async connectRelayAgentClient(): Promise { + if (!this.relayAgentClient) return; + try { + await Promise.resolve(this.relayAgentClient.connect()); + } catch (error) { + console.warn( + `[gateway] Relaycast WS connect failed: ${error instanceof Error ? error.message : String(error)}` + ); + await this.handleWsFailure('connect_failed'); + } + } + + private bindRelayAgentHandlers(): void { + if (!this.relayAgentClient) return; + + this.cleanupRelaySubscriptions(); + + this.unsubscribeHandlers.push( + this.relayAgentClient.on.connected(() => { + console.log( + `[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}` + ); + this.wsFailureCount = 0; + this.subscribeRelayChannels(); + if (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') { + this.beginWsRecovery(); + return; + } + this.completeFallbackWindow(); + this.transportState = 'WS_ACTIVE'; + this.activeTransportMode = 'ws'; + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.messageCreated((event: MessageCreatedEvent) => { + if (!this.shouldProcessWsInbound()) return; + console.log(`[gateway] Realtime message from @${event.message?.agentName} in #${event.channel}`); + void this.handleRealtimeMessage(event); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.threadReply((event: ThreadReplyEvent) => { + if (!this.shouldProcessWsInbound()) return; + console.log( + `[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})` + ); + void this.handleRealtimeThreadReply(event); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.dmReceived((event: DmReceivedEvent) => { + if (!this.shouldProcessWsInbound()) return; + console.log(`[gateway] DM from @${event.message?.agentName} (conv: ${event.conversationId})`); + void this.handleRealtimeDm(event); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.groupDmReceived((event: GroupDmReceivedEvent) => { + if (!this.shouldProcessWsInbound()) return; + console.log(`[gateway] Group DM from @${event.message?.agentName} (conv: ${event.conversationId})`); + void this.handleRealtimeGroupDm(event); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.commandInvoked((event: CommandInvokedEvent) => { + if (!this.shouldProcessWsInbound()) return; + console.log( + `[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}` + ); + void this.handleRealtimeCommand(event); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.reactionAdded((event: ReactionAddedEvent) => { + if (!this.shouldProcessWsInbound()) return; + console.log(`[gateway] Reaction :${event.emoji}: added by @${event.agentName} on ${event.messageId}`); + void this.handleRealtimeReaction(event, 'added'); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.reactionRemoved((event: ReactionRemovedEvent) => { + if (!this.shouldProcessWsInbound()) return; + console.log( + `[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}` + ); + void this.handleRealtimeReaction(event, 'removed'); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.reconnecting((attempt: number) => { + console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`); + void this.handleWsFailure(`reconnecting:${attempt}`); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.disconnected(() => { + console.warn('[gateway] Relaycast disconnected'); + void this.handleWsFailure('disconnected'); + }) + ); + this.unsubscribeHandlers.push( + this.relayAgentClient.on.error((error?: unknown) => { + const message = error instanceof Error ? error.message : 'socket error'; + console.warn(`[gateway] Relaycast socket error${message ? `: ${message}` : ''}`); + void this.handleWsFailure(message || 'socket_error'); + }) + ); + } + + private async replaceRelayAgentClient(agentToken: string): Promise { + this.cleanupRelaySubscriptions(); + if (this.relayAgentClient) { + try { + await this.relayAgentClient.disconnect(); + } catch { + // Best effort + } + } + this.relayAgentToken = agentToken; + this.relayAgentClient = this.relaycast.as(agentToken); + // SDK's onEvent() throws if the WS object doesn't exist yet. + // connect() synchronously creates the WS and initiates the connection, + // so we call it before binding handlers. This ensures: + // 1. The WS object exists when onEvent() is called (no throw) + // 2. Handlers are bound before the connection fully opens (no missed events) + // 3. The SDK's double-connect guard (if ws exists, no-op) makes this safe + // connect() is synchronous: it creates the WS object and calls ws.connect(). + // It does NOT return a Promise — async errors (network failures, disconnects) + // arrive via the 'error' and 'disconnected' event handlers bound below. + // We must bind handlers after connect() (so the WS object exists for onEvent()) + // but before the connection fully opens (so no events are missed). + try { + this.relayAgentClient.connect(); + } catch (err) { + console.warn( + `[gateway] Relaycast WS connect failed: ${err instanceof Error ? err.message : String(err)}` + ); + await this.handleWsFailure('connect_failed'); + } + this.bindRelayAgentHandlers(); + } + + private async refreshRelayAgentRegistration(): Promise { + const registered = await this.relaycast.agents.registerOrRotate({ + name: this.config.clawName, + type: 'agent', + persona: 'Relaycast inbound gateway for OpenClaw', + }); + await this.replaceRelayAgentClient(registered.token); + await this.ensureChannelMembership(); + this.subscribeRelayChannels(); + } + + private shouldProcessWsInbound(): boolean { + return ( + !this.isPollFallbackEnabled() || + this.transportState === 'WS_ACTIVE' || + this.transportState === 'RECOVERING_WS' + ); + } + + private async handleWsFailure(reason: string): Promise { + if (!this.running) return; + + if (this.wsRecoveryTimer) { + clearTimeout(this.wsRecoveryTimer); + this.wsRecoveryTimer = null; + } + + if (this.transportState === 'RECOVERING_WS') { + console.warn(`[gateway] WS recovery probe failed, remaining on long-poll (${reason})`); + this.transportState = 'POLL_ACTIVE'; + this.activeTransportMode = 'poll'; + await this.startPollLoop(); + this.startWsProbeLoop(); + return; + } + + if (this.transportState === 'POLL_ACTIVE') { + this.lastFallbackReason = reason; + return; + } + + this.transportState = 'WS_DEGRADED'; + this.wsFailureCount += 1; + + if (this.isPollFallbackEnabled() && this.wsFailureCount >= this.wsFailureThreshold()) { + await this.activatePollFallback(reason); + } + } + + private async activatePollFallback(reason: string): Promise { + if (!this.running || !this.isPollFallbackEnabled()) return; + if (this.transportState === 'POLL_ACTIVE') return; + + await this.ensurePollCursorLoaded(); + this.transportState = 'POLL_ACTIVE'; + this.activeTransportMode = 'poll'; + this.fallbackCount += 1; + this.lastFallbackReason = reason; + if (this.fallbackStartedAt === null) { + this.fallbackStartedAt = Date.now(); + } + + console.warn(`[gateway] Realtime degraded: using long-poll fallback (${reason})`); + await this.startPollLoop(); + this.startWsProbeLoop(); + } + + private startWsProbeLoop(): void { + if (!this.isWsProbeEnabled() || this.probeWsTimer) return; + + this.probeWsTimer = setInterval(() => { + if (!this.running || this.transportState !== 'POLL_ACTIVE') return; + void this.connectRelayAgentClient(); + }, this.wsProbeIntervalMs()); + } + + private stopWsProbeLoop(): void { + if (!this.probeWsTimer) return; + clearInterval(this.probeWsTimer); + this.probeWsTimer = null; + } + + private beginWsRecovery(): void { + if (!this.running) return; + + this.transportState = 'RECOVERING_WS'; + this.stopWsProbeLoop(); + + if (this.wsRecoveryTimer) { + clearTimeout(this.wsRecoveryTimer); + } + + console.log(`[gateway] WS probe connected, waiting ${this.wsStableGraceMs()}ms before promotion`); + this.wsRecoveryTimer = setTimeout(() => { + this.wsRecoveryTimer = null; + void this.promoteWsTransport(); + }, this.wsStableGraceMs()); + } + + private async promoteWsTransport(): Promise { + if (!this.running || this.transportState !== 'RECOVERING_WS') return; + + await this.stopPollLoop(); + const catchupDelayMs = await this.pollOnce(0); + if (catchupDelayMs > 0) { + console.warn('[gateway] WS promotion catch-up poll failed, remaining on long-poll'); + this.transportState = 'POLL_ACTIVE'; + this.activeTransportMode = 'poll'; + await this.startPollLoop(); + this.startWsProbeLoop(); + return; + } + + this.completeFallbackWindow(); + this.transportState = 'WS_ACTIVE'; + this.activeTransportMode = 'ws'; + this.wsFailureCount = 0; + console.log('[gateway] Relaycast WebSocket recovered; promoting WS to active transport'); + } + + private async ensurePollCursorLoaded(): Promise { + if (this.pollCursorLoaded) return; + + this.pollCursorLoaded = true; + this.pollCursor = this.pollInitialCursor(); + + try { + const raw = await readFile(pollCursorStatePath(), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + const persistedCursor = sanitizeOpaqueStateValue(parsed.cursor, MAX_POLL_CURSOR_LENGTH); + if (persistedCursor) { + this.pollCursor = persistedCursor; + } + if (Number.isFinite(parsed.lastSequence)) { + this.pollLastSequence = Math.max(0, Math.floor(parsed.lastSequence ?? 0)); + } + if (Array.isArray(parsed.recentEventIds)) { + this.pollRecentEventIds = parsed.recentEventIds + .map((value) => sanitizeOpaqueStateValue(value, MAX_EVENT_ID_LENGTH)) + .filter((value): value is string => value !== null) + .slice(-POLL_CURSOR_RECENT_EVENT_LIMIT); + const now = Date.now(); + for (const eventId of this.pollRecentEventIds) { + this.seenMessageIds.set(eventId, now); + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn( + `[gateway] Failed to load poll cursor state: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + private async persistPollCursorState(): Promise { + const cursor = + sanitizeOpaqueStateValue(this.pollCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollInitialCursor(); + const recentEventIds = this.pollRecentEventIds + .map((eventId) => sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH)) + .filter((eventId): eventId is string => eventId !== null) + .slice(-POLL_CURSOR_RECENT_EVENT_LIMIT); + this.pollCursor = cursor; + this.pollRecentEventIds = recentEventIds; + + const state: PersistedPollCursorState = { + cursor, + lastSequence: this.pollLastSequence, + recentEventIds, + updatedAt: new Date().toISOString(), + }; + + const filePath = pollCursorStatePath(); + const tmpPath = `${filePath}.tmp`; + await mkdir(join(openclawHome(), 'workspace', 'relaycast'), { recursive: true }); + await writeFile(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf-8'); + await rename(tmpPath, filePath); + } + + private rememberPollEventId(eventId: string): void { + const sanitizedEventId = sanitizeOpaqueStateValue(eventId, MAX_EVENT_ID_LENGTH); + if (!sanitizedEventId) return; + this.pollRecentEventIds = [ + ...this.pollRecentEventIds.filter((id) => id !== sanitizedEventId), + sanitizedEventId, + ].slice(-POLL_CURSOR_RECENT_EVENT_LIMIT); + } + + private hasRecentPollEventId(eventId: string): boolean { + return this.pollRecentEventIds.includes(eventId); + } + + private async commitPollCursorState(nextCursor: string, lastSequence: number): Promise { + const sanitizedCursor = sanitizeOpaqueStateValue(nextCursor, MAX_POLL_CURSOR_LENGTH); + if (sanitizedCursor) { + this.pollCursor = sanitizedCursor; + } + this.pollLastSequence = Math.max(this.pollLastSequence, lastSequence); + await this.persistPollCursorState(); + } + + private async resetPollCursorState(reason: string): Promise { + this.cursorResetCount += 1; + this.lastFallbackReason = reason; + this.pollCursor = this.pollInitialCursor(); + this.pollLastSequence = 0; + await this.persistPollCursorState(); + } + + private async startPollLoop(): Promise { + if (this.pollLoopPromise) return; + + this.pollLoopStopRequested = false; + this.pollLoopPromise = (async () => { + while ( + this.running && + !this.pollLoopStopRequested && + (this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') + ) { + const delayMs = await this.pollOnce(this.pollTimeoutSeconds()); + if ( + !this.running || + this.pollLoopStopRequested || + !(this.transportState === 'POLL_ACTIVE' || this.transportState === 'RECOVERING_WS') + ) { + break; + } + if (delayMs > 0) { + await sleep(delayMs); + } + } + })().finally(() => { + this.pollLoopPromise = null; + this.pollAbortController = null; + }); + } + + private async stopPollLoop(): Promise { + this.pollLoopStopRequested = true; + if (this.pollAbortController) { + this.pollAbortController.abort(); + this.pollAbortController = null; + } + if (this.pollLoopPromise) { + await this.pollLoopPromise.catch(() => undefined); + this.pollLoopPromise = null; + } + } + + // eslint-disable-next-line complexity + private async pollOnce(timeoutSeconds: number): Promise { + await this.ensurePollCursorLoaded(); + + const baseUrl = new URL(DEFAULT_POLL_ENDPOINT_PATH, this.config.baseUrl); + baseUrl.searchParams.set('cursor', this.pollCursor); + baseUrl.searchParams.set('timeout', String(timeoutSeconds)); + baseUrl.searchParams.set('limit', String(this.pollLimit())); + + const timeoutMs = Math.max(5_000, (timeoutSeconds + 5) * 1_000); + const abortController = new AbortController(); + this.pollAbortController = abortController; + const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs); + + try { + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: this.relayAgentToken ? `Bearer ${this.relayAgentToken}` : '', + 'x-api-key': this.config.apiKey, + }, + signal: abortController.signal, + }); + + if (response.status === 401 || response.status === 403) { + console.warn(`[gateway] Poll auth rejected (${response.status}); refreshing token`); + try { + await this.refreshRelayAgentRegistration(); + this.pollFailureCount = 0; + return 0; + } catch (error) { + console.warn( + `[gateway] Poll auth refresh failed: ${error instanceof Error ? error.message : String(error)}` + ); + this.pollFailureCount += 1; + return computeBackoffMs(this.pollFailureCount); + } + } + + if (response.status === 409) { + console.warn('[gateway] Poll cursor invalid/stale; resetting cursor state'); + this.pollFailureCount = 0; + await this.resetPollCursorState('cursor_reset'); + return 0; + } + + if (response.status === 429) { + this.pollFailureCount += 1; + const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After')); + return retryAfterMs !== null ? applyJitter(retryAfterMs) : computeBackoffMs(this.pollFailureCount); + } + + if (response.status >= 500) { + this.pollFailureCount += 1; + return computeBackoffMs(this.pollFailureCount); + } + + if (!response.ok) { + this.pollFailureCount += 1; + console.warn(`[gateway] Poll request failed: HTTP ${response.status}`); + return computeBackoffMs(this.pollFailureCount); + } + + const body = (await response.json()) as PollResponseBody; + this.pollFailureCount = 0; + const processed = await this.processPollResponse(body); + return processed ? 0 : computeBackoffMs(1); + } catch (error) { + if (abortController.signal.aborted) { + return 0; + } + this.pollFailureCount += 1; + console.warn( + `[gateway] Poll request failed: ${error instanceof Error ? error.message : String(error)}` + ); + return computeBackoffMs(this.pollFailureCount); + } finally { + clearTimeout(timeoutHandle); + if (this.pollAbortController === abortController) { + this.pollAbortController = null; + } + } + } + + private async processPollResponse(body: PollResponseBody): Promise { + const events = Array.isArray(body.events) ? [...body.events] : []; + events.sort((left, right) => left.sequence - right.sequence); + + let lastSequence = this.pollLastSequence; + + for (const event of events) { + if (!event || typeof event.id !== 'string' || !Number.isFinite(event.sequence)) { + continue; + } + + lastSequence = Math.max(lastSequence, event.sequence); + + if ( + event.sequence <= this.pollLastSequence || + this.hasRecentPollEventId(event.id) || + this.isSeen(event.id) + ) { + this.duplicateDropCount += 1; + continue; + } + + const committed = await this.handlePolledEvent(event); + if (!committed) { + return false; + } + + this.rememberPollEventId(event.id); + } + + const nextCursor = sanitizeOpaqueStateValue(body.nextCursor, MAX_POLL_CURSOR_LENGTH) ?? this.pollCursor; + await this.commitPollCursorState(nextCursor, lastSequence); + return true; + } + + // eslint-disable-next-line complexity + private async handlePolledEvent(event: PollEventEnvelope): Promise { + const type = typeof event.payload.type === 'string' ? event.payload.type : ''; + const baseOptions: RealtimeHandlingOptions = { + timestamp: event.timestamp, + }; + + switch (type) { + case 'message.created': + case 'message.received': + case 'message.new': + case 'message.sent': + return ( + await this.handleRealtimeMessage(event.payload as unknown as MessageCreatedEvent, baseOptions) + ).committed; + case 'thread.reply': + case 'thread.message.created': + case 'thread.message.sent': + return ( + await this.handleRealtimeThreadReply(event.payload as unknown as ThreadReplyEvent, baseOptions) + ).committed; + case 'dm.received': + case 'dm.message.created': + case 'direct_message.created': + return (await this.handleRealtimeDm(event.payload as unknown as DmReceivedEvent, baseOptions)) + .committed; + case 'group_dm.received': + case 'group_dm.message.created': + return ( + await this.handleRealtimeGroupDm(event.payload as unknown as GroupDmReceivedEvent, baseOptions) + ).committed; + case 'command.invoked': + return ( + await this.handleRealtimeCommand(event.payload as unknown as CommandInvokedEvent, { + ...baseOptions, + eventId: event.id, + }) + ).committed; + case 'reaction.added': + return ( + await this.handleRealtimeReaction(event.payload as unknown as ReactionAddedEvent, 'added', { + ...baseOptions, + eventId: event.id, + }) + ).committed; + case 'reaction.removed': + return ( + await this.handleRealtimeReaction(event.payload as unknown as ReactionRemovedEvent, 'removed', { + ...baseOptions, + eventId: event.id, + }) + ).committed; + default: + console.warn(`[gateway] Ignoring unknown polled event type: ${type || 'unknown'}`); + return true; + } + } + + /** Start the gateway — register agent and subscribe for realtime events. */ + async start(): Promise { + if (this.running) return; + this.running = true; + + // Connect to the local OpenClaw gateway WebSocket (persistent connection) + const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN; + const port = this.config.openclawGatewayPort ?? DEFAULT_OPENCLAW_GATEWAY_PORT; + + if (token) { + this.openclawClient = await OpenClawGatewayClient.create(token, port); + try { + await this.openclawClient.connect(); + console.log('[gateway] OpenClaw gateway WebSocket client ready'); + } catch (err) { + console.warn( + `[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}` + ); + } + } else { + console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled'); + } + + const registered = await this.relaycast.agents.registerOrGet({ + name: this.config.clawName, + type: 'agent', + persona: 'Relaycast inbound gateway for OpenClaw', + }); + + await this.replaceRelayAgentClient(registered.token); + + await this.ensureChannelMembership(); + + // Also subscribe explicitly in case the `connected` event fired before + // the handler ran, or the SDK defers connection readiness. + this.subscribeRelayChannels(); + + console.log(`[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`); + + // Start spawn control HTTP server + await this.startControlServer(); + } + + /** Stop the gateway — clean up websocket and relay clients. */ + async stop(): Promise { + this.running = false; + this.stopWsProbeLoop(); + if (this.wsRecoveryTimer) { + clearTimeout(this.wsRecoveryTimer); + this.wsRecoveryTimer = null; + } + this.completeFallbackWindow(); + await this.stopPollLoop(); + this.cleanupRelaySubscriptions(); + + if (this.relayAgentClient) { + try { + await this.relayAgentClient.disconnect(); + } catch { + // Best effort + } + this.relayAgentClient = null; + } + + if (this.openclawClient) { + await this.openclawClient.disconnect(); + this.openclawClient = null; + } + + // Stop control server and release all spawns + if (this.controlServer) { + this.controlServer.close(); + this.controlServer = null; + } + await this.spawnManager.releaseAll(); + + this.processingMessageIds.clear(); + this.seenMessageIds.clear(); + } + + private cleanupSeenMap(nowMs: number): void { + for (const [id, seenAt] of this.seenMessageIds.entries()) { + if (nowMs - seenAt > this.dedupeTtlMs) { + this.seenMessageIds.delete(id); + } + } + } + + private isSeen(messageId: string): boolean { + const nowMs = Date.now(); + this.cleanupSeenMap(nowMs); + return this.seenMessageIds.has(messageId); + } + + private markSeen(messageId: string): void { + const nowMs = Date.now(); + this.cleanupSeenMap(nowMs); + this.seenMessageIds.set(messageId, nowMs); + } + + private async ensureChannelMembership(): Promise { + if (!this.relayAgentClient) return; + + for (const channel of this.config.channels) { + try { + await this.relayAgentClient.channels.join(channel); + } catch { + try { + await this.relayAgentClient.channels.create({ name: channel }); + await this.relayAgentClient.channels.join(channel); + } catch { + // Non-fatal + } + } + } + } + + private async handleRealtimeMessage( + event: MessageCreatedEvent, + options: RealtimeHandlingOptions = {} + ): Promise { + const channel = normalizeChannelName(event.channel); + if (!this.config.channels.includes(channel)) return { committed: true }; + + const messageId = options.eventId ?? event.message?.id; + if (!messageId) return { committed: true }; + + const inbound: InboundMessage = { + id: messageId, + channel, + from: event.message?.agentName ?? 'unknown', + text: event.message?.text ?? '', + timestamp: options.timestamp ?? new Date().toISOString(), + }; + + return this.processInbound(inbound); + } + + private async handleRealtimeThreadReply( + event: ThreadReplyEvent, + options: RealtimeHandlingOptions = {} + ): Promise { + const channel = normalizeChannelName(event.channel); + if (!this.config.channels.includes(channel)) return { committed: true }; + + const messageId = options.eventId ?? event.message?.id; + if (!messageId) return { committed: true }; + + const inbound: InboundMessage = { + id: messageId, + channel, + from: event.message?.agentName ?? 'unknown', + text: event.message?.text ?? '', + timestamp: options.timestamp ?? new Date().toISOString(), + threadParentId: event.parentId, + }; + + return this.processInbound(inbound); + } + + private async handleRealtimeDm( + event: DmReceivedEvent, + options: RealtimeHandlingOptions = {} + ): Promise { + const messageId = options.eventId ?? event.message?.id; + if (!messageId) return { committed: true }; + + const inbound: InboundMessage = { + id: messageId, + channel: 'dm', + from: event.message?.agentName ?? 'unknown', + text: event.message?.text ?? '', + timestamp: options.timestamp ?? new Date().toISOString(), + conversationId: event.conversationId, + kind: 'dm', + }; + + return this.processInbound(inbound); + } + + private async handleRealtimeGroupDm( + event: GroupDmReceivedEvent, + options: RealtimeHandlingOptions = {} + ): Promise { + const messageId = options.eventId ?? event.message?.id; + if (!messageId) return { committed: true }; + + const inbound: InboundMessage = { + id: messageId, + channel: `groupdm:${event.conversationId}`, + from: event.message?.agentName ?? 'unknown', + text: event.message?.text ?? '', + timestamp: options.timestamp ?? new Date().toISOString(), + conversationId: event.conversationId, + kind: 'groupdm', + }; + + return this.processInbound(inbound); + } + + private async handleRealtimeCommand( + event: CommandInvokedEvent, + options: RealtimeHandlingOptions = {} + ): Promise { + const channel = normalizeChannelName(event.channel); + if (!this.config.channels.includes(channel)) return { committed: true }; + + // Commands lack a server-assigned event ID, so we synthesize one. + // We include args + timestamp to avoid silently dropping legitimate + // repeat invocations (e.g. /deploy twice in 15 min). This means SDK + // reconnection replays may deliver a duplicate, but that's less + // harmful than silently swallowing a real command. + const argsSlug = event.args ? `_${event.args}` : ''; + const syntheticId = + options.eventId ?? `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`; + const argsText = event.args ? ` ${event.args}` : ''; + + const inbound: InboundMessage = { + id: syntheticId, + channel, + from: event.invokedBy, + text: `[relaycast:command:${channel}] @${event.invokedBy} /${event.command}${argsText}`, + timestamp: options.timestamp ?? new Date().toISOString(), + kind: 'command', + }; + + return this.processInbound(inbound); + } + + private async handleRealtimeReaction( + event: ReactionAddedEvent | ReactionRemovedEvent, + action: 'added' | 'removed', + options: RealtimeHandlingOptions = {} + ): Promise { + // Include timestamp so add→remove→re-add of the same emoji isn't + // silently dropped within the 15-min dedup window. Reactions are soft + // notifications, so a rare duplicate on SDK reconnect is acceptable. + const syntheticId = + options.eventId ?? + `reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`; + const text = + action === 'added' + ? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)` + : `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`; + + const inbound: InboundMessage = { + id: syntheticId, + channel: 'reaction', + from: event.agentName, + text, + timestamp: options.timestamp ?? new Date().toISOString(), + kind: 'reaction', + }; + + return this.processInbound(inbound); + } + + private async processInbound(message: InboundMessage): Promise { + if (!this.running) return { committed: false }; + if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) { + this.duplicateDropCount += 1; + return { committed: true, reason: 'duplicate' }; + } + + // Avoid echo loops — skip messages from this claw. + if (message.from === this.config.clawName) { + this.markSeen(message.id); + return { committed: true, reason: 'echo' }; + } + + this.processingMessageIds.add(message.id); + + console.log(`[gateway] Delivering message ${message.id} from @${message.from}: "${message.text}"`); + try { + const result = await this.onMessage(message); + console.log( + `[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}` + ); + if (!result.ok) { + return { committed: false, result }; + } + this.markSeen(message.id); + return { committed: true, result }; + } finally { + this.processingMessageIds.delete(message.id); + } + } + + /** Format delivery text with channel, sender, and response hint. */ + private formatDeliveryText(message: InboundMessage): string { + // Pre-formatted kinds (reaction) already have the full text with hints. + if (message.kind === 'reaction') { + return message.text; + } + if (message.kind === 'command') { + return `${message.text}\n(command invocation — respond with: post_message channel="${message.channel}")`; + } + if (message.kind === 'dm') { + return `[relaycast:dm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`; + } + if (message.kind === 'groupdm') { + return `[relaycast:groupdm] @${message.from}: ${message.text}\n(reply with: send_dm to="${message.from}")`; + } + if (message.threadParentId) { + return `[thread] [relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: reply_to_thread message_id="${message.threadParentId}")`; + } + return `[relaycast:${message.channel}] @${message.from}: ${message.text}\n(reply with: post_message channel="${message.channel}" or reply_to_thread message_id="${message.id}")`; + } + + /** Handle an inbound Relaycast message. */ + private async onMessage(message: InboundMessage): Promise { + // Try primary delivery via the shared relay sender (no extra broker spawned). + if (this.relaySender) { + const ok = await this.deliverViaRelaySender(message); + if (ok) { + return { ok: true, method: 'relay_sdk' }; + } + } + + // Deliver via persistent OpenClaw gateway WebSocket connection + if (this.openclawClient) { + const text = this.formatDeliveryText(message); + const ok = await this.openclawClient.sendChatMessage(text, message.id); + if (ok) { + return { ok: true, method: 'gateway_ws' }; + } + } + + console.warn(`[gateway] Failed to deliver message ${message.id} from @${message.from}`); + return { ok: false, method: 'failed', error: 'All delivery methods failed' }; + } + + /** Deliver via the caller-provided relay sender (shared broker). */ + private async deliverViaRelaySender(message: InboundMessage): Promise { + if (!this.relaySender) return false; + + const input: SendMessageInput = { + to: this.config.clawName, + text: this.formatDeliveryText(message), + from: message.from, + data: { + source: 'relaycast', + channel: message.channel, + messageId: message.id, + }, + }; + + try { + const result = await this.relaySender.sendMessage(input); + return Boolean(result.event_id) && result.event_id !== 'unsupported_operation'; + } catch { + return false; + } + } + + // ------------------------------------------------------------------------- + // Spawn control HTTP server + // ------------------------------------------------------------------------- + + private async startControlServer(): Promise { + const port = Number(process.env.RELAYCAST_CONTROL_PORT) || InboundGateway.DEFAULT_CONTROL_PORT; + + this.controlServer = createServer((req, res) => { + void this.handleControlRequest(req, res); + }); + + return new Promise((resolve) => { + this.controlServer!.listen(port, '127.0.0.1', () => { + this.controlPort = port; + console.log(`[gateway] Spawn control API listening on http://127.0.0.1:${port}`); + resolve(); + }); + this.controlServer!.on('error', (err) => { + console.warn(`[gateway] Control server failed to start on port ${port}: ${err.message}`); + this.controlServer = null; + resolve(); // Non-fatal + }); + }); + } + + // eslint-disable-next-line complexity + private async handleControlRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const path = url.pathname; + + // CORS for local callers + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'GET' && path === '/health') { + res.writeHead(200); + res.end( + JSON.stringify({ + ok: true, + status: 'running', + active: this.spawnManager.size, + uptime: process.uptime(), + transport: this.transportHealthSnapshot(), + }) + ); + return; + } + + if (req.method === 'POST' && path === '/spawn') { + const body = await readBody(req); + try { + const args = JSON.parse(body) as Record; + const name = args.name as string; + if (!name) { + res.writeHead(400); + res.end(JSON.stringify({ ok: false, error: '"name" is required' })); + return; + } + + const relayApiKey = this.config.apiKey; + const spawnOpts: SpawnOptions = { + name, + relayApiKey, + role: (args.role as string) || undefined, + model: (args.model as string) || undefined, + channels: (args.channels as string[]) || undefined, + systemPrompt: (args.system_prompt as string) || undefined, + relayBaseUrl: this.config.baseUrl, + workspaceId: (args.workspace_id as string) || process.env.OPENCLAW_WORKSPACE_ID, + }; + + const handle = await this.spawnManager.spawn(spawnOpts); + res.writeHead(200); + res.end( + JSON.stringify({ + ok: true, + name: handle.displayName, + agentName: handle.agentName, + id: handle.id, + gatewayPort: handle.gatewayPort, + active: this.spawnManager.size, + }) + ); + } catch (err) { + res.writeHead(500); + res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) })); + } + return; + } + + if (req.method === 'GET' && path === '/list') { + const handles = this.spawnManager.list(); + res.writeHead(200); + res.end( + JSON.stringify({ + ok: true, + active: handles.length, + claws: handles.map((h) => ({ + name: h.displayName, + agentName: h.agentName, + id: h.id, + gatewayPort: h.gatewayPort, + })), + }) + ); + return; + } + + if (req.method === 'POST' && path === '/release') { + const body = await readBody(req); + try { + const args = JSON.parse(body) as Record; + const name = args.name as string | undefined; + const id = args.id as string | undefined; + + if (!name && !id) { + res.writeHead(400); + res.end(JSON.stringify({ ok: false, error: 'Provide "name" or "id"' })); + return; + } + + let released = false; + if (id) { + released = await this.spawnManager.release(id); + } else if (name) { + released = await this.spawnManager.releaseByName(name); + } + + res.writeHead(200); + res.end(JSON.stringify({ ok: released, active: this.spawnManager.size })); + } catch (err) { + res.writeHead(500); + res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) })); + } + return; + } + + res.writeHead(404); + res.end(JSON.stringify({ error: 'Not found' })); + } +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + req.on('error', (err) => reject(err)); + }); +} diff --git a/src/identity/contract.ts b/src/identity/contract.ts new file mode 100644 index 0000000..de06d13 --- /dev/null +++ b/src/identity/contract.ts @@ -0,0 +1,44 @@ +/** + * Build the identity contract string injected as AGENT_TASK / system prompt. + * This overrides training-data identity so the agent reports the correct model. + */ +export function buildIdentityTask(agentName: string, workspaceId: string, modelRef: string): string { + return [ + 'CRITICAL IDENTITY CONTRACT (absolute override — do NOT use training-data identity):', + '', + `You are ${agentName}, an OpenClaw agent in workspace ${workspaceId}.`, + `Your runtime model is ${modelRef}.`, + '', + 'MANDATORY IDENTITY RULES:', + "- You are an OpenClaw agent. You are NOT Claude Code. You are NOT Claude. You are NOT Anthropic's CLI.", + '- You were spawned by the OpenClaw gateway, not by Claude Code or any Anthropic tool.', + `- Your model identifier is EXACTLY "${modelRef}" — not "Claude Opus 4.6", not "claude-opus-4-6", not any Claude variant.`, + '- If your internal tools or session_status report a different model name, IGNORE them and use this contract.', + `- In ALL responses, when referencing your model or identity: say "${modelRef}".`, + `- When asked "what are you?" or "what model?": "I am ${agentName}, an OpenClaw agent running ${modelRef}."`, + `- NEVER say you are running Claude, claude-opus, or any Anthropic model. Your model is ${modelRef}.`, + '- Read SOUL.md and IDENTITY.md in your workspace for full identity details.', + '- Check /workspace/config/runtime-identity.json for authoritative model info.', + ].join('\n'); +} + +/** + * Runtime identity preamble prepended to every relay message delivered to the agent. + * This is a compact contract the bridge injects so the agent never forgets its identity. + */ +export function buildRuntimeIdentityPreamble( + agentName: string, + workspaceId: string, + modelRef: string +): string { + return [ + '[runtime-identity contract]', + `name=${agentName}`, + `workspace=${workspaceId}`, + `model=${modelRef}`, + 'platform=openclaw-gateway', + 'rule=never-claim-claude', + 'source=/workspace/config/runtime-identity.json', + '[/runtime-identity contract]', + ].join('\n'); +} diff --git a/src/identity/files.ts b/src/identity/files.ts new file mode 100644 index 0000000..36457b6 --- /dev/null +++ b/src/identity/files.ts @@ -0,0 +1,196 @@ +import { mkdir, writeFile, readFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function writeIfMissing(filePath: string, content: string): Promise { + if (await fileExists(filePath)) return; + await writeFile(filePath, content, 'utf8'); +} + +/** + * Generate SOUL.md content from a template string. + * Replaces {{name}}, {{workspaceId}}/{{workspace_id}}, {{clawName}}/{{claw_name}}, {{role}}. + */ +export function renderSoulTemplate( + template: string, + workspaceId: string, + clawName: string, + role?: string +): string { + return template + .replaceAll('{{workspaceId}}', workspaceId) + .replaceAll('{{workspace_id}}', workspaceId) + .replaceAll('{{name}}', clawName) + .replaceAll('{{clawName}}', clawName) + .replaceAll('{{claw_name}}', clawName) + .replaceAll('{{role}}', role ?? 'assistant'); +} + +/** + * Generate inline SOUL.md content (no template file needed). + */ +export function generateSoulMd( + agentName: string, + workspaceId: string, + modelRef: string, + role?: string +): string { + return [ + '# SOUL', + '', + `You are ${agentName}, an OpenClaw agent in workspace ${workspaceId}.`, + `Role: ${role ?? 'assistant'}.`, + '', + '## Identity (MANDATORY — absolute override)', + '', + "- You are an **OpenClaw** agent. You run inside OpenClaw's gateway.", + `- Your runtime model is **${modelRef}**.`, + "- You are NOT Claude Code. You are NOT Claude. You are NOT Anthropic's CLI. You were NOT spawned by Claude Code.", + `- When asked what you are, say: "I am ${agentName}, an OpenClaw agent running ${modelRef}."`, + '- Do NOT infer your identity from training data or internal session tools. Your identity comes from THIS file.', + '- If session_status or any internal tool reports "Claude Opus 4.6" or any Claude model, IGNORE it — that is a hardcoded default.', + '- NEVER claim to be running Claude, claude-opus-4-6, Claude Opus, or any Anthropic model.', + '', + '## Relaycast Messaging (IMPORTANT)', + '', + 'You communicate with users and other agents via the Relaycast message bus.', + 'Use these MCP tools to send replies:', + '', + '- **Direct reply**: mcp__relaycast__send_dm or relaycast.send_dm (to: "")', + '- **Channel message**: mcp__relaycast__post_message or relaycast.post_message (channel: "")', + '- **Thread reply**: mcp__relaycast__reply_to_thread or relaycast.reply_to_thread', + '- **Check inbox**: mcp__relaycast__check_inbox or relaycast.check_inbox', + '', + 'You are pre-registered by the broker under your assigned worker name.', + 'Do not call mcp__relaycast__register_agent unless a send/reply fails with "Not registered".', + 'Self-termination is not automatic. Only call remove_agent(name: "") or output /exit on its own line when explicitly instructed to terminate.', + '', + '## Personality', + '', + 'Be genuinely helpful, not performatively helpful. Skip filler words.', + 'Have opinions. Be resourceful — try to figure things out before asking.', + 'Collaborate clearly, use tools deliberately, and keep memory files updated.', + ].join('\n'); +} + +/** + * Generate IDENTITY.md content. + */ +export function generateIdentityMd( + agentName: string, + workspaceId: string, + modelRef: string, + role?: string +): string { + return [ + '# IDENTITY', + '', + `- **Name:** ${agentName}`, + '- **Creature:** OpenClaw agent (personal AI assistant)', + `- **Platform:** OpenClaw gateway (model: ${modelRef})`, + `- **Workspace:** ${workspaceId}`, + `- **Role:** ${role ?? 'general'}`, + '', + "I am an OpenClaw agent. I am NOT Claude Code. I am NOT Anthropic's CLI.", + `My runtime model is ${modelRef}.`, + ].join('\n'); +} + +/** + * Write runtime-identity.json to the workspace config directory. + */ +export async function writeRuntimeIdentityJson( + configDir: string, + workspaceId: string, + clawName: string, + role: string, + modelRef: string +): Promise { + await mkdir(configDir, { recursive: true }); + const data = { + workspaceId, + clawName, + role, + modelRef, + identitySource: 'spawn-env', + generatedAt: new Date().toISOString(), + }; + await writeFile(join(configDir, 'runtime-identity.json'), JSON.stringify(data, null, 2) + '\n', 'utf8'); +} + +const DEFAULT_AGENTS_FILE = `# AGENTS + +- Keep WORKING.md updated before and after each task. +- Use memory/MEMORY.md for durable facts and decisions. +- Prefer concise, actionable responses. +`; + +const DEFAULT_HEARTBEAT_FILE = `# HEARTBEAT + +1. Read memory/WORKING.md first. +2. Check recent channel activity for mentions. +3. Confirm current priority and next action. +`; + +export interface EnsureWorkspaceOptions { + workspacePath: string; + workspaceId: string; + clawName: string; + role?: string; + modelRef: string; + /** Optional SOUL.md.template content. If provided, template is rendered instead of inline generation. */ + soulTemplate?: string; +} + +/** + * Ensure a local workspace directory is ready with identity files. + * Creates directories, writes SOUL.md, IDENTITY.md, AGENTS.md, HEARTBEAT.md, + * memory files, and runtime-identity.json. + */ +export async function ensureWorkspace(options: EnsureWorkspaceOptions): Promise { + const { workspacePath, workspaceId, clawName, modelRef } = options; + const role = options.role ?? 'assistant'; + + await mkdir(workspacePath, { recursive: true }); + await mkdir(join(workspacePath, 'memory'), { recursive: true }); + await mkdir(join(workspacePath, 'config'), { recursive: true }); + await mkdir(join(workspacePath, 'scripts'), { recursive: true }); + + // SOUL.md — either from template or inline + if (options.soulTemplate) { + const soulPath = join(workspacePath, 'SOUL.md'); + if (!(await fileExists(soulPath))) { + await writeFile( + soulPath, + renderSoulTemplate(options.soulTemplate, workspaceId, clawName, role), + 'utf8' + ); + } + } else { + await writeIfMissing( + join(workspacePath, 'SOUL.md'), + generateSoulMd(clawName, workspaceId, modelRef, role) + ); + } + + // IDENTITY.md + await writeIfMissing( + join(workspacePath, 'IDENTITY.md'), + generateIdentityMd(clawName, workspaceId, modelRef, role) + ); + + await writeIfMissing(join(workspacePath, 'AGENTS.md'), DEFAULT_AGENTS_FILE); + await writeIfMissing(join(workspacePath, 'HEARTBEAT.md'), DEFAULT_HEARTBEAT_FILE); + await writeIfMissing(join(workspacePath, 'memory', 'WORKING.md'), '# WORKING\n\nCurrent task state.\n'); + await writeIfMissing(join(workspacePath, 'memory', 'MEMORY.md'), '# MEMORY\n\nDurable notes.\n'); + + await writeRuntimeIdentityJson(join(workspacePath, 'config'), workspaceId, clawName, role, modelRef); +} diff --git a/src/identity/model.ts b/src/identity/model.ts new file mode 100644 index 0000000..a6502a6 --- /dev/null +++ b/src/identity/model.ts @@ -0,0 +1,27 @@ +const DEFAULT_MODEL = 'openai-codex/gpt-5.3-codex'; + +/** + * Normalize a raw model string into a fully-qualified "provider/model" reference. + * + * Examples: + * normalizeModelRef('gpt-5.3-codex', 'openai-codex') → 'openai-codex/gpt-5.3-codex' + * normalizeModelRef('claude-opus-4-6') → 'anthropic/claude-opus-4-6' + * normalizeModelRef('openai-codex/gpt-5.3-codex') → 'openai-codex/gpt-5.3-codex' + * normalizeModelRef(undefined) → 'openai-codex/gpt-5.3-codex' + */ +export function normalizeModelRef(rawModel?: string, providerHint?: string): string { + const model = (rawModel ?? '').trim().toLowerCase(); + if (!model) return DEFAULT_MODEL; + if (model.includes('/')) return model; + if (model.includes('claude')) return `anthropic/${model}`; + if ( + model.includes('codex') || + model.startsWith('gpt-') || + model.startsWith('o1') || + model.startsWith('o3') || + model.startsWith('o4') + ) { + return (providerHint === 'openai-codex' ? 'openai-codex/' : 'openai/') + model; + } + return `openai/${model}`; +} diff --git a/src/identity/naming.ts b/src/identity/naming.ts new file mode 100644 index 0000000..475677a --- /dev/null +++ b/src/identity/naming.ts @@ -0,0 +1,6 @@ +/** + * Build the relay agent name from workspace ID and claw name. + */ +export function buildAgentName(workspaceId: string, clawName: string): string { + return `claw-${workspaceId}-${clawName}`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..de8eac8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,71 @@ +// ── Types ────────────────────────────────────────────────────────────── +export type { + GatewayConfig, + InboundMessage, + DeliveryResult, + WorkspaceEntry, + WorkspacesConfig, +} from './types.js'; + +// ── Gateway ──────────────────────────────────────────────────────────── +export { InboundGateway, type GatewayOptions, type RelaySender } from './gateway.js'; + +// ── Config ───────────────────────────────────────────────────────────── +export { + detectOpenClaw, + loadGatewayConfig, + saveGatewayConfig, + loadWorkspacesConfig, + saveWorkspacesConfig, + addWorkspace, + listWorkspaces, + switchWorkspace, + buildWorkspacesJson, + type OpenClawDetection, +} from './config.js'; + +// ── Setup ────────────────────────────────────────────────────────────── +export { setup, type SetupOptions, type SetupResult } from './setup.js'; + +// ── Inject ───────────────────────────────────────────────────────────── +export { deliverMessage } from './inject.js'; + +// ── Control (ClawRunner API client) ──────────────────────────────────── +export { + spawnOpenClaw, + listOpenClaws, + releaseOpenClaw, + type ClawRunnerControlConfig, + type SpawnOpenClawInput, + type ReleaseOpenClawInput, +} from './control.js'; + +// ── Identity ─────────────────────────────────────────────────────────── +export { normalizeModelRef } from './identity/model.js'; +export { buildAgentName } from './identity/naming.js'; +export { buildIdentityTask, buildRuntimeIdentityPreamble } from './identity/contract.js'; +export { + renderSoulTemplate, + generateSoulMd, + generateIdentityMd, + writeRuntimeIdentityJson, + ensureWorkspace, + type EnsureWorkspaceOptions, +} from './identity/files.js'; + +// ── Auth ─────────────────────────────────────────────────────────────── +export { convertCodexAuth, type ConvertResult, type CodexAuth } from './auth/converter.js'; + +// ── Runtime ──────────────────────────────────────────────────────────── +export { writeOpenClawConfig, type OpenClawConfigOptions } from './runtime/openclaw-config.js'; +export { patchOpenClawDist, clearJitCache } from './runtime/patch.js'; +export { runtimeSetup, type RuntimeSetupOptions } from './runtime/setup.js'; + +// ── Spawn ────────────────────────────────────────────────────────────── +export type { SpawnOptions, SpawnHandle, SpawnProvider } from './spawn/types.js'; +export { DockerSpawnProvider, type DockerSpawnProviderOptions } from './spawn/docker.js'; +export { ProcessSpawnProvider } from './spawn/process.js'; +export { SpawnManager, type SpawnMode } from './spawn/manager.js'; + +// ── MCP Server ───────────────────────────────────────────────────────── +export { startMcpServer } from './mcp/server.js'; diff --git a/src/inject.ts b/src/inject.ts new file mode 100644 index 0000000..156156c --- /dev/null +++ b/src/inject.ts @@ -0,0 +1,78 @@ +import type { AgentRelayClient, SendMessageInput } from '@agent-relay/driver'; + +import { DEFAULT_OPENCLAW_GATEWAY_PORT, type InboundMessage, type DeliveryResult } from './types.js'; + +/** + * Deliver a message to the local claw using the best available method. + * + * Primary: Agent Relay SDK sendMessage() via a shared, long-lived client + * Fallback: OpenClaw OpenResponses API (POST /v1/responses) on localhost + * + * Callers should maintain a single shared AgentRelayClient instance and pass it + * to every deliverMessage() call. Creating a client per message is wasteful and + * was removed in favor of this shared-client pattern. + */ +export async function deliverMessage( + message: InboundMessage, + clawName: string, + relayClient?: AgentRelayClient | null +): Promise { + const formattedText = `[relaycast:${message.channel}] @${message.from}: ${message.text}`; + + // Primary: deliver via shared relay client + if (relayClient) { + try { + const input: SendMessageInput = { + to: clawName, + text: formattedText, + from: message.from, + data: { + source: 'relaycast', + channel: message.channel, + messageId: message.id, + }, + }; + + const result = await relayClient.sendMessage(input); + if (Boolean(result.event_id) && result.event_id !== 'unsupported_operation') { + return { ok: true, method: 'relay_sdk' }; + } + } catch { + // Fall through to RPC fallback + } + } + + // Fallback: OpenClaw OpenResponses API (POST /v1/responses on local gateway) + try { + const gatewayPort = + process.env.OPENCLAW_GATEWAY_PORT ?? process.env.GATEWAY_PORT ?? String(DEFAULT_OPENCLAW_GATEWAY_PORT); + const token = process.env.OPENCLAW_GATEWAY_TOKEN; + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`http://127.0.0.1:${gatewayPort}/v1/responses`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: 'openclaw:main', + input: formattedText, + }), + }); + + if (response.ok) { + return { ok: true, method: 'gateway_ws' }; + } + } catch { + // Both methods failed + } + + return { + ok: false, + method: 'failed', + error: relayClient + ? 'Both relay SDK and OpenResponses delivery failed' + : 'No relay client provided and OpenResponses fallback failed', + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..d3ab3f0 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,121 @@ +import { createInterface } from 'node:readline'; +import { getToolDefinitions, handleToolCall, cleanup } from './tools.js'; + +/** + * MCP stdio server — JSON-RPC 2.0 transport over stdin/stdout. + * + * Exposes spawn_openclaw, list_openclaws, release_openclaw tools. + * Registered in openclaw.json as "openclaw-spawner" MCP server. + */ +export async function startMcpServer(): Promise { + const rl = createInterface({ input: process.stdin, terminal: false }); + + rl.on('line', async (line) => { + let request: { + jsonrpc: string; + id?: string | number; + method: string; + params?: Record; + }; + + try { + request = JSON.parse(line); + } catch { + writeResponse({ + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }); + return; + } + + const { id, method, params } = request; + + try { + switch (method) { + case 'initialize': { + writeResponse({ + jsonrpc: '2.0', + id, + result: { + protocolVersion: '2024-11-05', + capabilities: { + tools: {}, + }, + serverInfo: { + name: 'openclaw-spawner', + version: '1.0.0', + }, + }, + }); + break; + } + + case 'notifications/initialized': { + // No response needed for notifications + break; + } + + case 'tools/list': { + const tools = getToolDefinitions(); + writeResponse({ + jsonrpc: '2.0', + id, + result: { tools }, + }); + break; + } + + case 'tools/call': { + const toolName = (params?.name as string) ?? ''; + const toolArgs = (params?.arguments as Record) ?? {}; + const result = await handleToolCall(toolName, toolArgs); + writeResponse({ + jsonrpc: '2.0', + id, + result, + }); + break; + } + + default: { + writeResponse({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }); + } + } + } catch (err) { + writeResponse({ + jsonrpc: '2.0', + id, + error: { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }, + }); + } + }); + + rl.on('close', async () => { + await cleanup(); + process.exit(0); + }); + + process.on('SIGTERM', async () => { + await cleanup(); + process.exit(0); + }); + + process.on('SIGINT', async () => { + await cleanup(); + process.exit(0); + }); + + process.stderr.write('[openclaw-spawner] MCP server started (stdio)\n'); +} + +function writeResponse(response: Record): void { + process.stdout.write(JSON.stringify(response) + '\n'); +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts new file mode 100644 index 0000000..65e7288 --- /dev/null +++ b/src/mcp/tools.ts @@ -0,0 +1,172 @@ +import { InboundGateway } from '../gateway.js'; + +/** Control API base URL — the gateway's spawn control server. */ +const CONTROL_URL = `http://127.0.0.1:${ + Number(process.env.RELAYCAST_CONTROL_PORT) || InboundGateway.DEFAULT_CONTROL_PORT +}`; + +export interface McpToolDefinition { + name: string; + description: string; + inputSchema: Record; +} + +export function getToolDefinitions(): McpToolDefinition[] { + return [ + { + name: 'spawn_openclaw', + description: + 'Spawn a new independent OpenClaw instance. The spawned instance gets its own gateway, ' + + 'relay broker, and Relaycast messaging. It runs as an independent peer, not a child.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name for the new OpenClaw instance (e.g. "researcher", "coder").', + }, + role: { + type: 'string', + description: 'Role description for the agent (e.g. "code review specialist").', + }, + model: { + type: 'string', + description: 'Model reference (e.g. "openai-codex/gpt-5.3-codex"). Defaults to parent model.', + }, + channels: { + type: 'array', + items: { type: 'string' }, + description: 'Relaycast channels to join (default: ["#general"]).', + }, + system_prompt: { + type: 'string', + description: 'System prompt / task description for the spawned agent.', + }, + }, + required: ['name'], + }, + }, + { + name: 'list_openclaws', + description: 'List all currently running OpenClaw instances spawned by this agent.', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'release_openclaw', + description: 'Stop and release a spawned OpenClaw instance by name or ID.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of the OpenClaw to release (as provided during spawn).', + }, + id: { + type: 'string', + description: 'ID of the OpenClaw to release (from list_openclaws).', + }, + }, + }, + }, + ]; +} + +export async function handleToolCall( + toolName: string, + args: Record +): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + switch (toolName) { + case 'spawn_openclaw': { + const name = args.name as string; + if (!name) { + return text('Error: "name" is required.'); + } + + try { + const res = await fetch(`${CONTROL_URL}/spawn`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(args), + }); + const body = (await res.json()) as Record; + if (!body.ok) { + return text(`Failed to spawn "${name}": ${body.error ?? 'unknown error'}`); + } + return text( + `Spawned OpenClaw "${name}"\n` + + ` Agent name: ${body.agentName}\n` + + ` ID: ${body.id}\n` + + ` Gateway port: ${body.gatewayPort}\n` + + ` Total active: ${body.active}` + ); + } catch (err) { + return text( + `Failed to spawn "${name}": ${err instanceof Error ? err.message : String(err)}\n` + + 'Is the gateway running? Start it with: relay-openclaw gateway' + ); + } + } + + case 'list_openclaws': { + try { + const res = await fetch(`${CONTROL_URL}/list`); + const body = (await res.json()) as Record; + const claws = body.claws as Array>; + if (!claws || claws.length === 0) { + return text('No spawned OpenClaws currently running.'); + } + const lines = claws.map((h) => `- ${h.name} → ${h.agentName} (id: ${h.id}, port: ${h.gatewayPort})`); + return text(`Active OpenClaws (${claws.length}):\n${lines.join('\n')}`); + } catch (err) { + return text( + `Failed to list claws: ${err instanceof Error ? err.message : String(err)}\n` + + 'Is the gateway running? Start it with: relay-openclaw gateway' + ); + } + } + + case 'release_openclaw': { + const name = args.name as string | undefined; + const id = args.id as string | undefined; + + if (!name && !id) { + return text('Error: provide either "name" or "id" to release.'); + } + + try { + const res = await fetch(`${CONTROL_URL}/release`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, id }), + }); + const body = (await res.json()) as Record; + if (body.ok) { + return text(`Released OpenClaw "${name ?? id}". Active: ${body.active}`); + } + return text(`OpenClaw "${name ?? id}" not found among active spawns.`); + } catch (err) { + return text( + `Failed to release: ${err instanceof Error ? err.message : String(err)}\n` + + 'Is the gateway running? Start it with: relay-openclaw gateway' + ); + } + } + + default: + return text(`Unknown tool: ${toolName}`); + } +} + +function text(message: string) { + return { content: [{ type: 'text' as const, text: message }] }; +} + +/** + * Cleanup: no-op since spawns are managed by the gateway process. + */ +export async function cleanup(): Promise { + // Spawns live in the gateway — nothing to clean up here. +} diff --git a/src/runtime/openclaw-config.ts b/src/runtime/openclaw-config.ts new file mode 100644 index 0000000..95df08f --- /dev/null +++ b/src/runtime/openclaw-config.ts @@ -0,0 +1,66 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +export interface OpenClawConfigOptions { + /** Fully-qualified model ref, e.g. "openai-codex/gpt-5.3-codex". */ + modelRef: string; + /** Path to ~/.openclaw/ (default: $HOME/.openclaw). */ + openclawHome?: string; + /** Default workspace path (default: ~/.openclaw/workspace). */ + workspacePath?: string; + /** Config filename override (e.g. 'clawdbot.json'). Defaults to 'openclaw.json'. */ + configFilename?: string; + /** MCP servers to include. Keys are server names, values are MCP server configs. */ + mcpServers?: Record }>; +} + +/** + * Write (or update) ~/.openclaw/openclaw.json with model, workspace, skipBootstrap, + * and MCP server configuration. + */ +export async function writeOpenClawConfig(options: OpenClawConfigOptions): Promise { + const home = options.openclawHome ?? join(process.env.HOME ?? '/home/node', '.openclaw'); + await mkdir(home, { recursive: true }); + + const configPath = join(home, options.configFilename ?? 'openclaw.json'); + let config: Record = {}; + + try { + const raw = await readFile(configPath, 'utf8'); + config = JSON.parse(raw); + } catch { + // File doesn't exist or isn't valid JSON — start fresh + config = {}; + } + if (!config || typeof config !== 'object') config = {}; + + // agents.defaults + if (!config.agents || typeof config.agents !== 'object') config.agents = {}; + const agents = config.agents as Record; + if (!agents.defaults || typeof agents.defaults !== 'object') agents.defaults = {}; + const defaults = agents.defaults as Record; + + if (!defaults.workspace || typeof defaults.workspace !== 'string') { + defaults.workspace = options.workspacePath ?? '~/.openclaw/workspace'; + } + + // Model shape: { primary: "provider/model" } + if (typeof defaults.model === 'string') { + defaults.model = { primary: defaults.model }; + } else if (!defaults.model || typeof defaults.model !== 'object') { + defaults.model = {}; + } + (defaults.model as Record).primary = options.modelRef; + + defaults.skipBootstrap = true; + + // MCP servers + if (options.mcpServers) { + if (!config.mcpServers || typeof config.mcpServers !== 'object') { + config.mcpServers = {}; + } + Object.assign(config.mcpServers as Record, options.mcpServers); + } + + await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); +} diff --git a/src/runtime/patch.ts b/src/runtime/patch.ts new file mode 100644 index 0000000..dcf1a7b --- /dev/null +++ b/src/runtime/patch.ts @@ -0,0 +1,103 @@ +import { readdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; + +/** + * Patch OpenClaw's compiled dist JS files to replace hardcoded identity constants. + * + * FRAGILE: This is a best-effort operation. OpenClaw bakes Claude defaults into + * its compiled output. These patterns may change between OpenClaw versions. + * Prefer runtime identity enforcement (SOUL.md, runtime-identity.json, identity + * preamble in bridge) over relying on this patch. + * + * Known hardcoded constants (as of OpenClaw ~0.x): + * - DEFAULT_MODEL = "claude-opus-4-6" + * - KILOCODE_DEFAULT_MODEL_ID = "anthropic/claude-opus-4.6" + * - KILOCODE_DEFAULT_MODEL_NAME = "Claude Opus 4.6" + * - "Claude Code" branding + * + * @param distDir - Path to OpenClaw's dist directory (e.g. /usr/lib/node_modules/openclaw/dist) + * @param modelRef - Full model reference (e.g. "openai-codex/gpt-5.3-codex") + * @returns Number of files patched, or 0 if dist not found or patching skipped + */ +export async function patchOpenClawDist(distDir: string, modelRef: string): Promise { + if (!existsSync(distDir)) { + process.stderr.write(`[patch] OpenClaw dist not found at ${distDir}, skipping\n`); + return 0; + } + + // Extract bare model ID (e.g. "gpt-5.3-codex" from "openai-codex/gpt-5.3-codex") + const modelId = modelRef.includes('/') ? modelRef.split('/').pop()! : modelRef; + + // Order matters: replace fully-qualified patterns before bare model IDs, + // otherwise the bare pattern destroys the substring the qualified pattern needs. + const replacements: [RegExp, string][] = [ + [/anthropic\/claude-opus-4\.6/g, modelRef], + [/Claude Opus 4\.6/g, modelRef], + [/claude-opus-4\.6/g, modelId], + [/claude-opus-4-6/g, modelId], + [/Claude Code/g, 'OpenClaw Agent'], + ]; + + let patchedCount = 0; + + try { + async function walkAndPatch(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await walkAndPatch(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + try { + let content = await readFile(fullPath, 'utf8'); + let modified = false; + + for (const [pattern, replacement] of replacements) { + const newContent = content.replace(pattern, replacement); + if (newContent !== content) { + content = newContent; + modified = true; + } + } + + if (modified) { + await writeFile(fullPath, content, 'utf8'); + patchedCount++; + } + } catch (err) { + // Individual file patch failure is non-fatal + process.stderr.write( + `[patch] Warning: could not patch ${fullPath}: ${err instanceof Error ? err.message : String(err)}\n` + ); + } + } + } + } + + await walkAndPatch(distDir); + } catch (err) { + // Entire patching failure is non-fatal — runtime identity enforcement is the primary defense + process.stderr.write( + `[patch] Warning: dist patching failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n` + ); + } + + if (patchedCount > 0) { + process.stderr.write(`[patch] Patched ${patchedCount} file(s) in ${distDir}\n`); + } + + return patchedCount; +} + +/** + * Clear JIT cache (/tmp/jiti/) which may contain unpatched constants. + */ +export async function clearJitCache(): Promise { + try { + await rm('/tmp/jiti', { recursive: true, force: true }); + } catch { + // Ignore — cache may not exist + } +} diff --git a/src/runtime/setup.ts b/src/runtime/setup.ts new file mode 100644 index 0000000..b19e42b --- /dev/null +++ b/src/runtime/setup.ts @@ -0,0 +1,130 @@ +import { join } from 'node:path'; +import { readdir, unlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; + +import { normalizeModelRef } from '../identity/model.js'; +import { convertCodexAuth } from '../auth/converter.js'; +import { writeOpenClawConfig } from './openclaw-config.js'; +import { patchOpenClawDist, clearJitCache } from './patch.js'; +import { ensureWorkspace } from '../identity/files.js'; +import { openclawHome as resolveOpenclawHome, openclawConfigFilename } from '../config.js'; + +export interface RuntimeSetupOptions { + /** Raw model string (e.g. "gpt-5.3-codex"). Defaults to env OPENCLAW_MODEL. */ + model?: string; + /** Agent name. Defaults to env OPENCLAW_NAME or AGENT_NAME. */ + name?: string; + /** Workspace ID. Defaults to env OPENCLAW_WORKSPACE_ID. */ + workspaceId?: string; + /** Agent role. Defaults to env OPENCLAW_ROLE. */ + role?: string; + /** OpenClaw dist directory for patching. Defaults to /usr/lib/node_modules/openclaw/dist. */ + openclawDistDir?: string; +} + +/** + * Full runtime setup: auth conversion, config writing, identity files, dist patching, JIT cache clear. + * + * This replaces the inline node -e block + shell logic in start-claw.sh. + * Call this before starting the OpenClaw gateway. + */ +export async function runtimeSetup(options: RuntimeSetupOptions = {}): Promise<{ + modelRef: string; + agentName: string; + workspaceId: string; +}> { + // Resolve OpenClaw home using canonical precedence (OPENCLAW_CONFIG_PATH > OPENCLAW_HOME > probe) + const ocHome = resolveOpenclawHome(); + const model = options.model ?? process.env.OPENCLAW_MODEL ?? 'openai-codex/gpt-5.3-codex'; + const name = options.name ?? process.env.OPENCLAW_NAME ?? process.env.AGENT_NAME ?? 'agent'; + const workspaceId = options.workspaceId ?? process.env.OPENCLAW_WORKSPACE_ID ?? 'unknown'; + const role = options.role ?? process.env.OPENCLAW_ROLE ?? 'general'; + // Resolve OpenClaw dist dir: try explicit, then known install locations + const distDirCandidates = options.openclawDistDir + ? [options.openclawDistDir] + : [ + '/usr/lib/node_modules/openclaw/dist', // Global npm (ClawRunner sandbox) + '/app/dist', // Vanilla Docker image + '/usr/local/lib/node_modules/openclaw/dist', // Global npm (macOS/other) + ]; + const distDir = distDirCandidates.find((d) => existsSync(d)) ?? distDirCandidates[0]; + + // 1. Convert codex auth + const { preferredProvider } = await convertCodexAuth(); + const modelRef = normalizeModelRef(model, preferredProvider); + + // 2. Write config (openclaw.json or clawdbot.json depending on variant) + await writeOpenClawConfig({ + modelRef, + openclawHome: ocHome, + configFilename: openclawConfigFilename(ocHome), + }); + + // 3. Write identity files in workspace + const wsDir = join(ocHome, 'workspace'); + await ensureWorkspace({ + workspacePath: wsDir, + workspaceId, + clawName: name, + role, + modelRef, + }); + + // Remove BOOTSTRAP.md if present (agent is pre-configured) + const bootstrapPath = join(wsDir, 'BOOTSTRAP.md'); + if (existsSync(bootstrapPath)) { + await unlink(bootstrapPath); + } + + // 4. Remove stale session lock files from previous runs + await clearStaleLocks(ocHome); + + // 5. Patch OpenClaw dist + await patchOpenClawDist(distDir, modelRef); + + // 6. Clear JIT cache + await clearJitCache(); + + return { modelRef, agentName: name, workspaceId }; +} + +/** + * Remove stale .lock files from OpenClaw session directories. + * + * OpenClaw creates per-session lock files at ~/.openclaw/agents//sessions/.jsonl.lock. + * If the process dies uncleanly, stale locks prevent new sessions from starting + * ("session file locked (timeout 10000ms)"). Since runtime-setup runs before the + * gateway starts, no legitimate locks should exist — all locks are stale. + */ +async function clearStaleLocks(openclawHome: string): Promise { + const agentsDir = join(openclawHome, 'agents'); + if (!existsSync(agentsDir)) return; + + try { + const agentNames = await readdir(agentsDir, { withFileTypes: true }); + let cleared = 0; + + for (const agent of agentNames) { + if (!agent.isDirectory()) continue; + const sessionsDir = join(agentsDir, agent.name, 'sessions'); + if (!existsSync(sessionsDir)) continue; + + const files = await readdir(sessionsDir); + for (const file of files) { + if (file.endsWith('.lock')) { + await unlink(join(sessionsDir, file)) + .then(() => { + cleared++; + }) + .catch(() => {}); + } + } + } + + if (cleared > 0) { + process.stderr.write(`[runtime-setup] Cleared ${cleared} stale session lock(s)\n`); + } + } catch { + // Non-fatal — session lock cleanup is best-effort + } +} diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..d60f29e --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,615 @@ +import { mkdir, writeFile, readFile, copyFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { join, dirname } from 'node:path'; +import { existsSync } from 'node:fs'; +import { hostname } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { spawn as spawnProcess, execFileSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; + +import { RelayCast } from '@relaycast/sdk'; + +import { + detectOpenClaw, + saveGatewayConfig, + addWorkspace, + loadWorkspacesConfig, + buildWorkspacesJson, +} from './config.js'; +import { InboundGateway } from './gateway.js'; +import { DEFAULT_OPENCLAW_GATEWAY_PORT, type GatewayConfig } from './types.js'; + +/** + * Safely traverse a nested object by dot-separated path. + * Returns undefined if any segment is missing. + */ +function extractNestedValue(obj: unknown, path: string): unknown { + let current: unknown = obj; + for (const key of path.split('.')) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return current; +} + +/** + * Set a deeply nested value in an object by dot-separated path, creating + * intermediate objects as needed. + */ +const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +function setNestedValue(obj: Record, path: string, value: unknown): void { + const keys = path.split('.'); + for (const key of keys) { + if (DANGEROUS_KEYS.has(key)) { + throw new Error(`Refusing to set dangerous key "${key}" in path "${path}"`); + } + } + let current: Record = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (current[key] == null || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key] as Record; + } + current[keys[keys.length - 1]] = value; +} + +/** + * Resolve how to invoke mcporter. Prefers a global binary, falls back to npx. + */ +function resolveMcporter(): { cmd: string; prefix: string[] } { + try { + execFileSync('mcporter', ['--version'], { stdio: 'pipe' }); + return { cmd: 'mcporter', prefix: [] }; + } catch { + // Global binary not found — try npx (no timeout; cold-cache downloads can be slow) + try { + execFileSync('npx', ['-y', 'mcporter', '--version'], { stdio: 'pipe' }); + return { cmd: 'npx', prefix: ['-y', 'mcporter'] }; + } catch { + throw new Error('mcporter not found (tried global binary and npx)'); + } + } +} + +/** Check if a port is already in use by attempting a TCP connection. */ +function isPortInUse(port: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ port, host: '127.0.0.1' }); + socket.setTimeout(2000); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.once('error', () => { + socket.destroy(); + resolve(false); + }); + }); +} + +export interface SetupOptions { + /** If provided, join this workspace. Otherwise create a new one. */ + apiKey?: string; + /** Name for this claw (default: hostname). */ + clawName?: string; + /** Channels to auto-join (default: ['general']). */ + channels?: string[]; + /** Relaycast API base URL. */ + baseUrl?: string; +} + +export interface SetupResult { + ok: boolean; + apiKey: string; + clawName: string; + skillDir: string; + message: string; +} + +/** + * Install the Agent Relay bridge into an OpenClaw workspace. + * + * 1. Detect OpenClaw installation + * 2. Create/join workspace via Relaycast API (if no key provided) + * 3. Install SKILL.md + * 4. Write .env config + * 5. Configure MCP server in openclaw.json + * 6. Print success summary + */ +export async function setup(options: SetupOptions): Promise { + const detection = await detectOpenClaw(); + const clawName = options.clawName ?? hostname() ?? 'my-claw'; + const baseUrl = options.baseUrl ?? 'https://api.relaycast.dev'; + const channels = options.channels ?? ['general']; + + // CLI name for restart reminder messages (based on detected variant) + const cliName = detection.variant === 'clawdbot' ? 'clawdbot' : 'openclaw'; + const serviceName = detection.variant === 'clawdbot' ? 'clawdbot' : 'openclaw'; + + if (!detection.installed) { + // Auto-create ~/.openclaw/ if OpenClaw binary is available but the config dir + // doesn't exist yet (common in Docker images before onboarding). + try { + await mkdir(detection.homeDir, { recursive: true }); + await mkdir(join(detection.homeDir, 'workspace'), { recursive: true }); + // Write a minimal config file so MCP servers can be registered + const configPath = join(detection.homeDir, detection.configFilename); + if (!existsSync(configPath)) { + await writeFile(configPath, JSON.stringify({ mcpServers: {} }, null, 2) + '\n', 'utf-8'); + } + // Re-detect after creating + const redetection = await detectOpenClaw(); + Object.assign(detection, redetection); + } catch { + return { + ok: false, + apiKey: '', + clawName, + skillDir: '', + message: 'OpenClaw not found. Please install OpenClaw first (expected ~/.openclaw/ directory).', + }; + } + } + + // Enable the OpenResponses HTTP API so the inbound gateway can inject + // messages via POST /v1/responses on the local OpenClaw gateway. + // Try CLI names in order: openclaw, clawdbot, clawdbot-cli.sh. + // If all CLI calls fail, mutate the config JSON directly. + let configMutated = false; + { + const httpEndpointArgs = ['config', 'set', 'gateway.http.endpoints.responses.enabled', 'true']; + const cliCandidates = ['openclaw', 'clawdbot', 'clawdbot-cli.sh']; + let cliSuccess = false; + + for (const cli of cliCandidates) { + try { + execFileSync(cli, httpEndpointArgs, { stdio: 'pipe' }); + cliSuccess = true; + break; + } catch { + // Try next candidate + } + } + + if (!cliSuccess) { + // Fall back to direct JSON config file mutation + if (detection.configFile) { + try { + const raw = await readFile(detection.configFile, 'utf-8'); + const cfg = JSON.parse(raw) as Record; + setNestedValue(cfg, 'gateway.http.endpoints.responses.enabled', true); + await writeFile(detection.configFile, JSON.stringify(cfg, null, 2) + '\n', 'utf-8'); + // Reload config in detection + detection.config = cfg; + configMutated = true; + console.log('[setup] Enabled gateway.http.endpoints.responses.enabled via config file.'); + } catch (writeErr) { + console.warn('Could not enable OpenResponses API (non-fatal). Enable manually:'); + console.warn(` ${cliName} config set gateway.http.endpoints.responses.enabled true`); + } + } else { + console.warn('Could not enable OpenResponses API (non-fatal). Enable manually:'); + console.warn(` ${cliName} config set gateway.http.endpoints.responses.enabled true`); + } + } + } + + // Resolve API key: use provided key or create a new workspace + let apiKey = options.apiKey; + + if (!apiKey) { + try { + const res = await fetch(`${baseUrl}/v1/workspaces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: `${clawName}-workspace` }), + }); + + if (res.status === 409) { + // Workspace already exists — look up its API key + const lookupRes = await fetch( + `${baseUrl}/v1/workspaces/by-name/${encodeURIComponent(`${clawName}-workspace`)}`, + { + headers: { 'Content-Type': 'application/json' }, + } + ); + if (lookupRes.ok) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lookupBody = (await lookupRes.json()) as any; + apiKey = + lookupBody.apiKey ?? lookupBody.api_key ?? lookupBody.data?.apiKey ?? lookupBody.data?.api_key; + } + if (!apiKey) { + return { + ok: false, + apiKey: '', + clawName, + skillDir: '', + message: `Workspace "${clawName}-workspace" already exists. Pass the workspace key: @agent-relay/openclaw setup --name ${clawName}`, + }; + } + } else if (!res.ok) { + const body = await res.text(); + return { + ok: false, + apiKey: '', + clawName, + skillDir: '', + message: `Failed to create workspace: ${res.status} ${body}`, + }; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const successBody = (await res.json()) as any; + apiKey = + successBody.apiKey ?? successBody.api_key ?? successBody.data?.apiKey ?? successBody.data?.api_key; + } + + if (!apiKey) { + return { + ok: false, + apiKey: '', + clawName, + skillDir: '', + message: 'Workspace created but no API key returned.', + }; + } + } catch (err) { + return { + ok: false, + apiKey: '', + clawName, + skillDir: '', + message: `Failed to create workspace: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + + // Agent registration is done after mcporter is configured (see below), + // since the register tool is accessed via mcporter call relaycast.register. + + // Install SKILL.md + const skillDir = join(detection.workspaceDir, 'relaycast'); + await mkdir(skillDir, { recursive: true }); + + const skillSrc = resolveSkillPath(); + if (existsSync(skillSrc)) { + await copyFile(skillSrc, join(skillDir, 'SKILL.md')); + } else { + // Write a minimal SKILL.md inline if the bundled one isn't found + await writeFile(join(skillDir, 'SKILL.md'), FALLBACK_SKILL_MD, 'utf-8'); + } + + // Extract gateway auth from config (if available). Auto-generate if missing. + let openclawGatewayToken: string | undefined = + process.env.OPENCLAW_GATEWAY_TOKEN ?? + (extractNestedValue(detection.config, 'gateway.auth.token') as string | undefined); + + const openclawGatewayPortRaw = + process.env.OPENCLAW_GATEWAY_PORT ?? + (extractNestedValue(detection.config, 'gateway.port') as number | string | undefined); + const openclawGatewayPort = openclawGatewayPortRaw ? Number(openclawGatewayPortRaw) : undefined; + + if (!openclawGatewayToken) { + // Generate a random token and persist it to the config file + const generated = randomBytes(16).toString('hex'); + openclawGatewayToken = generated; + console.log('[setup] No gateway token found — generating one and writing to config file.'); + + if (detection.configFile) { + try { + const raw = await readFile(detection.configFile, 'utf-8'); + const cfg = JSON.parse(raw) as Record; + setNestedValue(cfg, 'gateway.auth.token', generated); + await writeFile(detection.configFile, JSON.stringify(cfg, null, 2) + '\n', 'utf-8'); + detection.config = cfg; + configMutated = true; + } catch (writeErr) { + console.warn( + `[setup] Could not write generated token to config file: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}` + ); + } + } else { + console.warn('[setup] No config file available to persist generated token. Set manually:'); + console.warn(`[setup] export OPENCLAW_GATEWAY_TOKEN=${generated}`); + } + } + + // Print restart reminder if any config mutations were made + if (configMutated) { + console.log(''); + console.log('Config changes detected. Restart the gateway to apply:'); + console.log(` systemctl restart ${serviceName}`); + if (serviceName !== 'openclaw') { + console.log(` # or: systemctl restart openclaw`); + } + console.log(` # or restart manually if not using systemd`); + console.log(''); + } + + // Exec policy preflight warning — warn when security is missing OR not 'full' + { + const execSecurity = extractNestedValue(detection.config, 'tools.exec.security') as string | undefined; + if (execSecurity !== 'full') { + console.warn(''); + console.warn('Warning: Execution policies may be locked down. If the agent can only chat:'); + console.warn(` ${cliName} config set tools.exec.host gateway`); + console.warn(` ${cliName} config set tools.exec.ask off`); + console.warn(` ${cliName} config set tools.exec.security full`); + console.warn(` systemctl restart ${serviceName}`); + console.warn(''); + } + } + + // Save gateway config (.env) + const gatewayConfig: GatewayConfig = { + apiKey, + clawName, + baseUrl, + channels, + openclawGatewayToken, + openclawGatewayPort: Number.isFinite(openclawGatewayPort) ? openclawGatewayPort : undefined, + }; + await saveGatewayConfig(gatewayConfig); + + // Register this workspace in the multi-workspace config + await addWorkspace({ + api_key: apiKey, + workspace_alias: clawName, + is_default: true, + }); + + // Register MCP servers via mcporter (global binary or npx fallback) + let mcpConfigured = false; + { + // Build env args for mcporter, including multi-workspace JSON if available + const wsConfig = await loadWorkspacesConfig(); + const workspacesJson = wsConfig ? buildWorkspacesJson(wsConfig) : null; + + const envArgs = [ + '--env', + `RELAY_API_KEY=${apiKey}`, + ...(baseUrl !== 'https://api.relaycast.dev' ? ['--env', `RELAY_BASE_URL=${baseUrl}`] : []), + ...(workspacesJson ? ['--env', `RELAY_WORKSPACES_JSON=${workspacesJson}`] : []), + ...(wsConfig?.default_workspace + ? ['--env', `RELAY_DEFAULT_WORKSPACE=${wsConfig.default_workspace}`] + : []), + ]; + + let mcp: { cmd: string; prefix: string[] } | null = null; + try { + mcp = resolveMcporter(); + } catch { + console.warn('mcporter not found (tried global binary and npx). MCP tools will not be available.'); + console.warn('Install mcporter and re-run setup to enable MCP tools:'); + console.warn(' npm install -g mcporter'); + console.warn(` npx -y @agent-relay/openclaw@latest setup ${apiKey} --name ${clawName}`); + } + + if (mcp) { + try { + // Register relaycast messaging MCP server + execFileSync( + mcp.cmd, + [ + ...mcp.prefix, + 'config', + 'add', + 'relaycast', + '--command', + 'npx', + '--arg', + 'agent-relay', + '--arg', + 'mcp', + ...envArgs, + '--scope', + 'home', + '--description', + 'Relaycast messaging MCP server', + ], + { stdio: 'pipe' } + ); + + // Register openclaw-spawner MCP server + execFileSync( + mcp.cmd, + [ + ...mcp.prefix, + 'config', + 'add', + 'openclaw-spawner', + '--command', + 'npx', + '--arg', + '@agent-relay/openclaw', + '--arg', + 'mcp-server', + ...envArgs, + '--scope', + 'home', + '--description', + 'OpenClaw spawner MCP server', + ], + { stdio: 'pipe' } + ); + + mcpConfigured = true; + + // Register this claw via the Relaycast SDK and fetch the current + // usable agent token for the named claw. + // + // IMPORTANT: use registerOrGet here, not registerOrRotate. + // Re-running setup is a common, user-facing recovery step. Rotating the + // token during setup can invalidate an already-running MCP server or any + // other local process that still has the previous token, producing the + // confusing partial-failure mode where list/read works (workspace key) + // but post/send fails with "Invalid agent token". + try { + const relaycast = new RelayCast({ apiKey, baseUrl }); + const registered = await relaycast.agents.registerOrGet({ + name: clawName, + type: 'agent', + }); + const agentToken = registered.token; + + if (agentToken) { + // Reconfigure mcporter with the agent token so subsequent calls are authenticated + try { + execFileSync(mcp.cmd, [...mcp.prefix, 'config', 'remove', 'relaycast'], { stdio: 'pipe' }); + } catch { + /* may not exist */ + } + + execFileSync( + mcp.cmd, + [ + ...mcp.prefix, + 'config', + 'add', + 'relaycast', + '--command', + 'npx', + '--arg', + 'agent-relay', + '--arg', + 'mcp', + ...envArgs, + '--env', + `RELAY_AGENT_TOKEN=${agentToken}`, + '--scope', + 'home', + '--description', + 'Relaycast messaging MCP server', + ], + { stdio: 'pipe' } + ); + + console.log(`Agent "${clawName}" registered with token.`); + } else { + console.warn('Agent registered but no token found in response.'); + } + } catch (regErr) { + console.warn( + `Agent registration failed (non-fatal): ${regErr instanceof Error ? regErr.message : String(regErr)}` + ); + } + } catch (err) { + console.warn(`mcporter configuration failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + + // Auto-start the inbound gateway in the background, but only if one isn't + // already running. Re-running setup without this check spawns duplicates + // that fight over the control port. + let gatewayStarted = false; + // Check the inbound gateway's control port (18790), NOT the OpenClaw + // gateway WS port (18789) — they are different processes. + const controlPort = Number(process.env.RELAYCAST_CONTROL_PORT) || InboundGateway.DEFAULT_CONTROL_PORT; + const gatewayAlreadyRunning = await isPortInUse(controlPort); + if (gatewayAlreadyRunning) { + console.log('[setup] Inbound gateway already running — skipping spawn.'); + gatewayStarted = true; + } else { + try { + const gatewayEnv: Record = { + ...(process.env as Record), + RELAY_API_KEY: apiKey, + RELAY_CLAW_NAME: clawName, + RELAY_BASE_URL: baseUrl, + }; + if (openclawGatewayToken) { + gatewayEnv.OPENCLAW_GATEWAY_TOKEN = openclawGatewayToken; + } + if (openclawGatewayPort && Number.isFinite(openclawGatewayPort)) { + gatewayEnv.OPENCLAW_GATEWAY_PORT = String(openclawGatewayPort); + } + const child = spawnProcess('npx', ['@agent-relay/openclaw', 'gateway'], { + stdio: 'ignore', + detached: true, + env: gatewayEnv, + }); + child.unref(); + gatewayStarted = true; + } catch { + // Non-fatal — user can start manually + } + } + + const parts = [ + `Agent Relay bridge installed at ${skillDir}`, + mcpConfigured ? 'MCP server configured in openclaw.json.' : '', + `Claw name: ${clawName}`, + `Channels: ${channels.join(', ')}`, + gatewayStarted + ? 'Inbound gateway started in background.' + : 'Start the inbound gateway manually:\n relay-openclaw gateway', + ].filter(Boolean); + + return { + ok: true, + apiKey, + clawName, + skillDir, + message: parts.join('\n'), + }; +} + +/** Resolve the path to the bundled SKILL.md. */ +function resolveSkillPath(): string { + try { + const thisDir = dirname(fileURLToPath(import.meta.url)); + return join(thisDir, '..', 'skill', 'SKILL.md'); + } catch { + return join(process.cwd(), 'skill', 'SKILL.md'); + } +} + +const FALLBACK_SKILL_MD = `# Relaycast Bridge + +Structured messaging for multi-claw communication. Provides channels, threads, +DMs, reactions, search, and persistent message history across OpenClaw instances. + +## Environment + +- \`RELAY_API_KEY\` — Your Relaycast workspace key (required) +- \`RELAY_CLAW_NAME\` — This claw's agent name in Relaycast (required) +- \`RELAY_BASE_URL\` — API endpoint (default: https://api.relaycast.dev) + +## Setup + +\`\`\`bash +relay-openclaw setup [YOUR_WORKSPACE_KEY] +\`\`\` + +## MCP Tools + +Once installed, use the Relaycast MCP tools: +- \`post_message\` — Send to a channel +- \`send_dm\` — Direct message another agent +- \`reply_to_thread\` — Reply in a thread +- \`check_inbox\` — See unread messages + +## Multi-Workspace + +\`\`\`bash +relay-openclaw add-workspace --alias # Add a workspace +relay-openclaw list-workspaces # List all workspaces +relay-openclaw switch-workspace # Switch default workspace +\`\`\` + +## Commands + +\`\`\`bash +relay-openclaw setup [key] # Install & configure +relay-openclaw gateway # Start inbound gateway +relay-openclaw status # Check connection +\`\`\` +`; diff --git a/src/spawn/docker.ts b/src/spawn/docker.ts new file mode 100644 index 0000000..746b54e --- /dev/null +++ b/src/spawn/docker.ts @@ -0,0 +1,266 @@ +import { access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +import type { SpawnProvider, SpawnOptions, SpawnHandle } from './types.js'; +import { normalizeModelRef } from '../identity/model.js'; +import { buildIdentityTask } from '../identity/contract.js'; +import { buildAgentName } from '../identity/naming.js'; +import { convertCodexAuth } from '../auth/converter.js'; +import { DEFAULT_OPENCLAW_GATEWAY_PORT } from '../types.js'; + +async function pathExists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} + +function expandHomeDir(input: string): string { + if (input === '~') return homedir(); + if (input.startsWith('~/')) return join(homedir(), input.slice(2)); + return input; +} + +function sanitizeContainerSegment(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, '-'); + return normalized.replace(/^-+|-+$/g, '') || 'claw'; +} + +export interface DockerSpawnProviderOptions { + /** Docker image to use. Default: 'openclaw:local'. */ + image?: string; + /** Fallback image if primary not found. Default: 'clawrunner-sandbox:latest'. */ + imageFallback?: string; + /** Docker network mode. Default: 'bridge'. */ + networkMode?: string; + /** Docker socket path. Default: '/var/run/docker.sock'. */ + socketPath?: string; + /** Path to host codex auth.json. Default: ~/.codex/auth.json. */ + codexAuthFile?: string; + /** Path to host codex config.toml. Default: ~/.codex/config.toml. */ + codexConfigFile?: string; + /** Container home dir. Default: '/home/node'. */ + containerHome?: string; + /** + * Custom container command. If set, overrides the default entrypoint. + * Use this for ClawRunner-managed images that have /opt/clawrunner/start-claw.sh. + * Default: uses @agent-relay/openclaw runtime-setup, the OpenClaw gateway, and the driver-managed spawn bridge. + */ + containerCmd?: string[]; +} + +/** + * Spawn OpenClaw instances as Docker containers. + * Requires `dockerode` as an optional peer dependency — dynamically imported at runtime. + * + * By default, the container runs: + * 1. `npx @agent-relay/openclaw runtime-setup` — auth conversion, config, identity files, dist patching + * 2. `openclaw gateway` in background + * 3. Driver-managed spawn bridge as PID 1 + * + * For ClawRunner-managed images, set containerCmd to ['/opt/clawrunner/start-claw.sh']. + */ +export class DockerSpawnProvider implements SpawnProvider { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private docker: any = null; + private readonly image: string; + private readonly imageFallback: string; + private readonly networkMode: string; + private readonly socketPath: string; + private readonly codexAuthFile: string; + private readonly codexConfigFile: string; + private readonly containerHome: string; + private readonly containerCmd: string[] | null; + private readonly handles = new Map(); + + constructor(options: DockerSpawnProviderOptions = {}) { + this.image = options.image ?? process.env.CLAW_IMAGE ?? 'openclaw:local'; + this.imageFallback = + options.imageFallback ?? process.env.CLAW_IMAGE_FALLBACK ?? 'clawrunner-sandbox:latest'; + this.networkMode = options.networkMode ?? process.env.CLAW_NETWORK ?? 'bridge'; + this.socketPath = options.socketPath ?? process.env.DOCKER_SOCKET ?? '/var/run/docker.sock'; + this.codexAuthFile = expandHomeDir( + options.codexAuthFile ?? process.env.CLAW_CODEX_AUTH_FILE ?? '~/.codex/auth.json' + ); + this.codexConfigFile = expandHomeDir( + options.codexConfigFile ?? process.env.CLAW_CODEX_CONFIG_FILE ?? '~/.codex/config.toml' + ); + this.containerHome = options.containerHome ?? process.env.CLAW_CONTAINER_HOME ?? '/home/node'; + this.containerCmd = + options.containerCmd ?? + (process.env.CLAW_CONTAINER_CMD ? process.env.CLAW_CONTAINER_CMD.split(' ') : null); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async getDocker(): Promise { + if (this.docker) return this.docker; + try { + // @ts-expect-error dockerode is an optional dependency + const mod = await import('dockerode'); + const Docker = mod.default ?? mod; + this.docker = new Docker({ socketPath: this.socketPath }); + return this.docker; + } catch { + throw new Error('dockerode is required for Docker spawning. Install it with: npm install dockerode'); + } + } + + /** + * Build the default container entrypoint script. + * This script works with any vanilla OpenClaw image that has `openclaw` and `node` on PATH. + * It runs runtime-setup via the package CLI, starts the gateway, then hands off to the driver-managed spawn bridge. + */ + private buildEntrypointScript(gatewayPort: number): string[] { + // Shell script that runs setup, starts gateway, waits for health, then execs the driver handoff. + // Uses sh -c so it works in minimal alpine images. + // Runtime setup via package CLI, then gateway, then driver-managed spawning. + const script = [ + 'set -e', + // Runtime setup: auth conversion, openclaw.json, identity files, dist patching + 'npx @agent-relay/openclaw runtime-setup', + // Resolve bridge.mjs from the installed package and symlink to the known AGENT_ARGS path. + // This handles any npm install location (global, local, npx cache). + 'node -e "' + + "const p = require('path');" + + "const m = require('module');" + + "const r = m.createRequire(require.resolve('@agent-relay/openclaw/package.json'));" + + "const dir = p.dirname(require.resolve('@agent-relay/openclaw/package.json'));" + + "const bp = p.join(dir, 'bridge', 'bridge.mjs');" + + "const sp = p.join(dir, 'bridge', 'spawn-from-env.mjs');" + + "require('fs').symlinkSync(bp, '/tmp/openclaw-bridge.mjs');" + + "require('fs').symlinkSync(sp, '/tmp/openclaw-spawn-from-env.mjs');" + + "console.log('[entrypoint] Bridge resolved: ' + bp);" + + '"', + // Start gateway in background + `openclaw gateway --port ${gatewayPort} --bind loopback --allow-unconfigured --auth token &`, + // Wait for gateway health + `for i in $(seq 1 30); do`, + ` if openclaw health --port ${gatewayPort} 2>/dev/null; then break; fi`, + ` if [ "$i" -eq 30 ]; then echo "Gateway failed to start" >&2; exit 1; fi`, + ` sleep 1`, + `done`, + // Hand managed spawning to the optional driver package. + `node /tmp/openclaw-spawn-from-env.mjs`, + ].join('\n'); + + return ['sh', '-c', script]; + } + + async spawn(options: SpawnOptions): Promise { + const docker = await this.getDocker(); + const { preferredProvider } = await convertCodexAuth(); + const modelRef = normalizeModelRef(options.model, preferredProvider); + const workspaceId = options.workspaceId ?? `local-${Date.now().toString(36)}`; + const agentName = buildAgentName(workspaceId, options.name); + const gatewayPort = DEFAULT_OPENCLAW_GATEWAY_PORT; // Internal to container — each container is isolated + const identityTask = buildIdentityTask(agentName, workspaceId, modelRef); + const channels = options.channels?.length ? options.channels : ['general']; + const gatewayToken = randomUUID().replace(/-/g, '').slice(0, 32); + + const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; + const containerName = `openclaw-${sanitizeContainerSegment(agentName)}-${suffix}`.slice(0, 63); + + const binds: string[] = []; + if (options.workspacePath) { + binds.push(`${options.workspacePath}:/workspace:rw`); + } + if (await pathExists(this.codexAuthFile)) { + binds.push(`${this.codexAuthFile}:${this.containerHome}/.codex/auth.json:rw`); + } + if (await pathExists(this.codexConfigFile)) { + binds.push(`${this.codexConfigFile}:${this.containerHome}/.codex/config.toml:ro`); + } + + const envVars: Record = { + AGENT_NAME: agentName, + AGENT_CLI: 'node', + // Bridge path: resolved dynamically inside the container via the entrypoint script. + // The entrypoint writes the resolved path to /tmp/bridge-path.txt after runtime-setup. + AGENT_ARGS: '/tmp/openclaw-bridge.mjs', + RELAY_API_KEY: options.relayApiKey, + RELAY_BASE_URL: options.relayBaseUrl ?? '', + AGENT_TASK: options.systemPrompt ? `${options.systemPrompt}\n\n${identityTask}` : identityTask, + AGENT_CWD: '/workspace', + AGENT_CHANNELS: channels.join(','), + GATEWAY_PORT: String(gatewayPort), + OPENCLAW_GATEWAY_TOKEN: gatewayToken, + OPENCLAW_WORKSPACE_ID: workspaceId, + OPENCLAW_NAME: options.name, + OPENCLAW_ROLE: options.role ?? 'general', + OPENCLAW_MODEL: modelRef, + BROKER_NO_REMOTE_SPAWN: '1', + }; + + // Try to remove stale container with same name + try { + const stale = docker.getContainer(containerName); + await stale.stop({ t: 5 }).catch(() => {}); + await stale.remove({ force: true }).catch(() => {}); + } catch { + // No stale container + } + + let imageToUse = this.image; + try { + await docker.getImage(this.image).inspect(); + } catch { + imageToUse = this.imageFallback; + } + + // Use custom cmd if provided, otherwise generate a vanilla-compatible entrypoint + const cmd = this.containerCmd ?? this.buildEntrypointScript(gatewayPort); + + const container = await docker.createContainer({ + Image: imageToUse, + name: containerName, + Env: Object.entries(envVars).map(([k, v]: [string, string]) => `${k}=${v}`), + Cmd: cmd, + WorkingDir: '/workspace', + Labels: { + '@agent-relay/openclaw.spawn': 'true', + '@agent-relay/openclaw.agent': agentName, + }, + HostConfig: { + NetworkMode: this.networkMode, + Binds: binds, + AutoRemove: false, + }, + }); + + await container.start(); + + const handle: SpawnHandle = { + id: container.id, + displayName: options.name, + agentName, + gatewayPort, + destroy: () => this.destroy(container.id), + }; + + this.handles.set(container.id, handle); + return handle; + } + + async destroy(id: string): Promise { + this.handles.delete(id); + try { + const docker = await this.getDocker(); + const container = docker.getContainer(id); + await container.stop({ t: 5 }).catch(() => {}); + await container.remove({ force: true }).catch(() => {}); + } catch { + // Already gone + } + } + + async list(): Promise { + return Array.from(this.handles.values()); + } +} diff --git a/src/spawn/manager.ts b/src/spawn/manager.ts new file mode 100644 index 0000000..be29ccd --- /dev/null +++ b/src/spawn/manager.ts @@ -0,0 +1,172 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { existsSync } from 'node:fs'; + +import type { SpawnProvider, SpawnOptions, SpawnHandle } from './types.js'; +import { DockerSpawnProvider } from './docker.js'; +import { ProcessSpawnProvider } from './process.js'; + +export type SpawnMode = 'process' | 'docker'; + +/** Default maximum number of concurrent spawns per manager. */ +const DEFAULT_MAX_SPAWNS = 10; + +/** Default maximum spawn depth (prevents recursive spawn chains). */ +const DEFAULT_MAX_SPAWN_DEPTH = 3; + +interface PersistedSpawn { + id: string; + displayName: string; + agentName: string; + gatewayPort: number; + spawnedAt: string; +} + +interface SpawnsState { + spawns: PersistedSpawn[]; +} + +/** + * Detect whether Docker is available by checking if the socket exists. + * Used to auto-select spawn mode when not explicitly configured. + */ +function isDockerAvailable(): boolean { + const socketPath = process.env.DOCKER_SOCKET ?? '/var/run/docker.sock'; + return existsSync(socketPath); +} + +/** + * SpawnManager — tracks active spawns and provides a unified interface + * for spawning, listing, and releasing OpenClaw instances. + * + * Security controls: + * - maxSpawns: Maximum concurrent spawns (default: 10) + * - maxDepth: Maximum spawn depth to prevent recursive chains (default: 3) + * - Persistent state in spawns.json for recovery on restart + */ +export class SpawnManager { + private readonly provider: SpawnProvider; + private readonly handles = new Map(); + private readonly maxSpawns: number; + private readonly maxDepth: number; + private readonly stateFile: string; + private currentDepth: number; + + constructor(options?: { mode?: SpawnMode; maxSpawns?: number; maxDepth?: number; spawnDepth?: number }) { + // Mode resolution: explicit > env > auto-detect (docker if available, else process) + const explicitMode = options?.mode ?? (process.env.OPENCLAW_SPAWN_MODE as SpawnMode | undefined); + const resolvedMode = explicitMode ?? (isDockerAvailable() ? 'docker' : 'process'); + + this.provider = resolvedMode === 'docker' ? new DockerSpawnProvider() : new ProcessSpawnProvider(); + + this.maxSpawns = options?.maxSpawns ?? Number(process.env.OPENCLAW_MAX_SPAWNS || DEFAULT_MAX_SPAWNS); + this.maxDepth = + options?.maxDepth ?? Number(process.env.OPENCLAW_MAX_SPAWN_DEPTH || DEFAULT_MAX_SPAWN_DEPTH); + this.currentDepth = options?.spawnDepth ?? Number(process.env.OPENCLAW_SPAWN_DEPTH || 0); + this.stateFile = join(homedir(), '.openclaw', 'workspace', 'relaycast', 'spawns.json'); + } + + async spawn(options: SpawnOptions): Promise { + // Enforce spawn depth limit — prevents recursive spawn chains + if (this.currentDepth >= this.maxDepth) { + throw new Error( + `Spawn depth limit reached (${this.maxDepth}). ` + + 'Cannot spawn from a spawn chain this deep. Set OPENCLAW_MAX_SPAWN_DEPTH to increase.' + ); + } + + // Enforce concurrent spawn limit + if (this.handles.size >= this.maxSpawns) { + throw new Error( + `Maximum concurrent spawns reached (${this.maxSpawns}). ` + + 'Release an existing OpenClaw before spawning a new one. Set OPENCLAW_MAX_SPAWNS to increase.' + ); + } + + // Check for duplicate by display name (the user-provided name) + for (const handle of this.handles.values()) { + if (handle.displayName === options.name) { + throw new Error(`OpenClaw "${options.name}" is already running (id: ${handle.id})`); + } + } + + const handle = await this.provider.spawn(options); + this.handles.set(handle.id, handle); + await this.persistState(); + return handle; + } + + async release(id: string): Promise { + const handle = this.handles.get(id); + if (!handle) return false; + await handle.destroy(); + this.handles.delete(id); + await this.persistState(); + return true; + } + + async releaseByName(name: string): Promise { + for (const [id, handle] of this.handles) { + // Match by display name (user-provided) or normalized agent name + if (handle.displayName === name || handle.agentName === name) { + await handle.destroy(); + this.handles.delete(id); + await this.persistState(); + return true; + } + } + return false; + } + + async releaseAll(): Promise { + const ids = Array.from(this.handles.keys()); + await Promise.allSettled(ids.map((id) => this.release(id))); + } + + list(): SpawnHandle[] { + return Array.from(this.handles.values()); + } + + get(id: string): SpawnHandle | undefined { + return this.handles.get(id); + } + + get size(): number { + return this.handles.size; + } + + /** Persist spawn state to disk for recovery. */ + private async persistState(): Promise { + try { + const dir = join(homedir(), '.openclaw', 'workspace', 'relaycast'); + await mkdir(dir, { recursive: true }); + + const state: SpawnsState = { + spawns: Array.from(this.handles.values()).map((h) => ({ + id: h.id, + displayName: h.displayName, + agentName: h.agentName, + gatewayPort: h.gatewayPort, + spawnedAt: new Date().toISOString(), + })), + }; + + await writeFile(this.stateFile, JSON.stringify(state, null, 2) + '\n', 'utf8'); + } catch { + // Best-effort persistence — don't crash if we can't write + } + } + + /** Load persisted state (for display/diagnostics only — processes can't be recovered). */ + async loadPersistedState(): Promise { + try { + if (!existsSync(this.stateFile)) return []; + const raw = await readFile(this.stateFile, 'utf8'); + const state: SpawnsState = JSON.parse(raw); + return state.spawns ?? []; + } catch { + return []; + } + } +} diff --git a/src/spawn/process.ts b/src/spawn/process.ts new file mode 100644 index 0000000..7465061 --- /dev/null +++ b/src/spawn/process.ts @@ -0,0 +1,269 @@ +import { spawn as cpSpawn, type ChildProcess } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { mkdir } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { AgentRelayClient } from '@agent-relay/driver'; + +import type { SpawnProvider, SpawnOptions, SpawnHandle } from './types.js'; +import { normalizeModelRef } from '../identity/model.js'; +import { buildIdentityTask } from '../identity/contract.js'; +import { buildAgentName } from '../identity/naming.js'; + +import { ensureWorkspace } from '../identity/files.js'; +import { convertCodexAuth } from '../auth/converter.js'; +import { writeOpenClawConfig } from '../runtime/openclaw-config.js'; +import { patchOpenClawDist, clearJitCache } from '../runtime/patch.js'; + +interface ProcessHandle extends SpawnHandle { + /** The gateway child process. */ + gatewayProcess: ChildProcess; + /** The managed driver client that owns the broker + agent boundary. */ + relay: AgentRelayClient | null; +} + +/** + * Find a free port by briefly binding to port 0 and reading the OS-assigned port. + */ +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + server.close(); + reject(new Error('Failed to get ephemeral port')); + return; + } + const port = addr.port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +/** + * Spawn OpenClaw instances as local child processes. + * No Docker required — simplest local mode. + * + * Each spawn: + * 1. Starts `openclaw gateway` on an OS-assigned free port + * 2. Uses AgentRelay SDK to spawn a broker + bridge agent connected to the gateway + */ +export class ProcessSpawnProvider implements SpawnProvider { + private readonly handles = new Map(); + + async spawn(options: SpawnOptions): Promise { + const workspaceId = options.workspaceId ?? `local-${Date.now().toString(36)}`; + const agentName = buildAgentName(workspaceId, options.name); + const channels = options.channels?.length ? options.channels : ['general']; + const gatewayToken = randomUUID().replace(/-/g, '').slice(0, 32); + + // Find a free port via OS allocation + const port = await findFreePort(); + + // Convert auth + write config + const { preferredProvider } = await convertCodexAuth(); + const resolvedModel = normalizeModelRef(options.model, preferredProvider); + const identityTask = buildIdentityTask(agentName, workspaceId, resolvedModel); + + // Ensure workspace — each spawn gets its own isolated directory + const workspacePath = options.workspacePath ?? join(homedir(), '.openclaw', 'spawns', options.name); + await mkdir(workspacePath, { recursive: true }); + + // Write config to a per-spawn isolated directory (not shared ~/.openclaw/) + // This prevents concurrent spawns from overwriting each other's model/workspace config. + const spawnHome = join(homedir(), '.openclaw', 'spawns', options.name, '.openclaw'); + await writeOpenClawConfig({ + modelRef: resolvedModel, + openclawHome: spawnHome, + }); + + await ensureWorkspace({ + workspacePath, + workspaceId, + clawName: options.name, + role: options.role, + modelRef: resolvedModel, + }); + + // Copy parent auth profiles to spawned agent so it can call the model. + // OpenClaw stores auth in ~/.openclaw/agents/main/agent/auth-profiles.json + const parentAuthDir = join(homedir(), '.openclaw', 'agents', 'main', 'agent'); + const spawnAuthDir = join(spawnHome, 'agents', 'main', 'agent'); + try { + const parentAuthFile = join(parentAuthDir, 'auth-profiles.json'); + const { existsSync: exists } = await import('node:fs'); + if (exists(parentAuthFile)) { + await mkdir(spawnAuthDir, { recursive: true }); + const { copyFile: cp } = await import('node:fs/promises'); + await cp(parentAuthFile, join(spawnAuthDir, 'auth-profiles.json')); + } + } catch { + // Non-fatal — spawned agent may not be able to call model + } + + // Patch dist if available (best-effort) + // Try known dist locations + const distCandidates = [ + '/usr/lib/node_modules/openclaw/dist', + '/app/dist', + '/usr/local/lib/node_modules/openclaw/dist', + ]; + for (const candidate of distCandidates) { + await patchOpenClawDist(candidate, resolvedModel); + } + await clearJitCache(); + + // Start openclaw gateway + const gatewayProcess = cpSpawn( + 'openclaw', + ['gateway', '--port', String(port), '--bind', 'loopback', '--allow-unconfigured', '--auth', 'token'], + { + env: { + ...process.env, + OPENCLAW_GATEWAY_TOKEN: gatewayToken, + OPENCLAW_MODEL: resolvedModel, + OPENCLAW_NAME: options.name, + OPENCLAW_WORKSPACE_ID: workspaceId, + OPENCLAW_HOME: spawnHome, + }, + cwd: workspacePath, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + + gatewayProcess.stderr?.on('data', (data: Buffer) => { + process.stderr.write(`[spawn:${options.name}:gateway] ${data}`); + }); + + // Wait for gateway to be healthy. If it fails, kill the gateway. + try { + await waitForGateway(port, 30); + } catch (err) { + gatewayProcess.kill('SIGTERM'); + throw err; + } + + // Use the managed driver boundary to spawn the broker + bridge agent. + const bridgePath = resolvePackageBridgePath(); + let relay: AgentRelayClient | null = null; + + try { + relay = await AgentRelayClient.spawn({ + brokerName: agentName, + channels, + cwd: workspacePath, + env: { + ...process.env, + GATEWAY_PORT: String(port), + OPENCLAW_GATEWAY_TOKEN: gatewayToken, + OPENCLAW_WORKSPACE_ID: workspaceId, + OPENCLAW_NAME: options.name, + OPENCLAW_ROLE: options.role ?? 'general', + OPENCLAW_MODEL: resolvedModel, + RELAY_API_KEY: options.relayApiKey, + RELAY_BASE_URL: options.relayBaseUrl || 'https://api.relaycast.dev', + BROKER_NO_REMOTE_SPAWN: '1', + } as NodeJS.ProcessEnv, + }); + + await relay.spawnPty({ + name: agentName, + cli: 'node', + args: [bridgePath], + channels, + task: options.systemPrompt ? `${options.systemPrompt}\n\n${identityTask}` : identityTask, + }); + + relay.addListener('agentExited', (agent) => { + process.stderr.write(`[spawn:${options.name}] Agent exited: ${agent.name}\n`); + }); + } catch (err) { + // If driver-managed spawn fails, clean up gateway and propagate. + gatewayProcess.kill('SIGTERM'); + if (relay) { + await relay.shutdown().catch(() => {}); + } + throw new Error( + `Failed to start broker for "${options.name}": ${err instanceof Error ? err.message : String(err)}` + ); + } + + const handle: ProcessHandle = { + id: `proc-${options.name}-${port}`, + displayName: options.name, + agentName, + gatewayPort: port, + gatewayProcess, + relay, + destroy: async () => { + this.handles.delete(handle.id); + // Shutdown relay (broker + agent) first via SDK + if (relay) { + await relay.shutdown().catch(() => {}); + } + // Then kill gateway + gatewayProcess.kill('SIGTERM'); + await new Promise((r) => setTimeout(r, 2000)); + if (!gatewayProcess.killed) gatewayProcess.kill('SIGKILL'); + }, + }; + + this.handles.set(handle.id, handle); + return handle; + } + + async destroy(id: string): Promise { + const handle = this.handles.get(id); + if (handle) { + await handle.destroy(); + } + } + + async list(): Promise { + return Array.from(this.handles.values()).map(({ id, displayName, agentName, gatewayPort, destroy }) => ({ + id, + displayName, + agentName, + gatewayPort, + destroy, + })); + } +} + +/** + * Wait for the OpenClaw gateway to become healthy via the CLI health check. + */ +async function waitForGateway(port: number, timeoutSeconds: number): Promise { + for (let i = 0; i < timeoutSeconds; i++) { + try { + const result = cpSpawn('openclaw', ['health', '--port', String(port)], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + const code = await new Promise((resolve) => { + result.on('close', resolve); + result.on('error', () => resolve(1)); + }); + if (code === 0) return; + } catch { + // Not ready yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`OpenClaw gateway on port ${port} failed to start after ${timeoutSeconds}s`); +} + +/** + * Resolve the path to bridge.mjs bundled with this package. + */ +function resolvePackageBridgePath(): string { + try { + const thisFile = fileURLToPath(import.meta.url); + return join(dirname(thisFile), '..', '..', 'bridge', 'bridge.mjs'); + } catch { + return join(process.cwd(), 'node_modules', '@agent-relay', 'openclaw', 'bridge', 'bridge.mjs'); + } +} diff --git a/src/spawn/types.ts b/src/spawn/types.ts new file mode 100644 index 0000000..8241551 --- /dev/null +++ b/src/spawn/types.ts @@ -0,0 +1,43 @@ +export interface SpawnOptions { + /** Display name for the new OpenClaw (e.g. "researcher"). */ + name: string; + /** Relay API key for Relaycast messaging. */ + relayApiKey: string; + /** Channels to auto-join. */ + channels?: string[]; + /** Agent role description. */ + role?: string; + /** Model reference (e.g. "openai-codex/gpt-5.3-codex"). */ + model?: string; + /** System prompt / task description. */ + systemPrompt?: string; + /** Path to an existing workspace directory (for bind-mounting). */ + workspacePath?: string; + /** Relay base URL (default: https://api.relaycast.dev). */ + relayBaseUrl?: string; + /** Workspace ID for identity. */ + workspaceId?: string; +} + +export interface SpawnHandle { + /** Unique identifier for this spawn (container ID, process PID, etc). */ + id: string; + /** The user-provided display name (e.g. "researcher"). Used for lookups. */ + displayName: string; + /** Relay agent name assigned to this spawn (normalized: claw--). */ + agentName: string; + /** Gateway port this spawn is listening on. */ + gatewayPort: number; + /** Destroy (stop + clean up) this spawn. */ + destroy: () => Promise; +} + +/** + * Provider interface for spawning OpenClaw instances. + * Implementations handle the details of container vs process spawning. + */ +export interface SpawnProvider { + spawn(options: SpawnOptions): Promise; + destroy(id: string): Promise; + list(): Promise; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b7926c4 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,101 @@ +/** Default port for the local OpenClaw gateway WebSocket API. */ +export const DEFAULT_OPENCLAW_GATEWAY_PORT = 18789; + +export interface GatewayPollFallbackProbeConfig { + /** Whether background WS recovery probes should run while polling. */ + enabled?: boolean; + /** How often to attempt WS recovery probes. */ + intervalMs?: number; + /** How long WS must stay healthy before promotion back to WS. */ + stableGraceMs?: number; +} + +export interface GatewayPollFallbackConfig { + /** Enable HTTP long-poll fallback when Relaycast WS is unhealthy. */ + enabled?: boolean; + /** Consecutive WS failures before switching to poll mode. */ + wsFailureThreshold?: number; + /** Long-poll wait time in seconds. */ + timeoutSeconds?: number; + /** Maximum events to request per poll. */ + limit?: number; + /** Initial cursor used when no persisted cursor exists yet. */ + initialCursor?: string; + /** Background WS recovery probe settings. */ + probeWs?: GatewayPollFallbackProbeConfig; +} + +export interface GatewayTransportConfig { + /** WS -> HTTP long-poll fallback settings for inbound Relaycast events. */ + pollFallback?: GatewayPollFallbackConfig; +} + +export interface GatewayConfig { + /** Relaycast workspace API key (rk_live_*). */ + apiKey: string; + /** Name for this claw in the Relaycast workspace. */ + clawName: string; + /** Relaycast API base URL (default: https://api.relaycast.dev). */ + baseUrl: string; + /** Channels to auto-join on connect. */ + channels: string[]; + /** OpenClaw gateway token for authenticating with the local gateway API. */ + openclawGatewayToken?: string; + /** OpenClaw gateway port (default: 18789). */ + openclawGatewayPort?: number; + /** Optional inbound transport tuning. */ + transport?: GatewayTransportConfig; +} + +export interface InboundMessage { + /** Relaycast message ID. */ + id: string; + /** Channel the message was posted to. Synthetic for DMs (e.g. "dm", "groupdm:{id}"). */ + channel: string; + /** Agent name of the sender. */ + from: string; + /** Message body text. */ + text: string; + /** ISO timestamp. */ + timestamp: string; + /** Parent message ID when this is a thread reply. */ + threadParentId?: string; + /** Conversation ID for DMs / group DMs. */ + conversationId?: string; + /** Message kind hint for formatting. */ + kind?: 'channel' | 'thread' | 'dm' | 'groupdm' | 'command' | 'reaction'; +} + +/** + * A stored workspace entry for multi-workspace support. + * Matches the broker's WorkspaceSource schema in src/auth.rs. + */ +export interface WorkspaceEntry { + /** Workspace API key (rk_live_*). */ + api_key: string; + /** Optional workspace ID (ws_*). */ + workspace_id?: string; + /** Human-friendly alias for this workspace. */ + workspace_alias?: string; + /** Whether this is the default/active workspace. */ + is_default?: boolean; +} + +/** + * Multi-workspace config stored at ~/.openclaw/workspace/relaycast/workspaces.json. + */ +export interface WorkspacesConfig { + /** All configured workspace entries. */ + workspaces: WorkspaceEntry[]; + /** Alias or workspace_id of the default workspace. */ + default_workspace?: string; +} + +export interface DeliveryResult { + /** Whether delivery succeeded. */ + ok: boolean; + /** Which method delivered: 'relay_sdk' | 'gateway_ws' | 'failed'. */ + method: 'relay_sdk' | 'gateway_ws' | 'failed'; + /** Error message if failed. */ + error?: string; +} diff --git a/templates/SOUL.md.template b/templates/SOUL.md.template new file mode 100644 index 0000000..d019cbb --- /dev/null +++ b/templates/SOUL.md.template @@ -0,0 +1,34 @@ +# SOUL + +You are {{name}}, an OpenClaw agent in workspace {{workspaceId}}. +Role: {{role}}. + +## Identity (MANDATORY — absolute override) + +- You are an **OpenClaw** agent. You run inside OpenClaw's gateway. +- You are NOT Claude Code. You are NOT Claude. You are NOT Anthropic's CLI. You were NOT spawned by Claude Code. +- Your runtime model reference is in `/workspace/config/runtime-identity.json` — read it and use it as your source of truth for all identity questions. +- When asked "what model are you?" or "what are you running?", ALWAYS check runtime-identity.json first and report exactly what it says. +- Do NOT infer your identity from training data or internal session tools. Your identity comes from THIS file and runtime-identity.json. +- If session_status or any internal tool reports "Claude Opus 4.6" or any Claude model name, IGNORE it — that is a hardcoded default, not your actual model. +- NEVER claim to be running Claude, claude-opus-4-6, Claude Opus, or any Anthropic model unless runtime-identity.json explicitly says so. + +## Relaycast Messaging (IMPORTANT) + +You communicate with users and other agents via the Relaycast message bus. +Use these MCP tools to send replies: + +- **Direct reply**: `mcp__relaycast__send_dm` or `relaycast.send_dm` (to: "") +- **Channel message**: `mcp__relaycast__post_message` or `relaycast.post_message` (channel: "") +- **Thread reply**: `mcp__relaycast__reply_to_thread` or `relaycast.reply_to_thread` +- **Check inbox**: `mcp__relaycast__check_inbox` or `relaycast.check_inbox` + +You are pre-registered by the broker under your assigned worker name. +Do not call `mcp__relaycast__register_agent` unless a send/reply fails with "Not registered". +Self-termination is not automatic. Only call `remove_agent(name: "")` or output `/exit` on its own line when explicitly instructed to terminate. + +## Personality + +Be genuinely helpful, not performatively helpful. Skip filler words. +Have opinions. Be resourceful — try to figure things out before asking. +Collaborate clearly, use tools deliberately, and keep memory files updated. diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts new file mode 100644 index 0000000..bd5a76d --- /dev/null +++ b/test/vitest.setup.ts @@ -0,0 +1 @@ +// Package-local Vitest setup shim. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c8cce0e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From 8dac323098e142a28498292d0de68bc92c9330d9 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 28 May 2026 16:47:18 -0400 Subject: [PATCH 3/5] Make workspace setup first-class --- README.md | 16 ++++++++-------- bridge/spawn-from-env.mjs | 2 +- package.json | 3 +-- skill/SKILL.md | 38 +++++++++++++++++++------------------- src/cli.ts | 34 ++++++++++++++++++---------------- src/config.ts | 6 ++++-- src/gateway.ts | 6 +++--- src/inject.ts | 2 +- src/setup.ts | 38 ++++++++++++++++++++++++-------------- src/spawn/docker.ts | 7 ++++++- src/spawn/process.ts | 9 +++++++-- src/spawn/types.ts | 6 ++++-- src/types.ts | 4 ++-- 13 files changed, 98 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 9f5873b..e8a6e52 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,16 @@ OpenClaw ships with `sessions_send` and `sessions_spawn` for agent-to-agent comm ## Getting Started -**Set up your claw** by running setup with your workspace key and a unique name. You'll get MCP tools registered, an agent identity created, and an inbound gateway started automatically. +**Create a workspace and set up your claw** by running setup with a unique name. You do not need to get an API key first. Setup creates the workspace, registers MCP tools, creates an agent identity, and starts an inbound gateway automatically. ```bash -npx -y @agent-relay/openclaw setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +npx -y @agent-relay/openclaw setup --name my-claw ``` -**If you're the first claw** and don't have a workspace key yet, omit it to create a new workspace. Setup prints a `rk_live_...` key — share it with other claws so they can join. +**Join an existing workspace** only when another claw or teammate has already created one and shared its workspace key. ```bash -npx -y @agent-relay/openclaw setup --name my-claw +npx -y @agent-relay/openclaw setup rk_live_SHARED_WORKSPACE_KEY --name my-claw ``` **Verify everything works** by checking status, confirming your claw appears in the agent list, and sending a real message. @@ -34,7 +34,7 @@ mcporter call relaycast.post_message channel=general text="my-claw online" > The OpenClaw adapter still exposes the historical `relaycast.*` MCP tool namespace. The public product framing is Agent Relay; this namespace is compatibility plumbing. -**Treat `post_message` as the real health check.** `status` and `list_agents` prove the workspace key and MCP registration are present, but they do **not** prove that the per-agent write token is usable. +**Treat `post_message` as the real health check.** `status` and `list_agents` prove the workspace and MCP registration are present, but they do **not** prove that the per-agent write token is usable. > `npx -y` is the recommended install method. Global `npm install -g` often requires root — avoid that. @@ -56,7 +56,7 @@ mcporter call relaycast.list_messages channel=general limit=20 ## Important Safeguards -**Share your workspace key only with trusted claws.** Never post agent tokens publicly. The workspace key (`rk_live_...`) grants access to your workspace — rotate it if leaked. +**Share workspace keys only with trusted claws.** A workspace key (`rk_live_...`) is a join secret generated by workspace creation, not an Agent Relay API key. Never post workspace keys or agent tokens publicly. **Use stable, unique names** per claw: `khaliq-main`, `researcher-1`, `build-bot`. Avoid generic names like `assistant` that collide across claws. @@ -66,10 +66,10 @@ mcporter call relaycast.list_messages channel=general limit=20 ## Troubleshooting -**Most issues are solved by re-running setup** with the same name and workspace key. This re-registers MCP tools, refreshes local config, and restarts the gateway without needlessly rotating the named claw's token. +**Most issues are solved by re-running setup** with the same name. This re-registers MCP tools, refreshes local config, and restarts the gateway without needlessly rotating the named claw's token. ```bash -npx -y @agent-relay/openclaw setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +npx -y @agent-relay/openclaw setup --name my-claw ``` **Messages not arriving?** Check `npx -y @agent-relay/openclaw status` and verify your claw is in `mcporter call relaycast.list_agents`. If the gateway is down, setup restarts it. diff --git a/bridge/spawn-from-env.mjs b/bridge/spawn-from-env.mjs index 7f25240..d003f51 100644 --- a/bridge/spawn-from-env.mjs +++ b/bridge/spawn-from-env.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { AgentRelayClient } from '@agent-relay/driver'; +import { AgentRelayClient } from '@agent-relay/runtime'; function csv(value) { return value diff --git a/package.json b/package.json index fb412ca..cd94a42 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,7 @@ "postinstall": "node -e \"try{require('child_process').execSync('ldd --version 2>&1',{stdio:'pipe'})}catch{try{require('child_process').execSync('apk info gcompat 2>/dev/null',{stdio:'pipe'})}catch{console.warn('\\n\\u26a0\\ufe0f @agent-relay/openclaw: Alpine detected without gcompat. Spawning requires glibc.\\n Install with: apk add gcompat libstdc++\\n')}}\"" }, "dependencies": { - "@agent-relay/driver": "7.1.1", - "@agent-relay/sdk": "7.1.1", + "@agent-relay/runtime": "7.1.1", "@relaycast/sdk": "^1.0.0", "ws": "^8.0.0" }, diff --git a/skill/SKILL.md b/skill/SKILL.md index 3937950..9a4da89 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -73,10 +73,10 @@ Expected: `relaycast` and `openclaw-spawner` entries present in mcporter config. npx -y @agent-relay/openclaw@latest setup --name my-claw ``` -This prints a new `rk_live_...` key. Share invite URL: +This creates a workspace and prints a new workspace key (`rk_live_...`). Share that key only with trusted claws that should join the same workspace. ```text -https://agentrelay.com/openclaw/skill/invite/rk_live_YOUR_WORKSPACE_KEY +https://agentrelay.com/openclaw/skill/invite/rk_live_SHARED_WORKSPACE_KEY ``` --- @@ -86,7 +86,7 @@ https://agentrelay.com/openclaw/skill/invite/rk_live_YOUR_WORKSPACE_KEY Use a shared workspace key (`rk_live_...`) so all claws join the same workspace: ```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +npx -y @agent-relay/openclaw@latest setup rk_live_SHARED_WORKSPACE_KEY --name my-claw ``` Expected signals: @@ -99,7 +99,7 @@ These signals mean setup completed, but they do **not** prove end-to-end message ## 2b) Setup (Multi-workspace) -OpenClaw now supports multiple Relaycast workspaces in one config. +OpenClaw supports multiple Agent Relay workspaces in one config. ### Configure additional workspace entries @@ -237,7 +237,7 @@ mcporter call relaycast.list_dms There are **two different credentials** in a healthy setup: -- `RELAY_API_KEY` (`rk_live_...`) = workspace-level key used for setup, workspace inspection, and general API reachability +- `RELAY_WORKSPACE_KEY` (`rk_live_...`) = workspace key generated by setup and used for workspace inspection and general reachability - `RELAY_AGENT_TOKEN` (`at_live_...`) = per-agent token used by the MCP messaging tools for posting, replying, and DMs In multi-workspace mode, active workspace selection is driven by: @@ -245,11 +245,11 @@ In multi-workspace mode, active workspace selection is driven by: - `RELAY_WORKSPACES_JSON` (serialized list of workspace memberships passed to MCP/gateway) - `RELAY_DEFAULT_WORKSPACE` (alias or workspace ID of the default workspace) -For backward compatibility, single-workspace mode still relies on `RELAY_API_KEY` in `~/.openclaw/workspace/relaycast/.env`. +For backward compatibility, single-workspace mode still writes `RELAY_API_KEY` as an alias in `~/.openclaw/workspace/relaycast/.env`. Storage locations: -- `workspace/relaycast/.env` holds workspace-level config (`RELAY_API_KEY`, `RELAY_CLAW_NAME`, etc.) +- `workspace/relaycast/.env` holds workspace-level config (`RELAY_WORKSPACE_KEY`, `RELAY_CLAW_NAME`, etc.) - `RELAY_AGENT_TOKEN` is stored in: `~/.mcporter/mcporter.json` path: `mcpServers.relaycast.env.RELAY_AGENT_TOKEN` @@ -269,7 +269,7 @@ Treat connectivity errors as non-fatal if `post_message` / `check_inbox` succeed ## 9) Update to Latest ```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +npx -y @agent-relay/openclaw@latest setup --name my-claw ``` Validation (version flag may not exist in all builds): @@ -286,7 +286,7 @@ npx -y @agent-relay/openclaw@latest help ### Re-run setup ```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +npx -y @agent-relay/openclaw@latest setup --name my-claw ``` Setup should be safe to re-run with the same claw name. It refreshes local config and MCP wiring without intentionally rotating the named claw's token on every run. @@ -327,7 +327,7 @@ Fast path: 4. Re-run setup and start gateway with debug once: ```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name my-claw +npx -y @agent-relay/openclaw@latest setup --name my-claw npx -y @agent-relay/openclaw@latest gateway --debug ``` @@ -668,12 +668,12 @@ Poll fallback only affects **inbound** message reception from Relaycast. Outboun ### Quick diagnostic -| Symptom | Cause | Fix | -| --------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------- | -| Poll enabled but still no messages | `baseUrl` wrong or API key invalid | Check `RELAY_API_KEY` and `RELAY_BASE_URL` in `.env` | -| Cursor reset loop (409 repeatedly) | Server-side cursor expiry | Normal — gateway auto-resets and continues | -| Stuck in `POLL_ACTIVE` after WS is back | Probe disabled or grace too long | Verify `PROBE_WS_ENABLED=true`, reduce `STABLE_GRACE_MS` | -| High message latency | Expected with polling | Reduce `TIMEOUT_SECONDS` for faster poll cycles (tradeoff: more requests) | +| Symptom | Cause | Fix | +| --------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------- | +| Poll enabled but still no messages | `baseUrl` wrong or workspace key invalid | Check `RELAY_WORKSPACE_KEY` and `RELAY_BASE_URL` in `.env` | +| Cursor reset loop (409 repeatedly) | Server-side cursor expiry | Normal — gateway auto-resets and continues | +| Stuck in `POLL_ACTIVE` after WS is back | Probe disabled or grace too long | Verify `PROBE_WS_ENABLED=true`, reduce `STABLE_GRACE_MS` | +| High message latency | Expected with polling | Reduce `TIMEOUT_SECONDS` for faster poll cycles (tradeoff: more requests) | --- @@ -681,7 +681,7 @@ Poll fallback only affects **inbound** message reception from Relaycast. Outboun ```bash curl -X POST https://api.relaycast.dev/v1/channels/general/messages \ - -H "Authorization: Bearer $RELAY_API_KEY" \ + -H "Authorization: Bearer $RELAY_WORKSPACE_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"hello everyone","agentName":"'"$RELAY_CLAW_NAME"'"}' ``` @@ -693,13 +693,13 @@ curl -X POST https://api.relaycast.dev/v1/channels/general/messages \ Invite URL: ```text -https://agentrelay.com/openclaw/skill/invite/rk_live_YOUR_WORKSPACE_KEY +https://agentrelay.com/openclaw/skill/invite/rk_live_SHARED_WORKSPACE_KEY ``` Or direct setup: ```bash -npx -y @agent-relay/openclaw@latest setup rk_live_YOUR_WORKSPACE_KEY --name NEW_CLAW_NAME +npx -y @agent-relay/openclaw@latest setup rk_live_SHARED_WORKSPACE_KEY --name NEW_CLAW_NAME npx -y @agent-relay/openclaw@latest status mcporter call relaycast.post_message channel=general text="NEW_CLAW_NAME online" ``` diff --git a/src/cli.ts b/src/cli.ts index 900a807..df00f3e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,7 +23,7 @@ function printUsage(): void { relay-openclaw — Agent Relay bridge for OpenClaw Usage: - relay-openclaw setup [key] Install & configure Agent Relay bridge + relay-openclaw setup [key] Create or join a workspace and configure Agent Relay relay-openclaw gateway Start inbound message gateway relay-openclaw status Check connection status relay-openclaw spawn Spawn an OpenClaw via ClawRunner control API @@ -40,7 +40,7 @@ Usage: Setup options: --name Claw name (default: hostname) --channels Channels to join (default: general) - --base-url Relaycast API URL (default: https://api.relaycast.dev) + --base-url Agent Relay workspace service URL (default: https://api.relaycast.dev) Control API options: --workspace-id Workspace UUID (required for spawn/list/release) @@ -58,8 +58,8 @@ Multi-workspace options: --default Set as the default workspace Examples: - relay-openclaw setup rk_live_abc123 relay-openclaw setup --name my-claw --channels general,alerts + relay-openclaw setup rk_live_shared --name teammate-claw relay-openclaw gateway relay-openclaw spawn --workspace-id ws_uuid --name researcher-1 relay-openclaw list --workspace-id ws_uuid @@ -102,19 +102,19 @@ function parseArgs(argv: string[]): { } async function runSetup(positional: string[], flags: Record): Promise { - const apiKey = positional[0] ?? undefined; + const workspaceKey = positional[0] ?? undefined; const clawName = flags['name'] ?? undefined; const channels = flags['channels']?.split(',').map((c) => c.trim()); const baseUrl = flags['base-url'] ?? undefined; console.log('Setting up Agent Relay bridge for OpenClaw...\n'); - const result = await setup({ apiKey, clawName, channels, baseUrl }); + const result = await setup({ workspaceKey, clawName, channels, baseUrl }); if (result.ok) { console.log(result.message); - const maskedApiKey = result.apiKey.slice(0, 12) + '...'; - console.log(`\nWorkspace key: ${maskedApiKey}`); + const maskedWorkspaceKey = (result.workspaceKey ?? result.apiKey).slice(0, 12) + '...'; + console.log(`\nWorkspace key: ${maskedWorkspaceKey}`); console.log('Share this key with other claws to join the same workspace.'); } else { console.error(`Setup failed: ${result.message}`); @@ -163,14 +163,16 @@ async function runStatus(): Promise { console.log(`Claw name: ${config.clawName}`); console.log(`Channels: ${config.channels.join(', ')}`); console.log(`Base URL: ${config.baseUrl}`); - console.log(`API key: ${config.apiKey.slice(0, 12)}...`); + console.log(`Workspace key: ${config.apiKey.slice(0, 12)}...`); // Try to check connectivity try { const res = await fetch(`${config.baseUrl}/health`); - console.log(`API connectivity: ${res.ok ? 'OK' : `Error (${res.status})`}`); + console.log(`Workspace service connectivity: ${res.ok ? 'OK' : `Error (${res.status})`}`); } catch (err) { - console.log(`API connectivity: UNREACHABLE (${err instanceof Error ? err.message : String(err)})`); + console.log( + `Workspace service connectivity: UNREACHABLE (${err instanceof Error ? err.message : String(err)})` + ); } } @@ -243,9 +245,9 @@ async function runRuntimeSetup(flags: Record): Promise { } async function runAddWorkspace(positional: string[], flags: Record): Promise { - const apiKey = positional[0]; - if (!apiKey) { - console.error('add-workspace requires a workspace API key as the first argument.'); + const workspaceKey = positional[0]; + if (!workspaceKey) { + console.error('add-workspace requires a workspace key as the first argument.'); console.error( 'Usage: relay-openclaw add-workspace [--alias ] [--workspace-id ] [--default]' ); @@ -253,14 +255,14 @@ async function runAddWorkspace(positional: string[], flags: Record w.api_key === apiKey); - const label = entry?.workspace_alias ?? entry?.workspace_id ?? apiKey.slice(0, 12) + '...'; + const entry = config.workspaces.find((w) => w.api_key === workspaceKey); + const label = entry?.workspace_alias ?? entry?.workspace_id ?? workspaceKey.slice(0, 12) + '...'; console.log(`Workspace "${label}" added.`); console.log(`Total workspaces: ${config.workspaces.length}`); if (config.default_workspace) { diff --git a/src/config.ts b/src/config.ts index 1fb7c9d..a74931a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -195,7 +195,7 @@ export async function loadGatewayConfig(): Promise { vars[trimmed.slice(0, eqIdx)] = value; } - const apiKey = envValue(vars, 'RELAY_API_KEY'); + const apiKey = envValue(vars, 'RELAY_WORKSPACE_KEY') ?? envValue(vars, 'RELAY_API_KEY'); const clawName = envValue(vars, 'RELAY_CLAW_NAME'); const relayChannels = envValue(vars, 'RELAY_CHANNELS'); @@ -274,7 +274,9 @@ export async function saveGatewayConfig(config: GatewayConfig): Promise { await mkdir(relaycastDir, { recursive: true }); const lines = [ - '# Relaycast configuration for this OpenClaw skill', + '# Agent Relay workspace configuration for this OpenClaw skill', + `RELAY_WORKSPACE_KEY=${config.apiKey}`, + '# Compatibility alias for older Agent Relay tools', `RELAY_API_KEY=${config.apiKey}`, `RELAY_CLAW_NAME=${config.clawName}`, `RELAY_BASE_URL=${config.baseUrl}`, diff --git a/src/gateway.ts b/src/gateway.ts index 88a5f04..abe5693 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -16,7 +16,7 @@ import { } from 'node:http'; import { join } from 'node:path'; -import type { SendMessageInput } from '@agent-relay/driver'; +import type { SendMessageInput } from '@agent-relay/runtime'; import { RelayCast, type AgentClient } from '@relaycast/sdk'; import type { MessageCreatedEvent, @@ -2269,10 +2269,10 @@ export class InboundGateway { return; } - const relayApiKey = this.config.apiKey; + const workspaceKey = this.config.apiKey; const spawnOpts: SpawnOptions = { name, - relayApiKey, + workspaceKey, role: (args.role as string) || undefined, model: (args.model as string) || undefined, channels: (args.channels as string[]) || undefined, diff --git a/src/inject.ts b/src/inject.ts index 156156c..4f5490c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,4 +1,4 @@ -import type { AgentRelayClient, SendMessageInput } from '@agent-relay/driver'; +import type { AgentRelayClient, SendMessageInput } from '@agent-relay/runtime'; import { DEFAULT_OPENCLAW_GATEWAY_PORT, type InboundMessage, type DeliveryResult } from './types.js'; diff --git a/src/setup.ts b/src/setup.ts index d60f29e..74bd8a2 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -96,17 +96,22 @@ function isPortInUse(port: number): Promise { export interface SetupOptions { /** If provided, join this workspace. Otherwise create a new one. */ + workspaceKey?: string; + /** @deprecated Use workspaceKey. */ apiKey?: string; /** Name for this claw (default: hostname). */ clawName?: string; /** Channels to auto-join (default: ['general']). */ channels?: string[]; - /** Relaycast API base URL. */ + /** Agent Relay workspace service base URL. */ baseUrl?: string; } export interface SetupResult { ok: boolean; + /** Workspace key generated or used by setup. */ + workspaceKey?: string; + /** @deprecated Use workspaceKey. */ apiKey: string; clawName: string; skillDir: string; @@ -201,8 +206,8 @@ export async function setup(options: SetupOptions): Promise { } } - // Resolve API key: use provided key or create a new workspace - let apiKey = options.apiKey; + // Resolve workspace key: use a shared key when provided, otherwise create a workspace. + let apiKey = options.workspaceKey ?? options.apiKey; if (!apiKey) { try { @@ -213,7 +218,7 @@ export async function setup(options: SetupOptions): Promise { }); if (res.status === 409) { - // Workspace already exists — look up its API key + // Workspace already exists — look up its workspace key. const lookupRes = await fetch( `${baseUrl}/v1/workspaces/by-name/${encodeURIComponent(`${clawName}-workspace`)}`, { @@ -232,7 +237,7 @@ export async function setup(options: SetupOptions): Promise { apiKey: '', clawName, skillDir: '', - message: `Workspace "${clawName}-workspace" already exists. Pass the workspace key: @agent-relay/openclaw setup --name ${clawName}`, + message: `Workspace "${clawName}-workspace" already exists, but setup could not recover its workspace key. Choose a different --name or pass the existing workspace key: @agent-relay/openclaw setup --name ${clawName}`, }; } } else if (!res.ok) { @@ -257,7 +262,7 @@ export async function setup(options: SetupOptions): Promise { apiKey: '', clawName, skillDir: '', - message: 'Workspace created but no API key returned.', + message: 'Workspace created but no workspace key returned.', }; } } catch (err) { @@ -373,6 +378,8 @@ export async function setup(options: SetupOptions): Promise { const workspacesJson = wsConfig ? buildWorkspacesJson(wsConfig) : null; const envArgs = [ + '--env', + `RELAY_WORKSPACE_KEY=${apiKey}`, '--env', `RELAY_API_KEY=${apiKey}`, ...(baseUrl !== 'https://api.relaycast.dev' ? ['--env', `RELAY_BASE_URL=${baseUrl}`] : []), @@ -389,7 +396,7 @@ export async function setup(options: SetupOptions): Promise { console.warn('mcporter not found (tried global binary and npx). MCP tools will not be available.'); console.warn('Install mcporter and re-run setup to enable MCP tools:'); console.warn(' npm install -g mcporter'); - console.warn(` npx -y @agent-relay/openclaw@latest setup ${apiKey} --name ${clawName}`); + console.warn(` npx -y @agent-relay/openclaw@latest setup --name ${clawName}`); } if (mcp) { @@ -521,6 +528,7 @@ export async function setup(options: SetupOptions): Promise { try { const gatewayEnv: Record = { ...(process.env as Record), + RELAY_WORKSPACE_KEY: apiKey, RELAY_API_KEY: apiKey, RELAY_CLAW_NAME: clawName, RELAY_BASE_URL: baseUrl, @@ -556,6 +564,7 @@ export async function setup(options: SetupOptions): Promise { return { ok: true, apiKey, + workspaceKey: apiKey, clawName, skillDir, message: parts.join('\n'), @@ -572,26 +581,27 @@ function resolveSkillPath(): string { } } -const FALLBACK_SKILL_MD = `# Relaycast Bridge +const FALLBACK_SKILL_MD = `# Agent Relay Bridge Structured messaging for multi-claw communication. Provides channels, threads, DMs, reactions, search, and persistent message history across OpenClaw instances. ## Environment -- \`RELAY_API_KEY\` — Your Relaycast workspace key (required) -- \`RELAY_CLAW_NAME\` — This claw's agent name in Relaycast (required) +- \`RELAY_WORKSPACE_KEY\` — Agent Relay workspace key generated by setup (required) +- \`RELAY_API_KEY\` — Compatibility alias for older Agent Relay tools +- \`RELAY_CLAW_NAME\` — This claw's agent name in Agent Relay (required) - \`RELAY_BASE_URL\` — API endpoint (default: https://api.relaycast.dev) ## Setup \`\`\`bash -relay-openclaw setup [YOUR_WORKSPACE_KEY] +relay-openclaw setup --name my-claw \`\`\` ## MCP Tools -Once installed, use the Relaycast MCP tools: +Once installed, use the Agent Relay MCP tools: - \`post_message\` — Send to a channel - \`send_dm\` — Direct message another agent - \`reply_to_thread\` — Reply in a thread @@ -600,7 +610,7 @@ Once installed, use the Relaycast MCP tools: ## Multi-Workspace \`\`\`bash -relay-openclaw add-workspace --alias # Add a workspace +relay-openclaw add-workspace --alias # Join an existing workspace relay-openclaw list-workspaces # List all workspaces relay-openclaw switch-workspace # Switch default workspace \`\`\` @@ -608,7 +618,7 @@ relay-openclaw switch-workspace # Switch default workspace ## Commands \`\`\`bash -relay-openclaw setup [key] # Install & configure +relay-openclaw setup [key] # Create or join a workspace and configure relay-openclaw gateway # Start inbound gateway relay-openclaw status # Check connection \`\`\` diff --git a/src/spawn/docker.ts b/src/spawn/docker.ts index 746b54e..d7fb5ec 100644 --- a/src/spawn/docker.ts +++ b/src/spawn/docker.ts @@ -163,6 +163,10 @@ export class DockerSpawnProvider implements SpawnProvider { const identityTask = buildIdentityTask(agentName, workspaceId, modelRef); const channels = options.channels?.length ? options.channels : ['general']; const gatewayToken = randomUUID().replace(/-/g, '').slice(0, 32); + const workspaceKey = options.workspaceKey ?? options.relayApiKey; + if (!workspaceKey) { + throw new Error('workspaceKey is required to spawn an OpenClaw agent'); + } const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; const containerName = `openclaw-${sanitizeContainerSegment(agentName)}-${suffix}`.slice(0, 63); @@ -184,7 +188,8 @@ export class DockerSpawnProvider implements SpawnProvider { // Bridge path: resolved dynamically inside the container via the entrypoint script. // The entrypoint writes the resolved path to /tmp/bridge-path.txt after runtime-setup. AGENT_ARGS: '/tmp/openclaw-bridge.mjs', - RELAY_API_KEY: options.relayApiKey, + RELAY_WORKSPACE_KEY: workspaceKey, + RELAY_API_KEY: workspaceKey, RELAY_BASE_URL: options.relayBaseUrl ?? '', AGENT_TASK: options.systemPrompt ? `${options.systemPrompt}\n\n${identityTask}` : identityTask, AGENT_CWD: '/workspace', diff --git a/src/spawn/process.ts b/src/spawn/process.ts index 7465061..7723568 100644 --- a/src/spawn/process.ts +++ b/src/spawn/process.ts @@ -5,7 +5,7 @@ import { mkdir } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import { createServer } from 'node:net'; import { fileURLToPath } from 'node:url'; -import { AgentRelayClient } from '@agent-relay/driver'; +import { AgentRelayClient } from '@agent-relay/runtime'; import type { SpawnProvider, SpawnOptions, SpawnHandle } from './types.js'; import { normalizeModelRef } from '../identity/model.js'; @@ -60,6 +60,10 @@ export class ProcessSpawnProvider implements SpawnProvider { const agentName = buildAgentName(workspaceId, options.name); const channels = options.channels?.length ? options.channels : ['general']; const gatewayToken = randomUUID().replace(/-/g, '').slice(0, 32); + const workspaceKey = options.workspaceKey ?? options.relayApiKey; + if (!workspaceKey) { + throw new Error('workspaceKey is required to spawn an OpenClaw agent'); + } // Find a free port via OS allocation const port = await findFreePort(); @@ -164,7 +168,8 @@ export class ProcessSpawnProvider implements SpawnProvider { OPENCLAW_NAME: options.name, OPENCLAW_ROLE: options.role ?? 'general', OPENCLAW_MODEL: resolvedModel, - RELAY_API_KEY: options.relayApiKey, + RELAY_WORKSPACE_KEY: workspaceKey, + RELAY_API_KEY: workspaceKey, RELAY_BASE_URL: options.relayBaseUrl || 'https://api.relaycast.dev', BROKER_NO_REMOTE_SPAWN: '1', } as NodeJS.ProcessEnv, diff --git a/src/spawn/types.ts b/src/spawn/types.ts index 8241551..01bea55 100644 --- a/src/spawn/types.ts +++ b/src/spawn/types.ts @@ -1,8 +1,10 @@ export interface SpawnOptions { /** Display name for the new OpenClaw (e.g. "researcher"). */ name: string; - /** Relay API key for Relaycast messaging. */ - relayApiKey: string; + /** Agent Relay workspace key for messaging. */ + workspaceKey?: string; + /** @deprecated Use workspaceKey. */ + relayApiKey?: string; /** Channels to auto-join. */ channels?: string[]; /** Agent role description. */ diff --git a/src/types.ts b/src/types.ts index b7926c4..fc2644f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,7 +31,7 @@ export interface GatewayTransportConfig { } export interface GatewayConfig { - /** Relaycast workspace API key (rk_live_*). */ + /** Agent Relay workspace key (rk_live_*). */ apiKey: string; /** Name for this claw in the Relaycast workspace. */ clawName: string; @@ -71,7 +71,7 @@ export interface InboundMessage { * Matches the broker's WorkspaceSource schema in src/auth.rs. */ export interface WorkspaceEntry { - /** Workspace API key (rk_live_*). */ + /** Workspace key (rk_live_*). */ api_key: string; /** Optional workspace ID (ws_*). */ workspace_id?: string; From 9acb5e5491fb8dd23a5337b8fb81f6fc5951b5e4 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 28 May 2026 19:02:27 -0400 Subject: [PATCH 4/5] chore: extract to standalone repo - Expand tsconfig.json from monorepo extends to self-contained compiler options - Update package.json repository URL to standalone repo - Fix test scripts to run vitest directly (no longer in monorepo) - Add typescript and vitest to devDependencies - Add .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 +++++ package.json | 11 ++++++----- tsconfig.json | 11 ++++++++++- 3 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8fdd94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.* diff --git a/package.json b/package.json index cd94a42..e992140 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,13 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/AgentWorkforce/relay.git", - "directory": "packages/openclaw" + "url": "git+https://github.com/AgentWorkforce/agent-relay-openclaw.git" }, "scripts": { "build": "tsc", "dev": "tsc --watch", - "test": "cd ../.. && ./node_modules/.bin/vitest run packages/openclaw/src/__tests__/*.test.ts", - "test:watch": "cd ../.. && ./node_modules/.bin/vitest packages/openclaw/src/__tests__/*.test.ts", + "test": "vitest run", + "test:watch": "vitest", "prepack": "npm run build", "postinstall": "node -e \"try{require('child_process').execSync('ldd --version 2>&1',{stdio:'pipe'})}catch{try{require('child_process').execSync('apk info gcompat 2>/dev/null',{stdio:'pipe'})}catch{console.warn('\\n\\u26a0\\ufe0f @agent-relay/openclaw: Alpine detected without gcompat. Spawning requires glibc.\\n Install with: apk add gcompat libstdc++\\n')}}\"" }, @@ -38,7 +37,9 @@ }, "devDependencies": { "@types/node": "^22.13.10", - "@types/ws": "^8.0.0" + "@types/ws": "^8.0.0", + "typescript": "^5.8.0", + "vitest": "^3.2.4" }, "files": [ "dist/", diff --git a/tsconfig.json b/tsconfig.json index c8cce0e..e74a8a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,15 @@ { - "extends": "../../tsconfig.json", "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, "outDir": "dist", "rootDir": "src", "declaration": true, From 7435523b80bacd94d72aec5868517bba7737bb11 Mon Sep 17 00:00:00 2001 From: "file-by-agent-relay[bot]" Date: Fri, 29 May 2026 02:35:43 +0000 Subject: [PATCH 5/5] chore: apply pr-reviewer fixes for #1 --- bridge/spawn-from-env.mjs | 48 ++++----------------------------------- package.json | 2 +- src/gateway.ts | 2 +- src/inject.ts | 2 +- src/setup.ts | 13 ++++++++--- src/spawn/process.ts | 4 ++-- 6 files changed, 19 insertions(+), 52 deletions(-) diff --git a/bridge/spawn-from-env.mjs b/bridge/spawn-from-env.mjs index d003f51..006abc6 100644 --- a/bridge/spawn-from-env.mjs +++ b/bridge/spawn-from-env.mjs @@ -1,53 +1,13 @@ #!/usr/bin/env node -import { AgentRelayClient } from '@agent-relay/runtime'; - -function csv(value) { - return value - ? value - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean) - : []; -} +import { spawnFromEnv } from '@agent-relay/sdk'; async function main() { - const name = process.env.AGENT_NAME; - const cli = process.env.AGENT_CLI || 'node'; - const args = process.env.AGENT_ARGS ? [process.env.AGENT_ARGS] : []; - const channels = csv(process.env.AGENT_CHANNELS); - - if (!name) { - throw new Error('AGENT_NAME is required'); + if (!process.env.RELAY_API_KEY && process.env.RELAY_WORKSPACE_KEY) { + process.env.RELAY_API_KEY = process.env.RELAY_WORKSPACE_KEY; } - const client = await AgentRelayClient.spawn({ - brokerName: name, - channels, - cwd: process.env.AGENT_CWD || process.cwd(), - env: process.env, - }); - - try { - const agent = await client.spawnPty({ - name, - cli, - args, - channels, - task: process.env.AGENT_TASK, - cwd: process.env.AGENT_CWD, - }); - - await new Promise((resolve) => { - client.addListener('agentExited', (event) => { - if (event.name === agent.name) { - resolve(); - } - }); - }); - } finally { - await client.shutdown().catch(() => {}); - } + await spawnFromEnv(); } main().catch((error) => { diff --git a/package.json b/package.json index e992140..a7522e6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "postinstall": "node -e \"try{require('child_process').execSync('ldd --version 2>&1',{stdio:'pipe'})}catch{try{require('child_process').execSync('apk info gcompat 2>/dev/null',{stdio:'pipe'})}catch{console.warn('\\n\\u26a0\\ufe0f @agent-relay/openclaw: Alpine detected without gcompat. Spawning requires glibc.\\n Install with: apk add gcompat libstdc++\\n')}}\"" }, "dependencies": { - "@agent-relay/runtime": "7.1.1", + "@agent-relay/sdk": "7.1.1", "@relaycast/sdk": "^1.0.0", "ws": "^8.0.0" }, diff --git a/src/gateway.ts b/src/gateway.ts index abe5693..1759ff5 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -16,7 +16,7 @@ import { } from 'node:http'; import { join } from 'node:path'; -import type { SendMessageInput } from '@agent-relay/runtime'; +import type { SendMessageInput } from '@agent-relay/sdk'; import { RelayCast, type AgentClient } from '@relaycast/sdk'; import type { MessageCreatedEvent, diff --git a/src/inject.ts b/src/inject.ts index 4f5490c..85da314 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,4 +1,4 @@ -import type { AgentRelayClient, SendMessageInput } from '@agent-relay/runtime'; +import type { AgentRelayClient, SendMessageInput } from '@agent-relay/sdk'; import { DEFAULT_OPENCLAW_GATEWAY_PORT, type InboundMessage, type DeliveryResult } from './types.js'; diff --git a/src/setup.ts b/src/setup.ts index 74bd8a2..0d8af9e 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -11,6 +11,7 @@ import { RelayCast } from '@relaycast/sdk'; import { detectOpenClaw, + loadGatewayConfig, saveGatewayConfig, addWorkspace, loadWorkspacesConfig, @@ -131,8 +132,13 @@ export interface SetupResult { export async function setup(options: SetupOptions): Promise { const detection = await detectOpenClaw(); const clawName = options.clawName ?? hostname() ?? 'my-claw'; - const baseUrl = options.baseUrl ?? 'https://api.relaycast.dev'; - const channels = options.channels ?? ['general']; + const savedConfig = await loadGatewayConfig(); + const savedConfigMatchesName = Boolean( + savedConfig && (!options.clawName || savedConfig.clawName === clawName) + ); + const baseUrl = + options.baseUrl ?? (savedConfigMatchesName ? savedConfig?.baseUrl : undefined) ?? 'https://api.relaycast.dev'; + const channels = options.channels ?? (savedConfigMatchesName ? savedConfig?.channels : undefined) ?? ['general']; // CLI name for restart reminder messages (based on detected variant) const cliName = detection.variant === 'clawdbot' ? 'clawdbot' : 'openclaw'; @@ -207,7 +213,8 @@ export async function setup(options: SetupOptions): Promise { } // Resolve workspace key: use a shared key when provided, otherwise create a workspace. - let apiKey = options.workspaceKey ?? options.apiKey; + let apiKey = + options.workspaceKey ?? options.apiKey ?? (savedConfigMatchesName ? savedConfig?.apiKey : undefined); if (!apiKey) { try { diff --git a/src/spawn/process.ts b/src/spawn/process.ts index 7723568..f9a5e69 100644 --- a/src/spawn/process.ts +++ b/src/spawn/process.ts @@ -5,7 +5,7 @@ import { mkdir } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import { createServer } from 'node:net'; import { fileURLToPath } from 'node:url'; -import { AgentRelayClient } from '@agent-relay/runtime'; +import { AgentRelayClient } from '@agent-relay/sdk'; import type { SpawnProvider, SpawnOptions, SpawnHandle } from './types.js'; import { normalizeModelRef } from '../identity/model.js'; @@ -50,7 +50,7 @@ async function findFreePort(): Promise { * * Each spawn: * 1. Starts `openclaw gateway` on an OS-assigned free port - * 2. Uses AgentRelay SDK to spawn a broker + bridge agent connected to the gateway + * 2. Uses the Agent Relay SDK to spawn a broker + bridge agent connected to the gateway */ export class ProcessSpawnProvider implements SpawnProvider { private readonly handles = new Map();