test-openclaw-protocol.mjs
1 #!/usr/bin/env node 2 /** 3 * OpenClaw Gateway Protocol Compatibility Test 4 * 5 * Validates that SwarmClaw's direct WebSocket implementation is compatible with 6 * the latest openclaw CLI's gateway protocol. Spins up a mock gateway server, 7 * then tests both our implementation and the openclaw CLI against it. 8 * 9 * Usage: node scripts/test-openclaw-protocol.mjs [--install-cli] 10 * --install-cli Install latest openclaw CLI to a temp dir for comparison testing 11 * 12 * Without --install-cli, only tests SwarmClaw's implementation against the mock gateway. 13 */ 14 15 import { WebSocketServer, WebSocket } from 'ws' 16 import { randomUUID } from 'crypto' 17 import { spawnSync } from 'child_process' 18 import { mkdtempSync, rmSync } from 'fs' 19 import { tmpdir } from 'os' 20 import { join } from 'path' 21 22 const PROTOCOL_VERSION = 3 23 const TEST_TOKEN = 'test-token-abc123' 24 const MOCK_PORT = 0 // random available port 25 const PASS = '\x1b[32m✓\x1b[0m' 26 const FAIL = '\x1b[31m✗\x1b[0m' 27 28 let passed = 0 29 let failed = 0 30 31 function assert(condition, label) { 32 if (condition) { 33 console.log(` ${PASS} ${label}`) 34 passed++ 35 } else { 36 console.log(` ${FAIL} ${label}`) 37 failed++ 38 } 39 } 40 41 // --- Mock Gateway Server --- 42 43 function createMockGateway() { 44 return new Promise((resolve) => { 45 const wss = new WebSocketServer({ port: MOCK_PORT }, () => { 46 const port = wss.address().port 47 resolve({ wss, port }) 48 }) 49 50 wss.on('connection', (ws) => { 51 const nonce = randomUUID() 52 53 // Step 1: Send connect challenge 54 ws.send(JSON.stringify({ 55 event: 'connect.challenge', 56 payload: { nonce }, 57 })) 58 59 ws.on('message', (data) => { 60 try { 61 const msg = JSON.parse(data.toString()) 62 63 // Step 2: Handle connect request 64 if (msg.type === 'req' && msg.method === 'connect') { 65 const token = msg.params?.auth?.token 66 if (token && token !== TEST_TOKEN) { 67 ws.send(JSON.stringify({ 68 type: 'res', 69 id: msg.id, 70 ok: false, 71 error: { message: 'unauthorized: gateway token mismatch' }, 72 })) 73 ws.close(1008, 'unauthorized') 74 return 75 } 76 77 ws.send(JSON.stringify({ 78 type: 'res', 79 id: msg.id, 80 ok: true, 81 payload: { 82 protocol: PROTOCOL_VERSION, 83 gateway: { version: 'mock-1.0.0' }, 84 policy: { tickIntervalMs: 30000 }, 85 }, 86 })) 87 return 88 } 89 90 // Step 3: Handle agent request 91 if (msg.type === 'req' && msg.method === 'agent') { 92 // Send accepted status first 93 ws.send(JSON.stringify({ 94 type: 'res', 95 id: msg.id, 96 ok: true, 97 payload: { status: 'accepted' }, 98 })) 99 100 // Then send final response 101 setTimeout(() => { 102 ws.send(JSON.stringify({ 103 type: 'res', 104 id: msg.id, 105 ok: true, 106 payload: { 107 status: 'final', 108 result: { 109 payloads: [{ text: `Echo: ${msg.params?.message || 'no message'}` }], 110 }, 111 summary: `Echo: ${msg.params?.message || 'no message'}`, 112 }, 113 })) 114 }, 50) 115 return 116 } 117 } catch { 118 // ignore 119 } 120 }) 121 }) 122 }) 123 } 124 125 // --- SwarmClaw Protocol Implementation Test --- 126 127 async function testSwarmClawProtocol(port) { 128 console.log('\n--- SwarmClaw WebSocket Protocol ---') 129 130 // Test 1: Successful connection with valid token 131 const result1 = await testConnect(port, TEST_TOKEN) 132 assert(result1.connected, 'Connects with valid token') 133 assert(result1.helloOk, 'Receives hello_ok response') 134 135 // Test 2: Connection with invalid token 136 const result2 = await testConnect(port, 'wrong-token') 137 assert(!result2.connected || !result2.helloOk, 'Rejects invalid token') 138 139 // Test 3: Agent request 140 const result3 = await testAgentRequest(port, TEST_TOKEN, 'Hello from test') 141 assert(result3.ok, 'Agent request succeeds') 142 assert(result3.text?.includes('Echo: Hello from test'), `Agent response text correct (got: ${result3.text})`) 143 144 // Test 4: Connect frame format 145 const result4 = await testConnectFrameFormat(port, TEST_TOKEN) 146 assert(result4.hasType, 'Connect frame has type: "req"') 147 assert(result4.hasId, 'Connect frame has id (UUID)') 148 assert(result4.hasMethod, 'Connect frame has method: "connect"') 149 assert(result4.hasProtocol, 'Connect frame has minProtocol/maxProtocol') 150 assert(result4.hasClient, 'Connect frame has client info') 151 assert(result4.hasAuth, 'Connect frame has auth.token') 152 } 153 154 function testConnect(port, token) { 155 return new Promise((resolve) => { 156 const ws = new WebSocket(`ws://127.0.0.1:${port}`) 157 let connected = false 158 let helloOk = false 159 const timer = setTimeout(() => { ws.close(); resolve({ connected, helloOk }) }, 5000) 160 161 ws.on('message', (data) => { 162 const msg = JSON.parse(data.toString()) 163 if (msg.event === 'connect.challenge') { 164 connected = true 165 ws.send(JSON.stringify({ 166 type: 'req', id: randomUUID(), method: 'connect', 167 params: { 168 minProtocol: 1, maxProtocol: 3, 169 auth: { token }, 170 client: { id: 'gateway-client', version: '1.0.0', platform: process.platform, mode: 'backend', instanceId: randomUUID() }, 171 caps: [], role: 'operator', scopes: ['operator.admin'], 172 }, 173 })) 174 } else if (msg.type === 'res' && msg.ok) { 175 helloOk = true 176 clearTimeout(timer); ws.close(); resolve({ connected, helloOk }) 177 } else if (msg.type === 'res' && !msg.ok) { 178 clearTimeout(timer); ws.close(); resolve({ connected, helloOk: false }) 179 } 180 }) 181 ws.on('error', () => { clearTimeout(timer); resolve({ connected, helloOk }) }) 182 }) 183 } 184 185 function testAgentRequest(port, token, message) { 186 return new Promise((resolve) => { 187 const ws = new WebSocket(`ws://127.0.0.1:${port}`) 188 let agentReqId = null 189 const timer = setTimeout(() => { ws.close(); resolve({ ok: false, text: 'timeout' }) }, 5000) 190 191 ws.on('message', (data) => { 192 const msg = JSON.parse(data.toString()) 193 if (msg.event === 'connect.challenge') { 194 ws.send(JSON.stringify({ 195 type: 'req', id: randomUUID(), method: 'connect', 196 params: { 197 minProtocol: 1, maxProtocol: 1, auth: { token }, 198 client: { id: 'swarmclaw', version: '1.0.0', mode: 'cli', instanceId: randomUUID() }, 199 caps: [], role: 'operator', scopes: ['operator.admin'], 200 }, 201 })) 202 } else if (msg.type === 'res' && msg.ok && !agentReqId) { 203 agentReqId = randomUUID() 204 ws.send(JSON.stringify({ 205 type: 'req', id: agentReqId, method: 'agent', 206 params: { message, agentId: 'main', timeout: 10, idempotencyKey: randomUUID() }, 207 })) 208 } else if (msg.type === 'res' && msg.id === agentReqId) { 209 if (msg.payload?.status === 'accepted') return // interim 210 const text = msg.payload?.result?.payloads?.[0]?.text || msg.payload?.summary || '' 211 clearTimeout(timer); ws.close(); resolve({ ok: msg.ok, text }) 212 } 213 }) 214 ws.on('error', (err) => { clearTimeout(timer); resolve({ ok: false, text: err.message }) }) 215 }) 216 } 217 218 function testConnectFrameFormat(port, token) { 219 return new Promise((resolve) => { 220 const ws = new WebSocket(`ws://127.0.0.1:${port}`) 221 const timer = setTimeout(() => { ws.close(); resolve({}) }, 5000) 222 223 // Intercept what we send 224 const origSend = ws.send.bind(ws) 225 ws.send = (data) => { 226 const msg = JSON.parse(data) 227 if (msg.method === 'connect') { 228 clearTimeout(timer); ws.close() 229 resolve({ 230 hasType: msg.type === 'req', 231 hasId: typeof msg.id === 'string' && msg.id.length > 10, 232 hasMethod: msg.method === 'connect', 233 hasProtocol: typeof msg.params?.minProtocol === 'number' && typeof msg.params?.maxProtocol === 'number', 234 hasClient: typeof msg.params?.client?.id === 'string', 235 hasAuth: msg.params?.auth?.token === token, 236 }) 237 } 238 origSend(data) 239 } 240 241 ws.on('message', (data) => { 242 const msg = JSON.parse(data.toString()) 243 if (msg.event === 'connect.challenge') { 244 ws.send(JSON.stringify({ 245 type: 'req', id: randomUUID(), method: 'connect', 246 params: { 247 minProtocol: 1, maxProtocol: 1, auth: { token }, 248 client: { id: 'swarmclaw', version: '1.0.0', mode: 'cli', instanceId: randomUUID() }, 249 caps: [], role: 'operator', scopes: ['operator.admin'], 250 }, 251 })) 252 } 253 }) 254 ws.on('error', () => { clearTimeout(timer); resolve({}) }) 255 }) 256 } 257 258 // --- Main --- 259 260 async function main() { 261 const installCli = process.argv.includes('--install-cli') 262 263 console.log('Starting mock OpenClaw gateway...') 264 const { wss, port } = await createMockGateway() 265 console.log(`Mock gateway listening on ws://127.0.0.1:${port}`) 266 267 await testSwarmClawProtocol(port) 268 269 if (installCli) { 270 console.log('\n--- OpenClaw CLI Comparison ---') 271 const tmpDir = mkdtempSync(join(tmpdir(), 'openclaw-test-')) 272 try { 273 console.log(`Installing latest openclaw CLI to ${tmpDir}...`) 274 const install = spawnSync('npm', ['install', 'openclaw'], { 275 cwd: tmpDir, encoding: 'utf-8', timeout: 60_000, 276 stdio: ['ignore', 'pipe', 'pipe'], 277 }) 278 if (install.status !== 0) { 279 console.log(` ${FAIL} Failed to install openclaw: ${(install.stderr || '').slice(0, 200)}`) 280 failed++ 281 } else { 282 const bin = join(tmpDir, 'node_modules/.bin/openclaw') 283 const version = spawnSync(bin, ['--version'], { encoding: 'utf-8', timeout: 5000 }) 284 console.log(` Installed: ${(version.stdout || '').trim()}`) 285 286 // Test gateway status against mock 287 const status = spawnSync(bin, ['gateway', 'status', '--url', `ws://127.0.0.1:${port}`, '--token', TEST_TOKEN, '--json', '--timeout', '5000'], { 288 encoding: 'utf-8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'], 289 }) 290 const statusJson = JSON.parse(status.stdout || '{}') 291 assert(statusJson.rpc?.ok === true || statusJson.gateway?.probeUrl?.includes(String(port)), 'CLI gateway status connects to mock') 292 293 // Check protocol version from CLI source 294 const grepResult = spawnSync('grep', ['-r', 'PROTOCOL_VERSION', join(tmpDir, 'node_modules/openclaw/dist/')], { 295 encoding: 'utf-8', timeout: 5000, 296 }) 297 const versionMatch = (grepResult.stdout || '').match(/PROTOCOL_VERSION\s*=\s*(\d+)/) 298 if (versionMatch) { 299 const cliVersion = parseInt(versionMatch[1]) 300 assert(cliVersion === PROTOCOL_VERSION, `Protocol version matches (CLI: ${cliVersion}, ours: ${PROTOCOL_VERSION})`) 301 } else { 302 console.log(` ${FAIL} Could not determine CLI protocol version`) 303 failed++ 304 } 305 } 306 } finally { 307 rmSync(tmpDir, { recursive: true, force: true }) 308 } 309 } 310 311 wss.close() 312 313 console.log(`\n${passed} passed, ${failed} failed`) 314 process.exit(failed > 0 ? 1 : 0) 315 } 316 317 main().catch((err) => { 318 console.error(err) 319 process.exit(1) 320 })