diff --git a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts index 3c2f7b70f..dbaf033c6 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts @@ -20,6 +20,7 @@ import { OpenClawInvalidAgentNameError, OpenClawProtectedAgentError, } from '../services/openclaw/errors' +import { OpenClawSessionNotFoundError } from '../services/openclaw/openclaw-http-client' import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map' import { getOpenClawService } from '../services/openclaw/openclaw-service' @@ -344,6 +345,29 @@ export function createOpenClawRoutes() { } }) + .get('/session/:key/history', async (c) => { + const key = c.req.param('key') + const limitRaw = c.req.query('limit') + const cursor = c.req.query('cursor') + const limit = + limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : undefined + + try { + const history = await getOpenClawService().getSessionHistory(key, { + limit, + cursor, + signal: c.req.raw.signal, + }) + return c.json(history) + } catch (err) { + if (err instanceof OpenClawSessionNotFoundError) { + return c.json({ error: 'session_not_found' }, 404) + } + const message = err instanceof Error ? err.message : String(err) + return c.json({ error: message }, 500) + } + }) + .get('/logs', async (c) => { try { const logs = await getOpenClawService().getLogs() diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts index 93216791b..112ba8c45 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts @@ -174,13 +174,12 @@ export class OpenClawCliClient { 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 { + agentId: input.name, + name: input.name, + workspace, + model: input.model, } - - return agent } async deleteAgent(agentId: string): Promise { diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-http-client.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-http-client.ts new file mode 100644 index 000000000..75369dcaa --- /dev/null +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-http-client.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { OpenClawAgentRecord } from './openclaw-cli-client' + +export interface OpenClawSessionSummary { + key: string + agentId?: string + model?: string + kind?: string + updatedAt?: number + messageCount?: number + [extra: string]: unknown +} + +export interface OpenClawListSessionsInput { + limit?: number + activeMinutes?: number + kinds?: string[] + signal?: AbortSignal +} + +export interface OpenClawSessionHistoryMessage { + role: 'user' | 'assistant' | 'system' | 'tool' + content: string + messageId?: string + messageSeq?: number + timestamp?: number +} + +export interface OpenClawSessionHistory { + sessionKey: string + messages: OpenClawSessionHistoryMessage[] + cursor?: string | null + hasMore?: boolean + truncated?: boolean +} + +export class OpenClawSessionNotFoundError extends Error { + constructor(readonly sessionKey: string) { + super(`OpenClaw session not found: ${sessionKey}`) + this.name = 'OpenClawSessionNotFoundError' + } +} + +type RawAgentRow = { + id: string + name?: string + workspace?: string + model?: string +} + +type AgentsListResult = RawAgentRow[] | { agents?: RawAgentRow[] } + +type ToolResponse = + | { ok: true; result: T } + | { ok: false; error?: { message?: string } } + +export class OpenClawHttpClient { + constructor( + private readonly hostPort: number, + private readonly getToken: () => Promise, + ) {} + + async probe(signal?: AbortSignal): Promise { + const response = await this.request('/v1/models', { + method: 'GET', + signal, + }) + if (!response.ok) { + throw await toError(response, 'OpenClaw probe failed') + } + } + + async listAgents(signal?: AbortSignal): Promise { + const result = await this.invokeTool( + 'agents_list', + {}, + signal, + ) + const rows = Array.isArray(result) ? result : (result.agents ?? []) + return rows.map((row) => ({ + agentId: row.id, + name: row.name ?? row.id, + workspace: row.workspace ?? '', + model: row.model, + })) + } + + async listSessions( + input: OpenClawListSessionsInput = {}, + ): Promise { + const args: Record = {} + if (input.limit !== undefined) { + args.limit = input.limit + } + if (input.activeMinutes !== undefined) { + args.activeMinutes = input.activeMinutes + } + if (input.kinds !== undefined) { + args.kinds = input.kinds + } + + return this.invokeTool( + 'sessions_list', + args, + input.signal, + ) + } + + async getSessionHistory( + sessionKey: string, + input: { + limit?: number + cursor?: string + signal?: AbortSignal + } = {}, + ): Promise { + const response = await this.request( + this.buildHistoryPath(sessionKey, input), + { + method: 'GET', + signal: input.signal, + }, + ) + + if (response.status === 404) { + throw new OpenClawSessionNotFoundError(sessionKey) + } + if (!response.ok) { + throw await toError(response, 'OpenClaw session history failed') + } + + return (await response.json()) as OpenClawSessionHistory + } + + protected async request(path: string, init: RequestInit): Promise { + const token = await this.getToken() + const headers = new Headers(init.headers) + headers.set('Authorization', `Bearer ${token}`) + + return fetch(`http://127.0.0.1:${this.hostPort}${path}`, { + ...init, + headers, + }) + } + + private async invokeTool( + tool: string, + args: Record, + signal?: AbortSignal, + ): Promise { + const response = await this.request('/tools/invoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tool, args }), + signal, + }) + + if (!response.ok) { + throw await toError(response, `OpenClaw tool '${tool}' failed`) + } + + const body = (await response.json()) as ToolResponse + if (!body.ok) { + throw new Error(body.error?.message ?? `OpenClaw tool '${tool}' failed`) + } + + return body.result + } + + private buildHistoryPath( + sessionKey: string, + input: { limit?: number; cursor?: string }, + ): string { + const params = new URLSearchParams() + if (input.limit !== undefined) { + params.set('limit', String(Number(input.limit))) + } + if (input.cursor !== undefined) { + params.set('cursor', input.cursor) + } + const query = params.toString() + const suffix = query ? `?${query}` : '' + return `/sessions/${encodeURIComponent(sessionKey)}/history${suffix}` + } +} + +async function toError(response: Response, fallback: string): Promise { + const detail = await readErrorDetail(response) + return new Error(detail || `${fallback} (HTTP ${response.status})`) +} + +async function readErrorDetail(response: Response): Promise { + const detail = await response.text().catch(() => '') + if (!detail) { + return '' + } + + try { + return extractErrorMessage(JSON.parse(detail)) ?? detail + } catch { + return detail + } +} + +function extractErrorMessage(value: unknown): string | null { + if (!value || typeof value !== 'object') { + return null + } + + const message = (value as { message?: unknown }).message + if (typeof message === 'string' && message) { + return message + } + + const error = (value as { error?: { message?: unknown } }).error + return typeof error?.message === 'string' && error.message + ? error.message + : null +} diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index d23362655..8e82a1afe 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -41,6 +41,10 @@ import { mergeEnvContent, } from './openclaw-env' import { OpenClawHttpChatClient } from './openclaw-http-chat-client' +import { + OpenClawHttpClient, + type OpenClawSessionHistory, +} from './openclaw-http-client' import { type ResolvedOpenClawProviderConfig, resolveSupportedOpenClawProvider, @@ -119,10 +123,11 @@ export class OpenClawService { private runtime: ContainerRuntime private cliClient: OpenClawCliClient private bootstrapCliClient: OpenClawCliClient + private httpClient: OpenClawHttpClient private chatClient: OpenClawHttpChatClient private openclawDir: string private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT - private token: string + private token = '' private tokenLoaded = false private lastError: string | null = null private browserosServerPort: number @@ -136,12 +141,13 @@ export class OpenClawService { constructor(config: OpenClawServiceConfig = {}) { this.openclawDir = getOpenClawDir() this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir) - this.token = crypto.randomUUID() this.cliClient = new OpenClawCliClient(this.runtime) this.bootstrapCliClient = this.buildBootstrapCliClient() - this.chatClient = new OpenClawHttpChatClient( - this.hostPort, - async () => this.token, + this.httpClient = new OpenClawHttpClient(this.hostPort, async () => + this.getGatewayToken(), + ) + this.chatClient = new OpenClawHttpChatClient(this.hostPort, async () => + this.getGatewayToken(), ) this.browserosServerPort = config.browserosServerPort ?? DEFAULT_PORTS.server @@ -224,8 +230,7 @@ export class OpenClawService { logProgress('Validating OpenClaw config...') await this.assertConfigValid(this.bootstrapCliClient) - - this.tokenLoaded = false + logProgress('Loading gateway auth token...') await this.loadTokenFromConfig() logProgress('Starting OpenClaw gateway...') @@ -248,7 +253,7 @@ export class OpenClawService { this.controlPlaneStatus = 'connecting' logProgress('Probing OpenClaw control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) + await this.runControlPlaneCall(() => this.httpClient.probe()) const existingAgents = await this.listAgents() logger.info('Fetched existing OpenClaw agents after setup', { @@ -283,9 +288,7 @@ export class OpenClawService { }) await this.runtime.ensureReady(logProgress) - - logProgress('Refreshing gateway auth token...') - this.tokenLoaded = false + logProgress('Loading gateway auth token...') await this.loadTokenFromConfig() await this.ensureStateEnvFile() @@ -296,7 +299,7 @@ export class OpenClawService { this.controlPlaneStatus = 'connecting' logProgress('Probing OpenClaw control plane...') try { - await this.runControlPlaneCall(() => this.cliClient.probe()) + await this.runControlPlaneCall(() => this.httpClient.probe()) this.lastError = null logger.info('OpenClaw gateway already running', { hostPort: this.hostPort, @@ -329,7 +332,7 @@ export class OpenClawService { this.controlPlaneStatus = 'connecting' logProgress('Probing OpenClaw control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) + await this.runControlPlaneCall(() => this.httpClient.probe()) this.lastError = null logger.info('OpenClaw gateway started', { hostPort: this.hostPort }) }) @@ -354,8 +357,7 @@ export class OpenClawService { this.controlPlaneStatus = 'reconnecting' this.stopGatewayLogTail() - logProgress('Refreshing gateway auth token...') - this.tokenLoaded = false + logProgress('Reloading gateway auth token...') await this.loadTokenFromConfig() await this.ensureStateEnvFile() await this.ensureGatewayPortAllocated(logProgress) @@ -377,7 +379,7 @@ export class OpenClawService { } logProgress('Probing OpenClaw control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) + await this.runControlPlaneCall(() => this.httpClient.probe()) this.lastError = null logProgress('Gateway restarted successfully') logger.info('OpenClaw gateway restarted', { hostPort: this.hostPort }) @@ -401,11 +403,10 @@ export class OpenClawService { } logProgress('Reloading gateway auth token...') - this.tokenLoaded = false await this.loadTokenFromConfig() this.controlPlaneStatus = 'reconnecting' logProgress('Reconnecting control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) + await this.runControlPlaneCall(() => this.httpClient.probe()) logProgress('Control plane connected') }) } @@ -464,12 +465,13 @@ export class OpenClawService { let agentCount = 0 if (ready) { try { + await this.ensureGatewayTokenAvailable() const agents = await this.runControlPlaneCall(() => - this.cliClient.listAgents(), + this.httpClient.listAgents(), ) agentCount = agents.length } catch { - // latest control plane error is captured by runControlPlaneCall + // latest control plane error is captured by ensureGatewayTokenAvailable/runControlPlaneCall } } @@ -509,9 +511,9 @@ export class OpenClawService { hasModel: !!input.modelId, hasApiKey: !!input.apiKey, }) - await this.assertGatewayReady() - const provider = resolveSupportedOpenClawProvider(input) + await this.assertGatewayReady() + await this.ensureGatewayTokenAvailable() const configChanged = await this.mergeProviderConfigIfChanged(provider) const keysChanged = await this.writeStateEnv(provider.envValues) @@ -525,14 +527,15 @@ export class OpenClawService { } const model = provider.model - let agent: OpenClawAgentRecord + let createdAgentId = name try { - agent = await this.runControlPlaneCall(() => + const created = await this.runControlPlaneCall(() => this.cliClient.createAgent({ name, model, }), ) + createdAgentId = created.agentId } catch (error) { const message = error instanceof Error ? error.message : String(error) if (message.includes('already exists')) { @@ -541,6 +544,7 @@ export class OpenClawService { throw error } + const agent = await this.findAgentById(createdAgentId) logger.info('Agent created via CLI', { agentId: agent.agentId, providerType: input.providerType, @@ -555,6 +559,7 @@ export class OpenClawService { } await this.assertGatewayReady() + await this.ensureGatewayTokenAvailable() try { await this.runControlPlaneCall(() => this.cliClient.deleteAgent(agentId)) } catch (error) { @@ -569,8 +574,9 @@ export class OpenClawService { async listAgents(): Promise { await this.assertGatewayReady() + await this.ensureGatewayTokenAvailable() logger.debug('Listing OpenClaw agents') - return this.runControlPlaneCall(() => this.cliClient.listAgents()) + return this.runControlPlaneCall(() => this.httpClient.listAgents()) } // ── Chat Stream (HTTP) ─────────────────────────────────────────────── @@ -582,6 +588,7 @@ export class OpenClawService { history: MonitoringChatTurn[] = [], ): Promise> { await this.assertGatewayReady() + await this.ensureGatewayTokenAvailable() logger.info('Starting OpenClaw chat stream', { agentId, sessionKey, @@ -598,6 +605,17 @@ export class OpenClawService { ) } + async getSessionHistory( + sessionKey: string, + input: { limit?: number; cursor?: string; signal?: AbortSignal } = {}, + ): Promise { + await this.assertGatewayReady() + await this.ensureGatewayTokenAvailable() + return this.runControlPlaneCall(() => + this.httpClient.getSessionHistory(sessionKey, input), + ) + } + // ── Podman Overrides ───────────────────────────────────────────────── async applyPodmanOverrides(input: { @@ -689,8 +707,6 @@ export class OpenClawService { try { await this.runtime.ensureReady() - - this.tokenLoaded = false await this.loadTokenFromConfig() await this.ensureStateEnvFile() @@ -712,7 +728,7 @@ export class OpenClawService { } } - await this.runControlPlaneCall(() => this.cliClient.probe()) + await this.runControlPlaneCall(() => this.httpClient.probe()) logger.info('OpenClaw gateway auto-started') } catch (err) { logger.warn('OpenClaw auto-start failed', { @@ -745,9 +761,11 @@ export class OpenClawService { private setPort(hostPort: number): void { if (hostPort === this.hostPort) return this.hostPort = hostPort - this.chatClient = new OpenClawHttpChatClient( - this.hostPort, - async () => this.token, + this.httpClient = new OpenClawHttpClient(this.hostPort, async () => + this.getGatewayToken(), + ) + this.chatClient = new OpenClawHttpChatClient(this.hostPort, async () => + this.getGatewayToken(), ) } @@ -822,7 +840,7 @@ export class OpenClawService { ): OpenClawGatewayRecoveryReason { const message = error instanceof Error ? error.message : String(error) if (message.includes('Unauthorized')) return 'token_mismatch' - if (message.includes('token')) return 'token_mismatch' + if (message.toLowerCase().includes('token')) return 'token_mismatch' if (message.includes('not ready')) return 'container_not_ready' return 'unknown' } @@ -967,6 +985,17 @@ export class OpenClawService { return entries } + private async findAgentById(agentId: string): Promise { + const agents = await this.runControlPlaneCall(() => + this.httpClient.listAgents(), + ) + const agent = agents.find((entry) => entry.agentId === agentId) + if (!agent) { + throw new Error(`Created agent ${agentId} was not found in agent list`) + } + return agent + } + private async applyCliMutation(action: () => Promise): Promise { let retried = false @@ -1024,6 +1053,77 @@ export class OpenClawService { } } + private async getGatewayToken(): Promise { + await this.ensureTokenLoaded() + return this.token + } + + private async ensureGatewayTokenAvailable(): Promise { + if (this.tokenLoaded && this.token) { + return + } + + try { + await this.loadTokenFromConfig() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.controlPlaneStatus = 'failed' + this.lastGatewayError = message + this.lastRecoveryReason = this.classifyControlPlaneError(error) + throw error + } + } + + private async ensureTokenLoaded(): Promise { + if (this.tokenLoaded && this.token) { + return + } + + throw new Error('OpenClaw gateway token has not been loaded') + } + + private async loadTokenFromConfig(): Promise { + this.token = '' + this.tokenLoaded = false + + const configPath = this.getStateConfigPath() + if (!existsSync(configPath)) { + throw new Error('OpenClaw mounted config is missing') + } + + let config: { + gateway?: { + auth?: { + token?: unknown + } + } + } + try { + config = JSON.parse(await readFile(configPath, 'utf-8')) as { + gateway?: { + auth?: { + token?: unknown + } + } + } + } catch (error) { + throw new Error( + `Failed to read OpenClaw mounted config: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + const token = config.gateway?.auth?.token + if (typeof token !== 'string' || !token) { + throw new Error('OpenClaw gateway token is missing from mounted config') + } + + this.token = token + this.tokenLoaded = true + logger.info('Loaded OpenClaw gateway token from mounted config') + } + private async ensureStateEnvFile(): Promise { const envPath = this.getStateEnvPath() if (existsSync(envPath)) return @@ -1147,41 +1247,6 @@ export class OpenClawService { return true } - private async ensureTokenLoaded(): Promise { - if (this.tokenLoaded) { - return - } - if (!existsSync(this.getStateConfigPath())) { - return - } - - await this.loadTokenFromConfig() - } - - private async loadTokenFromConfig(): Promise { - try { - const config = JSON.parse( - await readFile(this.getStateConfigPath(), 'utf-8'), - ) as { - gateway?: { - auth?: { - token?: unknown - } - } - } - const token = config.gateway?.auth?.token - if (typeof token === 'string' && token) { - this.token = token - this.tokenLoaded = true - logger.info('Loaded OpenClaw gateway token from mounted config') - } - } catch (err) { - logger.warn('Failed to load OpenClaw gateway token from mounted config', { - error: err instanceof Error ? err.message : String(err), - }) - } - } - private createProgressLogger( onLog?: (msg: string) => void, ): (msg: string) => void { diff --git a/packages/browseros-agent/apps/server/tests/api/routes/openclaw.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/openclaw.test.ts index 47c0c8778..a4a211479 100644 --- a/packages/browseros-agent/apps/server/tests/api/routes/openclaw.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/routes/openclaw.test.ts @@ -434,4 +434,124 @@ describe('createOpenClawRoutes', () => { modelId: 'gpt-5.4-mini', }) }) + + it('returns json session history for a session key', async () => { + const actualOpenClawService = await import( + '../../../src/api/services/openclaw/openclaw-service' + ) + const getSessionHistory = mock(async () => ({ + sessionKey: 'agent:main:main', + messages: [{ role: 'assistant', content: 'Ready' }], + cursor: 'cursor-2', + hasMore: true, + })) + + mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({ + ...actualOpenClawService, + getOpenClawService: () => + ({ + getSessionHistory, + }) as never, + })) + + const { createOpenClawRoutes } = await import( + '../../../src/api/routes/openclaw' + ) + const route = createOpenClawRoutes() + + const response = await route.request( + '/session/agent:main:main/history?limit=50&cursor=cursor-1', + ) + + expect(response.status).toBe(200) + expect(getSessionHistory).toHaveBeenCalledWith('agent:main:main', { + limit: 50, + cursor: 'cursor-1', + signal: expect.any(AbortSignal), + }) + expect(await response.json()).toEqual({ + sessionKey: 'agent:main:main', + messages: [{ role: 'assistant', content: 'Ready' }], + cursor: 'cursor-2', + hasMore: true, + }) + }) + + it('returns 404 when session history is missing', async () => { + const actualOpenClawService = await import( + '../../../src/api/services/openclaw/openclaw-service' + ) + const { OpenClawSessionNotFoundError } = await import( + '../../../src/api/services/openclaw/openclaw-http-client' + ) + const getSessionHistory = mock(async () => { + throw new OpenClawSessionNotFoundError('missing-session') + }) + + mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({ + ...actualOpenClawService, + getOpenClawService: () => + ({ + getSessionHistory, + }) as never, + })) + + const { createOpenClawRoutes } = await import( + '../../../src/api/routes/openclaw' + ) + const route = createOpenClawRoutes() + + const response = await route.request('/session/missing-session/history') + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: 'session_not_found', + }) + }) + + it('returns json session history even when event-stream is requested', async () => { + const actualOpenClawService = await import( + '../../../src/api/services/openclaw/openclaw-service' + ) + const getSessionHistory = mock(async () => ({ + sessionKey: 'session-1', + messages: [{ role: 'assistant', content: 'Ready' }], + cursor: 'cursor-2', + hasMore: true, + })) + + mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({ + ...actualOpenClawService, + getOpenClawService: () => + ({ + getSessionHistory, + }) as never, + })) + + const { createOpenClawRoutes } = await import( + '../../../src/api/routes/openclaw' + ) + const route = createOpenClawRoutes() + + const response = await route.request( + '/session/session-1/history?limit=10', + { + headers: { Accept: 'text/event-stream' }, + }, + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain('application/json') + expect(getSessionHistory).toHaveBeenCalledWith('session-1', { + limit: 10, + cursor: undefined, + signal: expect.any(AbortSignal), + }) + expect(await response.json()).toEqual({ + sessionKey: 'session-1', + messages: [{ role: 'assistant', content: 'Ready' }], + cursor: 'cursor-2', + hasMore: true, + }) + }) }) diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-cli-client.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-cli-client.test.ts index 17daf1d78..f421c4fc6 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-cli-client.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-cli-client.test.ts @@ -104,43 +104,22 @@ describe('OpenClawCliClient', () => { }) it('derives the workspace when creating an agent', 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', - ]) - 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 execInContainer = mock(async (command: string[]) => { + 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', + ]) + return 0 + }) const client = new OpenClawCliClient({ execInContainer }) const agent = await client.createAgent({ @@ -148,7 +127,7 @@ describe('OpenClawCliClient', () => { model: 'openai/gpt-5.4-mini', }) - expect(execInContainer).toHaveBeenCalledTimes(2) + expect(execInContainer).toHaveBeenCalledTimes(1) expect(agent).toEqual({ agentId: 'research', name: 'research', diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-http-chat-client.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-http-chat-client.test.ts index 694e635ca..9a2c56158 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-http-chat-client.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-http-chat-client.test.ts @@ -8,6 +8,7 @@ import { OpenClawHttpChatClient } from '../../../../src/api/services/openclaw/op describe('OpenClawHttpChatClient', () => { const originalFetch = globalThis.fetch + const getToken = async () => 'gateway-token' afterEach(() => { globalThis.fetch = originalFetch @@ -47,10 +48,7 @@ describe('OpenClawHttpChatClient', () => { ), ) globalThis.fetch = fetchMock as typeof globalThis.fetch - const client = new OpenClawHttpChatClient( - 18789, - async () => 'gateway-token', - ) + const client = new OpenClawHttpChatClient(18789, getToken) const stream = await client.streamChat({ agentId: 'research', @@ -103,10 +101,7 @@ describe('OpenClawHttpChatClient', () => { ), ) globalThis.fetch = fetchMock as typeof globalThis.fetch - const client = new OpenClawHttpChatClient( - 18789, - async () => 'gateway-token', - ) + const client = new OpenClawHttpChatClient(18789, getToken) await client.streamChat({ agentId: 'main', @@ -124,10 +119,7 @@ describe('OpenClawHttpChatClient', () => { globalThis.fetch = mock(() => Promise.resolve(new Response('Unauthorized', { status: 401 })), ) as typeof globalThis.fetch - const client = new OpenClawHttpChatClient( - 18789, - async () => 'gateway-token', - ) + const client = new OpenClawHttpChatClient(18789, getToken) await expect( client.streamChat({ @@ -161,10 +153,7 @@ describe('OpenClawHttpChatClient', () => { ), ), ) as typeof globalThis.fetch - const client = new OpenClawHttpChatClient( - 18789, - async () => 'gateway-token', - ) + const client = new OpenClawHttpChatClient(18789, getToken) const stream = await client.streamChat({ agentId: 'main', @@ -207,10 +196,7 @@ describe('OpenClawHttpChatClient', () => { ), ) globalThis.fetch = fetchMock as typeof globalThis.fetch - const client = new OpenClawHttpChatClient( - 18789, - async () => 'gateway-token', - ) + const client = new OpenClawHttpChatClient(18789, getToken) const stream = await client.streamChat({ agentId: 'research', diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-http-client.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-http-client.test.ts new file mode 100644 index 000000000..d427a64dd --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-http-client.test.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { + OpenClawHttpClient, + type OpenClawSessionHistory, + OpenClawSessionNotFoundError, + type OpenClawSessionSummary, +} from '../../../../src/api/services/openclaw/openclaw-http-client' + +describe('OpenClawHttpClient', () => { + const originalFetch = globalThis.fetch + const getToken = async () => 'gateway-token' + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('probes the loopback gateway with bearer auth', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response(null, { status: 204 })), + ) + globalThis.fetch = fetchMock as typeof globalThis.fetch + const signal = new AbortController().signal + const client = new OpenClawHttpClient(18789, getToken) + + await client.probe(signal) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://127.0.0.1:18789/v1/models', + ) + const init = fetchMock.mock.calls[0]?.[1] + const headers = init?.headers as Headers + + expect(init).toMatchObject({ + method: 'GET', + signal, + }) + expect(headers.get('Authorization')).toBe('Bearer gateway-token') + }) + + it('surfaces gateway probe failures with response details', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response('gateway unavailable', { status: 503 })), + ) as typeof globalThis.fetch + const client = new OpenClawHttpClient(18789, getToken) + + await expect(client.probe()).rejects.toThrow('gateway unavailable') + }) + + it('maps top-level agent arrays into BrowserOS agent records', async () => { + const fetchMock = mock(() => + Promise.resolve( + Response.json({ + ok: true, + result: [ + { + id: 'research', + name: 'Research', + workspace: '/workspace/research', + model: 'openclaw/research', + }, + { + id: 'ops', + }, + ], + }), + ), + ) + globalThis.fetch = fetchMock as typeof globalThis.fetch + const signal = new AbortController().signal + const client = new OpenClawHttpClient(18789, getToken) + + await expect(client.listAgents(signal)).resolves.toEqual([ + { + agentId: 'research', + name: 'Research', + workspace: '/workspace/research', + model: 'openclaw/research', + }, + { + agentId: 'ops', + name: 'ops', + workspace: '', + model: undefined, + }, + ]) + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://127.0.0.1:18789/tools/invoke', + ) + const init = fetchMock.mock.calls[0]?.[1] + const headers = init?.headers as Headers + + expect(init).toMatchObject({ + method: 'POST', + signal, + }) + expect(headers.get('Authorization')).toBe('Bearer gateway-token') + expect(headers.get('Content-Type')).toBe('application/json') + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + tool: 'agents_list', + args: {}, + }) + }) + + it('accepts wrapped agents_list payloads', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + Response.json({ + ok: true, + result: { + agents: [{ id: 'main', workspace: '/workspace/main' }], + }, + }), + ), + ) as typeof globalThis.fetch + const client = new OpenClawHttpClient(18789, getToken) + + await expect(client.listAgents()).resolves.toEqual([ + { + agentId: 'main', + name: 'main', + workspace: '/workspace/main', + model: undefined, + }, + ]) + }) + + it('surfaces tool error payloads and non-2xx failures from listAgents', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + Response.json({ + ok: false, + error: { message: 'agent list denied' }, + }), + ), + ) as typeof globalThis.fetch + const client = new OpenClawHttpClient(18789, getToken) + + await expect(client.listAgents()).rejects.toThrow('agent list denied') + + globalThis.fetch = mock(() => + Promise.resolve(new Response('gateway exploded', { status: 500 })), + ) as typeof globalThis.fetch + + await expect(client.listAgents()).rejects.toThrow('gateway exploded') + }) + + it('forwards only defined session-list filters and returns the raw tool payload', async () => { + const sessions: OpenClawSessionSummary[] = [ + { + key: 'session-1', + agentId: 'research', + kind: 'chat', + updatedAt: 123, + messageCount: 4, + }, + ] + const fetchMock = mock(() => + Promise.resolve( + Response.json({ + ok: true, + result: sessions, + }), + ), + ) + globalThis.fetch = fetchMock as typeof globalThis.fetch + const signal = new AbortController().signal + const client = new OpenClawHttpClient(18789, getToken) + + await expect( + client.listSessions({ + limit: 25, + activeMinutes: 10, + kinds: ['chat'], + signal, + }), + ).resolves.toEqual(sessions) + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://127.0.0.1:18789/tools/invoke', + ) + const init = fetchMock.mock.calls[0]?.[1] + const headers = init?.headers as Headers + + expect(init).toMatchObject({ + method: 'POST', + signal, + }) + expect(headers.get('Authorization')).toBe('Bearer gateway-token') + expect(headers.get('Content-Type')).toBe('application/json') + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + tool: 'sessions_list', + args: { + limit: 25, + activeMinutes: 10, + kinds: ['chat'], + }, + }) + }) + + it('fetches session history over loopback with encoded keys and optional query params', async () => { + const history: OpenClawSessionHistory = { + sessionKey: 'agent:main:cafe/1', + messages: [{ role: 'assistant', content: 'Ready' }], + cursor: 'cursor-2', + hasMore: true, + } + const fetchMock = mock(() => Promise.resolve(Response.json(history))) + globalThis.fetch = fetchMock as typeof globalThis.fetch + const signal = new AbortController().signal + const client = new OpenClawHttpClient(18789, getToken) + + await expect( + client.getSessionHistory('agent:main:cafe/1', { + limit: 25, + cursor: 'cursor two', + signal, + }), + ).resolves.toEqual(history) + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://127.0.0.1:18789/sessions/agent%3Amain%3Acafe%2F1/history?limit=25&cursor=cursor+two', + ) + const init = fetchMock.mock.calls[0]?.[1] + const headers = init?.headers as Headers + + expect(init).toMatchObject({ + method: 'GET', + signal, + }) + expect(headers.get('Authorization')).toBe('Bearer gateway-token') + }) + + it('maps missing session history to a typed not-found error', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response(null, { status: 404 })), + ) as typeof globalThis.fetch + const client = new OpenClawHttpClient(18789, getToken) + + await expect( + client.getSessionHistory('missing-session'), + ).rejects.toBeInstanceOf(OpenClawSessionNotFoundError) + }) + + it('surfaces structured history endpoint errors', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + Response.json( + { error: { message: 'history backend unavailable' } }, + { status: 503 }, + ), + ), + ) as typeof globalThis.fetch + const client = new OpenClawHttpClient(18789, getToken) + + await expect(client.getSessionHistory('session-1')).rejects.toThrow( + 'history backend unavailable', + ) + }) +}) diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index d9470a31d..11d526205 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -6,7 +6,6 @@ import { afterEach, describe, expect, it, mock } from 'bun:test' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw' @@ -43,11 +42,18 @@ type MutableOpenClawService = OpenClawService & { waitForReady?: () => Promise stopMachineIfSafe?: () => Promise } + httpClient: { + probe?: ReturnType + listAgents?: ReturnType + getSessionHistory?: ReturnType + } cliClient: { probe?: ReturnType createAgent?: ReturnType + deleteAgent?: ReturnType getConfig?: ReturnType listAgents?: ReturnType + setConfig?: ReturnType setDefaultModel?: ReturnType } bootstrapCliClient: { @@ -69,20 +75,44 @@ describe('OpenClawService', () => { } }) + async function writeOpenClawConfig( + baseDir: string, + config: Record, + ): Promise { + await mkdir(join(baseDir, '.openclaw'), { recursive: true }) + await writeFile( + join(baseDir, '.openclaw', 'openclaw.json'), + JSON.stringify(config), + 'utf-8', + ) + } + it('creates agents through the cli client without role bootstrap files', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - const createAgent = mock(async () => ({ + await writeOpenClawConfig(tempDir, { + gateway: { + auth: { + token: 'gateway-token', + }, + }, + }) + const agentRecord = { agentId: 'ops', name: 'ops', workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`, model: 'openclaw/default', - })) + } + const createAgent = mock(async () => agentRecord) + const listAgents = mock(async () => [agentRecord]) const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir service.runtime = { isReady: async () => true, } + service.httpClient = { + listAgents, + } service.cliClient = { createAgent, } @@ -95,12 +125,8 @@ describe('OpenClawService', () => { name: 'ops', model: undefined, }) - expect(agent).toEqual({ - agentId: 'ops', - name: 'ops', - workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-ops`, - model: 'openclaw/default', - }) + expect(listAgents).toHaveBeenCalledTimes(1) + expect(agent).toEqual(agentRecord) expect( existsSync( join(tempDir, '.openclaw', 'workspace-ops', '.browseros-role.json'), @@ -113,7 +139,13 @@ describe('OpenClawService', () => { await mkdir(join(tempDir, '.openclaw', 'workspace-ops'), { recursive: true, }) - await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}') + await writeOpenClawConfig(tempDir, { + gateway: { + auth: { + token: 'gateway-token', + }, + }, + }) await writeFile( join(tempDir, '.openclaw', 'workspace-ops', '.browseros-role.json'), '{"roleId":"chief-of-staff"}\n', @@ -125,8 +157,7 @@ describe('OpenClawService', () => { service.runtime = { isReady: async () => true, } - service.cliClient = { - getConfig: mock(async () => 'cli-token'), + service.httpClient = { listAgents: mock(async () => [ { agentId: 'ops', @@ -149,8 +180,13 @@ describe('OpenClawService', () => { it('maps successful cli client probes into connected status', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile(join(tempDir, '.openclaw', 'openclaw.json'), '{}') + await writeOpenClawConfig(tempDir, { + gateway: { + auth: { + token: 'gateway-token', + }, + }, + }) const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir @@ -159,8 +195,7 @@ describe('OpenClawService', () => { getMachineStatus: async () => ({ initialized: true, running: true }), isReady: async () => true, } - service.cliClient = { - getConfig: mock(async () => 'cli-token'), + service.httpClient = { listAgents: mock(async () => [ { agentId: 'main', @@ -192,9 +227,17 @@ describe('OpenClawService', () => { it('creates the main agent during setup when the gateway starts without one', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + const openclawTempDir = tempDir const steps: string[] = [] const runOnboard = mock(async () => { steps.push('onboard') + await writeOpenClawConfig(openclawTempDir, { + gateway: { + auth: { + token: 'gateway-token', + }, + }, + }) }) const setConfigBatch = mock(async () => { steps.push('batch') @@ -234,9 +277,11 @@ describe('OpenClawService', () => { }), } service.cliClient = { + createAgent, + } + service.httpClient = { probe: mock(async () => {}), listAgents: mock(async () => []), - createAgent, } service.bootstrapCliClient = { runOnboard, @@ -297,7 +342,7 @@ describe('OpenClawService', () => { hostPort: expect.any(Number), hostHome: tempDir, envFilePath: join(tempDir, '.openclaw', '.env'), - gatewayToken: undefined, + gatewayToken: 'gateway-token', }), expect.any(Function), ) @@ -306,7 +351,16 @@ describe('OpenClawService', () => { it('applies setup-time config in one batch before the gateway starts', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - const runOnboard = mock(async () => {}) + const openclawTempDir = tempDir + const runOnboard = mock(async () => { + await writeOpenClawConfig(openclawTempDir, { + gateway: { + auth: { + token: 'gateway-token', + }, + }, + }) + }) const setConfigBatch = mock(async () => {}) const validateConfig = mock(async () => ({ ok: true })) const createAgent = mock(async () => ({ @@ -330,9 +384,11 @@ describe('OpenClawService', () => { waitForReady, } service.cliClient = { + createAgent, + } + service.httpClient = { probe: mock(async () => {}), listAgents: mock(async () => []), - createAgent, } service.bootstrapCliClient = { runOnboard, @@ -353,68 +409,102 @@ describe('OpenClawService', () => { expect(restartGateway).not.toHaveBeenCalled() }) - it('loads the persisted gateway token from the mounted config before control plane calls', async () => { + it('loads the persisted gateway token before start probes the control plane', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, + await writeOpenClawConfig(tempDir, { + gateway: { + auth: { + token: 'gateway-token', }, + }, + }) + const startGateway = mock(async () => {}) + const probe = mock(async () => {}) + const service = new OpenClawService() as MutableOpenClawService + + service.openclawDir = tempDir + service.runtime = { + ensureReady: async () => {}, + isReady: async () => false, + startGateway, + waitForReady: async () => true, + } + service.httpClient = { + probe, + } + + await service.start() + + expect(service.token).toBe('gateway-token') + expect(probe).toHaveBeenCalledTimes(1) + expect(startGateway).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayToken: 'gateway-token', }), + expect.any(Function), ) + }) + + it('throws when the persisted gateway token is missing before start probes the control plane', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + await writeOpenClawConfig(tempDir, { + gateway: { + auth: {}, + }, + }) + const startGateway = mock(async () => {}) + const probe = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir - service.token = 'random-token' service.runtime = { - isReady: async () => true, + ensureReady: async () => {}, + isReady: async () => false, + startGateway, + waitForReady: async () => true, } - service.cliClient = { - listAgents: mock(async () => { - expect(service.token).toBe('cli-token') - return [] - }), + service.httpClient = { + probe, } - await service.listAgents() + await expect(service.start()).rejects.toThrow( + 'OpenClaw gateway token is missing from mounted config', + ) + + expect(probe).not.toHaveBeenCalled() + expect(startGateway).not.toHaveBeenCalled() }) - it('caches the loaded gateway token from config across steady-state control plane calls', async () => { + it('refreshes the persisted gateway token before probing an already-running control plane', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) - await mkdir(join(tempDir, '.openclaw'), { recursive: true }) - await writeFile( - join(tempDir, '.openclaw', 'openclaw.json'), - JSON.stringify({ - gateway: { - auth: { - token: 'cli-token', - }, + await writeOpenClawConfig(tempDir, { + gateway: { + auth: { + token: 'gateway-token', }, - }), - ) - const listAgents = mock(async () => []) + }, + }) + const probe = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir service.runtime = { + ensureReady: async () => {}, isReady: async () => true, } - service.cliClient = { - listAgents, + service.httpClient = { + probe, } - await service.listAgents() - await service.listAgents() + await service.start() - expect(listAgents).toHaveBeenCalledTimes(2) + expect(service.token).toBe('gateway-token') + expect(probe).toHaveBeenCalledTimes(1) }) it('writes provider credentials into the mounted state env file during setup', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + const openclawTempDir = tempDir const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir @@ -428,9 +518,11 @@ describe('OpenClawService', () => { waitForReady: async () => true, } service.cliClient = { - getConfig: mock(async (path: string) => - path === 'gateway.auth.token' ? 'cli-token' : null, - ), + createAgent: mock(async () => { + throw new Error('createAgent should not be called when main exists') + }), + } + service.httpClient = { probe: mock(async () => {}), listAgents: mock(async () => [ { @@ -439,12 +531,17 @@ describe('OpenClawService', () => { workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`, }, ]), - createAgent: mock(async () => { - throw new Error('createAgent should not be called when main exists') - }), } service.bootstrapCliClient = { - runOnboard: mock(async () => {}), + runOnboard: mock(async () => { + await writeOpenClawConfig(openclawTempDir, { + gateway: { + auth: { + token: 'gateway-token', + }, + }, + }) + }), setConfigBatch: mock(async () => {}), setDefaultModel: mock(async () => {}), validateConfig: mock(async () => ({ ok: true })), @@ -499,9 +596,11 @@ describe('OpenClawService', () => { waitForReady: async () => true, } service.cliClient = { + createAgent, + } + service.httpClient = { probe: mock(async () => {}), listAgents: mock(async () => []), - createAgent, } service.bootstrapCliClient = { runOnboard, @@ -561,7 +660,7 @@ describe('OpenClawService', () => { JSON.stringify({ gateway: { auth: { - token: 'cli-token', + token: 'gateway-token', }, }, }), @@ -579,7 +678,7 @@ describe('OpenClawService', () => { startGateway, waitForReady, } - service.cliClient = { + service.httpClient = { probe, } @@ -592,7 +691,7 @@ describe('OpenClawService', () => { hostPort: expect.any(Number), hostHome: tempDir, envFilePath: join(tempDir, '.openclaw', '.env'), - gatewayToken: 'cli-token', + gatewayToken: 'gateway-token', }), expect.any(Function), ) @@ -608,7 +707,7 @@ describe('OpenClawService', () => { JSON.stringify({ gateway: { auth: { - token: 'cli-token', + token: 'gateway-token', }, }, }), @@ -639,7 +738,7 @@ describe('OpenClawService', () => { startGateway, waitForReady, } - service.cliClient = { + service.httpClient = { probe, } @@ -663,7 +762,7 @@ describe('OpenClawService', () => { JSON.stringify({ gateway: { auth: { - token: 'cli-token', + token: 'gateway-token', }, }, }), @@ -681,7 +780,7 @@ describe('OpenClawService', () => { startGateway, waitForReady, } - service.cliClient = { + service.httpClient = { probe, } @@ -701,7 +800,7 @@ describe('OpenClawService', () => { JSON.stringify({ gateway: { auth: { - token: 'cli-token', + token: 'gateway-token', }, }, }), @@ -717,7 +816,7 @@ describe('OpenClawService', () => { restartGateway, waitForReady, } - service.cliClient = { + service.httpClient = { probe, } @@ -729,7 +828,7 @@ describe('OpenClawService', () => { hostPort: expect.any(Number), hostHome: tempDir, envFilePath: join(tempDir, '.openclaw', '.env'), - gatewayToken: 'cli-token', + gatewayToken: 'gateway-token', }), expect.any(Function), ) @@ -745,23 +844,12 @@ describe('OpenClawService', () => { JSON.stringify({ gateway: { auth: { - token: 'cli-token', + token: 'gateway-token', }, }, }), ) - const occupiedServer = createServer() - const occupiedPort = await new Promise((resolve, reject) => { - occupiedServer.once('error', reject) - occupiedServer.listen(0, '127.0.0.1', () => { - const address = occupiedServer.address() - if (!address || typeof address === 'string') { - reject(new Error('failed to allocate test port')) - return - } - resolve(address.port) - }) - }) + const occupiedPort = 42137 await writeFile( join(tempDir, '.openclaw', 'runtime-state.json'), `${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`, @@ -772,28 +860,18 @@ describe('OpenClawService', () => { const service = new OpenClawService() as MutableOpenClawService service.openclawDir = tempDir + ;(service as MutableOpenClawService & { hostPort: number }).hostPort = + occupiedPort service.runtime = { isReady: async (hostPort?: number) => hostPort === occupiedPort, restartGateway, waitForReady, } - service.cliClient = { + service.httpClient = { probe, } - try { - await service.restart() - } finally { - await new Promise((resolve, reject) => { - occupiedServer.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) - } + await service.restart() expect(restartGateway).toHaveBeenCalledWith( expect.objectContaining({ @@ -803,6 +881,35 @@ describe('OpenClawService', () => { ) }) + it('reconnects the control plane through the loopback http client', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + await mkdir(join(tempDir, '.openclaw'), { recursive: true }) + await writeFile( + join(tempDir, '.openclaw', 'openclaw.json'), + JSON.stringify({ + gateway: { + auth: { + token: 'gateway-token', + }, + }, + }), + ) + const probe = mock(async () => {}) + const service = new OpenClawService() as MutableOpenClawService + + service.openclawDir = tempDir + service.runtime = { + isReady: async () => true, + } + service.httpClient = { + probe, + } + + await service.reconnectControlPlane() + + expect(probe).toHaveBeenCalledTimes(1) + }) + it('stop calls runtime.stopGateway', async () => { const stopGateway = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService @@ -874,7 +981,7 @@ describe('OpenClawService', () => { JSON.stringify({ gateway: { auth: { - token: 'cli-token', + token: 'gateway-token', }, }, }), @@ -894,7 +1001,7 @@ describe('OpenClawService', () => { startGateway, waitForReady, } - service.cliClient = { + service.httpClient = { probe, } @@ -907,7 +1014,7 @@ describe('OpenClawService', () => { hostPort: expect.any(Number), hostHome: tempDir, envFilePath: join(tempDir, '.openclaw', '.env'), - gatewayToken: 'cli-token', + gatewayToken: 'gateway-token', }), ) expect(waitForReady).toHaveBeenCalledTimes(1) @@ -1034,18 +1141,26 @@ describe('OpenClawService', () => { it('passes openrouter model refs through verbatim into agent creation', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + await writeOpenClawConfig(tempDir, { + gateway: { + auth: { + token: 'gateway-token', + }, + }, + }) await mkdir(join(tempDir, '.openclaw'), { recursive: true }) await writeFile( join(tempDir, '.openclaw', '.env'), 'OPENROUTER_API_KEY=or-key\n', 'utf-8', ) - const createAgent = mock(async () => ({ + const agentRecord = { agentId: 'research', name: 'research', workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`, model: 'openrouter/anthropic/claude-haiku-4.5', - })) + } + const createAgent = mock(async () => agentRecord) const restart = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService @@ -1057,6 +1172,9 @@ describe('OpenClawService', () => { service.cliClient = { createAgent, } + service.httpClient = { + listAgents: mock(async () => [agentRecord]), + } await service.createAgent({ name: 'research', @@ -1088,12 +1206,13 @@ describe('OpenClawService', () => { ) await writeFile(join(tempDir, '.openclaw', '.env'), '', 'utf-8') - const createAgent = mock(async () => ({ + const agentRecord = { agentId: 'research', name: 'research', workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`, model: 'kimi-k2-5/accounts/fireworks/models/kimi-k2p5', - })) + } + const createAgent = mock(async () => agentRecord) const restart = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService @@ -1105,6 +1224,9 @@ describe('OpenClawService', () => { service.cliClient = { createAgent, } + service.httpClient = { + listAgents: mock(async () => [agentRecord]), + } await service.createAgent({ name: 'research', @@ -1188,12 +1310,13 @@ describe('OpenClawService', () => { ) await writeFile(join(tempDir, '.openclaw', '.env'), '', 'utf-8') - const createAgent = mock(async () => ({ + const agentRecord = { agentId: 'research', name: 'research', workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`, model: 'kimi-k2-5/accounts/fireworks/models/kimi-k2p5', - })) + } + const createAgent = mock(async () => agentRecord) const restart = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService @@ -1205,6 +1328,9 @@ describe('OpenClawService', () => { service.cliClient = { createAgent, } + service.httpClient = { + listAgents: mock(async () => [agentRecord]), + } await service.createAgent({ name: 'research',