mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
14 Commits
dev
...
fix/podman
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8a828b22 | ||
|
|
e0438a8b48 | ||
|
|
2fadbecb6b | ||
|
|
6fc65590c4 | ||
|
|
621d48b275 | ||
|
|
58d7637cfe | ||
|
|
57b8720e13 | ||
|
|
380fc566e4 | ||
|
|
4a8179f821 | ||
|
|
484718d116 | ||
|
|
e1483bc29b | ||
|
|
a2eb26b7f3 | ||
|
|
c75d3eb6a5 | ||
|
|
98cdca7bcb |
@@ -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
|
||||
@@ -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}`]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -128,7 +128,6 @@ export class Application {
|
||||
|
||||
configureOpenClawService({
|
||||
browserosServerPort: this.config.serverPort,
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
})
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user