Compare commits

...

2 Commits

Author SHA1 Message Date
Nikhil Sonti
ae3e79bb43 fix: address PR review comments for 0416-openclaw_cli_http_redesign 2026-04-16 16:22:27 -07:00
Nikhil Sonti
d2fb785367 feat: move OpenClaw control plane to CLI and HTTP 2026-04-16 15:34:06 -07:00
8 changed files with 1100 additions and 328 deletions

View File

@@ -0,0 +1,126 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
type LogFn = (line: string) => void
interface ContainerExecutor {
execInContainer(command: string[], onLog?: LogFn): Promise<number>
}
interface RawAgentRecord {
id: string
name?: string
workspace: string
model?: string
}
export interface OpenClawAgentRecord {
agentId: string
name: string
workspace: string
model?: string
}
export class OpenClawAdminClient {
constructor(
private readonly executor: ContainerExecutor,
private readonly getToken: () => Promise<string>,
) {}
async listAgents(): Promise<OpenClawAgentRecord[]> {
const records = await this.runJsonCommand<RawAgentRecord[]>([
'agents',
'list',
'--json',
])
return records.map((record) => ({
agentId: record.id,
name: record.name ?? record.id,
workspace: record.workspace,
model: record.model,
}))
}
async createAgent(input: {
name: string
workspace: string
model?: string
}): Promise<OpenClawAgentRecord> {
const args = ['agents', 'add', input.name, '--workspace', input.workspace]
if (input.model) {
args.push('--model', input.model)
}
args.push('--non-interactive', '--json')
await this.runCommand(args)
const agents = await this.listAgents()
const agent = agents.find((entry) => entry.agentId === input.name)
if (!agent) {
throw new Error(`Created agent ${input.name} was not found in agent list`)
}
return agent
}
async deleteAgent(agentId: string): Promise<void> {
await this.runCommand(['agents', 'delete', agentId, '--force', '--json'])
}
async probe(): Promise<void> {
await this.listAgents()
}
private async runJsonCommand<T>(args: string[]): Promise<T> {
const output = await this.runCommand(args)
return parseJsonOutput<T>(output)
}
private async runCommand(args: string[]): Promise<string> {
const output: string[] = []
const token = await this.getToken()
const command = ['node', 'dist/index.js', ...args, '--token', token]
const exitCode = await this.executor.execInContainer(command, (line) =>
output.push(line),
)
if (exitCode !== 0) {
const detail = output.join('\n').trim()
throw new Error(
detail || `OpenClaw command failed (${args.slice(0, 2).join(' ')})`,
)
}
return output.join('\n').trim()
}
}
function parseJsonOutput<T>(output: string): T {
const direct = tryParseJson<T>(output)
if (direct !== null) return direct
const start = output.search(/[[{]/)
if (start >= 0) {
const sliced = tryParseJson<T>(output.slice(start))
if (sliced !== null) return sliced
}
throw new Error(
`Failed to parse OpenClaw JSON output: ${output.slice(0, 200)}`,
)
}
function tryParseJson<T>(value: string): T | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
return JSON.parse(trimmed) as T
} catch {
return null
}
}

View File

@@ -0,0 +1,245 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createParser, type EventSourceMessage } from 'eventsource-parser'
import type { OpenClawStreamEvent } from './openclaw-types'
export interface OpenClawChatRequest {
agentId: string
sessionKey: string
message: string
signal?: AbortSignal
}
export class OpenClawHttpChatClient {
constructor(
private readonly port: number,
private readonly getToken: () => Promise<string>,
) {}
async streamChat(
input: OpenClawChatRequest,
): Promise<ReadableStream<OpenClawStreamEvent>> {
const response = await this.fetchChat(input)
const body = response.body
if (!body) {
throw new Error('OpenClaw chat response had no body')
}
return createEventStream(body, input.signal)
}
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.port}/v1/chat/completions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: resolveAgentModel(input.agentId),
stream: true,
messages: [{ role: 'user', content: input.message }],
user: `browseros:${input.agentId}:${input.sessionKey}`,
}),
signal: input.signal,
},
)
if (response.ok) {
return response
}
const detail = await response.text()
throw new Error(
detail || `OpenClaw chat failed with status ${response.status}`,
)
}
}
function resolveAgentModel(agentId: string): string {
return agentId === 'main' ? 'openclaw/default' : `openclaw/${agentId}`
}
function createEventStream(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): ReadableStream<OpenClawStreamEvent> {
return new ReadableStream<OpenClawStreamEvent>({
start(controller) {
void pumpChatEvents(body, controller, signal)
},
})
}
async function pumpChatEvents(
body: ReadableStream<Uint8Array>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let text = ''
let done = false
const parser = createParser({
onEvent(message) {
if (done) return
const nextText = updateAccumulatedText(message, text)
done = handleMessage(message, controller, nextText, done)
if (!done) {
text = nextText
}
},
})
try {
while (true) {
if (signal?.aborted) {
await reader.cancel()
controller.close()
return
}
const { done: streamDone, value } = await reader.read()
if (streamDone) break
parser.feed(decoder.decode(value, { stream: true }))
}
} catch (error) {
if (!done) {
controller.enqueue({
type: 'error',
data: {
message: error instanceof Error ? error.message : String(error),
},
})
controller.close()
}
} finally {
if (!done) {
controller.close()
}
reader.releaseLock()
}
}
function handleMessage(
message: EventSourceMessage,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
text: string,
done: boolean,
): boolean {
if (message.data === '[DONE]') {
return finishStream(controller, text, done)
}
const chunk = parseChunk(message.data)
if (!chunk) {
controller.enqueue({
type: 'error',
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
})
controller.close()
return true
}
for (const event of mapChunkToEvents(chunk)) {
controller.enqueue(event)
}
return hasFinishReason(chunk) ? finishStream(controller, text, done) : false
}
function updateAccumulatedText(
message: EventSourceMessage,
text: string,
): string {
const chunk = parseChunk(message.data)
if (!chunk) return text
let next = text
for (const choice of readChoices(chunk)) {
const delta = readDeltaText(choice)
if (delta) {
next += delta
}
}
return next
}
function finishStream(
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
text: string,
done: boolean,
): boolean {
if (!done) {
controller.enqueue({
type: 'done',
data: { text },
})
controller.close()
}
return true
}
function mapChunkToEvents(
chunk: Record<string, unknown>,
): OpenClawStreamEvent[] {
const events: OpenClawStreamEvent[] = []
for (const choice of readChoices(chunk)) {
const delta = readDeltaText(choice)
if (delta) {
events.push({
type: 'text-delta',
data: { text: delta },
})
}
}
return events
}
function hasFinishReason(chunk: Record<string, unknown>): boolean {
return readChoices(chunk).some((choice) => !!readFinishReason(choice))
}
function readChoices(
chunk: Record<string, unknown>,
): Array<Record<string, unknown>> {
const choices = chunk.choices
return Array.isArray(choices)
? choices.filter(
(choice): choice is Record<string, unknown> =>
!!choice && typeof choice === 'object',
)
: []
}
function readDeltaText(choice: Record<string, unknown>): string {
const delta = choice.delta
if (!delta || typeof delta !== 'object') return ''
const content = (delta as Record<string, unknown>).content
return typeof content === 'string' ? content : ''
}
function readFinishReason(choice: Record<string, unknown>): string | null {
const reason = choice.finish_reason
return typeof reason === 'string' && reason ? reason : null
}
function parseChunk(data: string): Record<string, unknown> | null {
try {
return JSON.parse(data) as Record<string, unknown>
} catch {
return null
}
}

View File

@@ -4,14 +4,17 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Main orchestrator for OpenClaw integration.
* Container lifecycle via Podman, agent CRUD via Gateway WS RPC,
* Container lifecycle via Podman, agent CRUD via in-container CLI,
* chat via HTTP /v1/chat/completions proxy.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_PORT,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import type {
BrowserOSAgentRoleId,
@@ -28,11 +31,9 @@ import {
OpenClawProtectedAgentError,
} from './errors'
import {
ensureClientIdentity,
type GatewayAgentEntry,
GatewayClient,
type OpenClawStreamEvent,
} from './gateway-client'
OpenClawAdminClient,
type OpenClawAgentRecord,
} from './openclaw-admin-client'
import {
buildBootstrapConfig,
buildEnvFile,
@@ -42,6 +43,8 @@ import {
resolveProviderKeys,
resolveProviderModel,
} from './openclaw-config'
import { OpenClawHttpChatClient } from './openclaw-http-chat-client'
import type { OpenClawStreamEvent } from './openclaw-types'
import { getPodmanRuntime } from './podman-runtime'
import {
buildRoleBootstrapFiles,
@@ -62,10 +65,12 @@ export type OpenClawControlPlaneStatus =
| 'connecting'
| 'connected'
| 'reconnecting'
// Retained for extension compatibility while the UI still branches on it.
| 'recovering'
| 'failed'
export type OpenClawGatewayRecoveryReason =
// Retained for extension compatibility while the UI still renders these reasons.
| 'transient_disconnect'
| 'signature_expired'
| 'pairing_required'
@@ -92,7 +97,7 @@ export interface OpenClawStatusResponse {
lastRecoveryReason: OpenClawGatewayRecoveryReason | null
}
export interface OpenClawAgentEntry extends GatewayAgentEntry {
export interface OpenClawAgentEntry extends OpenClawAgentRecord {
role?: BrowserOSAgentRoleSummary
}
@@ -106,7 +111,8 @@ export interface SetupInput {
export class OpenClawService {
private runtime: ContainerRuntime
private gateway: GatewayClient | null = null
private adminClient: OpenClawAdminClient
private chatClient: OpenClawHttpChatClient
private openclawDir: string
private port = OPENCLAW_GATEWAY_PORT
private token: string
@@ -115,13 +121,20 @@ export class OpenClawService {
private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected'
private lastGatewayError: string | null = null
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
private gatewayReconnectPromise: Promise<void> | null = null
private stopLogTail: (() => void) | null = null
constructor(browserosServerPort?: number) {
this.openclawDir = getOpenClawDir()
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
this.token = crypto.randomUUID()
this.adminClient = new OpenClawAdminClient(
this.runtime,
async () => this.token,
)
this.chatClient = new OpenClawHttpChatClient(
this.port,
async () => this.token,
)
this.browserosServerPort = browserosServerPort ?? DEFAULT_PORTS.server
}
@@ -199,32 +212,26 @@ export class OpenClawService {
throw new Error(this.lastError)
}
// Generate client device identity for WS auth
logProgress('Generating client device identity...')
ensureClientIdentity(this.openclawDir)
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.adminClient.probe())
logProgress('Connecting to gateway...')
await this.connectGatewayResiliently(logProgress)
// Ensure main agent exists (gateway may auto-create it)
// biome-ignore lint/style/noNonNullAssertion: gateway is guaranteed connected after connectGateway()
const existingAgents = await this.gateway!.listAgents()
const existingAgents = await this.listAgents()
logger.info('Fetched existing OpenClaw agents after setup', {
count: existingAgents.length,
names: existingAgents.map((agent) => agent.name),
})
const hasMain = existingAgents.some((a) => a.agentId === 'main')
if (!hasMain) {
logProgress('Creating main agent...')
const model = resolveProviderModel(input)
// biome-ignore lint/style/noNonNullAssertion: gateway is connected
await this.gateway!.createAgent({
name: 'main',
workspace: GatewayClient.agentWorkspace('main'),
model,
})
if (existingAgents.some((agent) => agent.agentId === 'main')) {
logProgress('Main agent detected')
} else {
logProgress('Main agent already exists')
logProgress('Creating main agent...')
await this.runControlPlaneCall(() =>
this.adminClient.createAgent({
name: 'main',
workspace: this.getContainerWorkspacePath('main'),
model: resolveProviderModel(input),
}),
)
}
this.lastError = null
@@ -253,15 +260,16 @@ export class OpenClawService {
throw new Error(this.lastError)
}
logProgress('Connecting to gateway...')
await this.connectGatewayResiliently(logProgress)
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.adminClient.probe())
this.lastError = null
logger.info('OpenClaw gateway started', { port: this.port })
}
async stop(): Promise<void> {
logger.info('Stopping OpenClaw service', { port: this.port })
this.disconnectGateway()
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
await this.runtime.composeStop()
logger.info('OpenClaw container stopped')
@@ -273,7 +281,7 @@ export class OpenClawService {
port: this.port,
})
this.disconnectGateway()
this.controlPlaneStatus = 'reconnecting'
this.stopGatewayLogTail()
logProgress('Loading gateway auth token...')
await this.loadTokenFromEnv()
@@ -289,8 +297,8 @@ export class OpenClawService {
throw new Error(this.lastError)
}
logProgress('Connecting to gateway...')
await this.connectGatewayResiliently(logProgress)
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.adminClient.probe())
this.lastError = null
logProgress('Gateway restarted successfully')
logger.info('OpenClaw gateway restarted', { port: this.port })
@@ -311,15 +319,14 @@ export class OpenClawService {
logProgress('Reloading gateway auth token...')
await this.loadTokenFromEnv()
this.disconnectGateway()
this.controlPlaneStatus = 'reconnecting'
logProgress('Reconnecting control plane...')
await this.ensureGatewayReady()
await this.runControlPlaneCall(() => this.adminClient.probe())
logProgress('Control plane connected')
}
async shutdown(): Promise<void> {
this.disconnectGateway()
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
try {
await this.runtime.composeStop()
@@ -370,12 +377,14 @@ export class OpenClawService {
: false
let agentCount = 0
if (ready && this.gateway?.isConnected) {
if (ready) {
try {
const agents = await this.gateway.listAgents()
const agents = await this.runControlPlaneCall(() =>
this.adminClient.listAgents(),
)
agentCount = agents.length
} catch {
// WS may be momentarily unavailable
// latest control plane error is captured by runControlPlaneCall
}
}
@@ -386,17 +395,13 @@ export class OpenClawService {
port: this.port,
agentCount,
error: this.lastError,
controlPlaneStatus: ready
? this.gateway?.isConnected
? 'connected'
: this.controlPlaneStatus
: 'disconnected',
controlPlaneStatus: ready ? this.controlPlaneStatus : 'disconnected',
lastGatewayError: this.lastGatewayError,
lastRecoveryReason: this.lastRecoveryReason,
}
}
// ── Agent Management (via WS RPC) ───────────────────────────────────
// ── Agent Management (via CLI) ──────────────────────────────────────
async createAgent(input: {
name: string
@@ -423,7 +428,7 @@ export class OpenClawService {
hasModel: !!input.modelId,
hasApiKey: !!input.apiKey,
})
await this.ensureGatewayReady()
await this.assertGatewayReady()
const configChanged = await this.mergeProviderConfigIfChanged(input)
const keysChanged =
@@ -441,19 +446,15 @@ export class OpenClawService {
}
const model = resolveProviderModel(input)
const gateway = this.gateway
if (!gateway) {
throw new Error('Gateway WS not connected')
}
let agent: GatewayAgentEntry
let agent: OpenClawAgentRecord
try {
agent = await gateway.createAgent({
name,
workspace: GatewayClient.agentWorkspace(name),
model,
})
agent = await this.runControlPlaneCall(() =>
this.adminClient.createAgent({
name,
workspace: this.getContainerWorkspacePath(name),
model,
}),
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('already exists')) {
@@ -463,10 +464,13 @@ export class OpenClawService {
}
if (input.roleId || input.customRole) {
await this.writeRoleBootstrapFiles(
name,
input.roleId ? resolveRoleTemplate(input.roleId) : input.customRole!,
)
const role = input.roleId
? resolveRoleTemplate(input.roleId)
: input.customRole
if (!role) {
throw new Error('Role bootstrap requested without a role definition')
}
await this.writeRoleBootstrapFiles(name, role)
}
const roleSummary = input.roleId
@@ -475,7 +479,7 @@ export class OpenClawService {
? toRoleSummary(input.customRole)
: undefined
logger.info('Agent created via WS RPC', {
logger.info('Agent created via CLI', {
agentId: agent.agentId,
roleId: input.roleId,
roleSource: roleSummary?.roleSource,
@@ -493,10 +497,11 @@ export class OpenClawService {
throw new OpenClawProtectedAgentError('Cannot delete the main agent')
}
await this.ensureGatewayReady()
await this.assertGatewayReady()
try {
// biome-ignore lint/style/noNonNullAssertion: ensureGatewayReady() guarantees a connected client
await this.gateway!.deleteAgent(agentId)
await this.runControlPlaneCall(() =>
this.adminClient.deleteAgent(agentId),
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('not found')) {
@@ -504,14 +509,15 @@ export class OpenClawService {
}
throw error
}
logger.info('Agent removed via WS RPC', { agentId })
logger.info('Agent removed via CLI', { agentId })
}
async listAgents(): Promise<OpenClawAgentEntry[]> {
await this.ensureGatewayReady()
await this.assertGatewayReady()
logger.debug('Listing OpenClaw agents')
// biome-ignore lint/style/noNonNullAssertion: ensureGatewayReady() guarantees a connected client
const agents = await this.gateway!.listAgents()
const agents = await this.runControlPlaneCall(() =>
this.adminClient.listAgents(),
)
return Promise.all(
agents.map(async (agent) => ({
...agent,
@@ -520,21 +526,26 @@ export class OpenClawService {
)
}
// ── Chat Stream (WS) ─────────────────────────────────────────────────
// ── Chat Stream (HTTP) ───────────────────────────────────────────────
async chatStream(
agentId: string,
sessionKey: string,
message: string,
): Promise<ReadableStream<OpenClawStreamEvent>> {
await this.ensureGatewayReady()
await this.assertGatewayReady()
logger.info('Starting OpenClaw chat stream', {
agentId,
sessionKey,
messageLength: message.length,
})
// biome-ignore lint/style/noNonNullAssertion: ensureGatewayReady() guarantees a connected client
return this.gateway!.chatStream(agentId, sessionKey, message)
return this.runControlPlaneCall(() =>
this.chatClient.streamChat({
agentId,
sessionKey,
message,
}),
)
}
// ── Provider Keys ────────────────────────────────────────────────────
@@ -587,7 +598,7 @@ export class OpenClawService {
}
}
await this.connectGatewayResiliently()
await this.runControlPlaneCall(() => this.adminClient.probe())
logger.info('OpenClaw gateway auto-started')
} catch (err) {
logger.warn('OpenClaw auto-start failed', {
@@ -596,260 +607,53 @@ export class OpenClawService {
}
}
private async connectGatewayResiliently(
onLog?: (msg: string) => void,
): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
const existingConnection =
!!this.gateway || this.controlPlaneStatus !== 'disconnected'
this.controlPlaneStatus = existingConnection ? 'reconnecting' : 'connecting'
this.lastGatewayError = null
this.lastRecoveryReason = null
// ── Internal ─────────────────────────────────────────────────────────
private async assertGatewayReady(): Promise<void> {
const portReady = await this.runtime.isReady(this.port)
logger.debug('Checking OpenClaw gateway readiness before use', {
port: this.port,
portReady,
controlPlaneStatus: this.controlPlaneStatus,
})
if (portReady) {
return
}
this.controlPlaneStatus = 'failed'
this.lastGatewayError = 'OpenClaw gateway is not ready'
this.lastRecoveryReason = 'container_not_ready'
throw new Error('OpenClaw gateway is not ready')
}
private async runControlPlaneCall<T>(fn: () => Promise<T>): Promise<T> {
try {
logger.info('Connecting OpenClaw control plane', {
port: this.port,
status: this.controlPlaneStatus,
})
await this.connectGateway()
await this.ensureTokenLoaded()
const result = await fn()
this.controlPlaneStatus = 'connected'
this.lastGatewayError = null
this.lastRecoveryReason = null
logger.info('OpenClaw gateway control plane connected', {
port: this.port,
})
return
return result
} catch (error) {
const reason = this.classifyGatewayError(error)
const message = error instanceof Error ? error.message : String(error)
const reason = this.classifyControlPlaneError(error)
this.controlPlaneStatus = 'failed'
this.lastGatewayError = message
this.lastRecoveryReason = reason
logger.warn('OpenClaw gateway connect failed', { reason, error: message })
if (!this.isRecoverableGatewayError(reason)) {
this.controlPlaneStatus = 'failed'
throw error
}
this.controlPlaneStatus = 'recovering'
logProgress(`Recovering gateway connection: ${reason}`)
await this.performGatewayRecovery(reason, logProgress)
try {
await this.connectGateway()
this.controlPlaneStatus = 'connected'
this.lastGatewayError = null
logger.info('OpenClaw gateway control plane recovered', {
reason,
port: this.port,
})
} catch (retryError) {
const retryMessage =
retryError instanceof Error ? retryError.message : String(retryError)
this.lastGatewayError = retryMessage
this.lastRecoveryReason = this.classifyGatewayError(retryError)
this.controlPlaneStatus = 'failed'
logger.error('OpenClaw gateway recovery failed', {
reason,
error: retryMessage,
})
throw retryError
}
throw error
}
}
// ── Internal ─────────────────────────────────────────────────────────
/**
* Approves the latest pending device pair request via the openclaw CLI
* running inside the container. This is needed because the gateway requires
* Ed25519 device identity and approval before granting operator scopes.
*/
private async approvePendingDevice(
logProgress: (msg: string) => void,
): Promise<void> {
logger.info('Approving pending OpenClaw device pairing')
// List pending devices to get the request ID
const output: string[] = []
const listCode = await this.runtime.execInContainer(
[
'node',
'dist/index.js',
'devices',
'list',
'--json',
'--token',
this.token,
],
(line) => output.push(line),
)
if (listCode !== 0) {
throw new Error(`Failed to list pending devices (exit ${listCode})`)
}
const jsonStr = output.join('\n')
let data: {
pending?: Array<{ requestId: string; deviceId?: string }>
}
try {
data = JSON.parse(jsonStr)
} catch {
throw new Error(
`Failed to parse device list output: ${jsonStr.slice(0, 200)}`,
)
}
const pending = data.pending
if (!pending?.length) {
logger.warn('No pending device pair requests found')
throw new Error('No pending device pair requests to approve')
}
const clientDeviceId = await this.readClientDeviceId()
const pendingRequest =
pending.find((request) => request.deviceId === clientDeviceId) ??
pending[0]
const requestId = pendingRequest.requestId
if (clientDeviceId && pendingRequest.deviceId !== clientDeviceId) {
logger.warn('Pending device request did not match client identity', {
clientDeviceId,
approvedRequestId: requestId,
})
}
logProgress(`Approving device pair request ${requestId.slice(0, 8)}...`)
const code = await this.runtime.execInContainer([
'node',
'dist/index.js',
'devices',
'approve',
requestId,
'--token',
this.token,
'--json',
])
if (code !== 0) {
logger.warn('Device approval command exited with code', { code })
throw new Error('Failed to approve client device pairing')
}
logProgress('Client device approved')
}
private async connectGateway(): Promise<void> {
this.disconnectGateway()
logger.info('Connecting OpenClaw gateway client', {
port: this.port,
})
const gateway = new GatewayClient(this.port, this.token, this.openclawDir)
await gateway.connect()
this.gateway = gateway
}
private disconnectGateway(): void {
if (this.gateway) {
this.gateway.disconnect()
this.gateway = null
}
this.controlPlaneStatus = 'disconnected'
}
private async ensureGatewayReady(): Promise<void> {
if (this.gateway?.isConnected) {
this.controlPlaneStatus = 'connected'
return
}
const portReady = await this.runtime.isReady(this.port)
logger.info('Checking OpenClaw gateway readiness before WS use', {
port: this.port,
portReady,
hasGatewayClient: !!this.gateway,
gatewayConnected: !!this.gateway?.isConnected,
})
if (!portReady) {
this.controlPlaneStatus = 'failed'
this.lastGatewayError = 'OpenClaw gateway is not ready'
this.lastRecoveryReason = 'container_not_ready'
throw new Error('OpenClaw gateway is not ready')
}
if (this.gatewayReconnectPromise) {
await this.gatewayReconnectPromise
return
}
this.gatewayReconnectPromise = this.connectGatewayResiliently()
try {
await this.gatewayReconnectPromise
} finally {
this.gatewayReconnectPromise = null
}
}
private classifyGatewayError(error: unknown): OpenClawGatewayRecoveryReason {
private classifyControlPlaneError(
error: unknown,
): OpenClawGatewayRecoveryReason {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('signature expired')) return 'signature_expired'
if (message.includes('pairing required')) return 'pairing_required'
if (message.includes('Gateway WS not connected'))
return 'transient_disconnect'
if (message.includes('Unauthorized')) return 'token_mismatch'
if (message.includes('token')) return 'token_mismatch'
if (message.includes('not ready')) return 'container_not_ready'
return 'unknown'
}
private isRecoverableGatewayError(
reason: OpenClawGatewayRecoveryReason,
): boolean {
return (
reason === 'transient_disconnect' ||
reason === 'signature_expired' ||
reason === 'pairing_required' ||
reason === 'token_mismatch'
)
}
private async performGatewayRecovery(
reason: OpenClawGatewayRecoveryReason,
logProgress: (msg: string) => void,
): Promise<void> {
switch (reason) {
case 'signature_expired': {
logProgress('Restarting gateway to resync device signature clock...')
await this.runtime.composeRestart(logProgress)
const ready = await this.runtime.waitForReady(
this.port,
READY_TIMEOUT_MS,
)
if (!ready) {
throw new Error('Gateway not ready after clock resync restart')
}
return
}
case 'pairing_required':
logProgress('Approving pending device pairing...')
await this.approvePendingDevice(logProgress)
return
case 'token_mismatch':
logProgress('Reloading gateway auth token...')
await this.loadTokenFromEnv()
return
case 'transient_disconnect':
logProgress('Retrying gateway connection...')
return
default:
throw new Error(`Unrecoverable gateway error: ${reason}`)
}
}
private async writeBootstrapConfig(
config: Record<string, unknown>,
): Promise<void> {
@@ -884,7 +688,7 @@ export class OpenClawService {
if (this.stopLogTail) return
try {
this.stopLogTail = this.runtime.tailGatewayLogs((line) => {
logger.debug(`[openclaw] ${line}`)
logger.debug(line)
})
logger.info('Streaming OpenClaw gateway logs into server log (dev mode)')
} catch (err) {
@@ -911,6 +715,12 @@ export class OpenClawService {
)
}
private getContainerWorkspacePath(agentName: string): string {
return agentName === 'main'
? `${OPENCLAW_CONTAINER_HOME}/workspace`
: `${OPENCLAW_CONTAINER_HOME}/workspace-${agentName}`
}
private async writeRoleBootstrapFiles(
agentName: string,
role: ReturnType<typeof resolveRoleTemplate> | BrowserOSCustomRoleInput,
@@ -1013,6 +823,14 @@ export class OpenClawService {
return addedNew || updatedExisting
}
private async ensureTokenLoaded(): Promise<void> {
if (!existsSync(join(this.openclawDir, '.env'))) {
return
}
await this.loadTokenFromEnv()
}
private async mergeProviderConfigIfChanged(input: {
providerType?: string
providerName?: string
@@ -1108,18 +926,6 @@ export class OpenClawService {
}
}
private async readClientDeviceId(): Promise<string | null> {
try {
const identityPath = join(this.openclawDir, 'client-identity.json')
const identity = JSON.parse(await readFile(identityPath, 'utf-8')) as {
deviceId?: string
}
return identity.deviceId ?? null
} catch {
return null
}
}
private createProgressLogger(
onLog?: (msg: string) => void,
): (msg: string) => void {

View File

@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}

View File

@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
describe('createOpenClawRoutes', () => {
afterEach(() => {
mock.restore()
})
it('preserves BrowserOS SSE framing and session headers for chat', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const chatStream = mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'text-delta',
data: { text: 'Hello' },
})
controller.enqueue({
type: 'done',
data: { text: 'Hello' },
})
controller.close()
},
}),
)
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
chatStream,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents/research/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'hi',
sessionKey: 'session-123',
}),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
expect(response.headers.get('X-Session-Key')).toBe('session-123')
expect(chatStream).toHaveBeenCalledWith('research', 'session-123', 'hi')
expect(await response.text()).toBe(
'data: {"type":"text-delta","data":{"text":"Hello"}}\n\n' +
'data: {"type":"done","data":{"text":"Hello"}}\n\n' +
'data: [DONE]\n\n',
)
})
})

View File

@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it, mock } from 'bun:test'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import { OpenClawAdminClient } from '../../../../src/api/services/openclaw/openclaw-admin-client'
describe('OpenClawAdminClient', () => {
it('lists agents from JSON CLI output', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-haiku-4-5',
},
]),
)
return 0
},
)
const client = new OpenClawAdminClient(
{ execInContainer },
async () => 'gateway-token',
)
const agents = await client.listAgents()
expect(execInContainer).toHaveBeenCalledTimes(1)
expect(execInContainer.mock.calls[0]?.[0]).toEqual([
'node',
'dist/index.js',
'agents',
'list',
'--json',
'--token',
'gateway-token',
])
expect(agents).toEqual([
{
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-haiku-4-5',
},
])
})
it('creates an agent non-interactively and reads it back from the agent list', async () => {
let callIndex = 0
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
callIndex += 1
if (callIndex === 1) {
expect(command).toEqual([
'node',
'dist/index.js',
'agents',
'add',
'research',
'--workspace',
`${OPENCLAW_CONTAINER_HOME}/workspace-research`,
'--model',
'openai/gpt-5.4-mini',
'--non-interactive',
'--json',
'--token',
'gateway-token',
])
return 0
}
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
},
{
id: 'research',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
model: 'openai/gpt-5.4-mini',
},
]),
)
return 0
},
)
const client = new OpenClawAdminClient(
{ execInContainer },
async () => 'gateway-token',
)
const agent = await client.createAgent({
name: 'research',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
model: 'openai/gpt-5.4-mini',
})
expect(execInContainer).toHaveBeenCalledTimes(2)
expect(agent).toEqual({
agentId: 'research',
name: 'research',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
model: 'openai/gpt-5.4-mini',
})
})
it('includes CLI stderr or stdout in thrown errors', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.('agent already exists')
return 1
},
)
const client = new OpenClawAdminClient(
{ execInContainer },
async () => 'gateway-token',
)
await expect(client.listAgents()).rejects.toThrow('agent already exists')
})
})

View File

@@ -0,0 +1,196 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { OpenClawHttpChatClient } from '../../../../src/api/services/openclaw/openclaw-http-chat-client'
describe('OpenClawHttpChatClient', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it('maps chat completion deltas into BrowserOS stream events', async () => {
const fetchMock = mock((_url: string | URL, _init?: RequestInit) =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
),
)
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
),
)
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
),
)
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
const stream = await client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
})
const events = await readEvents(stream)
const call = fetchMock.mock.calls[0]
expect(call?.[0]).toBe('http://127.0.0.1:18789/v1/chat/completions')
expect(call?.[1]).toMatchObject({
method: 'POST',
headers: {
Authorization: 'Bearer gateway-token',
'Content-Type': 'application/json',
},
})
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
model: 'openclaw/research',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
user: 'browseros:research:session-123',
})
expect(events).toEqual([
{ type: 'text-delta', data: { text: 'Hello' } },
{ type: 'text-delta', data: { text: ' world' } },
{ type: 'done', data: { text: 'Hello world' } },
])
})
it('uses openclaw/default for the main agent', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
await client.streamChat({
agentId: 'main',
sessionKey: 'session-123',
message: 'hi',
})
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as {
model: string
}
expect(body.model).toBe('openclaw/default')
})
it('throws on non-success HTTP responses', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('Unauthorized', { status: 401 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
await expect(
client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
}),
).rejects.toThrow('Unauthorized')
})
it('stops processing batched SSE events after a malformed chunk closes the stream', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' +
'data: not-json\n\n' +
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
),
)
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
const stream = await client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
})
await expect(readEvents(stream)).resolves.toEqual([
{ type: 'text-delta', data: { text: 'Hello' } },
{
type: 'error',
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
},
])
})
})
async function readEvents(
stream: ReadableStream<{ type: string; data: Record<string, unknown> }>,
): Promise<Array<{ type: string; data: Record<string, unknown> }>> {
const reader = stream.getReader()
const events: Array<{ type: string; data: Record<string, unknown> }> = []
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}

View File

@@ -0,0 +1,188 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import { OpenClawService } from '../../../../src/api/services/openclaw/openclaw-service'
type MutableOpenClawService = OpenClawService & {
openclawDir: string
token: string
runtime: {
ensureReady?: () => Promise<void>
isPodmanAvailable?: () => Promise<boolean>
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
isReady: () => Promise<boolean>
copyComposeFile?: (_source: string) => Promise<void>
writeEnvFile?: (_content: string) => Promise<void>
composePull?: () => Promise<void>
composeUp?: () => Promise<void>
waitForReady?: () => Promise<boolean>
}
adminClient: {
probe?: ReturnType<typeof mock>
createAgent?: ReturnType<typeof mock>
listAgents?: ReturnType<typeof mock>
}
}
describe('OpenClawService', () => {
let tempDir: string | null = null
afterEach(async () => {
mock.restore()
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = null
}
})
it('creates agents through the admin client and writes role bootstrap files', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
const createAgent = mock(async () => ({
agentId: 'ops',
name: 'ops',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
model: 'openclaw/default',
}))
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
isReady: async () => true,
}
service.adminClient = {
createAgent,
}
const agent = await service.createAgent({
name: 'ops',
roleId: 'chief-of-staff',
})
expect(createAgent).toHaveBeenCalledWith({
name: 'ops',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
model: undefined,
})
expect(agent.role).toEqual({
roleSource: 'builtin',
roleId: 'chief-of-staff',
roleName: 'Chief of Staff',
shortDescription:
'Executive coordination, follow-ups, scheduling, and briefing support.',
})
const roleMetadata = JSON.parse(
await readFile(
join(tempDir, 'workspace-ops', '.browseros-role.json'),
'utf-8',
),
) as {
roleId: string
agentName: string
}
expect(roleMetadata).toMatchObject({
roleId: 'chief-of-staff',
agentName: 'ops',
})
})
it('maps successful admin probes into connected status', async () => {
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
isPodmanAvailable: async () => true,
getMachineStatus: async () => ({ initialized: true, running: true }),
isReady: async () => true,
}
service.adminClient = {
listAgents: mock(async () => [
{
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
},
{
agentId: 'ops',
name: 'ops',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`,
},
]),
}
const status = await service.getStatus()
expect(status).toEqual({
status: 'running',
podmanAvailable: true,
machineReady: true,
port: 18789,
agentCount: 2,
error: null,
controlPlaneStatus: 'connected',
lastGatewayError: null,
lastRecoveryReason: null,
})
})
it('creates the main agent during setup when the gateway starts without one', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
const createAgent = mock(async () => ({
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
}))
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
isPodmanAvailable: async () => true,
ensureReady: async () => {},
isReady: async () => true,
copyComposeFile: async () => {},
writeEnvFile: async () => {},
composePull: async () => {},
composeUp: async () => {},
waitForReady: async () => true,
}
service.adminClient = {
probe: mock(async () => {}),
listAgents: mock(async () => []),
createAgent,
}
await service.setup({})
expect(createAgent).toHaveBeenCalledWith({
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: undefined,
})
})
it('loads the persisted gateway token before control plane calls', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await writeFile(join(tempDir, '.env'), 'OPENCLAW_GATEWAY_TOKEN=env-token\n')
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.token = 'random-token'
service.runtime = {
isReady: async () => true,
}
service.adminClient = {
listAgents: mock(async () => {
expect(service.token).toBe('env-token')
return []
}),
}
await service.listAgents()
})
})