diff --git a/packages/browseros-agent/apps/server/src/api/routes/agents.ts b/packages/browseros-agent/apps/server/src/api/routes/agents.ts index b7b6eaa31..12b639f66 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/agents.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/agents.ts @@ -14,7 +14,6 @@ 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, @@ -121,12 +120,6 @@ type AgentRouteDeps = { service?: AgentRouteService browser?: Pick 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 @@ -159,7 +152,6 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) { deps.service ?? new AgentHarnessService({ browserosServerPort: deps.browserosServerPort, - openclawGateway: deps.openclawGateway, openclawProvisioner: deps.openclawProvisioner, }) if (deps.onTurnLifecycle && service instanceof AgentHarnessService) { diff --git a/packages/browseros-agent/apps/server/src/api/server.ts b/packages/browseros-agent/apps/server/src/api/server.ts index c9ac50148..dfb569e93 100644 --- a/packages/browseros-agent/apps/server/src/api/server.ts +++ b/packages/browseros-agent/apps/server/src/api/server.ts @@ -136,12 +136,6 @@ 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), diff --git a/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts b/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts index 58f514b63..0193bc55e 100644 --- a/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { - AcpxRuntime, - type OpenclawGatewayAccessor, -} from '../../../lib/agents/acpx-runtime' +import { AcpxRuntime } from '../../../lib/agents/acpx-runtime' import { type ActiveTurnInfo, type TurnFrame, @@ -232,7 +229,6 @@ export class AgentHarnessService { runtime?: AgentRuntime browserosServerPort?: number browserosDir?: string - openclawGateway?: OpenclawGatewayAccessor openclawProvisioner?: OpenClawProvisioner turnRegistry?: TurnRegistry messageQueue?: FileMessageQueue @@ -244,7 +240,6 @@ export class AgentHarnessService { deps.runtime ?? new AcpxRuntime({ browserosServerPort: deps.browserosServerPort, - openclawGateway: deps.openclawGateway, }) this.openclawProvisioner = deps.openclawProvisioner ?? null this.turnRegistry = deps.turnRegistry ?? new TurnRegistry() diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/container-runtime-factory.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/container-runtime-factory.ts deleted file mode 100644 index 5e864f8d1..000000000 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/container-runtime-factory.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @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 { - 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 { - throw unsupportedPlatformError() - } - - override async isPodmanAvailable(): Promise { - return false - } - - override async getMachineStatus(): Promise<{ - initialized: boolean - running: boolean - }> { - return { initialized: false, running: false } - } - - override async pullImage(): Promise { - throw unsupportedPlatformError() - } - - override async prewarmGatewayImage(): Promise { - throw unsupportedPlatformError() - } - - override async isGatewayCurrent(): Promise { - return false - } - - override async startGateway(): Promise { - throw unsupportedPlatformError() - } - - override async stopGateway(): Promise {} - - override async restartGateway(): Promise { - throw unsupportedPlatformError() - } - - override async getGatewayLogs(): Promise { - return [] - } - - override async isHealthy(): Promise { - return false - } - - override async isReady(): Promise { - return false - } - - override async waitForReady(): Promise { - return false - } - - override async stopVm(): Promise {} - - override async execInContainer(): Promise { - throw unsupportedPlatformError() - } - - override async runInContainer(): Promise { - throw unsupportedPlatformError() - } - - override async runGatewaySetupCommand(): Promise { - throw unsupportedPlatformError() - } - - override tailGatewayLogs(): () => void { - return () => {} - } -} - -async function rejectUnsupportedPlatform(): Promise { - throw unsupportedPlatformError() -} - -function unsupportedPlatformError(): Error { - return new Error(UNSUPPORTED_PLATFORM_MESSAGE) -} diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/container-runtime.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/container-runtime.ts deleted file mode 100644 index c7721e0b0..000000000 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/container-runtime.ts +++ /dev/null @@ -1,436 +0,0 @@ -/** - * @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 - ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise - } - projectDir: string -} - -export class ContainerRuntime { - private readonly vm: VmRuntime - private readonly shell: ContainerCli - private readonly loader: { - ensureImageLoaded(ref: string, onLog?: LogFn): Promise - ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise - } - 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 { - logger.info('Ensuring BrowserOS VM runtime readiness') - await this.vm.ensureReady(onLog) - await this.vm.getDefaultGateway() - } - - async isPodmanAvailable(): Promise { - 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 { - await this.loader.ensureImageLoaded(image, onLog) - } - - /** Warm the gateway image in containerd without creating or starting containers. */ - async prewarmGatewayImage(onLog?: LogFn): Promise { - await this.ensureGatewayImageLoaded(onLog) - } - - /** Report whether the existing gateway container was created from the target image. */ - async isGatewayCurrent(): Promise { - 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 { - 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 { - await this.removeGatewayContainer(onLog) - } - - async restartGateway( - input: GatewayContainerSpec, - onLog?: LogFn, - ): Promise { - await this.startGateway(input, onLog) - } - - async getGatewayLogs(tail = 50): Promise { - 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 { - try { - const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`) - return res.ok - } catch { - return false - } - } - - async isReady(hostPort: number): Promise { - 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 { - 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 { - await this.vm.stopVm() - } - - async execInContainer(command: string[], onLog?: LogFn): Promise { - 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 { - return this.shell.runCommand([ - 'exec', - OPENCLAW_GATEWAY_CONTAINER_NAME, - ...command, - ]) - } - - async runGatewaySetupCommand( - command: string[], - spec: GatewayContainerSpec, - onLog?: LogFn, - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - return `host.containers.internal:${await this.vm.getDefaultGateway()}` - } - - private async ensureGatewayImageLoaded(onLog?: LogFn): Promise { - // 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 { - 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 - ) -} diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index 6c81cda29..1b2f0e8a2 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -17,6 +17,11 @@ 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' @@ -26,11 +31,6 @@ 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: ContainerRuntime + private runtime: OpenClawContainerRuntime private cliClient: OpenClawCliClient private bootstrapCliClient: OpenClawCliClient private httpClient: OpenClawHttpClient @@ -373,11 +373,11 @@ export class OpenClawService { constructor(config: OpenClawServiceConfig = {}) { this.openclawDir = getOpenClawDir() - this.runtime = buildContainerRuntime({ + this.runtime = ensureOpenClawRuntime({ resourcesDir: config.resourcesDir, - projectDir: this.openclawDir, - browserosRoot: config.browserosDir, + browserosDir: config.browserosDir, }) + this.runtime.setHostPort(this.hostPort) this.cliClient = new OpenClawCliClient(this.runtime) this.bootstrapCliClient = this.buildBootstrapCliClient() this.httpClient = new OpenClawHttpClient(this.hostPort) @@ -392,23 +392,17 @@ 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() } } @@ -562,10 +556,7 @@ export class OpenClawService { await this.assertConfigValid(this.bootstrapCliClient) logProgress('Starting OpenClaw gateway...') - await this.runtime.startGateway( - this.buildGatewayRuntimeSpec(), - logProgress, - ) + await this.runtime.startGateway(undefined, logProgress) this.startGatewayLogTail() logProgress('Waiting for gateway readiness...') const ready = await this.runtime.waitForReady( @@ -643,10 +634,7 @@ export class OpenClawService { } logProgress('Starting OpenClaw gateway...') - await this.runtime.startGateway( - this.buildGatewayRuntimeSpec(), - logProgress, - ) + await this.runtime.startGateway(undefined, logProgress) this.startGatewayLogTail() logProgress('Waiting for gateway readiness...') @@ -691,10 +679,7 @@ export class OpenClawService { await this.ensureStateEnvFile() await this.ensureGatewayPortAllocated(logProgress) logProgress('Restarting OpenClaw gateway...') - await this.runtime.restartGateway( - this.buildGatewayRuntimeSpec(), - logProgress, - ) + await this.runtime.restartGateway(undefined, logProgress) this.startGatewayLogTail() logProgress('Waiting for gateway readiness...') @@ -724,7 +709,7 @@ export class OpenClawService { }) logProgress('Checking gateway readiness...') - const ready = await this.runtime.isReady(this.hostPort) + const ready = await this.runtime.isReady() if (!ready) { this.controlPlaneStatus = 'failed' this.lastGatewayError = 'OpenClaw gateway is not ready' @@ -771,9 +756,7 @@ export class OpenClawService { } const machineStatus = await this.runtime.getMachineStatus() - const ready = machineStatus.running - ? await this.runtime.isReady(this.hostPort) - : false + const ready = machineStatus.running ? await this.runtime.isReady() : false let agentCount = 0 if (ready) { @@ -1159,7 +1142,7 @@ export class OpenClawService { if (!(await this.isCurrentGatewayAvailable(this.hostPort))) { await this.ensureGatewayPortAllocated() - await this.runtime.startGateway(this.buildGatewayRuntimeSpec()) + await this.runtime.startGateway(undefined) const ready = await this.runtime.waitForReady( this.hostPort, READY_TIMEOUT_MS, @@ -1257,28 +1240,17 @@ export class OpenClawService { private buildBootstrapCliClient(): OpenClawCliClient { return new OpenClawCliClient({ execInContainer: (command, onLog) => - this.runtime.runGatewaySetupCommand( - command, - this.buildGatewayRuntimeSpec(), - onLog, - ), + this.runtime.runGatewaySetupCommand(command, undefined, 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) } @@ -1329,19 +1301,21 @@ export class OpenClawService { } private async isGatewayPortReady(hostPort: number): Promise { - if (await this.runtime.isReady(hostPort)) return true - - const runtime = this.runtime as { - isHealthy?: (port: number) => Promise - } - if (runtime.isHealthy) { - return runtime.isHealthy(hostPort) + // 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 } + return r.isHealthy ? r.isHealthy() : false } - return false + if (await fetchOk(`http://127.0.0.1:${hostPort}/readyz`)) return true + return fetchOk(`http://127.0.0.1:${hostPort}/healthz`) } private async assertGatewayReady(): Promise { - const portReady = await this.runtime.isReady(this.hostPort) + const portReady = await this.runtime.isReady() logger.debug('Checking OpenClaw gateway readiness before use', { hostPort: this.hostPort, portReady, @@ -1600,15 +1574,6 @@ 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, ): Promise { @@ -1768,3 +1733,23 @@ export function getOpenClawService(): OpenClawService { if (!service) service = new OpenClawService() return service } + +async function fetchOk(url: string): Promise { + 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) +} diff --git a/packages/browseros-agent/apps/server/src/lib/agents/acpx-agent-adapter.ts b/packages/browseros-agent/apps/server/src/lib/agents/acpx-agent-adapter.ts index 0d824b19b..30c743196 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/acpx-agent-adapter.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/acpx-agent-adapter.ts @@ -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 { diff --git a/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts index d10567250..e28c48c2f 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts @@ -5,7 +5,6 @@ */ import { join } from 'node:path' -import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw' import { DEFAULT_PORTS } from '@browseros/shared/constants/ports' import { type AcpRuntimeEvent, @@ -32,7 +31,7 @@ import type { AgentHistoryEntry, AgentHistoryToolCall, } from './agent-types' -import { getHermesRuntime } from './runtime' +import { getHermesRuntime, getOpenClawRuntime } from './runtime' import type { AgentHistoryPage, AgentPromptInput, @@ -43,35 +42,11 @@ import type { AgentStreamEvent, } from './types' -/** - * 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 -} - 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 } @@ -91,7 +66,6 @@ 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 @@ -107,7 +81,6 @@ 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 } @@ -272,7 +245,6 @@ export class AcpxRuntime implements AgentRuntime { cwd: input.cwd, sessionStore: this.sessionStore, agentRegistry: createBrowserosAgentRegistry({ - openclawGateway: this.openclawGateway, openclawSessionKey: input.openclawSessionKey, commandEnv: input.commandEnv, }), @@ -683,7 +655,6 @@ function createBrowserosMcpServers( } function createBrowserosAgentRegistry(input: { - openclawGateway: OpenclawGatewayAccessor | null openclawSessionKey: string | null commandEnv: Record }): AcpRuntimeOptions['agentRegistry'] { @@ -697,18 +668,20 @@ function createBrowserosAgentRegistry(input: { const lower = agentName.trim().toLowerCase() if (lower === 'openclaw') { - 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) + const runtime = getOpenClawRuntime() + if (runtime) { + return runtime.buildExecArgv( + runtime.getAcpExecSpec({ + commandEnv: input.commandEnv, + openclawSessionKey: input.openclawSessionKey, + }), + ) } - return resolveOpenclawAcpCommand( - input.openclawGateway, - input.openclawSessionKey, - ) + // 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) } if (lower === 'hermes') { @@ -736,79 +709,6 @@ function createBrowserosAgentRegistry(input: { } } -/** - * Builds the command string acpx will spawn for an `openclaw` adapter. - * Runs `openclaw acp` inside the gateway container via the bundled - * `limactl shell -- 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. - */ -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 ` 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: session that does not resolve to any - // provisioned gateway agent. - // - // Harness keys are `agent::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=` 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(' ') -} - async function applyRuntimeControls( runtime: AcpxCoreRuntime, handle: AcpRuntimeHandle, diff --git a/packages/browseros-agent/apps/server/src/lib/agents/openclaw/prepare.ts b/packages/browseros-agent/apps/server/src/lib/agents/openclaw/prepare.ts deleted file mode 100644 index 8f6b61308..000000000 --- a/packages/browseros-agent/apps/server/src/lib/agents/openclaw/prepare.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @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 = - 'You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.' - -/** - * 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 { - 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, - } -} diff --git a/packages/browseros-agent/apps/server/src/lib/agents/runtime/index.ts b/packages/browseros-agent/apps/server/src/lib/agents/runtime/index.ts index 3392ee4b0..726f94b2f 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/runtime/index.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/runtime/index.ts @@ -35,6 +35,14 @@ 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, diff --git a/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts new file mode 100644 index 000000000..66800a029 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/agents/runtime/openclaw-container-runtime.ts @@ -0,0 +1,447 @@ +/** + * @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 = + 'You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.' + +export interface OpenClawContainerRuntimeConfig { + /** BrowserOS state root. */ + browserosDir: string + /** OpenClaw state dir (`/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 { + 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 { + 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 + 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 { + 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 { + 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 { + await this.deps.vm.ensureReady(onLog) + await this.deps.vm.getDefaultGateway() + } + + async stopVm(): Promise { + 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 { + 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 { + 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 { + await this.executeAction({ type: 'install' }, { onLog }) + } + + /** Start the gateway container with the runtime's own spec. */ + async startGateway(_unused?: unknown, onLog?: LogFn): Promise { + await this.executeAction({ type: 'start' }, { onLog }) + } + + async stopGateway(): Promise { + await this.executeAction({ type: 'stop' }) + } + + async restartGateway(_unused?: unknown, onLog?: LogFn): Promise { + await this.executeAction({ type: 'restart' }, { onLog }) + } + + /** Poll readiness until ready or timeout. Returns whether ready. */ + async waitForReady(_hostPort?: number, timeoutMs = 30_000): Promise { + 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 { + return this.getLogs(tail) + } + + tailGatewayLogs(onLine: LogFn): () => void { + return this.tailLogs(onLine) + } + + isGatewayCurrent(): Promise { + return this.isImageCurrent() + } + + /** Run a one-shot command in a `-setup` sibling container. */ + async runGatewaySetupCommand( + command: string[], + _unused?: unknown, + onLog?: LogFn, + ): Promise { + 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 { + 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 { + 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 { + 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 +} diff --git a/packages/browseros-agent/apps/server/src/main.ts b/packages/browseros-agent/apps/server/src/main.ts index c25632141..52e1a49dc 100644 --- a/packages/browseros-agent/apps/server/src/main.ts +++ b/packages/browseros-agent/apps/server/src/main.ts @@ -25,6 +25,7 @@ import { configureClaudeRuntime, configureCodexRuntime, configureHermesRuntime, + configureOpenClawRuntime, getHermesRuntime, } from './lib/agents/runtime' import { @@ -68,6 +69,7 @@ export class Application { configureVmRuntime({ resourcesDir }) configureClaudeRuntime() configureCodexRuntime() + configureOpenClawRuntime({ resourcesDir }) await this.initCoreServices() if (!this.config.cdpPort) { diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/container-runtime-factory.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/container-runtime-factory.test.ts deleted file mode 100644 index e197556c6..000000000 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/container-runtime-factory.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * @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', - ) - }) -}) diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/container-runtime.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/container-runtime.test.ts deleted file mode 100644 index 7dd677700..000000000 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/container-runtime.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * @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), - }, - } -} diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index 42b1c8e63..e9d337290 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -372,14 +372,7 @@ describe('OpenClawService', () => { model: undefined, }) expect(steps).toEqual(['onboard', 'batch', 'validate', 'start', 'ready']) - expect(startGateway).toHaveBeenCalledWith( - expect.objectContaining({ - hostPort: expect.any(Number), - hostHome: tempDir, - envFilePath: join(tempDir, '.openclaw', '.env'), - }), - expect.any(Function), - ) + expect(startGateway).toHaveBeenCalledTimes(1) expect(startGateway.mock.calls[0]?.[0]).not.toHaveProperty('image') expect(restartGateway).not.toHaveBeenCalled() }) @@ -606,14 +599,7 @@ describe('OpenClawService', () => { await service.start() expect(ensureReady).toHaveBeenCalledTimes(1) - expect(startGateway).toHaveBeenCalledWith( - expect.objectContaining({ - hostPort: expect.any(Number), - hostHome: tempDir, - envFilePath: join(tempDir, '.openclaw', '.env'), - }), - expect.any(Function), - ) + expect(startGateway).toHaveBeenCalledTimes(1) expect(waitForReady).toHaveBeenCalledTimes(1) expect(probe).toHaveBeenCalledTimes(1) }) @@ -820,14 +806,7 @@ describe('OpenClawService', () => { await service.restart() expect(ensureReady).toHaveBeenCalledTimes(1) - expect(restartGateway).toHaveBeenCalledWith( - expect.objectContaining({ - hostPort: expect.any(Number), - hostHome: tempDir, - envFilePath: join(tempDir, '.openclaw', '.env'), - }), - expect.any(Function), - ) + expect(restartGateway).toHaveBeenCalledTimes(1) expect(waitForReady).toHaveBeenCalledTimes(1) expect(probe).toHaveBeenCalledTimes(1) }) @@ -859,7 +838,8 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { ensureReady, - isReady: async (hostPort?: number) => hostPort === occupiedPort, + // Persisted port is reachable on /readyz; auth pass keeps it. + isReady: async () => true, restartGateway, waitForReady, } @@ -870,12 +850,8 @@ describe('OpenClawService', () => { await service.restart() - expect(restartGateway).toHaveBeenCalledWith( - expect.objectContaining({ - hostPort: occupiedPort, - }), - expect.any(Function), - ) + expect(restartGateway).toHaveBeenCalledTimes(1) + expect(service.getPort()).toBe(occupiedPort) expect(ensureReady).toHaveBeenCalledTimes(1) }) @@ -906,7 +882,9 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { ensureReady, - isReady: async (hostPort?: number) => hostPort === occupiedPort, + // Persisted port is reachable on the readiness probe; auth + // rejection drives the move-off branch. + isReady: async () => true, restartGateway, waitForReady, } @@ -917,15 +895,8 @@ describe('OpenClawService', () => { await service.restart() - 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(restartGateway).toHaveBeenCalledTimes(1) + expect(service.getPort()).not.toBe(occupiedPort) expect(ensureReady).toHaveBeenCalledTimes(1) }) @@ -1028,13 +999,7 @@ describe('OpenClawService', () => { await service.tryAutoStart() expect(ensureReady).toHaveBeenCalledTimes(1) - expect(startGateway).toHaveBeenCalledWith( - expect.objectContaining({ - hostPort: expect.any(Number), - hostHome: tempDir, - envFilePath: join(tempDir, '.openclaw', '.env'), - }), - ) + expect(startGateway).toHaveBeenCalledTimes(1) expect(waitForReady).toHaveBeenCalledTimes(1) expect(probe).toHaveBeenCalledTimes(1) expect(isReady).toHaveBeenCalledTimes(2) diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts index be67d1449..eb6adaea4 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts @@ -24,6 +24,7 @@ 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' @@ -1112,17 +1113,15 @@ 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) @@ -1171,17 +1170,15 @@ 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) @@ -1368,6 +1365,30 @@ 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'] diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts new file mode 100644 index 000000000..b4987c46c --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/openclaw-container-runtime.test.ts @@ -0,0 +1,269 @@ +/** + * @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 + removeContainer: (name: string, opts?: { force?: boolean }) => Promise + waitForContainerNameRelease: () => Promise + createContainer: (spec: ContainerSpec) => Promise + startContainer: (name: string) => Promise + waitForContainerRunning: (name: string) => Promise + exec: (name: string, cmd: string[]) => Promise +} + +function makeDeps(opts: { lockDir: string }): { + deps: ManagedContainerDeps + getCapturedSpec: () => ContainerSpec | null +} { + let capturedSpec: ContainerSpec | null = null + const fakeCli = { + inspectContainer: async (): Promise => ({ + 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 { + 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) + }) + }) +}) diff --git a/packages/browseros-agent/apps/server/tests/main.test.ts b/packages/browseros-agent/apps/server/tests/main.test.ts index 1c46194b0..fa6cf7c2f 100644 --- a/packages/browseros-agent/apps/server/tests/main.test.ts +++ b/packages/browseros-agent/apps/server/tests/main.test.ts @@ -221,6 +221,9 @@ async function setupApplicationTest() { spyOn(runtimeModule, 'configureCodexRuntime').mockImplementation( () => ({}) as never, ) + spyOn(runtimeModule, 'configureOpenClawRuntime').mockImplementation( + () => ({}) as never, + ) const { Application } = await import('../src/main') return {