mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
1 Commits
chore/apr1
...
feat/openc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a7f725d4e |
247
packages/browseros-agent/apps/server/poc/test-openclaw-multi.ts
Normal file
247
packages/browseros-agent/apps/server/poc/test-openclaw-multi.ts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user