Compare commits

...

1 Commits

Author SHA1 Message Date
shivammittal274
3a7f725d4e feat: add OpenClaw PoC test scripts
Two scripts validating OpenClaw integration for BrowserOS:
- test-openclaw-multi.ts: multi-agent, multi-session isolation via gateway call
- test-openclaw-ws-stream.ts: WebSocket streaming with Ed25519 device auth
2026-03-31 23:37:40 +05:30
2 changed files with 523 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
/**
* OpenClaw PoC: Multiple Agents + Multiple Sessions
*
* Uses `openclaw gateway call agent` via Bun.spawn.
* The gateway handles auth, session routing, and persistence.
*
* Run: bun run poc/test-openclaw-multi.ts
*/
import { randomUUID } from 'node:crypto'
// ─── Types ───────────────────────────────────────────────
interface AgentResult {
payloads: Array<{ text: string; mediaUrl: string | null }>
meta: {
durationMs: number
agentMeta: {
sessionId: string
provider: string
model: string
usage: { input: number; output: number; total: number }
}
}
}
interface AgentResponse {
text: string
sessionId: string
model: string
durationMs: number
tokens: number
}
// ─── OpenClaw Client ─────────────────────────────────────
class OpenClawClient {
/**
* Send a message to an OpenClaw agent session via the gateway.
*
* @param agentId - Agent name ("main", "code-helper", etc.)
* @param sessionTag - Unique conversation identifier
* @param message - User message text
*/
async chat(
agentId: string,
sessionTag: string,
message: string,
): Promise<AgentResponse> {
const sessionKey = `agent:${agentId}:browseros-${sessionTag}`
const params = JSON.stringify({
message,
sessionKey,
idempotencyKey: randomUUID(),
})
const proc = Bun.spawn(
[
'openclaw',
'gateway',
'call',
'agent',
'--params',
params,
'--expect-final',
'--json',
'--timeout',
'120000',
],
{ stdout: 'pipe', stderr: 'pipe' },
)
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode !== 0) {
throw new Error(`openclaw failed (${exitCode}): ${stderr}`)
}
// Parse JSON — skip log lines before the JSON object
const jsonStart = stdout.indexOf('{')
if (jsonStart === -1) throw new Error('No JSON in output')
const data = JSON.parse(stdout.slice(jsonStart))
const result: AgentResult = data.result ?? data
const meta = result.meta?.agentMeta
return {
text: result.payloads.map((p) => p.text).join('\n'),
sessionId: meta?.sessionId ?? 'unknown',
model: meta?.model ?? 'unknown',
durationMs: result.meta?.durationMs ?? 0,
tokens: meta?.usage?.total ?? 0,
}
}
}
// ─── Helpers ──────────────────────────────────────────────
function print(label: string, res: AgentResponse) {
console.log(` [${label}]`)
console.log(
` Model: ${res.model} | Session: ${res.sessionId.slice(0, 8)}...`,
)
console.log(` Duration: ${res.durationMs}ms | Tokens: ${res.tokens}`)
console.log(` Response: ${res.text.slice(0, 200)}`)
console.log()
}
// ─── Tests ────────────────────────────────────────────────
async function testMultipleSessionsSameAgent(client: OpenClawClient) {
console.log('═══════════════════════════════════════════════')
console.log(' TEST 1: Multiple Sessions, Same Agent')
console.log('═══════════════════════════════════════════════\n')
const convA = randomUUID()
const convB = randomUUID()
console.log('-- Turn 1: Start two conversations --\n')
const [a1, b1] = await Promise.all([
client.chat(
'main',
convA,
'Top 3 places in Tokyo. One line each, no extras.',
),
client.chat(
'main',
convB,
'Top 3 places in Berlin. One line each, no extras.',
),
])
print('Conv A - Tokyo', a1)
print('Conv B - Berlin', b1)
console.log('-- Turn 2: Follow-ups (session memory) --\n')
const [a2, b2] = await Promise.all([
client.chat('main', convA, 'Which of those 3 has the best nightlife?'),
client.chat('main', convB, 'Which of those 3 has the best museums?'),
])
print('Conv A - Tokyo nightlife', a2)
print('Conv B - Berlin museums', b2)
console.log('-- Turn 3: Isolation check --\n')
const [a3, b3] = await Promise.all([
client.chat('main', convA, 'What city are we discussing? Just the name.'),
client.chat('main', convB, 'What city are we discussing? Just the name.'),
])
print('Conv A - should say Tokyo', a3)
print('Conv B - should say Berlin', b3)
}
async function testMultipleAgents(client: OpenClawClient) {
console.log('═══════════════════════════════════════════════')
console.log(' TEST 2: Multiple Agents, Different Models')
console.log('═══════════════════════════════════════════════\n')
const convId = randomUUID()
const question = 'Write a one-line JS function that reverses a string.'
const [main, helper] = await Promise.all([
client.chat('main', convId, question),
client.chat('code-helper', convId, question),
])
print('Agent "main" (opus)', main)
print('Agent "code-helper" (sonnet)', helper)
}
async function testSessionFactory(client: OpenClawClient) {
console.log('═══════════════════════════════════════════════')
console.log(' TEST 3: BrowserOS Session Factory Pattern')
console.log('═══════════════════════════════════════════════\n')
const users = [
{ id: randomUUID(), name: 'Alice', q: 'What is TypeScript? One sentence.' },
{ id: randomUUID(), name: 'Bob', q: 'What is Rust? One sentence.' },
{ id: randomUUID(), name: 'Carol', q: 'What is Go? One sentence.' },
]
console.log('-- 3 parallel user messages --\n')
const results = await Promise.all(
users.map((u) => client.chat('main', u.id, u.q)),
)
for (let i = 0; i < users.length; i++) {
print(users[i].name, results[i])
}
console.log('-- Alice follow-up (others unaffected) --\n')
const followUp = await client.chat(
'main',
users[0].id,
'Give me one code example.',
)
print('Alice follow-up', followUp)
}
async function testToolCalls(client: OpenClawClient) {
console.log('═══════════════════════════════════════════════')
console.log(' TEST 4: Tool Calls (Web Search + Multi-Step)')
console.log('═══════════════════════════════════════════════\n')
const convId = randomUUID()
// This triggers a web search tool call — OpenClaw will use its web_search tool
const res = await client.chat(
'main',
convId,
'Search the web for the current population of Japan. Then calculate how many times larger it is than Iceland (population ~380,000). Show your math.',
)
console.log(
` Payloads received: ${res.text.split('\n').length > 1 ? 'multiple' : '1'}`,
)
console.log(` Duration: ${res.durationMs}ms | Tokens: ${res.tokens}`)
console.log()
// Show each payload separately to see intermediate steps
const lines = res.text.split('\n')
lines.forEach((line, i) => {
if (line.trim()) {
console.log(` [Payload ${i + 1}] ${line.slice(0, 300)}`)
console.log()
}
})
}
// ─── Main ─────────────────────────────────────────────────
const client = new OpenClawClient()
console.log('\n OpenClaw Multi-Agent Multi-Session PoC\n')
try {
await testMultipleSessionsSameAgent(client)
await testMultipleAgents(client)
await testSessionFactory(client)
await testToolCalls(client)
console.log('═══════════════════════════════════════════════')
console.log(' ALL TESTS COMPLETE')
console.log('═══════════════════════════════════════════════')
} catch (err) {
console.error('Test failed:', err)
}

View File

@@ -0,0 +1,276 @@
/**
* OpenClaw PoC: WebSocket Streaming with Device Auth
*
* Connects to the gateway using the proper Ed25519 device identity handshake.
* Subscribes to session events to see tool calls and reasoning in real-time.
*
* Run: bun run poc/test-openclaw-ws-stream.ts
*/
import crypto, { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import WebSocket from 'ws'
// ─── Load Config & Device Identity ───────────────────────
const HOME = homedir()
const config = JSON.parse(
readFileSync(join(HOME, '.openclaw', 'openclaw.json'), 'utf-8'),
)
const gatewayToken = config.gateway?.auth?.token ?? ''
const devicePath = join(HOME, '.openclaw', 'identity', 'device.json')
const device = JSON.parse(readFileSync(devicePath, 'utf-8'))
const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789'
// ─── Crypto Helpers ──────────────────────────────────────
function rawPublicKeyFromPem(pem: string): Buffer {
const der = Buffer.from(
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
'base64',
)
// SPKI for Ed25519: fixed 12-byte prefix (30 2a 30 05 06 03 2b 65 70 03 21 00) + 32 raw bytes
return der.subarray(12)
}
function base64url(buf: Buffer): string {
return buf.toString('base64url')
}
function signPayload(privateKeyPem: string, payload: string): string {
const privateKey = crypto.createPrivateKey(privateKeyPem)
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
return base64url(sig)
}
// ─── WebSocket Client ────────────────────────────────────
class OpenClawWSClient {
private ws: WebSocket | null = null
// biome-ignore lint/suspicious/noExplicitAny: PoC script — untyped gateway protocol
private pendingRequests = new Map<
string,
{ resolve: (v: any) => void; reject: (e: any) => void }
>()
// biome-ignore lint/suspicious/noExplicitAny: PoC script
private eventHandlers: Array<(event: string, payload: any) => void> = []
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(GATEWAY_URL)
this.ws.on('message', (data: Buffer) => {
const frame = JSON.parse(data.toString())
// Step 1: Gateway sends challenge
if (frame.type === 'event' && frame.event === 'connect.challenge') {
const nonce = frame.payload.nonce
const signedAt = Date.now()
const rawPubKey = rawPublicKeyFromPem(device.publicKeyPem)
const deviceId = device.deviceId
const role = 'operator'
const scopes =
'operator.admin,operator.read,operator.write,operator.approvals,operator.pairing'
const clientId = 'cli'
const clientMode = 'cli'
const platform = process.platform
// v3 signature payload
const payload = `v3|${deviceId}|${clientId}|${clientMode}|${role}|${scopes}|${signedAt}|${gatewayToken}|${nonce}|${platform}|`
const signature = signPayload(device.privateKeyPem, payload)
// Step 2: Send connect with device identity
this.ws?.send(
JSON.stringify({
type: 'req',
id: `connect-${randomUUID().slice(0, 8)}`,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: clientId,
version: '1.0.0',
platform,
mode: clientMode,
},
caps: [],
commands: [],
permissions: {},
auth: { token: gatewayToken },
role,
scopes: scopes.split(','),
device: {
id: deviceId,
publicKey: base64url(rawPubKey),
signature,
signedAt,
nonce,
},
},
}),
)
return
}
// Step 3: hello-ok
if (
frame.type === 'res' &&
frame.ok === true &&
frame.payload?.type === 'hello-ok'
) {
resolve()
return
}
// Auth failure
if (
frame.type === 'res' &&
frame.ok === false &&
!this.pendingRequests.has(frame.id)
) {
reject(new Error(`Connect failed: ${JSON.stringify(frame.error)}`))
return
}
// RPC responses
if (frame.type === 'res' && this.pendingRequests.has(frame.id)) {
const pending = this.pendingRequests.get(frame.id)
if (!pending) return
this.pendingRequests.delete(frame.id)
if (frame.ok === false) {
pending.reject(
new Error(frame.error?.message ?? JSON.stringify(frame.error)),
)
} else {
pending.resolve(frame.payload)
}
return
}
// Events (tool calls, text deltas, agent steps)
if (frame.type === 'event') {
for (const handler of this.eventHandlers) {
handler(frame.event, frame.payload)
}
}
})
this.ws.on('error', (err) => reject(err))
setTimeout(() => reject(new Error('Connection timeout')), 15_000)
})
}
// biome-ignore lint/suspicious/noExplicitAny: PoC script
onEvent(handler: (event: string, payload: any) => void) {
this.eventHandlers.push(handler)
}
// biome-ignore lint/suspicious/noExplicitAny: PoC script — untyped gateway protocol
private call(method: string, params: Record<string, any>): Promise<any> {
return new Promise((resolve, reject) => {
const id = randomUUID()
this.pendingRequests.set(id, { resolve, reject })
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id)
reject(new Error('Request timeout'))
}
}, 120_000)
})
}
/**
* Send a message and wait for the full response via events.
* Returns a promise that resolves when the agent run completes.
*/
// biome-ignore lint/suspicious/noExplicitAny: PoC script
async chatAndWait(
agentId: string,
sessionTag: string,
message: string,
): Promise<any> {
const idempotencyKey = randomUUID()
// Set up event listener before sending
// biome-ignore lint/suspicious/noExplicitAny: PoC script
const waitForFinal = new Promise<any>((resolve) => {
// biome-ignore lint/suspicious/noExplicitAny: PoC script
const handler = (event: string, payload: any) => {
if (event === 'chat' && payload?.state === 'final') {
const idx = this.eventHandlers.indexOf(handler)
if (idx >= 0) this.eventHandlers.splice(idx, 1)
resolve(payload)
}
}
this.eventHandlers.push(handler)
// Timeout after 2 minutes
setTimeout(() => {
const idx = this.eventHandlers.indexOf(handler)
if (idx >= 0) this.eventHandlers.splice(idx, 1)
resolve({ timeout: true })
}, 120_000)
})
// Send the request (returns "accepted" immediately)
this.call('agent', {
message,
sessionKey: `agent:${agentId}:browseros-${sessionTag}`,
idempotencyKey,
}).catch(() => {})
return waitForFinal
}
disconnect() {
this.ws?.close()
}
}
// ─── Main ─────────────────────────────────────────────────
console.log('\n OpenClaw WebSocket Streaming PoC\n')
const client = new OpenClawWSClient()
try {
console.log('Connecting with device identity...')
await client.connect()
console.log('Connected!\n')
// Listen for ALL events to see what comes through
client.onEvent((event, payload) => {
const ts = new Date().toISOString().slice(11, 23)
const summary = JSON.stringify(payload).slice(0, 150)
console.log(` [${ts}] EVENT: ${event} ${summary}`)
})
// Test 1: Simple message — watch the events stream
console.log('--- Test 1: Simple message ---\n')
const res1 = await client.chatAndWait(
'main',
'ws-stream-test',
'What is 2 + 2? One word answer.',
)
console.log(`\n Final result:`, JSON.stringify(res1).slice(0, 200), '\n')
// Test 2: Tool call (web search) — should show intermediate tool events
console.log('--- Test 2: Web search (watch for tool events) ---\n')
const res2 = await client.chatAndWait(
'main',
'ws-stream-test',
'Search the web for the current temperature in London right now.',
)
console.log(`\n Final result:`, JSON.stringify(res2).slice(0, 200), '\n')
} catch (err) {
console.error('Failed:', err)
} finally {
client.disconnect()
}