Compare commits

...

14 Commits

Author SHA1 Message Date
Nikhil Sonti
3a8a828b22 fix: harden OpenClaw setup container lifecycle 2026-04-18 13:47:11 -07:00
Nikhil Sonti
e0438a8b48 fix: make OpenClaw gateway removal idempotent 2026-04-18 13:28:52 -07:00
Nikhil Sonti
2fadbecb6b test: assert scoped OpenClaw terminal container name 2026-04-18 13:26:08 -07:00
Nikhil Sonti
6fc65590c4 fix: restore scoped OpenClaw gateway container name 2026-04-18 13:23:10 -07:00
Nikhil Sonti
621d48b275 fix: remove dead OpenClaw runtime env file flow 2026-04-18 13:12:48 -07:00
Nikhil Sonti
58d7637cfe refactor: drop obsolete setup-command overload 2026-04-18 12:57:08 -07:00
Nikhil Sonti
57b8720e13 chore: remove OpenClaw compose resources from server build 2026-04-18 12:56:24 -07:00
Nikhil Sonti
380fc566e4 fix: handle legacy openclaw gateway container during runtime cutover 2026-04-18 12:49:31 -07:00
Nikhil Sonti
4a8179f821 test: cover direct-runtime lifecycle paths in openclaw service 2026-04-18 12:39:40 -07:00
Nikhil Sonti
484718d116 refactor: switch OpenClaw service to direct podman runtime 2026-04-18 12:35:28 -07:00
Nikhil Sonti
e1483bc29b fix: stage direct runtime container migration safely 2026-04-18 12:24:02 -07:00
Nikhil Sonti
a2eb26b7f3 test: assert exact podman run args 2026-04-18 12:13:59 -07:00
Nikhil Sonti
c75d3eb6a5 feat: run OpenClaw containers with direct podman commands 2026-04-18 12:10:04 -07:00
Nikhil Sonti
98cdca7bcb refactor: rename OpenClaw runtime away from compose semantics 2026-04-18 11:57:20 -07:00
12 changed files with 791 additions and 343 deletions

View File

@@ -1,36 +0,0 @@
services:
openclaw-gateway:
# Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:2026.4.12}
ports:
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
env_file:
- ./.openclaw/.env
environment:
- HOME=/home/node
- OPENCLAW_HOME=/home/node
- OPENCLAW_STATE_DIR=/home/node/.openclaw
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-}
- OPENCLAW_NO_RESPAWN=1
- NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache
- NODE_ENV=production
- TZ=${TZ}
volumes:
- ${OPENCLAW_HOST_HOME}:/home/node
extra_hosts:
- "host.containers.internal:host-gateway"
command:
- node
- dist/index.js
- gateway
- --bind
- lan
- --port
- "18789"
- --allow-unconfigured
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped

View File

@@ -3,21 +3,24 @@
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Compose-level abstraction over PodmanRuntime.
* Manages a single compose project for the OpenClaw gateway container.
* OpenClaw container lifecycle abstraction over PodmanRuntime.
*/
import { copyFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
OPENCLAW_COMPOSE_PROJECT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
} from '@browseros/shared/constants/openclaw'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import { logger } from '../../../lib/logger'
import type { LogFn, PodmanRuntime } from './podman-runtime'
const COMPOSE_FILE_NAME = 'docker-compose.yml'
const ENV_FILE_NAME = '.env'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
export type GatewayContainerSpec = {
image: string
port: number
hostHome: string
envFilePath: string
gatewayToken?: string
timezone: string
}
export class ContainerRuntime {
constructor(
@@ -41,35 +44,69 @@ export class ContainerRuntime {
return this.podman.getMachineStatus()
}
async composeUp(onLog?: LogFn): Promise<void> {
const code = await this.compose(['up', '-d'], onLog)
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
async pullImage(image: string, onLog?: LogFn): Promise<void> {
const code = await this.runPodmanCommand(['pull', image], onLog)
if (code !== 0) throw new Error(`image pull failed with code ${code}`)
}
async composeDown(onLog?: LogFn): Promise<void> {
const code = await this.compose(['down'], onLog)
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
async startGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.ensureGatewayRemoved(onLog)
const code = await this.runPodmanCommand(
[
'run',
'-d',
'--name',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'--restart',
'unless-stopped',
'-p',
`127.0.0.1:${input.port}:18789`,
...this.buildGatewayContainerRuntimeArgs(input),
'--health-cmd',
'curl -sf http://127.0.0.1:18789/healthz',
'--health-interval',
'30s',
'--health-timeout',
'10s',
'--health-retries',
'3',
input.image,
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
'18789',
'--allow-unconfigured',
],
onLog,
)
if (code !== 0) throw new Error(`gateway start failed with code ${code}`)
}
async composeStop(onLog?: LogFn): Promise<void> {
const code = await this.compose(['stop'], onLog)
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
async stopGateway(onLog?: LogFn): Promise<void> {
const code = await this.removeGatewayContainer(onLog)
if (code !== 0) {
throw new Error(`gateway stop failed with code ${code}`)
}
}
async composeRestart(onLog?: LogFn): Promise<void> {
const code = await this.compose(['restart'], onLog)
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
async restartGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.startGateway(input, onLog)
}
async composePull(onLog?: LogFn): Promise<void> {
const code = await this.compose(['pull', '--quiet'], onLog)
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
}
async composeLogs(tail = 50): Promise<string[]> {
async getGatewayLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
lines.push(line),
await this.runPodmanCommand(
['logs', '--tail', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
(line) => lines.push(line),
)
return lines
}
@@ -112,16 +149,6 @@ export class ContainerRuntime {
return false
}
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
}
async writeEnvFile(content: string): Promise<void> {
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
mode: 0o600,
})
}
/**
* Stops the Podman machine only if no non-BrowserOS containers are running.
* Prevents killing the user's own Podman workloads.
@@ -132,8 +159,8 @@ export class ContainerRuntime {
try {
const containers = await this.podman.listRunningContainers()
const allOurs = containers.every((name) =>
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
const allOurs = containers.every(
(name) => name === OPENCLAW_GATEWAY_CONTAINER_NAME,
)
if (containers.length === 0 || allOurs) {
@@ -155,17 +182,25 @@ export class ContainerRuntime {
async runGatewaySetupCommand(
command: string[],
spec: GatewayContainerSpec,
onLog?: LogFn,
): Promise<number> {
return this.compose(
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.runPodmanCommand(
['rm', '-f', '--ignore', setupContainerName],
onLog,
)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
return this.runPodmanCommand(
[
'run',
'--rm',
'--no-deps',
'--entrypoint',
'--name',
setupContainerName,
...this.buildGatewayContainerRuntimeArgs(spec),
spec.image,
'node',
'openclaw-gateway',
...command.slice(1),
...setupArgs,
],
onLog,
)
@@ -178,15 +213,17 @@ export class ContainerRuntime {
)
}
private async compose(args: string[], onLog?: LogFn): Promise<number> {
private async runPodmanCommand(
args: string[],
onLog?: LogFn,
): Promise<number> {
const lines: string[] = []
const command = ['podman', 'compose', ...args].join(' ')
logger.info('Running OpenClaw compose command', {
const command = ['podman', ...args].join(' ')
logger.info('Running OpenClaw podman command', {
command,
})
const code = await this.podman.runCommand(['compose', ...args], {
const code = await this.podman.runCommand(args, {
cwd: this.projectDir,
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
onOutput: (line) => {
lines.push(line)
onLog?.(line)
@@ -194,17 +231,58 @@ export class ContainerRuntime {
})
if (code !== 0) {
logger.error('OpenClaw compose command failed', {
logger.error('OpenClaw podman command failed', {
command,
exitCode: code,
output: lines,
})
} else {
logger.info('OpenClaw compose command succeeded', {
logger.info('OpenClaw podman command succeeded', {
command,
})
}
return code
}
private async ensureGatewayRemoved(onLog?: LogFn): Promise<void> {
await this.removeGatewayContainer(onLog)
}
private async removeGatewayContainer(onLog?: LogFn): Promise<number> {
return this.runPodmanCommand(
['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
onLog,
)
}
private buildGatewayContainerRuntimeArgs(
input: GatewayContainerSpec,
): string[] {
return [
'--env-file',
input.envFilePath,
'-e',
`HOME=${GATEWAY_CONTAINER_HOME}`,
'-e',
`OPENCLAW_HOME=${GATEWAY_CONTAINER_HOME}`,
'-e',
`OPENCLAW_STATE_DIR=${GATEWAY_STATE_DIR}`,
'-e',
'OPENCLAW_NO_RESPAWN=1',
'-e',
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
'-e',
'NODE_ENV=production',
'-e',
`TZ=${input.timezone}`,
'-v',
`${input.hostHome}:${GATEWAY_CONTAINER_HOME}`,
'--add-host',
'host.containers.internal:host-gateway',
...(input.gatewayToken
? ['-e', `OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`]
: []),
]
}
}

View File

@@ -5,10 +5,7 @@
*/
import { join } from 'node:path'
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
// Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:2026.4.12'
const STATE_DIR_NAME = '.openclaw'
export function getOpenClawStateDir(openclawDir: string): string {
@@ -33,26 +30,6 @@ export function getHostWorkspaceDir(
)
}
export function buildComposeEnvFile(input: {
hostHome: string
image?: string
port?: number
timezone?: string
gatewayToken?: string
}): string {
const lines = [
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
`OPENCLAW_HOST_HOME=${input.hostHome}`,
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
]
if (input.gatewayToken) {
lines.push(`OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`)
}
lines.push('')
return lines.join('\n')
}
export function mergeEnvContent(
current: string,
updates: Record<string, string>,

View File

@@ -10,7 +10,6 @@
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_PORT,
@@ -18,7 +17,10 @@ import {
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import { ContainerRuntime } from './container-runtime'
import {
ContainerRuntime,
type GatewayContainerSpec,
} from './container-runtime'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
@@ -31,7 +33,6 @@ import {
type OpenClawConfigBatchEntry,
} from './openclaw-cli-client'
import {
buildComposeEnvFile,
getHostWorkspaceDir,
getOpenClawStateConfigPath,
getOpenClawStateDir,
@@ -43,27 +44,9 @@ import { resolveSupportedOpenClawProvider } from './openclaw-provider-map'
import type { OpenClawStreamEvent } from './openclaw-types'
import { getPodmanRuntime } from './podman-runtime'
export const SOURCE_COMPOSE_RESOURCE = resolve(
import.meta.dir,
'../../../../resources/openclaw-compose.yml',
)
const READY_TIMEOUT_MS = 30_000
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
export function resolveComposeResourcePath(resourcesDir?: string): string {
if (resourcesDir) {
const bundledComposePath = join(resourcesDir, 'openclaw-compose.yml')
if (existsSync(bundledComposePath)) {
return bundledComposePath
}
logger.warn(
'Bundled openclaw-compose.yml not found in resourcesDir, falling back to source tree',
{ resourcesDir },
)
}
return SOURCE_COMPOSE_RESOURCE
}
export type OpenClawControlPlaneStatus =
| 'disconnected'
| 'connecting'
@@ -118,7 +101,6 @@ export interface OpenClawProviderUpdateResult {
export interface OpenClawServiceConfig {
browserosServerPort?: number
resourcesDir?: string
}
export class OpenClawService {
@@ -127,7 +109,6 @@ export class OpenClawService {
private bootstrapCliClient: OpenClawCliClient
private chatClient: OpenClawHttpChatClient
private openclawDir: string
private composeResourcePath: string
private port = OPENCLAW_GATEWAY_PORT
private token: string
private tokenLoaded = false
@@ -145,13 +126,16 @@ export class OpenClawService {
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = new OpenClawCliClient({
execInContainer: (command, onLog) =>
this.runtime.runGatewaySetupCommand(command, onLog),
this.runtime.runGatewaySetupCommand(
command,
this.buildGatewayRuntimeSpec(),
onLog,
),
})
this.chatClient = new OpenClawHttpChatClient(
this.port,
async () => this.token,
)
this.composeResourcePath = resolveComposeResourcePath(config.resourcesDir)
this.browserosServerPort =
config.browserosServerPort ?? DEFAULT_PORTS.server
}
@@ -160,9 +144,6 @@ export class OpenClawService {
if (config.browserosServerPort !== undefined) {
this.browserosServerPort = config.browserosServerPort
}
if (config.resourcesDir !== undefined) {
this.composeResourcePath = resolveComposeResourcePath(config.resourcesDir)
}
}
// ── Lifecycle ────────────────────────────────────────────────────────
@@ -195,15 +176,6 @@ export class OpenClawService {
await mkdir(this.getStateDir(), { recursive: true })
await mkdir(this.getHostWorkspaceDir('main'), { recursive: true })
logProgress('Copying compose file...')
await this.runtime.copyComposeFile(this.composeResourcePath)
await this.writeComposeEnv()
logProgress('Generated .env file')
logger.info('Wrote OpenClaw env file', {
openclawDir: this.openclawDir,
})
await this.ensureStateEnvFile()
await this.writeStateEnv(provider.envValues)
logger.info('Updated OpenClaw state env', {
@@ -211,7 +183,7 @@ export class OpenClawService {
})
logProgress('Pulling OpenClaw image...')
await this.runtime.composePull(logProgress)
await this.runtime.pullImage(this.getGatewayImage(), logProgress)
logProgress('Image ready')
logProgress('Bootstrapping OpenClaw config...')
@@ -236,16 +208,15 @@ export class OpenClawService {
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.writeComposeEnv()
logProgress('Starting OpenClaw gateway...')
await this.runtime.composeUp(logProgress)
await this.runtime.startGateway(this.buildGatewayRuntimeSpec(), logProgress)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
if (!ready) {
this.lastError = 'Gateway did not become ready within 30 seconds'
const logs = await this.runtime.composeLogs()
const logs = await this.runtime.getGatewayLogs()
logger.error('Gateway readiness check failed', { logs })
throw new Error(this.lastError)
}
@@ -288,10 +259,9 @@ export class OpenClawService {
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.writeComposeEnv()
logProgress('Starting OpenClaw gateway...')
await this.runtime.composeUp(logProgress)
await this.runtime.startGateway(this.buildGatewayRuntimeSpec(), logProgress)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
@@ -312,7 +282,7 @@ export class OpenClawService {
logger.info('Stopping OpenClaw service', { port: this.port })
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
await this.runtime.composeStop()
await this.runtime.stopGateway()
logger.info('OpenClaw container stopped')
}
@@ -324,8 +294,15 @@ export class OpenClawService {
this.controlPlaneStatus = 'reconnecting'
this.stopGatewayLogTail()
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
logProgress('Restarting OpenClaw gateway...')
await this.runtime.composeRestart(logProgress)
await this.runtime.restartGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
@@ -335,9 +312,6 @@ export class OpenClawService {
throw new Error(this.lastError)
}
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
@@ -371,7 +345,7 @@ export class OpenClawService {
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
try {
await this.runtime.composeStop()
await this.runtime.stopGateway()
} catch {
// Best effort during shutdown
}
@@ -583,7 +557,7 @@ export class OpenClawService {
async getLogs(tail = 100): Promise<string[]> {
logger.debug('Fetching OpenClaw container logs', { tail })
return this.runtime.composeLogs(tail)
return this.runtime.getGatewayLogs(tail)
}
// ── Auto-start on BrowserOS boot ────────────────────────────────────
@@ -604,10 +578,9 @@ export class OpenClawService {
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.writeComposeEnv()
if (!(await this.runtime.isReady(this.port))) {
await this.runtime.composeUp()
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
const ready = await this.runtime.waitForReady(
this.port,
READY_TIMEOUT_MS,
@@ -875,13 +848,20 @@ export class OpenClawService {
await writeFile(envPath, '', { mode: 0o600 })
}
private async writeComposeEnv(): Promise<void> {
const envContent = buildComposeEnvFile({
hostHome: this.openclawDir,
// Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
private getGatewayImage(): string {
return process.env.OPENCLAW_IMAGE || 'ghcr.io/openclaw/openclaw:2026.4.12'
}
private buildGatewayRuntimeSpec(): GatewayContainerSpec {
return {
image: this.getGatewayImage(),
port: this.port,
hostHome: this.openclawDir,
envFilePath: this.getStateEnvPath(),
gatewayToken: this.tokenLoaded ? this.token : undefined,
})
await this.runtime.writeEnvFile(envContent)
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
}
private async writeStateEnv(

View File

@@ -128,7 +128,6 @@ export class Application {
configureOpenClawService({
browserosServerPort: this.config.serverPort,
resourcesDir: path.resolve(this.config.resourcesDir),
})
.tryAutoStart()
.catch((err) =>

View File

@@ -4,6 +4,7 @@
*/
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import {
parseTerminalClientMessage,
serializeTerminalServerMessage,
@@ -53,7 +54,7 @@ describe('terminal protocol', () => {
expect(
buildTerminalExecCommand(
'podman',
'browseros-openclaw-openclaw-gateway-1',
OPENCLAW_GATEWAY_CONTAINER_NAME,
TERMINAL_HOME_DIR,
),
).toEqual([
@@ -62,7 +63,7 @@ describe('terminal protocol', () => {
'-it',
'-w',
'/home/node/.openclaw',
'browseros-openclaw-openclaw-gateway-1',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'/bin/sh',
])
})

View File

@@ -0,0 +1,326 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
const PROJECT_DIR = '/tmp/openclaw'
const defaultSpec = {
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
port: 18789,
hostHome: '/tmp/openclaw',
envFilePath: '/tmp/openclaw/.openclaw/.env',
gatewayToken: 'token-123',
timezone: 'America/Los_Angeles',
}
function createRuntime(
runCommand: (
args: string[],
options?: { cwd?: string; onOutput?: (line: string) => void },
) => Promise<number>,
listRunningContainers: () => Promise<string[]> = async () => [],
stopMachine: () => Promise<void> = async () => {},
): ContainerRuntime {
return new ContainerRuntime(
{
ensureReady: async () => {},
isPodmanAvailable: async () => true,
getMachineStatus: async () => ({ initialized: true, running: true }),
runCommand,
tailContainerLogs: () => () => {},
listRunningContainers,
stopMachine,
} as never,
PROJECT_DIR,
)
}
function expectedGatewayRuntimeArgs(spec: typeof defaultSpec): string[] {
return [
'--env-file',
spec.envFilePath,
'-e',
'HOME=/home/node',
'-e',
'OPENCLAW_HOME=/home/node',
'-e',
'OPENCLAW_STATE_DIR=/home/node/.openclaw',
'-e',
'OPENCLAW_NO_RESPAWN=1',
'-e',
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
'-e',
'NODE_ENV=production',
'-e',
`TZ=${spec.timezone}`,
'-v',
`${spec.hostHome}:/home/node`,
'--add-host',
'host.containers.internal:host-gateway',
'-e',
`OPENCLAW_GATEWAY_TOKEN=${spec.gatewayToken}`,
]
}
function expectedStartGatewayRunArgs(spec: typeof defaultSpec): string[] {
return [
'run',
'-d',
'--name',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'--restart',
'unless-stopped',
'-p',
`127.0.0.1:${spec.port}:18789`,
...expectedGatewayRuntimeArgs(spec),
'--health-cmd',
'curl -sf http://127.0.0.1:18789/healthz',
'--health-interval',
'30s',
'--health-timeout',
'10s',
'--health-retries',
'3',
spec.image,
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
'18789',
'--allow-unconfigured',
]
}
describe('ContainerRuntime', () => {
it('pullImage runs podman pull for the requested image', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12')
expect(calls).toEqual([
{
args: ['pull', 'ghcr.io/openclaw/openclaw:2026.4.12'],
cwd: PROJECT_DIR,
},
])
})
it('startGateway removes any existing gateway and runs a fresh container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.startGateway(defaultSpec)
expect(calls).toHaveLength(2)
expect(calls[0]).toEqual({
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
})
expect(calls[1]).toEqual({
cwd: PROJECT_DIR,
args: expectedStartGatewayRunArgs(defaultSpec),
})
})
it('runGatewaySetupCommand in direct mode builds a one-off podman run command', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.runGatewaySetupCommand(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
)
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: [
'rm',
'-f',
'--ignore',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
],
},
{
cwd: PROJECT_DIR,
args: [
'run',
'--rm',
'--name',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
...expectedGatewayRuntimeArgs(defaultSpec),
defaultSpec.image,
'node',
'dist/index.js',
'agents',
'list',
'--json',
],
},
])
})
it('stopGateway removes the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.stopGateway()
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('stopGateway is idempotent when the managed container is already absent', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
options?.onOutput?.(
`Error: no container with name "${OPENCLAW_GATEWAY_CONTAINER_NAME}" found`,
)
return 0
})
await expect(runtime.stopGateway()).resolves.toBeUndefined()
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('getGatewayLogs tails logs from the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
options?.onOutput?.('first')
options?.onOutput?.('second')
return 0
})
const logs = await runtime.getGatewayLogs(25)
expect(logs).toEqual(['first', 'second'])
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['logs', '--tail', '25', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('restartGateway recreates and launches the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.restartGateway(defaultSpec)
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
{
cwd: PROJECT_DIR,
args: expectedStartGatewayRunArgs(defaultSpec),
},
])
})
it('stopMachineIfSafe allows the managed gateway container', async () => {
let stopCalls = 0
const runtime = createRuntime(
async () => 0,
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME],
async () => {
stopCalls += 1
},
)
await runtime.stopMachineIfSafe()
expect(stopCalls).toBe(1)
})
it('stopMachineIfSafe does not stop machine if non-BrowserOS containers are running', async () => {
let stopCalls = 0
const runtime = createRuntime(
async () => 0,
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME, 'postgres-dev'],
async () => {
stopCalls += 1
},
)
await runtime.stopMachineIfSafe()
expect(stopCalls).toBe(0)
})
it('execInContainer targets the shared gateway container name', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.execInContainer(['node', '--version'])
expect(calls).toEqual([
{
cwd: undefined,
args: ['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, 'node', '--version'],
},
])
})
it('tailGatewayLogs targets the shared gateway container name', () => {
const names: string[] = []
const runtime = new ContainerRuntime(
{
ensureReady: async () => {},
isPodmanAvailable: async () => true,
getMachineStatus: async () => ({ initialized: true, running: true }),
runCommand: async () => 0,
tailContainerLogs: (containerName: string) => {
names.push(containerName)
return () => {}
},
listRunningContainers: async () => [],
stopMachine: async () => {},
} as never,
PROJECT_DIR,
)
const stop = runtime.tailGatewayLogs(() => {})
stop()
expect(names).toEqual([OPENCLAW_GATEWAY_CONTAINER_NAME])
})
})

View File

@@ -1,32 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
resolveComposeResourcePath,
SOURCE_COMPOSE_RESOURCE,
} from '../../../../src/api/services/openclaw/openclaw-service'
describe('resolveComposeResourcePath', () => {
let tempDir: string | null = null
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = null
}
})
it('prefers the packaged resourcesDir copy when present', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-compose-resource-'))
const resourcesDir = join(tempDir, 'resources')
const composePath = join(resourcesDir, 'openclaw-compose.yml')
await Bun.write(composePath, 'services:\n')
expect(resolveComposeResourcePath(resourcesDir)).toBe(composePath)
})
it('falls back to the source tree when no packaged copy exists', () => {
expect(resolveComposeResourcePath(undefined)).toBe(SOURCE_COMPOSE_RESOURCE)
})
})

View File

@@ -4,25 +4,39 @@
*/
import { describe, expect, it } from 'bun:test'
import { buildComposeEnvFile } from '../../../../src/api/services/openclaw/openclaw-env'
import { mergeEnvContent } from '../../../../src/api/services/openclaw/openclaw-env'
describe('buildComposeEnvFile', () => {
it('pins the default OpenClaw image to 2026.4.12', () => {
describe('mergeEnvContent', () => {
it('appends new env keys and normalizes trailing newline', () => {
expect(
buildComposeEnvFile({
hostHome: '/tmp/openclaw-home',
timezone: 'UTC',
mergeEnvContent('OPENAI_API_KEY=sk-old', {
ANTHROPIC_API_KEY: 'ant-key',
}),
).toContain('OPENCLAW_IMAGE=ghcr.io/openclaw/openclaw:2026.4.12')
).toEqual({
changed: true,
content: 'OPENAI_API_KEY=sk-old\nANTHROPIC_API_KEY=ant-key\n',
})
})
it('respects an explicit image override', () => {
it('overwrites existing keys when values change', () => {
expect(
buildComposeEnvFile({
hostHome: '/tmp/openclaw-home',
timezone: 'UTC',
image: 'ghcr.io/openclaw/openclaw:custom',
mergeEnvContent('OPENAI_API_KEY=sk-old\n', {
OPENAI_API_KEY: 'sk-new',
}),
).toContain('OPENCLAW_IMAGE=ghcr.io/openclaw/openclaw:custom')
).toEqual({
changed: true,
content: 'OPENAI_API_KEY=sk-new\n',
})
})
it('reports unchanged when incoming values match existing content', () => {
expect(
mergeEnvContent('OPENAI_API_KEY=sk-test\n', {
OPENAI_API_KEY: 'sk-test',
}),
).toEqual({
changed: false,
content: 'OPENAI_API_KEY=sk-test\n',
})
})
})

View File

@@ -24,12 +24,22 @@ type MutableOpenClawService = OpenClawService & {
isPodmanAvailable?: () => Promise<boolean>
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
isReady: () => Promise<boolean>
copyComposeFile?: (_source: string) => Promise<void>
writeEnvFile?: (_content: string) => Promise<void>
composePull?: () => Promise<void>
composeRestart?: () => Promise<void>
composeUp?: () => Promise<void>
pullImage?: (
_image: string,
_onLog?: (_line: string) => void,
) => Promise<void>
startGateway?: (
_input: unknown,
_onLog?: (_line: string) => void,
) => Promise<void>
restartGateway?: (
_input: unknown,
_onLog?: (_line: string) => void,
) => Promise<void>
stopGateway?: (_onLog?: (_line: string) => void) => Promise<void>
getGatewayLogs?: (_tail?: number) => Promise<string[]>
waitForReady?: () => Promise<boolean>
stopMachineIfSafe?: () => Promise<void>
}
cliClient: {
probe?: ReturnType<typeof mock>
@@ -191,39 +201,36 @@ describe('OpenClawService', () => {
steps.push('validate')
return { ok: true }
})
const getConfig = mock(async (path: string) => {
if (path === 'gateway.auth.token') return 'cli-token'
return null
})
const createAgent = mock(async () => ({
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
}))
const writeEnvFile = mock(async (_content: string) => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
const pullImage = mock(async () => {
steps.push('pull')
})
const restartGateway = mock(async () => {
steps.push('restart')
})
const startGateway = mock(async () => {
steps.push('start')
})
service.runtime = {
isPodmanAvailable: async () => true,
ensureReady: async () => {},
isReady: async () => true,
copyComposeFile: async () => {},
writeEnvFile,
composePull: async () => {},
composeRestart: mock(async () => {
steps.push('restart')
}),
composeUp: mock(async () => {
steps.push('up')
}),
pullImage,
restartGateway,
startGateway,
waitForReady: mock(async () => {
steps.push('ready')
return true
}),
}
service.cliClient = {
getConfig,
probe: mock(async () => {}),
listAgents: mock(async () => []),
createAgent,
@@ -269,11 +276,29 @@ describe('OpenClawService', () => {
name: 'main',
model: undefined,
})
expect(steps).toEqual(['onboard', 'batch', 'validate', 'up', 'ready'])
expect(writeEnvFile).toHaveBeenCalledWith(
expect.stringContaining(`OPENCLAW_HOST_HOME=${tempDir}`),
expect(steps).toEqual([
'pull',
'onboard',
'batch',
'validate',
'start',
'ready',
])
expect(pullImage).toHaveBeenCalledWith(
'ghcr.io/openclaw/openclaw:2026.4.12',
expect.any(Function),
)
expect(service.runtime.composeRestart).not.toHaveBeenCalled()
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
port: 18789,
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: undefined,
}),
expect.any(Function),
)
expect(restartGateway).not.toHaveBeenCalled()
})
it('applies setup-time config in one batch before the gateway starts', async () => {
@@ -281,10 +306,6 @@ describe('OpenClawService', () => {
const runOnboard = mock(async () => {})
const setConfigBatch = mock(async () => {})
const validateConfig = mock(async () => ({ ok: true }))
const getConfig = mock(async (path: string) => {
if (path === 'gateway.auth.token') return 'cli-token'
return null
})
const createAgent = mock(async () => ({
agentId: 'main',
name: 'main',
@@ -294,19 +315,18 @@ describe('OpenClawService', () => {
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
const restartGateway = mock(async () => {})
const startGateway = mock(async () => {})
service.runtime = {
isPodmanAvailable: async () => true,
ensureReady: async () => {},
isReady: async () => true,
copyComposeFile: async () => {},
writeEnvFile: async () => {},
composePull: async () => {},
composeRestart: mock(async () => {}),
composeUp: async () => {},
pullImage: async () => {},
restartGateway,
startGateway,
waitForReady,
}
service.cliClient = {
getConfig,
probe: mock(async () => {}),
listAgents: mock(async () => []),
createAgent,
@@ -326,7 +346,8 @@ describe('OpenClawService', () => {
name: 'main',
model: undefined,
})
expect(service.runtime.composeRestart).not.toHaveBeenCalled()
expect(startGateway).toHaveBeenCalledTimes(1)
expect(restartGateway).not.toHaveBeenCalled()
})
it('loads the persisted gateway token from the mounted config before control plane calls', async () => {
@@ -398,11 +419,9 @@ describe('OpenClawService', () => {
isPodmanAvailable: async () => true,
ensureReady: async () => {},
isReady: async () => true,
copyComposeFile: async () => {},
writeEnvFile: async () => {},
composePull: async () => {},
composeRestart: async () => {},
composeUp: async () => {},
pullImage: async () => {},
restartGateway: async () => {},
startGateway: async () => {},
waitForReady: async () => true,
}
service.cliClient = {
@@ -439,6 +458,209 @@ describe('OpenClawService', () => {
).toContain('OPENAI_API_KEY=sk-test')
})
it('start uses the direct runtime startGateway flow', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
token: 'cli-token',
},
},
}),
)
const ensureReady = mock(async () => {})
const startGateway = mock(async () => {})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady: async () => true,
startGateway,
waitForReady,
}
service.cliClient = {
probe,
}
await service.start()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
port: 18789,
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
}),
expect.any(Function),
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
})
it('restart uses the direct runtime restartGateway flow', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
token: 'cli-token',
},
},
}),
)
const restartGateway = mock(async () => {})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
isReady: async () => true,
restartGateway,
waitForReady,
}
service.cliClient = {
probe,
}
await service.restart()
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
port: 18789,
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
}),
expect.any(Function),
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
})
it('stop calls runtime.stopGateway', async () => {
const stopGateway = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
isReady: async () => true,
stopGateway,
}
await service.stop()
expect(stopGateway).toHaveBeenCalledTimes(1)
})
it('getLogs proxies to runtime.getGatewayLogs with tail', async () => {
const getGatewayLogs = mock(async (tail = 50) => [`tail:${tail}`])
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
isReady: async () => true,
getGatewayLogs,
}
await expect(service.getLogs(25)).resolves.toEqual(['tail:25'])
expect(getGatewayLogs).toHaveBeenCalledWith(25)
})
it('shutdown stops gateway and then stops machine when safe', async () => {
const stopGateway = mock(async () => {})
const stopMachineIfSafe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
isReady: async () => true,
stopGateway,
stopMachineIfSafe,
}
await service.shutdown()
expect(stopGateway).toHaveBeenCalledTimes(1)
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
})
it('shutdown still stops machine when stopGateway fails', async () => {
const stopGateway = mock(async () => {
throw new Error('stop failed')
})
const stopMachineIfSafe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
isReady: async () => true,
stopGateway,
stopMachineIfSafe,
}
await expect(service.shutdown()).resolves.toBeUndefined()
expect(stopGateway).toHaveBeenCalledTimes(1)
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
})
it('tryAutoStart uses direct-runtime startGateway when gateway is not ready', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
token: 'cli-token',
},
},
}),
)
const ensureReady = mock(async () => {})
const isReady = mock(async () => false)
const startGateway = mock(async () => {})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
isPodmanAvailable: async () => true,
ensureReady,
isReady,
startGateway,
waitForReady,
}
service.cliClient = {
probe,
}
await service.tryAutoStart()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
port: 18789,
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
}),
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
expect(isReady).toHaveBeenCalledTimes(1)
})
it('keeps openrouter model refs verbatim without rewriting dots', () => {
const provider = resolveSupportedOpenClawProvider({
providerType: 'openrouter',

View File

@@ -1,13 +1,5 @@
{
"resources": [
{
"name": "OpenClaw compose file",
"source": {
"type": "local",
"path": "apps/server/resources/openclaw-compose.yml"
},
"destination": "resources/openclaw-compose.yml"
},
{
"name": "Podman CLI - macOS ARM64",
"source": {

View File

@@ -1,19 +1,8 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { loadManifest } from './manifest'
import { stageCompiledArtifact } from './stage'
import type { BuildTarget } from './types'
const TARGET: BuildTarget = {
id: 'darwin-arm64',
name: 'macOS arm64',
os: 'macos',
arch: 'arm64',
bunTarget: 'bun-darwin-arm64-modern',
serverBinaryName: 'browseros-server-darwin-arm64',
}
describe('server artifact staging', () => {
let tempDir: string | null = null
@@ -25,75 +14,13 @@ describe('server artifact staging', () => {
}
})
it('loads local resource rules from the manifest', async () => {
it('loads empty local-resource rules from the manifest', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
const manifestPath = join(tempDir, 'manifest.json')
await writeFile(
manifestPath,
JSON.stringify({
resources: [
{
name: 'OpenClaw compose file',
source: {
type: 'local',
path: 'apps/server/resources/openclaw-compose.yml',
},
destination: 'resources/openclaw-compose.yml',
},
],
}),
)
await writeFile(manifestPath, JSON.stringify({ resources: [] }))
expect(loadManifest(manifestPath)).toEqual({
resources: [
{
name: 'OpenClaw compose file',
source: {
type: 'local',
path: 'apps/server/resources/openclaw-compose.yml',
},
destination: 'resources/openclaw-compose.yml',
executable: false,
},
],
resources: [],
})
})
it('copies local resource files into the packaged artifact', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
const distRoot = join(tempDir, 'dist')
const compiledBinaryPath = join(tempDir, 'browseros-server')
const sourceRoot = join(tempDir, 'repo')
const composeSourcePath = join(
sourceRoot,
'apps/server/resources/openclaw-compose.yml',
)
await writeFile(compiledBinaryPath, '#!/bin/sh\n')
await Bun.write(composeSourcePath, 'services:\n')
const staged = await stageCompiledArtifact(
distRoot,
compiledBinaryPath,
TARGET,
'1.2.3',
[
{
name: 'OpenClaw compose file',
source: {
type: 'local',
path: 'apps/server/resources/openclaw-compose.yml',
},
destination: 'resources/openclaw-compose.yml',
},
],
sourceRoot,
)
expect(
await readFile(
join(staged.resourcesDir, 'openclaw-compose.yml'),
'utf-8',
),
).toBe('services:\n')
})
})