Compare commits

...

3 Commits

Author SHA1 Message Date
Nikhil Sonti
63055a8ea2 fix: address review feedback for PR #868 2026-04-29 12:01:05 -07:00
Nikhil Sonti
40cf3f56e9 fix: use container port for OpenClaw ACP bridge 2026-04-29 12:01:04 -07:00
Nikhil Sonti
13c069631a fix: load OpenClaw gateway image from VM cache 2026-04-29 11:58:41 -07:00
12 changed files with 203 additions and 37 deletions

View File

@@ -1,4 +1,4 @@
name: build-agent
name: Publish VM Agent Cache
on:
workflow_dispatch:
@@ -16,7 +16,7 @@ on:
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- ".github/workflows/build-agent.yml"
- ".github/workflows/publish-vm-agent-cache.yml"
env:
BUN_VERSION: "1.3.6"
@@ -48,6 +48,8 @@ jobs:
include:
- arch: arm64
runner: ubuntu-24.04-arm
- arch: x64
runner: ubuntu-24.04
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
@@ -74,7 +76,15 @@ jobs:
smoke:
needs: build
runs-on: ubuntu-24.04-arm
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: ubuntu-24.04-arm
- arch: x64
runner: ubuntu-24.04
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
@@ -82,7 +92,7 @@ jobs:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v4
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-arm64
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
path: dist/images
- name: Install podman
run: |
@@ -96,12 +106,12 @@ jobs:
AGENT: ${{ inputs.agent || 'openclaw' }}
run: |
set -euo pipefail
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-arm64.tar.gz" -print -quit)"
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-${{ matrix.arch }}.tar.gz" -print -quit)"
if [ -z "$tarball" ]; then
echo "missing arm64 tarball artifact for ${AGENT}" >&2
echo "missing ${{ matrix.arch }} tarball artifact for ${AGENT}" >&2
exit 1
fi
bun run smoke:tarball -- --agent "$AGENT" --arch arm64 --tarball "$tarball"
bun run smoke:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --tarball "$tarball"
publish:
needs: [build, smoke]

View File

@@ -123,15 +123,27 @@ class DeferredImageLoader {
) {}
async ensureImageLoaded(ref: string, onLog?: (msg: string) => void) {
const loader = await this.buildLoader()
await loader.ensureImageLoaded(ref, onLog)
}
async ensureAgentImageLoaded(
name: string,
onLog?: (msg: string) => void,
): Promise<string> {
const loader = await this.buildLoader()
return loader.ensureAgentImageLoaded(name, onLog)
}
private async buildLoader(): Promise<ImageLoader> {
await this.ensureCacheSynced()
const manifest = await readCachedManifest(this.browserosRoot)
const loader = new ImageLoader(
return new ImageLoader(
this.shell,
manifest,
detectArch(),
this.browserosRoot,
)
await loader.ensureImageLoaded(ref, onLog)
}
private async ensureCacheSynced(): Promise<void> {
@@ -151,7 +163,10 @@ class UnsupportedPlatformTestRuntime extends ContainerRuntime {
super({
vm: {} as VmRuntime,
shell: {} as ContainerCli,
loader: { ensureImageLoaded: rejectUnsupportedPlatform },
loader: {
ensureImageLoaded: rejectUnsupportedPlatform,
ensureAgentImageLoaded: rejectUnsupportedPlatform,
},
projectDir,
})
}

View File

@@ -5,6 +5,7 @@
*/
import {
OPENCLAW_AGENT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
} from '@browseros/shared/constants/openclaw'
@@ -39,7 +40,6 @@ const GATEWAY_PATH = [
].join(':')
export type GatewayContainerSpec = {
image: string
hostPort: number
hostHome: string
envFilePath: string
@@ -50,7 +50,10 @@ export type GatewayContainerSpec = {
export interface ContainerRuntimeConfig {
vm: VmRuntime
shell: ContainerCli
loader: { ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> }
loader: {
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string>
}
projectDir: string
}
@@ -59,6 +62,7 @@ export class ContainerRuntime {
private readonly shell: ContainerCli
private readonly loader: {
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string>
}
private readonly projectDir: string
@@ -96,8 +100,8 @@ export class ContainerRuntime {
onLog?: LogFn,
): Promise<void> {
await this.removeGatewayContainer(onLog)
await this.loader.ensureImageLoaded(input.image, onLog)
const container = await this.buildGatewayContainerSpec(input)
const image = await this.ensureGatewayImageLoaded(onLog)
const container = await this.buildGatewayContainerSpec(input, image)
await this.shell.createContainer(container, onLog)
await this.shell.startContainer(container.name)
}
@@ -183,7 +187,7 @@ export class ContainerRuntime {
): Promise<number> {
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
await this.loader.ensureImageLoaded(spec.image, onLog)
const image = await this.ensureGatewayImageLoaded(onLog)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
const createResult = await this.shell.runCommand(
[
@@ -191,7 +195,7 @@ export class ContainerRuntime {
'--name',
setupContainerName,
...(await this.buildGatewayRunArgs(spec)),
spec.image,
image,
'node',
...setupArgs,
],
@@ -235,10 +239,11 @@ export class ContainerRuntime {
private async buildGatewayContainerSpec(
input: GatewayContainerSpec,
image: string,
): Promise<ContainerSpec> {
return {
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: input.image,
image,
restart: 'unless-stopped',
ports: [
{
@@ -290,6 +295,16 @@ export class ContainerRuntime {
return `host.containers.internal:${await this.vm.getDefaultGateway()}`
}
private async ensureGatewayImageLoaded(onLog?: LogFn): Promise<string> {
// Local image testing can bypass the synced VM manifest with OPENCLAW_IMAGE.
const override = process.env.OPENCLAW_IMAGE?.trim()
if (override) {
await this.loader.ensureImageLoaded(override, onLog)
return override
}
return this.loader.ensureAgentImageLoaded(OPENCLAW_AGENT_NAME, onLog)
}
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,

View File

@@ -1330,14 +1330,8 @@ export class OpenClawService {
await writeFile(envPath, '', { mode: 0o600 })
}
// 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(),
hostPort: this.hostPort,
hostHome: this.openclawDir,
envFilePath: this.getStateEnvPath(),

View File

@@ -757,6 +757,8 @@ function resolveOpenclawAcpCommand(
`LIMA_HOME=${limaHome}`,
limactl,
'shell',
'--workdir',
'/',
vm,
'--',
'nerdctl',

View File

@@ -6,7 +6,7 @@
import { basename, join } from 'node:path'
import { ContainerCliError, ImageLoadError } from '../vm/errors'
import type { VmManifest } from '../vm/manifest'
import type { VmAgentTarball, VmManifest } from '../vm/manifest'
import type { Arch } from '../vm/paths'
import { getImageCacheDir, hostPathToGuest } from '../vm/paths'
import type { ContainerCli } from './container-cli'
@@ -24,6 +24,28 @@ export class ImageLoader {
if (await this.cli.imageExists(ref)) return
const tarball = this.resolveTarball(ref)
await this.loadResolvedTarball(ref, tarball, onLog)
}
/** Load an agent tarball from the VM cache and return its local image ref. */
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
const agent = this.resolveAgent(name)
const ref = `${agent.image}:${agent.version}`
if (await this.cli.imageExists(ref)) return ref
const tarball = agent.tarballs[this.arch]
if (!tarball) {
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
}
await this.loadResolvedTarball(ref, tarball, onLog)
return ref
}
private async loadResolvedTarball(
ref: string,
tarball: VmAgentTarball,
onLog?: LogFn,
): Promise<void> {
const hostPath = join(
getImageCacheDir(this.browserosRoot),
basename(tarball.key),
@@ -47,9 +69,7 @@ export class ImageLoader {
}
}
private resolveTarball(
ref: string,
): VmManifest['agents'][string]['tarballs'][Arch] {
private resolveTarball(ref: string): VmAgentTarball {
for (const agent of Object.values(this.manifest.agents)) {
if (`${agent.image}:${agent.version}` !== ref) continue
const tarball = agent.tarballs[this.arch]
@@ -61,4 +81,10 @@ export class ImageLoader {
throw new ImageLoadError(ref, `no agent in manifest matches ${ref}`)
}
private resolveAgent(name: string): VmManifest['agents'][string] {
const agent = this.manifest.agents[name]
if (!agent) throw new ImageLoadError(name, `no agent in manifest: ${name}`)
return agent
}
}

View File

@@ -29,6 +29,7 @@ export interface VmManifest {
agents: Record<string, VmAgentEntry>
}
export type VmAgentTarball = VmArtifact
export type VersionComparison = 'same' | 'upgrade' | 'downgrade' | 'fresh'
export async function readCachedManifest(
@@ -78,7 +79,7 @@ export function agentForArch(
): {
image: string
version: string
tarball: VmManifest['agents'][string]['tarballs'][Arch]
tarball: VmAgentTarball
} {
const agent = manifest.agents[name]
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)

View File

@@ -8,8 +8,8 @@ import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/ope
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
const PROJECT_DIR = '/tmp/openclaw'
const GATEWAY_IMAGE_REF = 'ghcr.io/openclaw/openclaw:2026.4.12'
const defaultSpec = {
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: 18789,
hostHome: '/Users/me/.browseros/vm/openclaw',
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
@@ -34,14 +34,14 @@ describe('ContainerRuntime', () => {
{ force: true },
undefined,
)
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
defaultSpec.image,
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
)
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: defaultSpec.image,
image: GATEWAY_IMAGE_REF,
restart: 'unless-stopped',
ports: [
{
@@ -66,6 +66,35 @@ describe('ContainerRuntime', () => {
)
})
it('uses OPENCLAW_IMAGE as a direct image override', async () => {
const previous = process.env.OPENCLAW_IMAGE
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
try {
await runtime.startGateway(defaultSpec)
} finally {
if (previous === undefined) delete process.env.OPENCLAW_IMAGE
else process.env.OPENCLAW_IMAGE = previous
}
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
'localhost/openclaw:test',
undefined,
)
expect(deps.loader.ensureAgentImageLoaded).not.toHaveBeenCalled()
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({ image: 'localhost/openclaw:test' }),
undefined,
)
})
it('delegates ensureReady and stopVm to VmRuntime', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
@@ -108,6 +137,7 @@ describe('ContainerRuntime', () => {
'/mnt/browseros/vm/openclaw:/home/node',
'--add-host',
'host.containers.internal:192.168.5.2',
GATEWAY_IMAGE_REF,
]),
undefined,
)
@@ -171,6 +201,7 @@ function createDeps() {
},
loader: {
ensureImageLoaded: mock(async () => {}),
ensureAgentImageLoaded: mock(async () => GATEWAY_IMAGE_REF),
},
}
}

View File

@@ -315,7 +315,6 @@ describe('OpenClawService', () => {
expect(steps).toEqual(['onboard', 'batch', 'validate', 'start', 'ready'])
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
@@ -323,6 +322,7 @@ describe('OpenClawService', () => {
}),
expect.any(Function),
)
expect(startGateway.mock.calls[0]?.[0]).not.toHaveProperty('image')
expect(restartGateway).not.toHaveBeenCalled()
})
@@ -610,7 +610,6 @@ describe('OpenClawService', () => {
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
@@ -753,7 +752,6 @@ describe('OpenClawService', () => {
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
@@ -962,7 +960,6 @@ describe('OpenClawService', () => {
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),

View File

@@ -512,7 +512,9 @@ open &lt;example.com&gt;
const runtimeOptions = calls[0]?.input as AcpRuntimeOptions
const command = runtimeOptions.agentRegistry.resolve('openclaw')
expect(command).toContain('env LIMA_HOME=/Users/dev/.browseros-dev/lima')
expect(command).toContain('/opt/homebrew/bin/limactl shell browseros-vm --')
expect(command).toContain(
'/opt/homebrew/bin/limactl shell --workdir / browseros-vm --',
)
expect(command).toContain(
'nerdctl exec -i -e OPENCLAW_HIDE_BANNER=1 -e OPENCLAW_SUPPRESS_NOTES=1 browseros-openclaw-openclaw-gateway-1',
)

View File

@@ -62,6 +62,78 @@ describe('ImageLoader', () => {
])
})
it('loads an agent image by manifest name and returns its image ref', async () => {
const cli = new FakeContainerCli([false, true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(loader.ensureAgentImageLoaded('openclaw')).resolves.toBe(
'ghcr.io/openclaw/openclaw:2026.4.12',
)
expect(cli.loadCalls).toEqual([
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
])
expect(cli.existsCalls).toEqual([
'ghcr.io/openclaw/openclaw:2026.4.12',
'ghcr.io/openclaw/openclaw:2026.4.12',
])
})
it('returns an agent image ref without loading when already cached', async () => {
const cli = new FakeContainerCli([true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(loader.ensureAgentImageLoaded('openclaw')).resolves.toBe(
'ghcr.io/openclaw/openclaw:2026.4.12',
)
expect(cli.loadCalls).toEqual([])
expect(cli.existsCalls).toEqual(['ghcr.io/openclaw/openclaw:2026.4.12'])
})
it('throws ImageLoadError when the agent name is absent from the manifest', async () => {
const cli = new FakeContainerCli([])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const error = await loader
.ensureAgentImageLoaded('missing')
.catch((err) => err)
expect(error).toBeInstanceOf(ImageLoadError)
expect(error.message).toContain('no agent in manifest: missing')
expect(cli.existsCalls).toEqual([])
expect(cli.loadCalls).toEqual([])
})
it('throws ImageLoadError when the manifest lacks a tarball for the arch', async () => {
const missingArchManifest = {
...manifest,
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'agent-arm',
sizeBytes: 1,
},
},
},
},
} as unknown as VmManifest
const cli = new FakeContainerCli([false])
const loader = new ImageLoader(cli as never, missingArchManifest, 'x64')
const error = await loader
.ensureAgentImageLoaded('openclaw')
.catch((err) => err)
expect(error).toBeInstanceOf(ImageLoadError)
expect(error.message).toContain('no x64 tarball in manifest')
expect(cli.loadCalls).toEqual([])
})
it('resolves image tarballs against the configured BrowserOS root', async () => {
const cli = new FakeContainerCli([false, true])
const browserosRoot = '/tmp/browseros-custom-root'

View File

@@ -1,3 +1,4 @@
export const OPENCLAW_AGENT_NAME = 'openclaw'
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'