/ scripts / test-openclaw-protocol.mjs
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  })