Compare commits

..

13 Commits

Author SHA1 Message Date
Nikhil Sonti
4e75fc6c18 fix: address review feedback for PR #995 2026-05-11 14:22:56 -07:00
Nikhil Sonti
aa852febc2 fix(server): stabilize Hermes harness state paths 2026-05-11 14:06:51 -07:00
Nikhil Sonti
09eb44f522 Revert "fix: allow pasted images in agent text box"
This reverts commit b89ea201fa.
2026-05-11 14:01:07 -07:00
Nikhil Sonti
727dd687fd Revert "fix: add cloud sync sign-in disclosure"
This reverts commit f1ebfa5232.
2026-05-11 14:01:07 -07:00
Nikhil Sonti
cab79dca82 Revert "feat(agent): add reset controls for sessions and memory"
This reverts commit f54eff4543.
2026-05-11 14:01:06 -07:00
Nikhil Sonti
d27ceac36c Revert "fix(server): support Gemini computer use requests"
This reverts commit 8b6483a633.
2026-05-11 14:01:06 -07:00
Nikhil Sonti
80fd76ab28 Revert "fix(server): tolerate existing workspace dirs"
This reverts commit d7e1125db3.
2026-05-11 14:01:06 -07:00
shivammittal274
dad2331448 refactor(agent): clean up hermes adapter structure (#994) 2026-05-11 22:57:59 +05:30
Nikhil
d7e1125db3 fix(server): tolerate existing workspace dirs
Fixes #974
2026-05-08 19:17:29 -07:00
Nikhil
8b6483a633 fix(server): support Gemini computer use requests
Fixes #148
2026-05-08 19:12:07 -07:00
Nikhil
f54eff4543 feat(agent): add reset controls for sessions and memory
Fixes #418
2026-05-08 19:06:44 -07:00
Nikhil
f1ebfa5232 fix: add cloud sync sign-in disclosure
Fixes #419
2026-05-08 18:34:31 -07:00
Nikhil
b89ea201fa fix: allow pasted images in agent text box
Fixes #150
2026-05-08 18:26:31 -07:00
29 changed files with 2017 additions and 1093 deletions

View File

@@ -131,15 +131,14 @@ export interface CreateHarnessAgentInput {
modelId?: string
reasoningEffort?: string
/**
* Hermes-only — provider id from `HERMES_SUPPORTED_PROVIDERS`. When
* paired with `apiKey`, the backend writes a per-agent
* config.yaml + .env into the agent's HERMES_HOME so the first chat
* doesn't depend on the user having run `hermes setup` globally.
* Adapter provider id from the user's BrowserOS AI Settings entry.
* Provider-backed adapters use this with `apiKey`/`baseUrl` to write
* or provision their runtime-specific provider config.
*/
providerType?: string
/** Hermes-only — API key paired with `providerType`. */
/** API key paired with `providerType` when the selected adapter needs one. */
apiKey?: string
/** Hermes-only — base URL for the `custom` provider. */
/** Base URL for OpenAI-compatible/custom provider entries. */
baseUrl?: string
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'bun:test'
import { stageAttachment } from './attachments'
function restoreGlobal(name: string, value: unknown) {
if (value === undefined) {
Reflect.deleteProperty(globalThis, name)
return
}
Reflect.set(globalThis, name, value)
}
describe('stageAttachment', () => {
it('uses the recompressed blob media type for large images', async () => {
const originalCreateImageBitmap = Reflect.get(
globalThis,
'createImageBitmap',
)
const originalOffscreenCanvas = Reflect.get(globalThis, 'OffscreenCanvas')
const originalHTMLCanvasElement = Reflect.get(
globalThis,
'HTMLCanvasElement',
)
class FakeOffscreenCanvas {
width: number
height: number
constructor(width: number, height: number) {
this.width = width
this.height = height
}
getContext() {
return {
drawImage() {},
}
}
async convertToBlob(options: { type?: string }) {
return new Blob([new Uint8Array([9, 8, 7])], {
type: options.type ?? 'image/jpeg',
})
}
}
try {
Reflect.set(globalThis, 'createImageBitmap', async () => ({
width: 4096,
height: 2048,
close() {},
}))
Reflect.set(globalThis, 'OffscreenCanvas', FakeOffscreenCanvas)
Reflect.set(globalThis, 'HTMLCanvasElement', class HTMLCanvasElement {})
const file = new File([new Uint8Array(2 * 1024 * 1024)], 'shot.png', {
type: 'image/png',
})
const result = await stageAttachment(file)
expect(result.ok).toBe(true)
if (!result.ok) throw new Error(result.error.message)
expect(result.attachment.mediaType).toBe('image/jpeg')
expect(result.attachment.dataUrl).toStartWith('data:image/jpeg;base64,')
expect(result.attachment.payload).toMatchObject({
kind: 'image',
mediaType: 'image/jpeg',
dataUrl: result.attachment.dataUrl,
})
} finally {
restoreGlobal('createImageBitmap', originalCreateImageBitmap)
restoreGlobal('OffscreenCanvas', originalOffscreenCanvas)
restoreGlobal('HTMLCanvasElement', originalHTMLCanvasElement)
}
})
})

View File

@@ -100,6 +100,7 @@ export async function stageAttachment(
try {
const compressed = await compressImageIfNeeded(file)
const dataUrl = await readAsDataUrl(compressed)
const encodedMediaType = compressed.type || mediaType
// Rough byte ceiling — `data:image/png;base64,...` doubles size with
// base64. Reject early so we never POST something the route will 400.
if (dataUrl.length > MAX_IMAGE_BYTES * 2) {
@@ -118,12 +119,12 @@ export async function stageAttachment(
attachment: {
id: makeId(),
kind: 'image',
mediaType,
mediaType: encodedMediaType,
name: file.name || 'image',
dataUrl,
payload: {
kind: 'image',
mediaType,
mediaType: encodedMediaType,
dataUrl,
name: file.name || undefined,
},

View File

@@ -14,6 +14,7 @@ import { stream } from 'hono/streaming'
import { formatUserMessage } from '../../agent/format-message'
import type { Browser } from '../../browser/browser'
import { createAcpUIMessageStreamResponse } from '../../lib/agents/acp-ui-message-stream'
import type { OpenclawGatewayAccessor } from '../../lib/agents/acpx-runtime'
import type {
ActiveTurnInfo,
TurnFrame,
@@ -120,6 +121,12 @@ type AgentRouteDeps = {
service?: AgentRouteService
browser?: Pick<Browser, 'resolveTabIds'>
browserosServerPort?: number
/**
* Required when an `openclaw` adapter agent is in use; harmless when
* absent. Forwarded to the AcpxRuntime so it can spawn `openclaw acp`
* inside the gateway container.
*/
openclawGateway?: OpenclawGatewayAccessor
/**
* Optional. Enables the image-attachment carve-out for OpenClaw
* Required to dual-create/delete `openclaw` adapter agents on the
@@ -152,6 +159,7 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
deps.service ??
new AgentHarnessService({
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
openclawProvisioner: deps.openclawProvisioner,
})
if (deps.onTurnLifecycle && service instanceof AgentHarnessService) {

View File

@@ -136,6 +136,12 @@ export async function createHttpServer(config: HttpServerConfig) {
createAgentRoutes({
browserosServerPort: port,
browser,
openclawGateway: {
getContainerName: () => OPENCLAW_GATEWAY_CONTAINER_NAME,
getLimaHomeDir: () => getLimaHomeDir(),
getLimactlPath: () => resolveBundledLimactl(resourcesDir),
getVmName: () => VM_NAME,
},
openclawProvisioner: {
createAgent: (input) => getOpenClawService().createAgent(input),
removeAgent: (agentId) => getOpenClawService().removeAgent(agentId),

View File

@@ -4,7 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { AcpxRuntime } from '../../../lib/agents/acpx-runtime'
import {
AcpxRuntime,
type OpenclawGatewayAccessor,
} from '../../../lib/agents/acpx-runtime'
import {
type ActiveTurnInfo,
type TurnFrame,
@@ -16,13 +19,13 @@ import type {
} from '../../../lib/agents/agent-store'
import type { AgentDefinition } from '../../../lib/agents/agent-types'
import { DbAgentStore } from '../../../lib/agents/db-agent-store'
import { writeHermesPerAgentProvider } from '../../../lib/agents/hermes/hermes-paths'
import { getHermesProviderMapping } from '../../../lib/agents/hermes/hermes-provider-map'
import {
FileMessageQueue,
type QueuedMessage,
type QueuedMessageAttachment,
} from '../../../lib/agents/message-queue'
import { writeHermesPerAgentProvider } from '../hermes/hermes-paths'
import { getHermesProviderMapping } from '../hermes/hermes-provider-map'
export {
MessageQueueFullError,
@@ -30,14 +33,14 @@ export {
type QueuedMessageAttachment,
} from '../../../lib/agents/message-queue'
import { basename } from 'node:path'
import { basename, join } from 'node:path'
import type {
AgentHistoryPage,
AgentRowSnapshot,
AgentRuntime,
AgentStreamEvent,
} from '../../../lib/agents/types'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { getBrowserosDir, getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import {
buildFilePreview,
@@ -195,16 +198,11 @@ export type TurnLifecycleListener = (
export class AgentHarnessService {
private readonly agentStore: AgentStore
private readonly runtime: AgentRuntime
private readonly browserosDir: string
private readonly openclawProvisioner: OpenClawProvisioner | null
private readonly turnRegistry: TurnRegistry
private readonly messageQueue: FileMessageQueue
private readonly turnLifecycleListeners = new Set<TurnLifecycleListener>()
/**
* Optional override for the BrowserOS dir used by Hermes per-agent
* provider config writes. Defaults to the global `getBrowserosDir()`
* lookup at write time when undefined; tests can inject a tmp dir.
*/
private readonly browserosDir: string | undefined
/**
* Lazy-initialised so tests that swap in a fake `agentStore` don't
* eagerly hit `getDb()` (which throws when the test harness hasn't
@@ -227,24 +225,36 @@ export class AgentHarnessService {
deps: {
agentStore?: AgentStore
runtime?: AgentRuntime
browserosServerPort?: number
browserosDir?: string
browserosServerPort?: number
openclawGateway?: OpenclawGatewayAccessor
openclawProvisioner?: OpenClawProvisioner
turnRegistry?: TurnRegistry
messageQueue?: FileMessageQueue
producedFilesStore?: ProducedFilesStore
} = {},
) {
this.browserosDir = deps.browserosDir ?? getBrowserosDir()
this.agentStore = deps.agentStore ?? new DbAgentStore()
this.runtime =
deps.runtime ??
new AcpxRuntime({
browserosDir: this.browserosDir,
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
})
this.openclawProvisioner = deps.openclawProvisioner ?? null
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
this.browserosDir = deps.browserosDir
this.messageQueue =
deps.messageQueue ??
new FileMessageQueue({
filePath: join(
this.browserosDir,
'agents',
'harness',
'message-queues.json',
),
})
if (deps.producedFilesStore) {
this.explicitProducedFilesStore = deps.producedFilesStore
}

View File

@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cpSync, existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { getBrowserosDir } from '../../../lib/browseros-dir'
import { ContainerCli, ImageLoader } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
getLimaHomeDir,
resolveBundledLimactl,
resolveBundledLimaTemplate,
VM_NAME,
VmRuntime,
} from '../../../lib/vm'
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
import { ContainerRuntime } from './container-runtime'
const UNSUPPORTED_PLATFORM_MESSAGE =
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
export interface ContainerRuntimeFactoryInput {
resourcesDir?: string
projectDir: string
browserosRoot?: string
platform?: NodeJS.Platform
}
export function buildContainerRuntime(
input: ContainerRuntimeFactoryInput,
): ContainerRuntime {
const platform = input.platform ?? process.platform
if (platform !== 'darwin') {
// BROWSEROS_SKIP_OPENCLAW=1 is the explicit opt-in for non-darwin hosts
// (e.g. Linux CI runners) where OpenClaw can't actually run but the rest
// of the server should still come up. Returns a no-op runtime — any
// OpenClaw API call hitting it will fail loudly at request time.
if (
process.env.NODE_ENV === 'test' ||
process.env.BROWSEROS_SKIP_OPENCLAW === '1'
) {
return new UnsupportedPlatformTestRuntime(input.projectDir)
}
throw unsupportedPlatformError()
}
const browserosRoot = input.browserosRoot ?? getBrowserosDir()
if (input.resourcesDir) {
migrateLegacyOpenClawDirSync(browserosRoot)
}
const limactlPath = input.resourcesDir
? resolveBundledLimactl(input.resourcesDir)
: 'limactl'
const limaHome = getLimaHomeDir(browserosRoot)
const vm = new VmRuntime({
limactlPath,
limaHome,
templatePath: input.resourcesDir
? resolveBundledLimaTemplate(input.resourcesDir)
: undefined,
browserosRoot,
})
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
const loader = new ImageLoader(shell)
return new ContainerRuntime({
vm,
shell,
loader,
projectDir: input.projectDir,
})
}
export async function migrateLegacyOpenClawDir(
browserosRoot = getBrowserosDir(),
): Promise<void> {
migrateLegacyOpenClawDirSync(browserosRoot)
}
function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
const legacyDir = join(browserosRoot, 'openclaw')
const nextDir = join(browserosRoot, 'vm', 'openclaw')
if (!existsSync(legacyDir)) return
if (existsSync(nextDir)) {
logger.warn('OpenClaw legacy and VM state directories both exist', {
legacyDir,
nextDir,
})
return
}
mkdirSync(dirname(nextDir), { recursive: true })
cpSync(legacyDir, nextDir, { recursive: true })
logger.info(VM_TELEMETRY_EVENTS.migrationOpenClawMoved, {
from: legacyDir,
to: nextDir,
})
}
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
constructor(projectDir: string) {
super({
vm: {} as VmRuntime,
shell: {} as ContainerCli,
loader: {
ensureImageLoaded: rejectUnsupportedPlatform,
ensureAgentImageLoaded: rejectUnsupportedPlatform,
},
projectDir,
})
}
override async ensureReady(): Promise<void> {
throw unsupportedPlatformError()
}
override async isPodmanAvailable(): Promise<boolean> {
return false
}
override async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
return { initialized: false, running: false }
}
override async pullImage(): Promise<void> {
throw unsupportedPlatformError()
}
override async prewarmGatewayImage(): Promise<void> {
throw unsupportedPlatformError()
}
override async isGatewayCurrent(): Promise<boolean> {
return false
}
override async startGateway(): Promise<void> {
throw unsupportedPlatformError()
}
override async stopGateway(): Promise<void> {}
override async restartGateway(): Promise<void> {
throw unsupportedPlatformError()
}
override async getGatewayLogs(): Promise<string[]> {
return []
}
override async isHealthy(): Promise<boolean> {
return false
}
override async isReady(): Promise<boolean> {
return false
}
override async waitForReady(): Promise<boolean> {
return false
}
override async stopVm(): Promise<void> {}
override async execInContainer(): Promise<number> {
throw unsupportedPlatformError()
}
override async runInContainer(): Promise<never> {
throw unsupportedPlatformError()
}
override async runGatewaySetupCommand(): Promise<number> {
throw unsupportedPlatformError()
}
override tailGatewayLogs(): () => void {
return () => {}
}
}
async function rejectUnsupportedPlatform(): Promise<never> {
throw unsupportedPlatformError()
}
function unsupportedPlatformError(): Error {
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
}

View File

@@ -0,0 +1,436 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
OPENCLAW_AGENT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import type {
ContainerCli,
ContainerCommandResult,
ContainerSpec,
LogFn,
WaitForContainerNameReleaseOptions,
} from '../../../lib/container'
import { isContainerNameInUse } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
GUEST_VM_STATE,
hostPathToGuest,
type VmRuntime,
} from '../../../lib/vm'
import { ContainerNameInUseError } from '../../../lib/vm/errors'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
const CREATE_CONTAINER_MAX_ATTEMPTS = 3
const OPENCLAW_NAME_RELEASE_WAIT: WaitForContainerNameReleaseOptions = {
timeoutMs: 10_000,
intervalMs: 100,
}
// Prepend user-installed bin so tools like `claude` / `gemini` CLI that
// are installed via npm into the mounted home are discoverable by
// OpenClaw's child-process spawns (no login shell is involved).
const GATEWAY_PATH = [
`${GATEWAY_NPM_PREFIX}/bin`,
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin',
].join(':')
export type GatewayContainerSpec = {
hostPort: number
hostHome: string
envFilePath: string
timezone: string
}
export interface ContainerRuntimeConfig {
vm: VmRuntime
shell: ContainerCli
loader: {
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string>
}
projectDir: string
}
export class ContainerRuntime {
private readonly vm: VmRuntime
private readonly shell: ContainerCli
private readonly loader: {
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string>
}
private readonly projectDir: string
constructor(config: ContainerRuntimeConfig) {
this.vm = config.vm
this.shell = config.shell
this.loader = config.loader
this.projectDir = config.projectDir
}
async ensureReady(onLog?: LogFn): Promise<void> {
logger.info('Ensuring BrowserOS VM runtime readiness')
await this.vm.ensureReady(onLog)
await this.vm.getDefaultGateway()
}
async isPodmanAvailable(): Promise<boolean> {
return true
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
const running = await this.vm.isReady()
return { initialized: running, running }
}
async pullImage(image: string, onLog?: LogFn): Promise<void> {
await this.loader.ensureImageLoaded(image, onLog)
}
/** Warm the gateway image in containerd without creating or starting containers. */
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
await this.ensureGatewayImageLoaded(onLog)
}
/** Report whether the existing gateway container was created from the target image. */
async isGatewayCurrent(): Promise<boolean> {
const image = await this.shell.containerImageRef(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
const expected = this.expectedGatewayImageRef()
const current = imageMatchesExpectedRef(image, expected)
if (!current) {
logger.info('OpenClaw gateway image is not current', {
actualImageRef: image,
expectedImageRef: expected,
})
}
return current
}
async startGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
const image = await this.ensureGatewayImageLoaded(onLog)
const container = await this.buildGatewayContainerSpec(input, image)
await this.createContainerWithNameReconcile(container, onLog)
await this.shell.startContainer(container.name)
}
async stopGateway(onLog?: LogFn): Promise<void> {
await this.removeGatewayContainer(onLog)
}
async restartGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.startGateway(input, onLog)
}
async getGatewayLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.shell.runCommand(
['logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
(line) => lines.push(line),
)
return lines
}
async isHealthy(hostPort: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
return res.ok
} catch {
return false
}
}
async isReady(hostPort: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
return res.ok
} catch {
return false
}
}
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
logger.info('Waiting for OpenClaw gateway readiness', {
hostPort,
timeoutMs,
})
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.isReady(hostPort)) return true
await Bun.sleep(1000)
}
logger.error('Timed out waiting for OpenClaw gateway readiness', {
hostPort,
timeoutMs,
})
return false
}
async stopVm(): Promise<void> {
await this.vm.stopVm()
}
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
return this.shell.exec(OPENCLAW_GATEWAY_CONTAINER_NAME, command, onLog)
}
// Unlike execInContainer, this returns stdout and stderr separately
// so callers that need to parse program output (e.g. JSON status
// commands) aren't forced to untangle it from nerdctl's stderr.
async runInContainer(command: string[]): Promise<ContainerCommandResult> {
return this.shell.runCommand([
'exec',
OPENCLAW_GATEWAY_CONTAINER_NAME,
...command,
])
}
async runGatewaySetupCommand(
command: string[],
spec: GatewayContainerSpec,
onLog?: LogFn,
): Promise<number> {
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.removeContainerAndWait(setupContainerName, onLog)
const image = await this.ensureGatewayImageLoaded(onLog)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
const createResult = await this.runSetupCreateWithNameReconcile(
setupContainerName,
[
'create',
'--name',
setupContainerName,
...(await this.buildGatewayRunArgs(spec)),
image,
'node',
...setupArgs,
],
onLog,
)
if (createResult.exitCode !== 0) {
await this.shell.removeContainer(
setupContainerName,
{ force: true },
onLog,
)
return createResult.exitCode
}
try {
const startResult = await this.shell.runCommand(
['start', '-a', setupContainerName],
onLog,
)
return startResult.exitCode
} finally {
await this.shell.removeContainer(
setupContainerName,
{ force: true },
onLog,
)
}
}
tailGatewayLogs(onLine: LogFn): () => void {
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
}
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
await this.removeContainerAndWait(OPENCLAW_GATEWAY_CONTAINER_NAME, onLog)
}
/** Create the fixed-name gateway after reconciling stale nerdctl name ownership. */
private async createContainerWithNameReconcile(
container: ContainerSpec,
onLog?: LogFn,
): Promise<void> {
let attempt = 1
while (true) {
await this.removeContainerAndWait(container.name, onLog)
try {
await this.shell.createContainer(container, onLog)
return
} catch (err) {
if (
!(err instanceof ContainerNameInUseError) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
throw err
}
logger.warn('OpenClaw container name still in use; retrying create', {
containerName: container.name,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
})
attempt++
}
}
}
private async runSetupCreateWithNameReconcile(
setupContainerName: string,
createArgs: string[],
onLog?: LogFn,
): Promise<ContainerCommandResult> {
let attempt = 1
while (true) {
const result = await this.shell.runCommand(createArgs, onLog)
if (
result.exitCode === 0 ||
!isContainerNameInUse(result.stderr) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
return result
}
logger.warn(
'OpenClaw setup container name still in use; retrying create',
{
containerName: setupContainerName,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
},
)
await this.removeContainerAndWait(setupContainerName, onLog)
attempt++
}
}
private async removeContainerAndWait(
containerName: string,
onLog?: LogFn,
): Promise<void> {
await this.shell.removeContainer(containerName, { force: true }, onLog)
await this.shell.waitForContainerNameRelease(
containerName,
OPENCLAW_NAME_RELEASE_WAIT,
)
}
private async buildGatewayContainerSpec(
input: GatewayContainerSpec,
image: string,
): Promise<ContainerSpec> {
return {
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort: input.hostPort,
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
},
],
envFile: this.translateHostPath(input.envFilePath, input.hostHome),
env: this.buildGatewayEnv(input),
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
addHosts: [await this.hostContainersInternalEntry()],
health: {
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
interval: '30s',
timeout: '10s',
retries: 3,
},
command: [
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
'--allow-unconfigured',
],
}
}
private async buildGatewayRunArgs(
input: GatewayContainerSpec,
): Promise<string[]> {
const args = [
'--env-file',
this.translateHostPath(input.envFilePath, input.hostHome),
'-v',
`${GUEST_OPENCLAW_HOME}:${GATEWAY_CONTAINER_HOME}`,
]
for (const [key, value] of Object.entries(this.buildGatewayEnv(input))) {
args.push('-e', `${key}=${value}`)
}
args.push('--add-host', await this.hostContainersInternalEntry())
return args
}
private async hostContainersInternalEntry(): Promise<string> {
return `host.containers.internal:${await this.vm.getDefaultGateway()}`
}
private async ensureGatewayImageLoaded(onLog?: LogFn): Promise<string> {
// Local image testing can override the pinned GHCR image with OPENCLAW_IMAGE.
const override = process.env.OPENCLAW_IMAGE?.trim()
if (override) {
await this.loader.ensureImageLoaded(override, onLog)
return override
}
return this.loader.ensureAgentImageLoaded(OPENCLAW_AGENT_NAME, onLog)
}
private expectedGatewayImageRef(): string {
return process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
}
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
OPENCLAW_NO_RESPAWN: '1',
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
NODE_ENV: 'production',
TZ: input.timezone,
PATH: GATEWAY_PATH,
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}
}
private translateHostPath(path: string, openclawHostDir: string): string {
if (path === openclawHostDir) return GUEST_OPENCLAW_HOME
if (path.startsWith(`${openclawHostDir}/`)) {
return `${GUEST_OPENCLAW_HOME}${path.slice(openclawHostDir.length)}`
}
return hostPathToGuest(path)
}
}
function imageMatchesExpectedRef(
actual: string | null,
expected: string,
): boolean {
return (
actual === expected || actual?.startsWith(`${expected}@sha256:`) === true
)
}

View File

@@ -17,11 +17,6 @@ import {
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import {
configureOpenClawRuntime,
getOpenClawRuntime,
type OpenClawContainerRuntime,
} from '../../../lib/agents/runtime'
import type { AgentStreamEvent } from '../../../lib/agents/types'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
@@ -31,6 +26,11 @@ import {
type AgentSessionState,
ClawSession,
} from './claw-session'
import type {
ContainerRuntime,
GatewayContainerSpec,
} from './container-runtime'
import { buildContainerRuntime } from './container-runtime-factory'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
@@ -354,7 +354,7 @@ export interface DashboardResponse {
}
export class OpenClawService {
private runtime: OpenClawContainerRuntime
private runtime: ContainerRuntime
private cliClient: OpenClawCliClient
private bootstrapCliClient: OpenClawCliClient
private httpClient: OpenClawHttpClient
@@ -373,11 +373,11 @@ export class OpenClawService {
constructor(config: OpenClawServiceConfig = {}) {
this.openclawDir = getOpenClawDir()
this.runtime = ensureOpenClawRuntime({
this.runtime = buildContainerRuntime({
resourcesDir: config.resourcesDir,
browserosDir: config.browserosDir,
projectDir: this.openclawDir,
browserosRoot: config.browserosDir,
})
this.runtime.setHostPort(this.hostPort)
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
this.httpClient = new OpenClawHttpClient(this.hostPort)
@@ -392,17 +392,23 @@ export class OpenClawService {
this.browserosServerPort = config.browserosServerPort
}
let runtimeChanged = false
if (
config.resourcesDir !== undefined &&
config.resourcesDir !== this.resourcesDir
) {
this.resourcesDir = config.resourcesDir
runtimeChanged = true
}
if (
config.browserosDir !== undefined &&
config.browserosDir !== this.browserosDir
) {
this.browserosDir = config.browserosDir
runtimeChanged = true
}
if (runtimeChanged) {
this.rebuildRuntimeClients()
}
}
@@ -556,7 +562,10 @@ export class OpenClawService {
await this.assertConfigValid(this.bootstrapCliClient)
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(undefined, logProgress)
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
@@ -634,7 +643,10 @@ export class OpenClawService {
}
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(undefined, logProgress)
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
@@ -679,7 +691,10 @@ export class OpenClawService {
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Restarting OpenClaw gateway...')
await this.runtime.restartGateway(undefined, logProgress)
await this.runtime.restartGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
@@ -709,7 +724,7 @@ export class OpenClawService {
})
logProgress('Checking gateway readiness...')
const ready = await this.runtime.isReady()
const ready = await this.runtime.isReady(this.hostPort)
if (!ready) {
this.controlPlaneStatus = 'failed'
this.lastGatewayError = 'OpenClaw gateway is not ready'
@@ -756,7 +771,9 @@ export class OpenClawService {
}
const machineStatus = await this.runtime.getMachineStatus()
const ready = machineStatus.running ? await this.runtime.isReady() : false
const ready = machineStatus.running
? await this.runtime.isReady(this.hostPort)
: false
let agentCount = 0
if (ready) {
@@ -1142,7 +1159,7 @@ export class OpenClawService {
if (!(await this.isCurrentGatewayAvailable(this.hostPort))) {
await this.ensureGatewayPortAllocated()
await this.runtime.startGateway(undefined)
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
@@ -1240,17 +1257,28 @@ export class OpenClawService {
private buildBootstrapCliClient(): OpenClawCliClient {
return new OpenClawCliClient({
execInContainer: (command, onLog) =>
this.runtime.runGatewaySetupCommand(command, undefined, onLog),
this.runtime.runGatewaySetupCommand(
command,
this.buildGatewayRuntimeSpec(),
onLog,
),
})
}
private rebuildRuntimeClients(): void {
this.stopGatewayLogTail()
this.runtime = buildContainerRuntime({
resourcesDir: this.resourcesDir ?? undefined,
projectDir: this.openclawDir,
browserosRoot: this.browserosDir,
})
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
}
private setPort(hostPort: number): void {
if (hostPort === this.hostPort) return
this.hostPort = hostPort
// Tests sometimes overwrite this.runtime with a partial mock that
// doesn't carry every method — guard so we don't crash when the
// mock omits setHostPort.
this.runtime.setHostPort?.(hostPort)
this.httpClient = new OpenClawHttpClient(this.hostPort)
}
@@ -1301,21 +1329,19 @@ export class OpenClawService {
}
private async isGatewayPortReady(hostPort: number): Promise<boolean> {
// Route through the runtime's probe when the port matches its
// configured one — preserves the no-direct-fetch semantics the
// legacy adapter exposed (and that several tests rely on by
// mocking runtime.isReady but not the HTTP layer).
if (hostPort === this.hostPort) {
if (await this.runtime.isReady()) return true
const r = this.runtime as { isHealthy?: () => Promise<boolean> }
return r.isHealthy ? r.isHealthy() : false
if (await this.runtime.isReady(hostPort)) return true
const runtime = this.runtime as {
isHealthy?: (port: number) => Promise<boolean>
}
if (await fetchOk(`http://127.0.0.1:${hostPort}/readyz`)) return true
return fetchOk(`http://127.0.0.1:${hostPort}/healthz`)
if (runtime.isHealthy) {
return runtime.isHealthy(hostPort)
}
return false
}
private async assertGatewayReady(): Promise<void> {
const portReady = await this.runtime.isReady()
const portReady = await this.runtime.isReady(this.hostPort)
logger.debug('Checking OpenClaw gateway readiness before use', {
hostPort: this.hostPort,
portReady,
@@ -1574,6 +1600,15 @@ export class OpenClawService {
await writeFile(envPath, '', { mode: 0o600 })
}
private buildGatewayRuntimeSpec(): GatewayContainerSpec {
return {
hostPort: this.hostPort,
hostHome: this.openclawDir,
envFilePath: this.getStateEnvPath(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
}
private async writeStateEnv(
values: Record<string, string>,
): Promise<boolean> {
@@ -1733,23 +1768,3 @@ export function getOpenClawService(): OpenClawService {
if (!service) service = new OpenClawService()
return service
}
async function fetchOk(url: string): Promise<boolean> {
try {
const res = await fetch(url)
return res.ok
} catch {
return false
}
}
/** Resolve the OpenClawContainerRuntime, registering it lazily if
* main.ts didn't already do so (e.g. tests that build the service
* directly). Always succeeds — the runtime constructs on every
* platform; lifecycle calls fail at limactl-not-found on non-darwin. */
function ensureOpenClawRuntime(opts: {
resourcesDir?: string
browserosDir?: string
}): OpenClawContainerRuntime {
return getOpenClawRuntime() ?? configureOpenClawRuntime(opts)
}

View File

@@ -5,11 +5,11 @@
*/
import type { AgentDefinition } from './agent-types'
import { prepareOpenClawContext } from './openclaw/prepare'
import {
prepareClaudeCodeContext,
prepareCodexContext,
prepareHermesContext,
prepareOpenClawContext,
} from './runtime'
export interface PreparedAcpxAgentContext {

View File

@@ -31,7 +31,11 @@ import type {
AgentHistoryEntry,
AgentHistoryToolCall,
} from './agent-types'
import { getHermesRuntime, getOpenClawRuntime } from './runtime'
import {
type OpenclawGatewayAccessor,
resolveOpenclawAcpCommand,
} from './openclaw/acp-command'
import { getHermesRuntime } from './runtime'
import type {
AgentHistoryPage,
AgentPromptInput,
@@ -42,11 +46,18 @@ import type {
AgentStreamEvent,
} from './types'
export type { OpenclawGatewayAccessor } from './openclaw/acp-command'
type AcpxRuntimeOptions = {
cwd?: string
browserosDir?: string
stateDir?: string
browserosServerPort?: number
/**
* Required for adapter='openclaw' agents; harmless when absent for
* claude/codex (their adapters spawn their own CLI binaries).
*/
openclawGateway?: OpenclawGatewayAccessor
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
}
@@ -66,6 +77,7 @@ export class AcpxRuntime implements AgentRuntime {
private readonly browserosDir: string
private readonly stateDir: string
private readonly browserosServerPort: number
private readonly openclawGateway: OpenclawGatewayAccessor | null
private readonly runtimeFactory: (
options: AcpRuntimeOptions,
) => AcpxCoreRuntime
@@ -81,6 +93,7 @@ export class AcpxRuntime implements AgentRuntime {
join(this.browserosDir, 'agents', 'acpx')
this.browserosServerPort =
options.browserosServerPort ?? DEFAULT_PORTS.server
this.openclawGateway = options.openclawGateway ?? null
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
}
@@ -245,6 +258,7 @@ export class AcpxRuntime implements AgentRuntime {
cwd: input.cwd,
sessionStore: this.sessionStore,
agentRegistry: createBrowserosAgentRegistry({
openclawGateway: this.openclawGateway,
openclawSessionKey: input.openclawSessionKey,
commandEnv: input.commandEnv,
}),
@@ -655,6 +669,7 @@ function createBrowserosMcpServers(
}
function createBrowserosAgentRegistry(input: {
openclawGateway: OpenclawGatewayAccessor | null
openclawSessionKey: string | null
commandEnv: Record<string, string>
}): AcpRuntimeOptions['agentRegistry'] {
@@ -668,20 +683,18 @@ function createBrowserosAgentRegistry(input: {
const lower = agentName.trim().toLowerCase()
if (lower === 'openclaw') {
const runtime = getOpenClawRuntime()
if (runtime) {
return runtime.buildExecArgv(
runtime.getAcpExecSpec({
commandEnv: input.commandEnv,
openclawSessionKey: input.openclawSessionKey,
}),
)
if (!input.openclawGateway) {
// Fall back to acpx's built-in `openclaw` adapter, which assumes
// a host-side openclaw binary. BrowserOS doesn't install one on
// the host, so this branch will fail at spawn time with a
// descriptive error — the harness should be wired with a
// gateway accessor.
return registry.resolve(agentName)
}
// Tests / non-darwin: fall back to acpx-core's built-in
// `openclaw` adapter, which assumes a host-side openclaw
// binary. BrowserOS doesn't install one on the host, so this
// branch fails at spawn time with a descriptive error.
return registry.resolve(agentName)
return resolveOpenclawAcpCommand(
input.openclawGateway,
input.openclawSessionKey,
)
}
if (lower === 'hermes') {

View File

@@ -15,7 +15,7 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getVmStateDir } from '../../../lib/browseros-dir'
import { getVmStateDir } from '../../browseros-dir'
/** Top-level Hermes state directory: `<browserosDir>/vm/hermes`. */
export function getHermesHostStateDir(browserosDir?: string): string {

View File

@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
/**
* Live-getter access to the OpenClaw gateway runtime info. Required
* when spawning the OpenClaw ACP adapter inside the gateway container.
*
* Fields are getters (not snapshot values) so the harness picks up the
* current VM/container paths at spawn time. The bundled gateway runs
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
*/
export interface OpenclawGatewayAccessor {
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
getContainerName(): string
/** LIMA_HOME directory containing the browseros-vm instance. */
getLimaHomeDir(): string
/** Resolved path to the `limactl` binary (bundled or host). */
getLimactlPath(): string
/** VM name registered in LIMA_HOME (e.g. browseros-vm). */
getVmName(): string
}
/**
* Builds the command string acpx will spawn for an `openclaw` adapter.
* Runs `openclaw acp` inside the gateway container via the bundled
* `limactl shell <vm> -- nerdctl exec -i ...` chain so the binary
* already installed alongside the gateway is reused; BrowserOS does
* not require a host-side OpenClaw install.
*
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
* so no gateway token flag is needed for the local ACP bridge.
*
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
* the ACP message stream.
*/
export function resolveOpenclawAcpCommand(
gateway: OpenclawGatewayAccessor,
sessionKey: string | null,
): string {
const limactl = gateway.getLimactlPath()
const vm = gateway.getVmName()
const container = gateway.getContainerName()
const limaHome = gateway.getLimaHomeDir()
const gatewayUrlInsideContainer = `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`
// `--session <key>` routes the bridge's newSession requests to the
// matching gateway agent. acpx does not pass sessionKey through ACP
// newSession params, so without this CLI flag the bridge falls back
// to a synthetic acp:<uuid> session that does not resolve to any
// provisioned gateway agent.
//
// Harness keys are `agent:<harness-id>:main`; the harness id matches
// a dual-created gateway agent name, so the bridge resolves directly.
// Any legacy non-agent key falls back to the always-provisioned
// `main` gateway agent with the original key encoded as a channel
// suffix.
const bridgeSessionKey = sessionKey
? sessionKey.startsWith('agent:')
? sessionKey
: `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
: null
// Prefix `env LIMA_HOME=<path>` so the spawned limactl finds the
// BrowserOS-owned VM instance. The BrowserOS server doesn't set
// LIMA_HOME on its own process env (it injects per-spawn elsewhere),
// so the acpx-spawned subprocess won't inherit it without this hint.
const argv = [
'env',
`LIMA_HOME=${limaHome}`,
limactl,
'shell',
'--workdir',
'/',
vm,
'--',
'nerdctl',
'exec',
'-i',
'-e',
'OPENCLAW_HIDE_BANNER=1',
'-e',
'OPENCLAW_SUPPRESS_NOTES=1',
container,
'openclaw',
'acp',
'--url',
gatewayUrlInsideContainer,
]
if (bridgeSessionKey) {
argv.push('--session', bridgeSessionKey)
}
return argv.join(' ')
}

View File

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type {
PrepareAcpxAgentContextInput,
PreparedAcpxAgentContext,
} from '../acpx-agent-adapter'
import {
buildBrowserosAcpPrompt,
ensureUsableCwd,
resolveAgentRuntimePaths,
} from '../acpx-runtime-context'
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'
/**
* Prepares OpenClaw without BrowserOS SOUL/MEMORY or BrowserOS MCP.
* OpenClaw runs inside the gateway VM/container, so a selected host cwd is not visible there.
*/
export async function prepareOpenClawContext(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext> {
const paths = resolveAgentRuntimePaths({
browserosDir: input.browserosDir,
agentId: input.agent.id,
})
await ensureUsableCwd(paths.effectiveCwd, true)
return {
cwd: paths.effectiveCwd,
runtimeSessionKey: input.sessionKey,
runPrompt: buildBrowserosAcpPrompt(
OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS,
input.message,
),
commandEnv: {},
commandIdentity: 'openclaw',
useBrowserosMcp: false,
openclawSessionKey: input.sessionKey,
}
}

View File

@@ -15,11 +15,6 @@ import {
HERMES_CONTAINER_NAME,
HERMES_IMAGE,
} from '@browseros/shared/constants/hermes'
import {
getHermesAgentHomeHostDir,
getHermesHarnessHostDir,
getHermesHostStateDir,
} from '../../../api/services/hermes/hermes-paths'
import { getBrowserosDir } from '../../browseros-dir'
import { ContainerCli } from '../../container/container-cli'
import { ImageLoader } from '../../container/image-loader'
@@ -46,6 +41,11 @@ import {
finishBrowserosManagedContext,
prepareBrowserosManagedContext,
} from '../acpx-agent-common'
import {
getHermesAgentHomeHostDir,
getHermesHarnessHostDir,
getHermesHostStateDir,
} from '../hermes/hermes-paths'
import { ContainerAgentRuntime } from './container-agent-runtime'
import { getAgentRuntimeRegistry } from './registry'
import type { ExecSpec } from './types'
@@ -53,8 +53,6 @@ import type { ExecSpec } from './types'
const HERMES_BINARY = '/opt/hermes/.venv/bin/hermes'
export interface HermesContainerRuntimeConfig {
/** BrowserOS state root — used to compute per-agent home paths. */
browserosDir: string
/** Host-side directory where Hermes per-agent home dirs live. */
hermesHarnessHostDir: string
}
@@ -150,10 +148,7 @@ export class HermesContainerRuntime extends ContainerAgentRuntime {
// ── AgentRuntime additions ───────────────────────────────────────
getPerAgentHomeDir(agentId: string): string {
return getHermesAgentHomeHostDir({
browserosDir: this.hermesConfig.browserosDir,
agentId,
})
return join(this.hermesConfig.hermesHarnessHostDir, agentId, 'home')
}
/**
@@ -191,9 +186,8 @@ export class HermesContainerRuntime extends ContainerAgentRuntime {
*/
function translateHermesHomeToContainerPath(
hostHome: string,
browserosDir: string,
harnessHostRoot: string,
): string {
const harnessHostRoot = getHermesHarnessHostDir(browserosDir)
if (hostHome === harnessHostRoot) return HERMES_CONTAINER_HARNESS_DIR
if (hostHome.startsWith(`${harnessHostRoot}/`)) {
return `${HERMES_CONTAINER_HARNESS_DIR}${hostHome.slice(harnessHostRoot.length)}`
@@ -232,7 +226,7 @@ export async function prepareHermesContext(
const hermesAgentHomeInContainer = translateHermesHomeToContainerPath(
hermesAgentHome,
input.browserosDir,
getHermesHarnessHostDir(input.browserosDir),
)
return finishBrowserosManagedContext({
@@ -260,6 +254,16 @@ export interface ConfigureHermesRuntimeOptions {
browserosDir?: string
}
export type HermesRuntimeStartupPhase = 'configure' | 'install' | 'start'
export interface StartHermesRuntimeBestEffortOptions
extends ConfigureHermesRuntimeOptions {
configureRuntime?: (
options: ConfigureHermesRuntimeOptions,
) => HermesContainerRuntime | null
onError?: (phase: HermesRuntimeStartupPhase, error: unknown) => void
}
/**
* Build a `HermesContainerRuntime` with production deps (bundled
* limactl, BrowserOS state dirs, Lima VM runtime) and register it in
@@ -310,7 +314,7 @@ export function configureHermesRuntime(
vmName: VM_NAME,
lockDir: join(hermesStateDir, '.locks'),
},
{ browserosDir, hermesHarnessHostDir },
{ hermesHarnessHostDir },
)
getAgentRuntimeRegistry().register(runtime)
@@ -318,8 +322,55 @@ export function configureHermesRuntime(
return runtime
}
/**
* Startup wiring for the Hermes adapter. Kept beside the adapter runtime so
* the server entry point does not need to know Hermes' install/start sequence.
*/
export function startHermesRuntimeBestEffort(
options: StartHermesRuntimeBestEffortOptions = {},
): HermesContainerRuntime | null {
const {
configureRuntime = configureHermesRuntime,
onError = logHermesStartupError,
...configureOptions
} = options
let runtime: HermesContainerRuntime | null
try {
runtime = configureRuntime(configureOptions)
} catch (err) {
onError('configure', err)
return null
}
if (!runtime) return null
void runtime
.executeAction({ type: 'install' })
.catch((err) => onError('install', err))
void runtime
.executeAction({ type: 'start' })
.catch((err) => onError('start', err))
return runtime
}
/** Convenience getter — returns the registered runtime or null. */
export function getHermesRuntime(): HermesContainerRuntime | null {
const r = getAgentRuntimeRegistry().get('hermes')
return r instanceof HermesContainerRuntime ? r : null
}
function logHermesStartupError(
phase: HermesRuntimeStartupPhase,
error: unknown,
): void {
const message =
phase === 'configure'
? 'Hermes container configuration failed, continuing without it'
: phase === 'install'
? 'Hermes prewarm failed'
: 'Hermes container start failed'
logger.warn(message, {
error: error instanceof Error ? error.message : String(error),
})
}

View File

@@ -30,19 +30,13 @@ export {
HermesContainerRuntime,
type HermesContainerRuntimeConfig,
prepareHermesContext,
type StartHermesRuntimeBestEffortOptions,
startHermesRuntimeBestEffort,
} from './hermes-container-runtime'
export {
HostProcessAgentRuntime,
type HostProcessAgentRuntimeDeps,
} from './host-process-agent-runtime'
export {
type ConfigureOpenClawRuntimeOptions,
configureOpenClawRuntime,
getOpenClawRuntime,
OpenClawContainerRuntime,
type OpenClawContainerRuntimeConfig,
prepareOpenClawContext,
} from './openclaw-container-runtime'
export {
AgentRuntimeRegistry,
getAgentRuntimeRegistry,

View File

@@ -1,447 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { join } from 'node:path'
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { getOpenClawStateEnvPath } from '../../../api/services/openclaw/openclaw-env'
import { getBrowserosDir, getOpenClawDir } from '../../browseros-dir'
import { ContainerCli } from '../../container/container-cli'
import { ImageLoader } from '../../container/image-loader'
import type {
ContainerDescriptor,
ManagedContainerDeps,
MountRoot,
} from '../../container/managed'
import type { ContainerSpec, LogFn } from '../../container/types'
import { logger } from '../../logger'
import {
GUEST_VM_STATE,
getLimaHomeDir,
resolveBundledLimactl,
resolveBundledLimaTemplate,
VM_NAME,
VmRuntime,
} from '../../vm'
import type {
PrepareAcpxAgentContextInput,
PreparedAcpxAgentContext,
} from '../acpx-agent-adapter'
import {
buildBrowserosAcpPrompt,
ensureUsableCwd,
resolveAgentRuntimePaths,
} from '../acpx-runtime-context'
import { ContainerAgentRuntime } from './container-agent-runtime'
import { getAgentRuntimeRegistry } from './registry'
import type { ExecSpec } from './types'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
const GATEWAY_PATH = [
`${GATEWAY_NPM_PREFIX}/bin`,
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin',
].join(':')
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'
export interface OpenClawContainerRuntimeConfig {
/** BrowserOS state root. */
browserosDir: string
/** OpenClaw state dir (`<browserosDir>/vm/openclaw`). */
openclawDir: string
}
export class OpenClawContainerRuntime extends ContainerAgentRuntime {
readonly descriptor: ContainerDescriptor & { kind: 'container' } = {
adapterId: 'openclaw',
displayName: 'OpenClaw',
kind: 'container',
defaultImage: process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE,
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
platforms: ['darwin'],
readinessProbe: { timeoutMs: 60_000, intervalMs: 1_000 },
}
private readonly openclawConfig: OpenClawContainerRuntimeConfig
private hostPort: number = OPENCLAW_GATEWAY_CONTAINER_PORT
constructor(
deps: ManagedContainerDeps,
config: OpenClawContainerRuntimeConfig,
) {
super(deps)
this.openclawConfig = config
}
/** Service owns port allocation; the runtime re-reads it at spec-build and probe time. */
setHostPort(port: number): void {
this.hostPort = port
}
getHostPort(): number {
return this.hostPort
}
// ── ManagedContainer abstracts ───────────────────────────────────
protected mountRoots(): readonly MountRoot[] {
return [
{
hostPath: this.openclawConfig.openclawDir,
containerPath: GATEWAY_CONTAINER_HOME,
kind: 'shared',
},
]
}
protected async buildContainerSpec(): Promise<ContainerSpec> {
const hostPort = this.hostPort
const envFilePath = getOpenClawStateEnvPath(this.openclawConfig.openclawDir)
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const gateway = await this.deps.vm.getDefaultGateway()
return {
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: this.descriptor.defaultImage,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort,
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
},
],
envFile: this.translateHostPathToGuest(envFilePath),
env: this.buildGatewayEnv(timezone),
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
addHosts: [`host.containers.internal:${gateway}`],
health: {
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
interval: '30s',
timeout: '10s',
retries: 3,
},
command: [
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
'--allow-unconfigured',
],
}
}
protected async readinessProbe(): Promise<boolean> {
const hostPort = this.hostPort
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
return res.ok
} catch {
return false
}
}
// ── AgentRuntime additions ───────────────────────────────────────
getPerAgentHomeDir(_agentId: string): string {
return this.openclawConfig.openclawDir
}
/** Build the ExecSpec for `openclaw acp` inside the gateway container. */
getAcpExecSpec(input: {
commandEnv: Record<string, string>
openclawSessionKey: string | null
}): ExecSpec {
const argv: [string, ...string[]] = ['openclaw', 'acp']
argv.push('--url', `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`)
const bridgeSessionKey = normalizeBridgeSessionKey(input.openclawSessionKey)
if (bridgeSessionKey) argv.push('--session', bridgeSessionKey)
return {
argv,
env: {
OPENCLAW_HIDE_BANNER: '1',
OPENCLAW_SUPPRESS_NOTES: '1',
...input.commandEnv,
},
}
}
prepareTurnContext(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext> {
return prepareOpenClawContext(input)
}
// ── OpenClaw-specific surface kept on the runtime ────────────────
/** Run argv in the gateway container; satisfies OpenClawCliClient's ContainerExecutor. */
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
return this.deps.cli.exec(this.descriptor.containerName, command, onLog)
}
/** Run argv in the gateway container with stdout + stderr captured separately. */
async runInContainer(
command: string[],
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return this.deps.cli.runCommand([
'exec',
this.descriptor.containerName,
...command,
])
}
/** Standalone VM-ready entry point used by prewarm / auto-start gating. */
async ensureReady(onLog?: LogFn): Promise<void> {
await this.deps.vm.ensureReady(onLog)
await this.deps.vm.getDefaultGateway()
}
async stopVm(): Promise<void> {
await this.deps.vm.stopVm()
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
const running = await this.deps.vm.isReady()
return { initialized: running, running }
}
isHealthy(): Promise<boolean> {
const hostPort = this.hostPort
return fetchOk(`http://127.0.0.1:${hostPort}/healthz`)
}
/** Public proxy for the readiness probe so callers don't need to
* reach into the protected method. */
isReady(): Promise<boolean> {
return this.readinessProbe()
}
// ── Service-facing compat surface ────────────────────────────────
// These wrap inherited lifecycle methods using the legacy method
// names OpenClawService still uses. Keeping them lets the service
// swap from the legacy `ContainerRuntime` to this class with
// minimal touch; a follow-up can rename the call sites to use
// `executeAction(...)` directly and drop these wrappers.
/** Pre-pull the gateway image without starting the container. */
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
await this.executeAction({ type: 'install' }, { onLog })
}
/** Start the gateway container with the runtime's own spec. */
async startGateway(_unused?: unknown, onLog?: LogFn): Promise<void> {
await this.executeAction({ type: 'start' }, { onLog })
}
async stopGateway(): Promise<void> {
await this.executeAction({ type: 'stop' })
}
async restartGateway(_unused?: unknown, onLog?: LogFn): Promise<void> {
await this.executeAction({ type: 'restart' }, { onLog })
}
/** Poll readiness until ready or timeout. Returns whether ready. */
async waitForReady(_hostPort?: number, timeoutMs = 30_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.readinessProbe()) return true
await Bun.sleep(1000)
}
return false
}
async getGatewayLogs(tail = 50): Promise<string[]> {
return this.getLogs(tail)
}
tailGatewayLogs(onLine: LogFn): () => void {
return this.tailLogs(onLine)
}
isGatewayCurrent(): Promise<boolean> {
return this.isImageCurrent()
}
/** Run a one-shot command in a `<name>-setup` sibling container. */
async runGatewaySetupCommand(
command: string[],
_unused?: unknown,
onLog?: LogFn,
): Promise<number> {
const argv = command[0] === 'node' ? command.slice(1) : command
const result = await this.runOneShot(['node', ...argv], { onLog })
return result.exitCode
}
// ── Internals ────────────────────────────────────────────────────
private buildGatewayEnv(timezone: string): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
OPENCLAW_NO_RESPAWN: '1',
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
NODE_ENV: 'production',
TZ: timezone,
PATH: GATEWAY_PATH,
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}
}
private translateHostPathToGuest(hostPath: string): string {
const root = this.openclawConfig.openclawDir
if (hostPath === root) return GUEST_OPENCLAW_HOME
if (hostPath.startsWith(`${root}/`)) {
return `${GUEST_OPENCLAW_HOME}${hostPath.slice(root.length)}`
}
// Fall back to the generic VM path translation. acpx-side callers
// never pass paths outside openclawDir today, but the legacy
// implementation tolerated it so we mirror the behaviour.
return hostPath
}
}
async function fetchOk(url: string): Promise<boolean> {
try {
const res = await fetch(url)
return res.ok
} catch {
return false
}
}
/** Normalize an acpx session key into the form OpenClaw expects on
* `--session`: must start with `agent:` and be alphanumeric/dash. */
function normalizeBridgeSessionKey(sessionKey: string | null): string | null {
if (!sessionKey) return null
if (sessionKey.startsWith('agent:')) return sessionKey
return `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
}
/** Prepare OpenClaw without BrowserOS SOUL/MEMORY or BrowserOS MCP. */
export async function prepareOpenClawContext(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext> {
const paths = resolveAgentRuntimePaths({
browserosDir: input.browserosDir,
agentId: input.agent.id,
})
await ensureUsableCwd(paths.effectiveCwd, true)
return {
cwd: paths.effectiveCwd,
runtimeSessionKey: input.sessionKey,
runPrompt: buildBrowserosAcpPrompt(
OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS,
input.message,
),
commandEnv: {},
commandIdentity: 'openclaw',
useBrowserosMcp: false,
openclawSessionKey: input.sessionKey,
}
}
// ── Factory + wire-up ──────────────────────────────────────────────
export interface ConfigureOpenClawRuntimeOptions {
resourcesDir?: string
browserosDir?: string
}
/** Build an OpenClawContainerRuntime with production deps and register
* it. Idempotent — repeat calls return the already-registered runtime.
* Constructs on every platform so service callers (and tests that
* override `service.runtime` post-construction) work uniformly. The
* descriptor's `platforms: ['darwin']` is the live signal for the UI
* / adapter health, and `start()` itself fails at limactl-not-found
* on non-darwin if anyone actually invokes it. */
export function configureOpenClawRuntime(
options: ConfigureOpenClawRuntimeOptions = {},
): OpenClawContainerRuntime {
const existing = getOpenClawRuntime()
if (existing) return existing
const browserosDir = options.browserosDir ?? getBrowserosDir()
const openclawDir = getOpenClawDir()
const resourcesDir = options.resourcesDir ?? null
// Resolve bundled paths optimistically — on platforms / CI runners
// without Lima, fall back to the bare command names so construction
// succeeds. Lifecycle ops will fail at spawn time with the same
// "not on PATH" error, matching how the other runtimes degrade.
const limactlPath = (() => {
if (!resourcesDir) return 'limactl'
try {
return resolveBundledLimactl(resourcesDir)
} catch (err) {
logger.warn('OpenClaw bundled limactl unavailable; falling back', {
error: err instanceof Error ? err.message : String(err),
})
return 'limactl'
}
})()
const templatePath = (() => {
if (!resourcesDir) return undefined
try {
return resolveBundledLimaTemplate(resourcesDir)
} catch {
return undefined
}
})()
const limaHome = getLimaHomeDir(browserosDir)
const vm = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: browserosDir,
})
const cli = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
const loader = new ImageLoader(cli)
const runtime = new OpenClawContainerRuntime(
{
cli,
loader,
vm,
limactlPath,
limaHome,
vmName: VM_NAME,
lockDir: join(openclawDir, '.locks'),
},
{ browserosDir, openclawDir },
)
getAgentRuntimeRegistry().register(runtime)
logger.debug('OpenClawContainerRuntime registered', {
image: runtime.descriptor.defaultImage,
})
return runtime
}
export function getOpenClawRuntime(): OpenClawContainerRuntime | null {
const r = getAgentRuntimeRegistry().get('openclaw')
return r instanceof OpenClawContainerRuntime ? r : null
}

View File

@@ -24,9 +24,8 @@ import { INLINED_ENV } from './env'
import {
configureClaudeRuntime,
configureCodexRuntime,
configureHermesRuntime,
configureOpenClawRuntime,
getHermesRuntime,
startHermesRuntimeBestEffort,
} from './lib/agents/runtime'
import {
cleanOldSessions,
@@ -69,7 +68,6 @@ export class Application {
configureVmRuntime({ resourcesDir })
configureClaudeRuntime()
configureCodexRuntime()
configureOpenClawRuntime({ resourcesDir })
await this.initCoreServices()
if (!this.config.cdpPort) {
@@ -160,32 +158,7 @@ export class Application {
})
}
// Hermes container is also best-effort — same crash isolation
// semantics as OpenClaw above. Image is pulled in the background;
// an idle container is brought up so per-turn `nerdctl exec hermes acp`
// calls from the harness don't pay container-create latency.
try {
const hermesRuntime = configureHermesRuntime({ resourcesDir })
if (hermesRuntime) {
void hermesRuntime.executeAction({ type: 'install' }).catch((err) =>
logger.warn('Hermes prewarm failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
void hermesRuntime.executeAction({ type: 'start' }).catch((err) =>
logger.warn('Hermes container start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
}
} catch (err) {
logger.warn(
'Hermes container configuration failed, continuing without it',
{
error: err instanceof Error ? err.message : String(err),
},
)
}
startHermesRuntimeBestEffort({ resourcesDir })
metrics.log('http_server.started', { version: VERSION })
}

View File

@@ -5,6 +5,7 @@
import { describe, expect, it } from 'bun:test'
import { mkdtempSync, readFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
@@ -445,206 +446,179 @@ describe('AgentHarnessService', () => {
})
it('writes a per-agent Hermes config.yaml + .env when adapter=hermes and provider config complete', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
const agent = await service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
apiKey: 'sk-or-v1-test-key',
modelId: 'anthropic/claude-haiku-4.5',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
expect(yaml).toContain('"openrouter"')
expect(yaml).toContain('"anthropic/claude-haiku-4.5"')
expect(env).toContain('OPENROUTER_API_KEY=sk-or-v1-test-key')
})
it('rejects Hermes agent creation when apiKey is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
modelId: 'anthropic/claude-haiku-4.5',
}),
).rejects.toThrow(/apiKey/i)
expect(agents).toHaveLength(0)
})
it('rejects Hermes agent creation when providerType is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({ name: 'Hermes bot', adapter: 'hermes' }),
).rejects.toThrow(/providerType/i)
expect(agents).toHaveLength(0)
})
it('rejects Hermes agent creation when modelId is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
const agent = await service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
apiKey: 'sk-or-v1-test-key',
}),
).rejects.toThrow(/modelId/i)
expect(agents).toHaveLength(0)
modelId: 'anthropic/claude-haiku-4.5',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
expect(yaml).toContain('"openrouter"')
expect(yaml).toContain('"anthropic/claude-haiku-4.5"')
expect(env).toContain('OPENROUTER_API_KEY=sk-or-v1-test-key')
})
})
it('rejects Hermes agent creation when apiKey is missing', async () => {
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
modelId: 'anthropic/claude-haiku-4.5',
}),
).rejects.toThrow(/apiKey/i)
expect(agents).toHaveLength(0)
})
})
it('rejects Hermes agent creation when providerType is missing', async () => {
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({ name: 'Hermes bot', adapter: 'hermes' }),
).rejects.toThrow(/providerType/i)
expect(agents).toHaveLength(0)
})
})
it('rejects Hermes agent creation when modelId is missing', async () => {
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
apiKey: 'sk-or-v1-test-key',
}),
).rejects.toThrow(/modelId/i)
expect(agents).toHaveLength(0)
})
})
it('writes provider:custom + base_url for openai-compatible providers', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
const agent = await service.createAgent({
name: 'Custom Hermes',
adapter: 'hermes',
providerType: 'openai-compatible',
apiKey: 'sk-test',
modelId: 'my-model',
baseUrl: 'https://api.example.com/v1',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
// Hermes has no provider key called "openai" — the canonical shape
// for any OpenAI-compatible endpoint is `provider: custom` with
// `base_url` set. Hermes then short-circuits provider lookup and
// calls the URL directly using OPENAI_API_KEY.
expect(yaml).toContain('"custom"')
expect(yaml).toContain('"my-model"')
expect(yaml).toContain('"https://api.example.com/v1"')
expect(env).toContain('OPENAI_API_KEY=sk-test')
})
it('falls back to OpenAI default base_url for the openai provider type', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
const agent = await service.createAgent({
name: 'OpenAI Hermes',
adapter: 'hermes',
providerType: 'openai',
apiKey: 'sk-openai-test',
modelId: 'gpt-4o-mini',
// No baseUrl supplied — provider:custom still requires one,
// so the mapping's defaultBaseUrl must take over.
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
expect(yaml).toContain('"custom"')
expect(yaml).toContain('"gpt-4o-mini"')
expect(yaml).toContain('"https://api.openai.com/v1"')
})
it('rejects openai-compatible Hermes agent creation when baseUrl is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
const agent = await service.createAgent({
name: 'Custom Hermes',
adapter: 'hermes',
providerType: 'openai-compatible',
apiKey: 'sk-test',
modelId: 'my-model',
}),
).rejects.toThrow(/baseUrl/i)
expect(agents).toHaveLength(0)
baseUrl: 'https://api.example.com/v1',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
// Hermes has no provider key called "openai" — the canonical shape
// for any OpenAI-compatible endpoint is `provider: custom` with
// `base_url` set. Hermes then short-circuits provider lookup and
// calls the URL directly using OPENAI_API_KEY.
expect(yaml).toContain('"custom"')
expect(yaml).toContain('"my-model"')
expect(yaml).toContain('"https://api.example.com/v1"')
expect(env).toContain('OPENAI_API_KEY=sk-test')
})
})
it('falls back to OpenAI default base_url for the openai provider type', async () => {
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
const agent = await service.createAgent({
name: 'OpenAI Hermes',
adapter: 'hermes',
providerType: 'openai',
apiKey: 'sk-openai-test',
modelId: 'gpt-4o-mini',
// No baseUrl supplied — provider:custom still requires one,
// so the mapping's defaultBaseUrl must take over.
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
expect(yaml).toContain('"custom"')
expect(yaml).toContain('"gpt-4o-mini"')
expect(yaml).toContain('"https://api.openai.com/v1"')
})
})
it('rejects openai-compatible Hermes agent creation when baseUrl is missing', async () => {
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({
name: 'Custom Hermes',
adapter: 'hermes',
providerType: 'openai-compatible',
apiKey: 'sk-test',
modelId: 'my-model',
}),
).rejects.toThrow(/baseUrl/i)
expect(agents).toHaveLength(0)
})
})
it('rejects Hermes agent creation when providerType is not in the supported set', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({
name: 'Unknown Hermes',
adapter: 'hermes',
providerType: 'bedrock',
apiKey: 'sk-test',
modelId: 'm',
}),
).rejects.toThrow(/not supported/i)
expect(agents).toHaveLength(0)
})
await expect(
service.createAgent({
name: 'Unknown Hermes',
adapter: 'hermes',
providerType: 'bedrock',
apiKey: 'sk-test',
modelId: 'm',
}),
).rejects.toThrow(/not supported/i)
expect(agents).toHaveLength(0)
})
})
async function withHermesBrowserosDir<T>(
run: (input: {
agents: AgentDefinition[]
browserosDir: string
service: AgentHarnessService
}) => Promise<T>,
): Promise<T> {
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const agents: AgentDefinition[] = []
try {
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
browserosDir,
runtime: stubRuntime(),
})
return await run({ agents, browserosDir, service })
} finally {
await rm(browserosDir, { recursive: true, force: true })
}
}
function stubRuntime(): AgentRuntime {
return {
async status() {

View File

@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
buildContainerRuntime,
migrateLegacyOpenClawDir,
} from '../../../../src/api/services/openclaw/container-runtime-factory'
import { logger } from '../../../../src/lib/logger'
describe('container-runtime factory', () => {
let root: string
let resourcesDir: string
let originalNodeEnv: string | undefined
beforeEach(async () => {
root = await mkdtemp('/tmp/openclaw-runtime-factory-')
resourcesDir = join(root, 'resources')
const limaRoot = join(resourcesDir, 'bin', 'third_party', 'lima')
const limactlPath = join(limaRoot, 'bin', 'limactl')
const armGuestAgentPath = join(
limaRoot,
'share',
'lima',
'lima-guestagent.Linux-aarch64.gz',
)
const x64GuestAgentPath = join(
limaRoot,
'share',
'lima',
'lima-guestagent.Linux-x86_64.gz',
)
await mkdir(dirname(limactlPath), { recursive: true })
await mkdir(dirname(armGuestAgentPath), { recursive: true })
await mkdir(join(resourcesDir, 'vm'), { recursive: true })
await writeFile(limactlPath, '#!/bin/sh\n')
await writeFile(armGuestAgentPath, 'guest-agent\n')
await writeFile(x64GuestAgentPath, 'guest-agent\n')
await writeFile(
join(resourcesDir, 'vm', 'browseros-vm.yaml'),
'mounts: []\n',
)
originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
})
afterEach(async () => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
await rm(root, { recursive: true, force: true })
})
it('rejects non-macOS platforms', () => {
expect(() =>
buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'linux',
}),
).toThrow('supports macOS only')
})
it('returns a disabled runtime on non-macOS platforms in test mode', async () => {
process.env.NODE_ENV = 'test'
const runtime = buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'linux',
})
await expect(runtime.getMachineStatus()).resolves.toEqual({
initialized: false,
running: false,
})
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
await expect(runtime.prewarmGatewayImage()).rejects.toThrow(
'supports macOS only',
)
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
it('migrates legacy OpenClaw state into the VM state directory', async () => {
const legacyFile = join(root, 'openclaw', '.openclaw', 'openclaw.json')
await mkdir(dirname(legacyFile), { recursive: true })
await writeFile(legacyFile, '{"ok":true}\n')
await migrateLegacyOpenClawDir(root)
await expect(
readFile(
join(root, 'vm', 'openclaw', '.openclaw', 'openclaw.json'),
'utf8',
),
).resolves.toBe('{"ok":true}\n')
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
})
it('builds a runtime whose image loader pulls directly through nerdctl', async () => {
const runtime = buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'darwin',
})
expect(runtime).toBeDefined()
})
it('leaves both directories in place when new OpenClaw state already exists', async () => {
const legacyFile = join(root, 'openclaw', 'legacy.txt')
const newFile = join(root, 'vm', 'openclaw', 'new.txt')
await mkdir(dirname(legacyFile), { recursive: true })
await mkdir(dirname(newFile), { recursive: true })
await writeFile(legacyFile, 'legacy')
await writeFile(newFile, 'new')
const originalWarn = logger.warn
const warnings: string[] = []
logger.warn = (message) => warnings.push(message)
try {
await migrateLegacyOpenClawDir(root)
} finally {
logger.warn = originalWarn
}
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('legacy')
await expect(readFile(newFile, 'utf8')).resolves.toBe('new')
expect(warnings).toContain(
'OpenClaw legacy and VM state directories both exist',
)
})
})

View File

@@ -0,0 +1,401 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it, mock } from 'bun:test'
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
import { ContainerNameInUseError } from '../../../../src/lib/vm/errors'
const PROJECT_DIR = '/tmp/openclaw'
const OPENCLAW_NAME_RELEASE_WAIT = { timeoutMs: 10_000, intervalMs: 100 }
const defaultSpec = {
hostPort: 18789,
hostHome: '/Users/me/.browseros/vm/openclaw',
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
gatewayToken: 'token-123',
timezone: 'America/Los_Angeles',
}
describe('ContainerRuntime', () => {
it('starts the gateway by loading the image, creating, and starting a container', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway(defaultSpec)
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
undefined,
)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_NAME_RELEASE_WAIT,
)
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
)
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: OPENCLAW_IMAGE,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort: 18789,
containerPort: 18789,
},
],
envFile: '/mnt/browseros/vm/openclaw/.openclaw/.env',
mounts: [
{
source: '/mnt/browseros/vm/openclaw',
target: '/home/node',
},
],
addHosts: ['host.containers.internal:192.168.5.2'],
}),
undefined,
)
expect(deps.shell.startContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('reconciles and retries when gateway create reports name-in-use', async () => {
const deps = createDeps()
deps.shell.createContainer = mock(async () => {
if (deps.shell.createContainer.mock.calls.length === 1) {
throw new ContainerNameInUseError(
OPENCLAW_GATEWAY_CONTAINER_NAME,
'nerdctl create',
1,
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
)
}
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway(defaultSpec)
expect(deps.shell.createContainer).toHaveBeenCalledTimes(2)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(2)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
expect(deps.shell.startContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('bounds gateway create retries when the name stays in use', async () => {
const deps = createDeps()
deps.shell.createContainer = mock(async () => {
throw new ContainerNameInUseError(
OPENCLAW_GATEWAY_CONTAINER_NAME,
'nerdctl create',
1,
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
)
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.startGateway(defaultSpec)).rejects.toBeInstanceOf(
ContainerNameInUseError,
)
expect(deps.shell.createContainer).toHaveBeenCalledTimes(3)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(3)
expect(deps.shell.startContainer).not.toHaveBeenCalled()
})
it('uses OPENCLAW_IMAGE as a direct image override', async () => {
const previous = process.env.OPENCLAW_IMAGE
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
try {
await runtime.startGateway(defaultSpec)
} finally {
if (previous === undefined) delete process.env.OPENCLAW_IMAGE
else process.env.OPENCLAW_IMAGE = previous
}
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
'localhost/openclaw:test',
undefined,
)
expect(deps.loader.ensureAgentImageLoaded).not.toHaveBeenCalled()
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({ image: 'localhost/openclaw:test' }),
undefined,
)
})
it('passes private-ingress no-auth only when requested', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway({
...defaultSpec,
gatewayToken: undefined,
privateIngressNoAuth: true,
})
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}),
}),
undefined,
)
})
it('delegates ensureReady and stopVm to VmRuntime', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.ensureReady()
await runtime.stopVm()
expect(deps.vm.ensureReady).toHaveBeenCalled()
expect(deps.vm.getDefaultGateway).toHaveBeenCalled()
expect(deps.vm.stopVm).toHaveBeenCalled()
})
it('runs setup commands with guest paths', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.runGatewaySetupCommand(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
expect.arrayContaining([
'create',
'--name',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
'--env-file',
'/mnt/browseros/vm/openclaw/.openclaw/.env',
'-v',
'/mnt/browseros/vm/openclaw:/home/node',
'--add-host',
'host.containers.internal:192.168.5.2',
OPENCLAW_IMAGE,
]),
undefined,
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
['start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
undefined,
)
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
{ force: true },
undefined,
)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
OPENCLAW_NAME_RELEASE_WAIT,
)
})
it('reconciles and retries when setup create reports name-in-use', async () => {
const deps = createDeps()
let setupCreateCount = 0
deps.shell.runCommand = mock(async (args: string[]) => {
if (args[0] === 'create') {
setupCreateCount += 1
if (setupCreateCount === 1) {
return {
exitCode: 1,
stdout: '',
stderr: `name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup" is already used`,
}
}
}
return { exitCode: 0, stdout: '', stderr: '' }
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(
runtime.runGatewaySetupCommand(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
),
).resolves.toBe(0)
expect(setupCreateCount).toBe(2)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
})
it('tails and fetches gateway logs through the new transport', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
const stop = runtime.tailGatewayLogs(() => {})
const logs = await runtime.getGatewayLogs(10)
stop()
expect(deps.shell.tailLogs).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
expect.any(Function),
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
['logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
expect.any(Function),
)
expect(logs).toEqual(['log line'])
})
it('prewarms the gateway image without creating a container', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.prewarmGatewayImage()
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
)
expect(deps.shell.createContainer).not.toHaveBeenCalled()
})
it('detects when the gateway container uses the current image', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(async () => OPENCLAW_IMAGE)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
expect(deps.shell.containerImageRef).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('treats a digest-qualified current image ref as current', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(
async () => `${OPENCLAW_IMAGE}@sha256:${'a'.repeat(64)}`,
)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
})
it('detects when the gateway container uses an old image', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(
async () => 'ghcr.io/openclaw/openclaw:old',
)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
})
})
function createDeps() {
return {
vm: {
ensureReady: mock(async () => {}),
getDefaultGateway: mock(async () => '192.168.5.2'),
stopVm: mock(async () => {}),
isReady: mock(async () => true),
},
shell: {
createContainer: mock(async () => {}),
startContainer: mock(async () => {}),
stopContainer: mock(async () => {}),
removeContainer: mock(async () => {}),
containerImageRef: mock(async () => OPENCLAW_IMAGE),
waitForContainerNameRelease: mock(async () => {}),
exec: mock(async () => 0),
runCommand: mock(
async (_args: string[], onLog?: (line: string) => void) => {
onLog?.('log line')
return { exitCode: 0, stdout: 'log line\n', stderr: '' }
},
),
tailLogs: mock(() => () => {}),
},
loader: {
ensureImageLoaded: mock(async () => {}),
ensureAgentImageLoaded: mock(async () => OPENCLAW_IMAGE),
},
}
}

View File

@@ -372,7 +372,14 @@ describe('OpenClawService', () => {
model: undefined,
})
expect(steps).toEqual(['onboard', 'batch', 'validate', 'start', 'ready'])
expect(startGateway).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
}),
expect.any(Function),
)
expect(startGateway.mock.calls[0]?.[0]).not.toHaveProperty('image')
expect(restartGateway).not.toHaveBeenCalled()
})
@@ -599,7 +606,14 @@ describe('OpenClawService', () => {
await service.start()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
}),
expect.any(Function),
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
})
@@ -806,7 +820,14 @@ describe('OpenClawService', () => {
await service.restart()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(restartGateway).toHaveBeenCalledTimes(1)
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
}),
expect.any(Function),
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
})
@@ -838,8 +859,7 @@ describe('OpenClawService', () => {
service.openclawDir = tempDir
service.runtime = {
ensureReady,
// Persisted port is reachable on /readyz; auth pass keeps it.
isReady: async () => true,
isReady: async (hostPort?: number) => hostPort === occupiedPort,
restartGateway,
waitForReady,
}
@@ -850,8 +870,12 @@ describe('OpenClawService', () => {
await service.restart()
expect(restartGateway).toHaveBeenCalledTimes(1)
expect(service.getPort()).toBe(occupiedPort)
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: occupiedPort,
}),
expect.any(Function),
)
expect(ensureReady).toHaveBeenCalledTimes(1)
})
@@ -882,9 +906,7 @@ describe('OpenClawService', () => {
service.openclawDir = tempDir
service.runtime = {
ensureReady,
// Persisted port is reachable on the readiness probe; auth
// rejection drives the move-off branch.
isReady: async () => true,
isReady: async (hostPort?: number) => hostPort === occupiedPort,
restartGateway,
waitForReady,
}
@@ -895,8 +917,15 @@ describe('OpenClawService', () => {
await service.restart()
expect(restartGateway).toHaveBeenCalledTimes(1)
expect(service.getPort()).not.toBe(occupiedPort)
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
}),
expect.any(Function),
)
expect(
(restartGateway.mock.calls[0]?.[0] as { hostPort: number }).hostPort,
).not.toBe(occupiedPort)
expect(ensureReady).toHaveBeenCalledTimes(1)
})
@@ -999,7 +1028,13 @@ describe('OpenClawService', () => {
await service.tryAutoStart()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
}),
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
expect(isReady).toHaveBeenCalledTimes(2)

View File

@@ -24,7 +24,6 @@ import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
import {
getAgentRuntimeRegistry,
HermesContainerRuntime,
OpenClawContainerRuntime,
resetAgentRuntimeRegistry,
} from '../../../src/lib/agents/runtime'
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
@@ -985,7 +984,6 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
lockDir: stateDir,
}
const hermesRuntime = new HermesContainerRuntime(fakeManagedDeps, {
browserosDir,
hermesHarnessHostDir: join(browserosDir, 'vm', 'hermes', 'harness'),
})
getAgentRuntimeRegistry().register(hermesRuntime)
@@ -1113,15 +1111,17 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
})
it('resolves the openclaw adapter to a lima/nerdctl exec command', async () => {
registerFakeOpenClawRuntime({
limactlPath: '/opt/homebrew/bin/limactl',
limaHome: '/Users/dev/.browseros-dev/lima',
vmName: 'browseros-vm',
})
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
cwd: '/tmp/browseros-acpx-runtime',
stateDir: '/tmp/browseros-acpx-state',
openclawGateway: {
getGatewayToken: () => 'test-token-abc',
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
getLimactlPath: () => '/opt/homebrew/bin/limactl',
getVmName: () => 'browseros-vm',
},
runtimeFactory: (options) => {
calls.push({ method: 'createRuntime', input: options })
return createFakeAcpRuntime(calls)
@@ -1170,15 +1170,17 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
})
it('rewrites non-harness OpenClaw session keys onto the gateway main agent', async () => {
registerFakeOpenClawRuntime({
limactlPath: '/opt/homebrew/bin/limactl',
limaHome: '/Users/dev/.browseros-dev/lima',
vmName: 'browseros-vm',
})
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
cwd: '/tmp/browseros-acpx-runtime',
stateDir: '/tmp/browseros-acpx-state',
openclawGateway: {
getGatewayToken: () => 'test-token-abc',
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
getLimactlPath: () => '/opt/homebrew/bin/limactl',
getVmName: () => 'browseros-vm',
},
runtimeFactory: (options) => {
calls.push({ method: 'createRuntime', input: options })
return createFakeAcpRuntime(calls)
@@ -1365,30 +1367,6 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
})
})
function registerFakeOpenClawRuntime(opts: {
limactlPath: string
limaHome: string
vmName: string
}): OpenClawContainerRuntime {
resetAgentRuntimeRegistry()
const fakeDeps: ManagedContainerDeps = {
cli: {} as ManagedContainerDeps['cli'],
loader: {} as ManagedContainerDeps['loader'],
vm: {} as ManagedContainerDeps['vm'],
limactlPath: opts.limactlPath,
limaHome: opts.limaHome,
vmName: opts.vmName,
lockDir: '/tmp/openclaw-test-locks',
}
const runtime = new OpenClawContainerRuntime(fakeDeps, {
browserosDir: '/tmp/browseros-test',
openclawDir: '/tmp/browseros-test/vm/openclaw',
})
runtime.setHostPort(18789)
getAgentRuntimeRegistry().register(runtime)
return runtime
}
function makeAgent(input: {
id: string
adapter: AgentDefinition['adapter']

View File

@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { describe, expect, it } from 'bun:test'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
getHermesAgentHomeHostDir,
getHermesHarnessHostDir,
writeHermesPerAgentProvider,
} from '../../../../src/lib/agents/hermes/hermes-paths'
import { getHermesProviderMapping } from '../../../../src/lib/agents/hermes/hermes-provider-map'
describe('Hermes adapter helpers', () => {
it('resolves Hermes state under the BrowserOS VM state root', () => {
const browserosDir = '/tmp/browseros-test'
expect(getHermesHarnessHostDir(browserosDir)).toBe(
'/tmp/browseros-test/vm/hermes/harness',
)
expect(
getHermesAgentHomeHostDir({ browserosDir, agentId: 'agent-1' }),
).toBe('/tmp/browseros-test/vm/hermes/harness/agent-1/home')
})
it('writes per-agent provider config from the Hermes provider map', async () => {
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-hermes-'))
try {
const mapping = getHermesProviderMapping('openai')
expect(mapping).toEqual({
hermesProvider: 'custom',
envVarName: 'OPENAI_API_KEY',
requiresBaseUrl: false,
defaultBaseUrl: 'https://api.openai.com/v1',
})
await writeHermesPerAgentProvider({
browserosDir,
agentId: 'agent-1',
providerId: mapping?.hermesProvider as string,
envVarName: mapping?.envVarName as string,
apiKey: 'sk-test',
modelId: 'gpt-5.5',
baseUrl: mapping?.defaultBaseUrl,
})
const home = getHermesAgentHomeHostDir({
browserosDir,
agentId: 'agent-1',
})
await expect(readFile(join(home, 'config.yaml'), 'utf8')).resolves.toBe(
[
'model:',
' default: "gpt-5.5"',
' provider: "custom"',
' base_url: "https://api.openai.com/v1"',
'',
].join('\n'),
)
await expect(readFile(join(home, '.env'), 'utf8')).resolves.toBe(
['OPENAI_API_KEY=sk-test', ''].join('\n'),
)
} finally {
await rm(browserosDir, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
import {
type OpenclawGatewayAccessor,
resolveOpenclawAcpCommand,
} from '../../../../src/lib/agents/openclaw/acp-command'
describe('resolveOpenclawAcpCommand', () => {
const gateway: OpenclawGatewayAccessor = {
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
getLimactlPath: () => '/Applications/BrowserOS.app/limactl',
getVmName: () => 'browseros-vm',
}
it('builds the in-gateway ACP bridge command', () => {
const command = resolveOpenclawAcpCommand(gateway, 'agent:oc-123:main')
expect(command).toContain('env LIMA_HOME=/Users/dev/.browseros-dev/lima')
expect(command).toContain(
'/Applications/BrowserOS.app/limactl shell --workdir / browseros-vm --',
)
expect(command).toContain('nerdctl exec -i')
expect(command).toContain('-e OPENCLAW_HIDE_BANNER=1')
expect(command).toContain('-e OPENCLAW_SUPPRESS_NOTES=1')
expect(command).toContain('browseros-openclaw-openclaw-gateway-1')
expect(command).toContain(
`openclaw acp --url ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`,
)
expect(command).toContain('--session agent:oc-123:main')
})
it('maps legacy non-agent session keys onto the main gateway agent', () => {
const command = resolveOpenclawAcpCommand(
gateway,
'openai-user:browseros:abc/def',
)
expect(command).toContain(
'--session agent:main:openai-user-browseros-abc-def',
)
})
})

View File

@@ -19,7 +19,9 @@ import {
getHermesRuntime,
HermesContainerRuntime,
resetAgentRuntimeRegistry,
startHermesRuntimeBestEffort,
} from '../../../../src/lib/agents/runtime'
import type { RuntimeAction } from '../../../../src/lib/agents/runtime/types'
import type {
ManagedContainerDeps,
MountRoot,
@@ -105,7 +107,6 @@ describe('HermesContainerRuntime', () => {
const browserosDir = extraConfig?.browserosDir ?? '/host/browseros'
const { deps, getCapturedSpec } = makeDeps({ lockDir })
const runtime = new HermesContainerRuntime(deps, {
browserosDir,
hermesHarnessHostDir: `${browserosDir}/vm/hermes/harness`,
})
return { runtime, getCapturedSpec, browserosDir }
@@ -145,7 +146,6 @@ describe('HermesContainerRuntime', () => {
},
})
const runtime = new HermesContainerRuntime(deps, {
browserosDir: '/host/browseros',
hermesHarnessHostDir: '/host/browseros/vm/hermes/harness',
})
await runtime.start()
@@ -157,7 +157,6 @@ describe('HermesContainerRuntime', () => {
const lockDir = mkTempDir()
const { deps } = makeDeps({ lockDir, exec: async () => 1 })
const runtime = new HermesContainerRuntime(deps, {
browserosDir: '/host/browseros',
hermesHarnessHostDir: '/host/browseros/vm/hermes/harness',
})
await expect(runtime.start()).rejects.toThrow(/probe failed/i)
@@ -252,4 +251,77 @@ describe('HermesContainerRuntime', () => {
)
})
})
describe('startHermesRuntimeBestEffort', () => {
it('configures Hermes and schedules install + start actions', async () => {
const actions: RuntimeAction[] = []
const runtime = {
executeAction: async (action: RuntimeAction) => {
actions.push(action)
},
} as HermesContainerRuntime
const result = startHermesRuntimeBestEffort({
resourcesDir: '/Applications/BrowserOS.app/Contents/Resources',
configureRuntime: (options) => {
expect(options).toEqual({
resourcesDir: '/Applications/BrowserOS.app/Contents/Resources',
})
return runtime
},
onError: (phase, error) => {
throw new Error(`${phase}: ${String(error)}`)
},
})
expect(result).toBe(runtime)
expect(actions).toEqual([{ type: 'install' }, { type: 'start' }])
})
it('returns null when Hermes configuration throws', () => {
const errors: Array<{ phase: string; message: string }> = []
const result = startHermesRuntimeBestEffort({
configureRuntime: () => {
throw new Error('unsupported')
},
onError: (phase, error) => {
errors.push({
phase,
message: error instanceof Error ? error.message : String(error),
})
},
})
expect(result).toBeNull()
expect(errors).toEqual([{ phase: 'configure', message: 'unsupported' }])
})
it('reports install and start failures without throwing', async () => {
const errors: Array<{ phase: string; message: string }> = []
const runtime = {
executeAction: async (action: RuntimeAction) => {
throw new Error(`${action.type} failed`)
},
} as HermesContainerRuntime
const result = startHermesRuntimeBestEffort({
configureRuntime: () => runtime,
onError: (phase, error) => {
errors.push({
phase,
message: error instanceof Error ? error.message : String(error),
})
},
})
expect(result).toBe(runtime)
await Promise.resolve()
await Promise.resolve()
expect(errors).toEqual([
{ phase: 'install', message: 'install failed' },
{ phase: 'start', message: 'start failed' },
])
})
})
})

View File

@@ -1,269 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
} from '../../../../../../packages/shared/src/constants/openclaw'
import {
configureOpenClawRuntime,
getAgentRuntimeRegistry,
getOpenClawRuntime,
OpenClawContainerRuntime,
resetAgentRuntimeRegistry,
} from '../../../../src/lib/agents/runtime'
import type {
ManagedContainerDeps,
MountRoot,
} from '../../../../src/lib/container/managed'
import type {
ContainerInfo,
ContainerSpec,
} from '../../../../src/lib/container/types'
interface FakeCli {
inspectContainer: (name: string) => Promise<ContainerInfo | null>
removeContainer: (name: string, opts?: { force?: boolean }) => Promise<void>
waitForContainerNameRelease: () => Promise<void>
createContainer: (spec: ContainerSpec) => Promise<void>
startContainer: (name: string) => Promise<void>
waitForContainerRunning: (name: string) => Promise<void>
exec: (name: string, cmd: string[]) => Promise<number>
}
function makeDeps(opts: { lockDir: string }): {
deps: ManagedContainerDeps
getCapturedSpec: () => ContainerSpec | null
} {
let capturedSpec: ContainerSpec | null = null
const fakeCli = {
inspectContainer: async (): Promise<ContainerInfo | null> => ({
id: 'cid',
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: 'docker.io/openclaw:latest',
status: 'running',
running: true,
}),
removeContainer: async () => {},
waitForContainerNameRelease: async () => {},
createContainer: async (spec: ContainerSpec) => {
capturedSpec = spec
},
startContainer: async () => {},
waitForContainerRunning: async () => {},
exec: async () => 0,
} satisfies FakeCli
const fakeLoader = { ensureImageLoaded: async () => {} }
const fakeVm = {
ensureReady: async () => {},
getDefaultGateway: async () => '192.168.5.2',
isReady: async () => true,
stopVm: async () => {},
}
const deps: ManagedContainerDeps = {
cli: fakeCli as unknown as ManagedContainerDeps['cli'],
loader: fakeLoader as unknown as ManagedContainerDeps['loader'],
vm: fakeVm as unknown as ManagedContainerDeps['vm'],
limactlPath: '/opt/homebrew/bin/limactl',
limaHome: '/Users/dev/.browseros/lima',
vmName: 'browseros-vm',
lockDir: opts.lockDir,
}
return { deps, getCapturedSpec: () => capturedSpec }
}
describe('OpenClawContainerRuntime', () => {
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
resetAgentRuntimeRegistry()
})
function mkTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'openclaw-runtime-test-'))
tempDirs.push(dir)
return dir
}
class TestRuntime extends OpenClawContainerRuntime {
// Override the live HTTP probe so tests don't need a real server.
protected override async readinessProbe(): Promise<boolean> {
return true
}
}
function makeRuntime() {
const lockDir = mkTempDir()
const browserosDir = '/host/browseros'
const { deps, getCapturedSpec } = makeDeps({ lockDir })
const runtime = new TestRuntime(deps, {
browserosDir,
openclawDir: `${browserosDir}/vm/openclaw`,
})
return { runtime, getCapturedSpec, browserosDir }
}
it('declares the canonical OpenClaw runtime descriptor', () => {
const { runtime } = makeRuntime()
expect(runtime.descriptor.adapterId).toBe('openclaw')
expect(runtime.descriptor.kind).toBe('container')
expect(runtime.descriptor.containerName).toBe(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
expect(runtime.descriptor.platforms).toContain('darwin')
})
it('mountRoots maps the openclaw state dir to the gateway container home', () => {
const { runtime } = makeRuntime()
const mounts: readonly MountRoot[] = (
runtime as unknown as { mountRoots(): readonly MountRoot[] }
).mountRoots()
expect(mounts).toEqual([
{
hostPath: '/host/browseros/vm/openclaw',
containerPath: '/home/node',
kind: 'shared',
},
])
})
it('setHostPort updates the port referenced by buildContainerSpec', async () => {
const { runtime, getCapturedSpec } = makeRuntime()
runtime.setHostPort(41091)
await runtime.start()
const spec = getCapturedSpec()
if (!spec) throw new Error('createContainer was never called')
expect(spec.ports).toEqual([
{
hostIp: '127.0.0.1',
hostPort: 41091,
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
},
])
})
it('builds the gateway spec with sleep-free entrypoint, mount, host-gateway, and command', async () => {
const { runtime, getCapturedSpec } = makeRuntime()
await runtime.start()
const spec = getCapturedSpec()
if (!spec) throw new Error('createContainer was never called')
expect(spec.command?.[0]).toBe('node')
expect(spec.command).toEqual(
expect.arrayContaining([
'gateway',
'--bind',
'lan',
'--allow-unconfigured',
]),
)
expect(spec.addHosts).toContain('host.containers.internal:192.168.5.2')
expect(spec.mounts).toEqual([
{ source: '/mnt/browseros/vm/openclaw', target: '/home/node' },
])
expect(spec.env?.OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH).toBe('1')
})
it('getAcpExecSpec composes the openclaw acp argv with optional --session', () => {
const { runtime } = makeRuntime()
const noSession = runtime.getAcpExecSpec({
commandEnv: {},
openclawSessionKey: null,
})
expect(noSession.argv).toEqual([
'openclaw',
'acp',
'--url',
`ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`,
])
expect(noSession.env?.OPENCLAW_HIDE_BANNER).toBe('1')
expect(noSession.env?.OPENCLAW_SUPPRESS_NOTES).toBe('1')
const withSession = runtime.getAcpExecSpec({
commandEnv: {},
openclawSessionKey: 'agent:research:main',
})
expect(withSession.argv).toEqual(
expect.arrayContaining(['--session', 'agent:research:main']),
)
const withSyntheticSession = runtime.getAcpExecSpec({
commandEnv: {},
openclawSessionKey: 'sidepanel:c0ffee:openclaw:default:medium',
})
expect(withSyntheticSession.argv).toEqual(
expect.arrayContaining([
'--session',
'agent:main:sidepanel-c0ffee-openclaw-default-medium',
]),
)
})
it('buildExecArgv produces the canonical limactl/nerdctl spawn string', () => {
const { runtime } = makeRuntime()
const out = runtime.buildExecArgv(
runtime.getAcpExecSpec({
commandEnv: {},
openclawSessionKey: 'agent:main:main',
}),
)
expect(out).toContain('LIMA_HOME=/Users/dev/.browseros/lima')
expect(out).toContain('shell --workdir / browseros-vm --')
expect(out).toContain('nerdctl exec -i')
expect(out).toContain(OPENCLAW_GATEWAY_CONTAINER_NAME)
expect(out).toContain('openclaw acp --url ws://127.0.0.1:18789')
expect(out).toContain('-e OPENCLAW_HIDE_BANNER=1')
expect(out).toContain('--session agent:main:main')
})
it('compat methods delegate to inherited base primitives', () => {
const { runtime } = makeRuntime()
// Just verifying these don't throw and that the names exist —
// their semantics are exercised by the openclaw-service tests.
expect(typeof runtime.startGateway).toBe('function')
expect(typeof runtime.stopGateway).toBe('function')
expect(typeof runtime.restartGateway).toBe('function')
expect(typeof runtime.prewarmGatewayImage).toBe('function')
expect(typeof runtime.getGatewayLogs).toBe('function')
expect(typeof runtime.tailGatewayLogs).toBe('function')
expect(typeof runtime.isGatewayCurrent).toBe('function')
expect(typeof runtime.runGatewaySetupCommand).toBe('function')
})
describe('configureOpenClawRuntime', () => {
let originalPlatform: string
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
it('registers on darwin and is idempotent across repeat calls', () => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin' })
const browserosDir = mkTempDir()
const first = configureOpenClawRuntime({ browserosDir })
const second = configureOpenClawRuntime({ browserosDir })
expect(first).toBeInstanceOf(OpenClawContainerRuntime)
expect(second).toBe(first)
expect(getAgentRuntimeRegistry().get('openclaw')).toBe(first)
})
it('also registers on non-darwin so callers get a real instance back; lifecycle ops fail at use time', () => {
originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'linux' })
const browserosDir = mkTempDir()
const runtime = configureOpenClawRuntime({ browserosDir })
expect(runtime).toBeInstanceOf(OpenClawContainerRuntime)
expect(getOpenClawRuntime()).toBe(runtime)
})
})
})

View File

@@ -221,9 +221,6 @@ async function setupApplicationTest() {
spyOn(runtimeModule, 'configureCodexRuntime').mockImplementation(
() => ({}) as never,
)
spyOn(runtimeModule, 'configureOpenClawRuntime').mockImplementation(
() => ({}) as never,
)
const { Application } = await import('../src/main')
return {