mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
2 Commits
fix/nerdct
...
test/apr16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae3e79bb43 | ||
|
|
d2fb785367 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user