mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-18 02:57:47 +00:00
Compare commits
13 Commits
feat/openc
...
fix/revert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e75fc6c18 | ||
|
|
aa852febc2 | ||
|
|
09eb44f522 | ||
|
|
727dd687fd | ||
|
|
cab79dca82 | ||
|
|
d27ceac36c | ||
|
|
80fd76ab28 | ||
|
|
dad2331448 | ||
|
|
d7e1125db3 | ||
|
|
8b6483a633 | ||
|
|
f54eff4543 | ||
|
|
f1ebfa5232 | ||
|
|
b89ea201fa |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
76
packages/browseros-agent/apps/agent/lib/attachments.test.ts
Normal file
76
packages/browseros-agent/apps/agent/lib/attachments.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user