Compare commits

...

14 Commits

Author SHA1 Message Date
Nikhil Sonti
5297fe6b67 fix(dev): stop stale watch supervisors 2026-04-30 11:08:01 -07:00
Nikhil Sonti
3397c4fbce fix(openclaw): resolve review findings 2026-04-30 11:02:13 -07:00
Nikhil Sonti
70c41ff298 fix(openclaw): address review feedback 2026-04-30 10:58:41 -07:00
Nikhil Sonti
2c4626a339 fix(openclaw): suppress prewarm pull progress logs 2026-04-30 10:52:07 -07:00
Nikhil Sonti
97f0274853 chore: self-review fixes 2026-04-30 10:48:52 -07:00
Nikhil Sonti
d59203708c refactor(build-tools): remove openclaw tarball pipeline 2026-04-30 10:47:20 -07:00
Nikhil Sonti
2982447487 refactor(vm): remove browseros image cache runtime 2026-04-30 10:42:58 -07:00
Nikhil Sonti
620f8eb2ef feat(openclaw): prewarm runtime on server startup 2026-04-30 10:37:46 -07:00
Nikhil Sonti
455f8f1489 feat(openclaw): prewarm vm runtime and reuse current gateway 2026-04-30 10:34:29 -07:00
Nikhil Sonti
9250698bfc feat(openclaw): detect current gateway image 2026-04-30 10:32:31 -07:00
Nikhil Sonti
1145ec3c26 refactor(openclaw): remove vm cache from runtime factory 2026-04-30 10:31:34 -07:00
Nikhil Sonti
4a7fc77bc6 refactor(vm): decouple readiness from image cache 2026-04-30 10:30:42 -07:00
Nikhil Sonti
65cea3064e feat(openclaw): pull gateway image from registry 2026-04-30 10:28:47 -07:00
Nikhil Sonti
044a7801b4 feat(openclaw): add gateway image inspection 2026-04-30 10:24:34 -07:00
66 changed files with 704 additions and 3591 deletions

View File

@@ -1,176 +0,0 @@
name: Publish VM Agent Cache
on:
workflow_dispatch:
inputs:
agent:
description: "Agent name from bundle.json"
required: true
type: string
default: openclaw
publish:
description: "Upload to R2 and merge manifest slice"
required: false
default: false
type: boolean
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- ".github/workflows/publish-vm-agent-cache.yml"
env:
BUN_VERSION: "1.3.6"
PKG_DIR: packages/browseros-agent/packages/build-tools
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- working-directory: packages/browseros-agent
run: bun run --filter @browseros/build-tools typecheck
- working-directory: packages/browseros-agent
run: bun run --filter @browseros/build-tools test
build:
needs: check
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@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Build tarball
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
OUT: ${{ github.workspace }}/dist/images
run: bun run build:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --output-dir "$OUT"
- uses: actions/upload-artifact@v7
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
path: dist/images/
retention-days: 7
smoke:
needs: build
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@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v8
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
path: dist/images
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Smoke test tarball
timeout-minutes: 10
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
run: |
set -euo pipefail
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-${{ matrix.arch }}.tar.gz" -print -quit)"
if [ -z "$tarball" ]; then
echo "missing ${{ matrix.arch }} tarball artifact for ${AGENT}" >&2
exit 1
fi
checksum="${tarball}.sha256"
if [ ! -f "$checksum" ]; then
echo "missing checksum sidecar: $checksum" >&2
exit 1
fi
echo "smoke-testing $tarball"
ls -lh "$tarball" "$checksum"
(cd "$(dirname "$tarball")" && sha256sum -c "$(basename "$checksum")")
timeout --verbose --kill-after=30s 8m bun run smoke:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --tarball "$tarball"
publish:
needs: [build, smoke]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
runs-on: ubuntu-24.04
environment: release
concurrency:
group: r2-manifest-publish
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v8
with:
pattern: tarball-*
path: dist/images
merge-multiple: true
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Upload tarballs to R2
working-directory: ${{ env.PKG_DIR }}
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
base="$(basename "$file")"
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
done
- name: Merge agent slice into manifest
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
mkdir -p dist/images
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
bun run emit-manifest -- \
--slice "agents:${AGENT}" \
--dist-dir dist \
--merge-from dist/baseline-manifest.json \
--out dist/manifest.json
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"

View File

@@ -79,14 +79,15 @@ cp apps/server/.env.example apps/server/.env.development
cp apps/agent/.env.example apps/agent/.env.development
cp apps/server/.env.production.example apps/server/.env.production
# Install deps, generate agent code, and sync the VM cache
# Install deps and generate agent code
bun run dev:setup
# Start the full dev environment
bun run dev:watch
```
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
`dev:watch` starts the server immediately. OpenClaw VM/image prewarm runs from
the server startup path and pulls the configured GHCR image on demand.
### Environment Variables

View File

@@ -7,11 +7,6 @@ BROWSEROS_EXTENSION_PORT=9300
# BROWSEROS_RESOURCES_DIR=./resources
# BROWSEROS_EXECUTION_DIR=./out
# VM cache (optional - runtime downloads published agent cache in background)
# Set prefetch=false to skip startup warmup; VM/OpenClaw startup still syncs on demand.
BROWSEROS_VM_CACHE_PREFETCH=true
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
# BrowserOS config
BROWSEROS_CONFIG_URL=https://llm.browseros.com/api/browseros-server/config
BROWSEROS_VERSION=

View File

@@ -5,9 +5,6 @@ CODEGEN_SERVICE_URL=
POSTHOG_API_KEY=
SENTRY_DSN=
BROWSEROS_VM_CACHE_PREFETCH=true
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=

View File

@@ -10,19 +10,12 @@ import { getBrowserosDir } from '../../../lib/browseros-dir'
import { ContainerCli, ImageLoader } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
detectArch,
getLimaHomeDir,
resolveBundledLimactl,
resolveBundledLimaTemplate,
VM_NAME,
VmRuntime,
} from '../../../lib/vm'
import {
ensureVmCacheAvailable,
ensureVmCacheSynced,
type VmCacheSyncOptions,
} from '../../../lib/vm/cache-sync'
import { readCachedManifest } from '../../../lib/vm/manifest'
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
import { ContainerRuntime } from './container-runtime'
@@ -34,13 +27,6 @@ export interface ContainerRuntimeFactoryInput {
projectDir: string
browserosRoot?: string
platform?: NodeJS.Platform
vmCache?: VmCacheRuntimeConfig
}
export interface VmCacheRuntimeConfig
extends Pick<VmCacheSyncOptions, 'manifestUrl'> {
ensureAvailable?: () => Promise<void>
ensureSynced?: () => Promise<unknown>
}
export function buildContainerRuntime(
@@ -77,16 +63,9 @@ export function buildContainerRuntime(
? resolveBundledLimaTemplate(input.resourcesDir)
: undefined,
browserosRoot,
ensureCacheAvailable:
input.vmCache?.ensureAvailable ??
(() =>
ensureVmCacheAvailable({
browserosRoot,
manifestUrl: input.vmCache?.manifestUrl,
})),
})
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
const loader = new DeferredImageLoader(shell, browserosRoot, input.vmCache)
const loader = new ImageLoader(shell)
return new ContainerRuntime({
vm,
@@ -122,49 +101,6 @@ function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
})
}
class DeferredImageLoader {
constructor(
private readonly shell: ContainerCli,
private readonly browserosRoot: string,
private readonly vmCache?: VmCacheRuntimeConfig,
) {}
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)
return new ImageLoader(
this.shell,
manifest,
detectArch(),
this.browserosRoot,
)
}
private async ensureCacheSynced(): Promise<void> {
if (this.vmCache?.ensureSynced) {
await this.vmCache.ensureSynced()
return
}
await ensureVmCacheSynced({
browserosRoot: this.browserosRoot,
manifestUrl: this.vmCache?.manifestUrl,
})
}
}
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
constructor(projectDir: string) {
super({
@@ -197,6 +133,14 @@ class UnsupportedPlatformTestRuntime extends ContainerRuntime {
throw unsupportedPlatformError()
}
override async prewarmGatewayImage(): Promise<void> {
throw unsupportedPlatformError()
}
override async isGatewayCurrent(): Promise<boolean> {
return false
}
override async startGateway(): Promise<void> {
throw unsupportedPlatformError()
}

View File

@@ -8,6 +8,7 @@ import {
OPENCLAW_AGENT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import type {
ContainerCli,
@@ -95,6 +96,27 @@ export class ContainerRuntime {
await this.loader.ensureImageLoaded(image, onLog)
}
/** Warm the gateway image in containerd without creating or starting containers. */
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
await this.ensureGatewayImageLoaded(onLog)
}
/** Report whether the existing gateway container was created from the target image. */
async isGatewayCurrent(): Promise<boolean> {
const image = await this.shell.containerImageRef(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
const expected = this.expectedGatewayImageRef()
const current = imageMatchesExpectedRef(image, expected)
if (!current) {
logger.info('OpenClaw gateway image is not current', {
actualImageRef: image,
expectedImageRef: expected,
})
}
return current
}
async startGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
@@ -296,7 +318,7 @@ export class ContainerRuntime {
}
private async ensureGatewayImageLoaded(onLog?: LogFn): Promise<string> {
// Local image testing can bypass the synced VM manifest with OPENCLAW_IMAGE.
// Local image testing can override the pinned GHCR image with OPENCLAW_IMAGE.
const override = process.env.OPENCLAW_IMAGE?.trim()
if (override) {
await this.loader.ensureImageLoaded(override, onLog)
@@ -305,6 +327,10 @@ export class ContainerRuntime {
return this.loader.ensureAgentImageLoaded(OPENCLAW_AGENT_NAME, onLog)
}
private expectedGatewayImageRef(): string {
return process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
}
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,
@@ -330,3 +356,12 @@ export class ContainerRuntime {
return hostPathToGuest(path)
}
}
function imageMatchesExpectedRef(
actual: string | null,
expected: string,
): boolean {
return (
actual === expected || actual?.startsWith(`${expected}@sha256:`) === true
)
}

View File

@@ -13,6 +13,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import { getOpenClawDir } from '../../../lib/browseros-dir'
@@ -26,10 +27,7 @@ import type {
ContainerRuntime,
GatewayContainerSpec,
} from './container-runtime'
import {
buildContainerRuntime,
type VmCacheRuntimeConfig,
} from './container-runtime-factory'
import { buildContainerRuntime } from './container-runtime-factory'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
@@ -135,7 +133,6 @@ export interface OpenClawServiceConfig {
browserosServerPort?: number
resourcesDir?: string
browserosDir?: string
vmCache?: VmCacheRuntimeConfig
}
export type OpenClawSessionSource =
@@ -267,7 +264,6 @@ export class OpenClawService {
private browserosServerPort: number
private resourcesDir: string | null
private browserosDir: string | undefined
private vmCache: VmCacheRuntimeConfig | undefined
private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected'
private lastGatewayError: string | null = null
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
@@ -282,7 +278,6 @@ export class OpenClawService {
resourcesDir: config.resourcesDir,
projectDir: this.openclawDir,
browserosRoot: config.browserosDir,
vmCache: config.vmCache,
})
this.token = crypto.randomUUID()
this.cliClient = new OpenClawCliClient(this.runtime)
@@ -295,7 +290,6 @@ export class OpenClawService {
config.browserosServerPort ?? DEFAULT_PORTS.server
this.resourcesDir = config.resourcesDir ?? null
this.browserosDir = config.browserosDir
this.vmCache = config.vmCache
}
configure(config: OpenClawServiceConfig): void {
@@ -318,13 +312,6 @@ export class OpenClawService {
this.browserosDir = config.browserosDir
runtimeChanged = true
}
if (
config.vmCache !== undefined &&
!sameVmCacheRuntimeConfig(config.vmCache, this.vmCache)
) {
this.vmCache = config.vmCache
runtimeChanged = true
}
if (runtimeChanged) {
this.rebuildRuntimeClients()
}
@@ -361,6 +348,23 @@ export class OpenClawService {
// ── Lifecycle ────────────────────────────────────────────────────────
/** Warm the VM and gateway image so later setup/start avoids registry work. */
async prewarm(onLog?: (msg: string) => void): Promise<void> {
return this.withLifecycleLock('prewarm', async () => {
const imageRef = process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
const logProgress = (message: string) => {
// Startup prewarm runs outside a user request, so keep phase logs visible without streaming command progress.
logger.info(message)
onLog?.(message)
}
logProgress('OpenClaw prewarm: ensuring BrowserOS VM is ready')
await this.runtime.ensureReady()
logProgress(`OpenClaw prewarm: ensuring image ${imageRef} is available`)
await this.runtime.prewarmGatewayImage()
logProgress('OpenClaw prewarm: ready')
})
}
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
return this.withLifecycleLock('setup', async () => {
const logProgress = this.createProgressLogger(onLog)
@@ -478,7 +482,7 @@ export class OpenClawService {
await this.ensureGatewayPortAllocated(logProgress)
if (await this.isGatewayAvailable(this.hostPort)) {
if (await this.isCurrentGatewayAvailable(this.hostPort)) {
this.startGatewayLogTail()
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
@@ -873,7 +877,7 @@ export class OpenClawService {
this.setPort(persistedPort)
}
if (!(await this.isGatewayAvailable(this.hostPort))) {
if (!(await this.isCurrentGatewayAvailable(this.hostPort))) {
await this.ensureGatewayPortAllocated()
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
const ready = await this.runtime.waitForReady(
@@ -987,7 +991,6 @@ export class OpenClawService {
resourcesDir: this.resourcesDir ?? undefined,
projectDir: this.openclawDir,
browserosRoot: this.browserosDir,
vmCache: this.vmCache,
})
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
@@ -1046,6 +1049,11 @@ export class OpenClawService {
return authenticated
}
private async isCurrentGatewayAvailable(hostPort: number): Promise<boolean> {
if (!(await this.isGatewayAvailable(hostPort))) return false
return this.runtime.isGatewayCurrent()
}
private async isGatewayPortReady(hostPort: number): Promise<boolean> {
if (await this.runtime.isReady(hostPort)) return true
@@ -1529,7 +1537,6 @@ export function configureOpenClawService(
export function configureVmRuntime(config: {
resourcesDir?: string
browserosDir?: string
vmCache?: VmCacheRuntimeConfig
}): OpenClawService {
return configureOpenClawService(config)
}
@@ -1538,14 +1545,3 @@ export function getOpenClawService(): OpenClawService {
if (!service) service = new OpenClawService()
return service
}
function sameVmCacheRuntimeConfig(
left: VmCacheRuntimeConfig | undefined,
right: VmCacheRuntimeConfig | undefined,
): boolean {
return (
left?.manifestUrl === right?.manifestUrl &&
left?.ensureAvailable === right?.ensureAvailable &&
left?.ensureSynced === right?.ensureSynced
)
}

View File

@@ -8,7 +8,6 @@
import fs from 'node:fs'
import path from 'node:path'
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { Command, InvalidArgumentError } from 'commander'
import { z } from 'zod'
@@ -31,8 +30,6 @@ export const ServerConfigSchema = z.object({
instanceBrowserosVersion: z.string().optional(),
instanceChromiumVersion: z.string().optional(),
aiSdkDevtoolsEnabled: z.boolean(),
vmCachePrefetch: z.boolean(),
vmCacheManifestUrl: z.string().url(),
})
export type ServerConfig = z.infer<typeof ServerConfigSchema>
@@ -229,11 +226,6 @@ function parseConfigFile(filePath?: string): ConfigResult<PartialConfig> {
cfg.flags?.allow_remote_in_mcp === true ? true : undefined,
aiSdkDevtoolsEnabled:
cfg.flags?.ai_sdk_devtools === true ? true : undefined,
vmCachePrefetch:
typeof cfg.vm_cache?.prefetch === 'boolean'
? cfg.vm_cache.prefetch
: undefined,
vmCacheManifestUrl: parseTrimmedString(cfg.vm_cache?.manifest_url),
instanceClientId:
typeof cfg.instance?.client_id === 'string'
? cfg.instance.client_id
@@ -280,10 +272,6 @@ function parseRuntimeEnv(): PartialConfig {
instanceClientId: process.env.BROWSEROS_CLIENT_ID,
aiSdkDevtoolsEnabled:
process.env.BROWSEROS_AI_SDK_DEVTOOLS === 'true' ? true : undefined,
vmCachePrefetch: parseBooleanEnv(process.env.BROWSEROS_VM_CACHE_PREFETCH),
vmCacheManifestUrl: parseTrimmedString(
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
),
})
}
@@ -317,8 +305,6 @@ function getDefaults(cwd: string): PartialConfig {
executionDir: cwd,
mcpAllowRemote: false,
aiSdkDevtoolsEnabled: false,
vmCachePrefetch: true,
vmCacheManifestUrl: EXTERNAL_URLS.VM_CACHE_MANIFEST,
}
}
@@ -339,18 +325,6 @@ function safeParseInt(value: string): number | undefined {
return Number.isNaN(num) ? undefined : num
}
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === 'true') return true
if (value === 'false') return false
return undefined
}
function parseTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
function omitUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_, v]) => v !== undefined),

View File

@@ -19,8 +19,6 @@ export const INLINED_ENV = {
CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL,
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL,
BROWSEROS_VM_CACHE_PREFETCH: process.env.BROWSEROS_VM_CACHE_PREFETCH,
BROWSEROS_VM_CACHE_MANIFEST_URL: process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
SKILLS_CATALOG_URL: process.env.SKILLS_CATALOG_URL,
} as const
@@ -29,6 +27,4 @@ export const REQUIRED_FOR_PRODUCTION = [
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'BROWSEROS_CONFIG_URL',
'BROWSEROS_VM_CACHE_PREFETCH',
'BROWSEROS_VM_CACHE_MANIFEST_URL',
] as const satisfies readonly (keyof typeof INLINED_ENV)[]

View File

@@ -75,10 +75,6 @@ export function getVmDisksDir(): string {
return getVmCacheDir()
}
export function getAgentCacheDir(): string {
return join(getVmCacheDir(), 'images')
}
export function getLazyMonitoringDir(): string {
return join(getBrowserosDir(), 'lazy-monitoring')
}
@@ -116,7 +112,7 @@ export async function ensureBrowserosDir(): Promise<void> {
await mkdir(getBuiltinSkillsDir(), { recursive: true })
await mkdir(getSessionsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
await mkdir(getAgentCacheDir(), { recursive: true })
await mkdir(getVmDisksDir(), { recursive: true })
}
export async function cleanOldSessions(): Promise<void> {

View File

@@ -41,13 +41,20 @@ export class ContainerCli {
return result.exitCode === 0
}
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
await this.runRequired(['pull', ref], onLog)
/** Return the image ref used to create a container, or null when absent. */
async containerImageRef(name: string): Promise<string | null> {
const args = ['inspect', '--format', '{{.Config.Image}}', name]
const result = await this.runCommand(args)
if (result.exitCode === 0) {
const image = result.stdout.trim()
return image || null
}
if (isNoSuchContainer(result.stderr)) return null
throw this.commandError(args, result)
}
async loadImage(tarballPath: string, onLog?: LogFn): Promise<string[]> {
const result = await this.runRequired(['load', '-i', tarballPath], onLog)
return parseLoadedImageRefs(result.stdout)
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
await this.runRequired(['pull', ref], onLog)
}
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
@@ -191,13 +198,6 @@ function mountArg(mount: MountSpec): string {
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
}
function parseLoadedImageRefs(stdout: string): string[] {
return stdout
.split('\n')
.map((line) => line.match(/^Loaded image(?:\(s\))?:\s*(.+)$/i)?.[1]?.trim())
.filter((ref): ref is string => !!ref)
}
function isNoSuchContainer(stderr: string): boolean {
const lower = stderr.toLowerCase()
return lower.includes('no such container') || lower.includes('not found')

View File

@@ -4,87 +4,41 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { basename, join } from 'node:path'
import {
OPENCLAW_AGENT_NAME,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { ContainerCliError, ImageLoadError } from '../vm/errors'
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'
import type { LogFn } from './types'
export class ImageLoader {
constructor(
private readonly cli: ContainerCli,
private readonly manifest: VmManifest,
private readonly arch: Arch,
private readonly browserosRoot?: string,
) {}
constructor(private readonly cli: ContainerCli) {}
/** Ensure an image ref exists in the VM's persistent containerd store. */
async ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> {
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),
)
const guestPath = hostPathToGuest(hostPath, this.browserosRoot)
try {
await this.cli.loadImage(guestPath, onLog)
await this.cli.pullImage(ref, onLog)
} catch (error) {
if (error instanceof ContainerCliError) {
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
throw new ImageLoadError(ref, `pull failed: ${error.stderr}`, error)
}
throw error
}
if (!(await this.cli.imageExists(ref))) {
throw new ImageLoadError(
ref,
`image not present after successful load of ${guestPath}`,
)
throw new ImageLoadError(ref, 'image not present after successful pull')
}
}
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]
if (!tarball) {
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
}
return tarball
/** Resolve BrowserOS agent names to image refs and ensure the image exists. */
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
if (name !== OPENCLAW_AGENT_NAME) {
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
}
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
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
return OPENCLAW_IMAGE
}
}

View File

@@ -1,322 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createHash } from 'node:crypto'
import { createReadStream, existsSync } from 'node:fs'
import { mkdir, readFile, rename, rm } from 'node:fs/promises'
import { arch as hostArch } from 'node:os'
import { dirname, join } from 'node:path'
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import type { VmArtifact, VmManifest } from './manifest'
import type { Arch } from './paths'
import { getCachedManifestPath } from './paths'
const DEFAULT_TIMEOUT_MS = 30_000
const ARCHES: Arch[] = ['arm64', 'x64']
const CANONICAL_MANIFEST_SUFFIX = '/vm/manifest.json'
export interface VmCacheSyncOptions {
browserosRoot?: string
manifestUrl?: string
allArches?: boolean
fetchImpl?: typeof fetch
rawHostArch?: NodeJS.Architecture
timeoutMs?: number
}
export interface VmCacheSyncResult {
downloaded: string[]
manifestPath: string
skipped: boolean
}
const inFlight = new Map<string, Promise<VmCacheSyncResult>>()
export function prefetchVmCache(
options: VmCacheSyncOptions = {},
): Promise<VmCacheSyncResult> {
return startOrReuseSync(options)
}
export function ensureVmCacheSynced(
options: VmCacheSyncOptions = {},
): Promise<VmCacheSyncResult> {
return startOrReuseSync(options)
}
export async function ensureVmCacheAvailable(
options: VmCacheSyncOptions = {},
): Promise<void> {
const cfg = resolveSyncConfig(options)
const pending = inFlight.get(syncKey(cfg))
if (pending) {
await pending.catch(() => {})
}
if (existsSync(getCachedManifestPath(cfg.browserosRoot))) return
await startOrReuseSyncWithConfig(cfg)
}
function startOrReuseSync(
options: VmCacheSyncOptions,
): Promise<VmCacheSyncResult> {
try {
return startOrReuseSyncWithConfig(resolveSyncConfig(options))
} catch (error) {
return Promise.reject(error)
}
}
function startOrReuseSyncWithConfig(
cfg: SyncConfig,
): Promise<VmCacheSyncResult> {
const key = syncKey(cfg)
const existing = inFlight.get(key)
if (existing) return existing
const current = syncVmCache(cfg).finally(() => {
if (inFlight.get(key) === current) inFlight.delete(key)
})
inFlight.set(key, current)
return current
}
async function syncVmCache(cfg: SyncConfig): Promise<VmCacheSyncResult> {
const remote = await fetchManifest(cfg)
const manifestPath = getCachedManifestPath(cfg.browserosRoot)
const local = await readLocalManifest(manifestPath)
const plan = await planDownloads({
remote,
local,
cacheRoot: cacheRootForManifest(manifestPath),
arches: cfg.arches,
})
for (const item of plan) {
await downloadArtifact(
cfg.fetchImpl,
artifactUrlForKey(cfg.manifestUrl, item.key),
item.destPath,
item.sha256,
cfg.timeoutMs,
)
}
await mkdir(dirname(manifestPath), { recursive: true })
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
await Bun.write(tempPath, `${JSON.stringify(remote, null, 2)}\n`)
await rename(tempPath, manifestPath)
return {
downloaded: plan.map((item) => item.key),
manifestPath,
skipped: plan.length === 0,
}
}
interface SyncConfig {
browserosRoot?: string
manifestUrl: string
fetchImpl: typeof fetch
arches: Arch[]
timeoutMs: number
}
function resolveSyncConfig(options: VmCacheSyncOptions): SyncConfig {
return {
browserosRoot: options.browserosRoot,
manifestUrl:
trimNonEmpty(options.manifestUrl) ??
trimNonEmpty(process.env.BROWSEROS_VM_CACHE_MANIFEST_URL) ??
EXTERNAL_URLS.VM_CACHE_MANIFEST,
fetchImpl: options.fetchImpl ?? fetch,
arches: selectSyncArches(options),
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
}
}
async function fetchManifest(cfg: SyncConfig): Promise<VmManifest> {
const response = await fetchWithTimeout(
cfg.fetchImpl,
cfg.manifestUrl,
cfg.timeoutMs,
)
if (!response.ok) {
throw new Error(
`manifest fetch failed: ${cfg.manifestUrl} (${response.status})`,
)
}
return (await response.json()) as VmManifest
}
interface DownloadPlanItem {
key: string
destPath: string
sha256: string
}
async function planDownloads(opts: {
remote: VmManifest
local: VmManifest | null
cacheRoot: string
arches: Arch[]
}): Promise<DownloadPlanItem[]> {
const out: DownloadPlanItem[] = []
for (const arch of opts.arches) {
for (const [name, agent] of Object.entries(opts.remote.agents)) {
const remote = agent.tarballs[arch]
if (!remote) continue
const destPath = join(opts.cacheRoot, remote.key)
if (
!(await needsDownload(
remote,
opts.local?.agents[name]?.tarballs[arch],
destPath,
))
) {
continue
}
out.push({ key: remote.key, destPath, sha256: remote.sha256 })
}
}
return out
}
async function needsDownload(
remote: VmArtifact,
local: VmArtifact | undefined,
destPath: string,
): Promise<boolean> {
if (!existsSync(destPath)) return true
if (local?.sha256 === remote.sha256) return false
try {
return (await sha256File(destPath)) !== remote.sha256
} catch {
return true
}
}
async function downloadArtifact(
fetchImpl: typeof fetch,
url: string,
destPath: string,
sha256: string,
timeoutMs: number,
): Promise<void> {
const partialPath = `${destPath}.partial`
await mkdir(dirname(destPath), { recursive: true })
await rm(partialPath, { force: true })
try {
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs)
if (!response.ok || !response.body) {
throw new Error(`download failed: ${url} (${response.status})`)
}
const sink = Bun.file(partialPath).writer()
const reader = response.body.getReader()
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
sink.write(value)
}
} finally {
await sink.end()
}
await verifySha256(partialPath, sha256)
await rename(partialPath, destPath)
} catch (error) {
await rm(partialPath, { force: true })
throw error
}
}
async function fetchWithTimeout(
fetchImpl: typeof fetch,
url: string,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetchImpl(url, { signal: controller.signal })
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
}
throw error
} finally {
clearTimeout(timer)
}
}
async function verifySha256(path: string, expected: string): Promise<void> {
const actual = await sha256File(path)
if (actual !== expected) {
throw new Error(
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
)
}
}
async function sha256File(path: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) {
hash.update(chunk)
}
return hash.digest('hex')
}
async function readLocalManifest(path: string): Promise<VmManifest | null> {
try {
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
function selectSyncArches(options: VmCacheSyncOptions): Arch[] {
if (options.allArches) return [...ARCHES]
const rawArch = options.rawHostArch ?? hostArch()
if (rawArch === 'arm64') return ['arm64']
if (rawArch === 'x64' || rawArch === 'ia32') return ['x64']
throw new Error(`unsupported host arch: ${rawArch}`)
}
function cacheRootForManifest(manifestPath: string): string {
return dirname(dirname(manifestPath))
}
function syncKey(cfg: SyncConfig): string {
return [
getCachedManifestPath(cfg.browserosRoot),
cfg.manifestUrl,
cfg.arches.join(','),
String(cfg.timeoutMs),
].join('\0')
}
function artifactUrlForKey(manifestUrl: string, key: string): string {
const artifactKey = key.replace(/^\/+/, '')
const url = new URL(manifestUrl)
const normalizedPath = url.pathname.replace(/\/+$/, '')
const prefix = normalizedPath.endsWith(CANONICAL_MANIFEST_SUFFIX)
? normalizedPath.slice(0, -CANONICAL_MANIFEST_SUFFIX.length)
: normalizedPath.slice(0, Math.max(0, normalizedPath.lastIndexOf('/')))
url.pathname = `${prefix.replace(/\/+$/, '')}/${artifactKey}`
url.search = ''
url.hash = ''
return url.toString()
}
function trimNonEmpty(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}

View File

@@ -44,17 +44,3 @@ export class ImageLoadError extends VmError {
super(`failed to load image ${imageRef}: ${message}`)
}
}
export class ManifestMissingError extends VmError {
constructor(public readonly manifestPath: string) {
super(manifestMissingMessage(manifestPath))
}
}
function manifestMissingMessage(manifestPath: string): string {
const message = `VM manifest is missing at ${manifestPath}`
if (process.env.NODE_ENV === 'development') {
return `${message}; run bun run dev:setup before starting the server`
}
return message
}

View File

@@ -7,7 +7,6 @@
export * from './errors'
export * from './lima-cli'
export * from './lima-config'
export * from './manifest'
export * from './paths'
export * from './telemetry'
export * from './vm-runtime'

View File

@@ -8,7 +8,6 @@ export function renderLimaTemplate(
template: string,
cfg: {
vmStateDir: string
imageCacheDir: string
},
): string {
const mounts = [
@@ -16,9 +15,6 @@ export function renderLimaTemplate(
`- location: "${cfg.vmStateDir}"`,
' mountPoint: "/mnt/browseros/vm"',
' writable: true',
`- location: "${cfg.imageCacheDir}"`,
' mountPoint: "/mnt/browseros/cache/images"',
' writable: false',
].join('\n')
if (!template.includes('mounts: []')) {

View File

@@ -1,103 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { ManifestMissingError } from './errors'
import type { Arch } from './paths'
import { getCachedManifestPath, getInstalledManifestPath } from './paths'
export interface VmArtifact {
key: string
sha256: string
sizeBytes: number
}
export interface VmAgentEntry {
image: string
version: string
tarballs: Record<Arch, VmArtifact>
}
export interface VmManifest {
schemaVersion: number
updatedAt: string
agents: Record<string, VmAgentEntry>
}
export type VmAgentTarball = VmArtifact
export type VersionComparison = 'same' | 'upgrade' | 'downgrade' | 'fresh'
export async function readCachedManifest(
browserosRoot?: string,
): Promise<VmManifest> {
const manifestPath = getCachedManifestPath(browserosRoot)
if (!existsSync(manifestPath)) throw new ManifestMissingError(manifestPath)
return readManifest(manifestPath)
}
export async function readInstalledManifest(
browserosRoot?: string,
): Promise<VmManifest | null> {
const manifestPath = getInstalledManifestPath(browserosRoot)
if (!existsSync(manifestPath)) return null
return readManifest(manifestPath)
}
export async function writeInstalledManifest(
manifest: VmManifest,
browserosRoot?: string,
): Promise<void> {
const manifestPath = getInstalledManifestPath(browserosRoot)
await mkdir(dirname(manifestPath), { recursive: true })
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`)
await rename(tempPath, manifestPath)
}
export function compareVersions(
installed: VmManifest | null,
cached: VmManifest,
): VersionComparison {
if (!installed) return 'fresh'
const comparison = compareVersionStrings(
installed.updatedAt,
cached.updatedAt,
)
if (comparison === 0) return 'same'
return comparison < 0 ? 'upgrade' : 'downgrade'
}
export function agentForArch(
manifest: VmManifest,
name: string,
arch: Arch,
): {
image: string
version: string
tarball: VmAgentTarball
} {
const agent = manifest.agents[name]
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)
const tarball = agent.tarballs[arch]
if (!tarball) throw new Error(`missing ${arch} tarball for agent ${name}`)
return {
image: agent.image,
version: agent.version,
tarball,
}
}
async function readManifest(path: string): Promise<VmManifest> {
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
}
function compareVersionStrings(left: string, right: string): number {
if (left < right) return -1
if (left > right) return 1
return 0
}

View File

@@ -19,7 +19,6 @@ import { PATHS } from '@browseros/shared/constants/paths'
export const VM_NAME = 'browseros-vm'
export const GUEST_VM_STATE = '/mnt/browseros/vm'
export const GUEST_IMAGE_CACHE = '/mnt/browseros/cache/images'
const HOST_LIMACTL_BINARY = 'limactl'
export type Arch = 'arm64' | 'x64'
@@ -54,18 +53,6 @@ export function getVmCacheDir(browserosRoot = rootDir()): string {
return join(browserosRoot, PATHS.CACHE_DIR_NAME, 'vm')
}
export function getImageCacheDir(browserosRoot = rootDir()): string {
return join(getVmCacheDir(browserosRoot), 'images')
}
export function getCachedManifestPath(browserosRoot = rootDir()): string {
return join(getVmCacheDir(browserosRoot), 'manifest.json')
}
export function getInstalledManifestPath(browserosRoot = rootDir()): string {
return join(getVmStateDir(browserosRoot), 'manifest.json')
}
export function getContainerdSocketPath(browserosRoot = rootDir()): string {
return join(getLimaHomeDir(browserosRoot), VM_NAME, 'sock', 'containerd.sock')
}
@@ -110,7 +97,7 @@ export function resolveBundledLimactl(
const candidate = join(limaRoot, 'bin', 'limactl')
if (!existsSync(candidate)) {
throw new Error(
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
`bundled limactl not found at ${candidate}; refresh server resources from the build-tools README`,
)
}
assertBundledLimaGuestAgent(limaRoot, hostArch)
@@ -158,7 +145,7 @@ export function resolveBundledLimaTemplate(resourcesDir: string): string {
const candidate = join(resourcesDir, 'vm', 'browseros-vm.yaml')
if (!existsSync(candidate)) {
throw new Error(
`bundled Lima template not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
`bundled Lima template not found at ${candidate}; refresh server resources from the build-tools README`,
)
}
return candidate
@@ -215,16 +202,10 @@ export function hostPathToGuest(
browserosRoot = rootDir(),
): string {
const vmState = getVmStateDir(browserosRoot)
const imageCache = getImageCacheDir(browserosRoot)
const vmStateRelative = mountedRelativePath(vmState, hostPath)
if (vmStateRelative !== null)
return guestPath(GUEST_VM_STATE, vmStateRelative)
const imageCacheRelative = mountedRelativePath(imageCache, hostPath)
if (imageCacheRelative !== null) {
return guestPath(GUEST_IMAGE_CACHE, imageCacheRelative)
}
throw new Error(`host path ${hostPath} is not under any known guest mount`)
}

View File

@@ -11,19 +11,12 @@ export const VM_TELEMETRY_EVENTS = {
create: 'vm.create',
start: 'vm.start',
stop: 'vm.stop',
upgradeDetected: 'vm.upgrade.detected',
downgradeDetected: 'vm.downgrade.detected',
upgradeSwap: 'vm.upgrade.swap',
upgradeReplay: 'vm.upgrade.replay',
resetDetected: 'vm.reset.detected',
resetOk: 'vm.reset.ok',
nerdctlWaitStart: 'vm.nerdctl_wait.start',
nerdctlWaitOk: 'vm.nerdctl_wait.ok',
nerdctlWaitPoll: 'vm.nerdctl_wait.poll',
nerdctlWaitTimeout: 'vm.nerdctl_wait.timeout',
manifestMissing: 'vm.manifest.missing',
manifestCompared: 'vm.manifest.compared',
manifestWritten: 'vm.manifest.written',
migrationOpenClawMoved: 'vm.migration.openclaw_moved',
limaSpawn: 'vm.lima.spawn',
limaExit: 'vm.lima.exit',

View File

@@ -7,17 +7,10 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { logger } from '../logger'
import { ensureVmCacheAvailable } from './cache-sync'
import { LimaCommandError, VmError, VmNotReadyError } from './errors'
import { LimaCli } from './lima-cli'
import { renderLimaTemplate } from './lima-config'
import {
compareVersions,
readCachedManifest,
readInstalledManifest,
writeInstalledManifest,
} from './manifest'
import { getImageCacheDir, getVmStateDir, VM_NAME } from './paths'
import { getVmStateDir, VM_NAME } from './paths'
import { VM_TELEMETRY_EVENTS } from './telemetry'
export type LogFn = (msg: string) => void
@@ -31,7 +24,6 @@ export interface VmRuntimeDeps {
browserosRoot?: string
readinessTimeoutMs?: number
readinessPollMs?: number
ensureCacheAvailable?: () => Promise<void>
}
export class VmRuntime {
@@ -59,34 +51,17 @@ export class VmRuntime {
limactlPath: this.deps.limactlPath,
})
await this.ensureCacheAvailable()
const cached = await readCachedManifest(this.deps.browserosRoot)
const installed = await readInstalledManifest(this.deps.browserosRoot)
const versionComparison = compareVersions(installed, cached)
logger.debug(VM_TELEMETRY_EVENTS.manifestCompared, {
versionComparison,
installedUpdatedAt: installed?.updatedAt ?? null,
cachedUpdatedAt: cached.updatedAt,
})
const vms = await this.cli.list()
const existing = vms.find((vm) => vm.name === VM_NAME)
let shouldWriteInstalledManifest =
!existing || versionComparison === 'fresh' || versionComparison === 'same'
let branch = !existing
? 'provision-fresh'
: existing.status !== 'Running'
? 'start-existing'
: versionComparison === 'upgrade'
? 'running-upgrade-warn'
: versionComparison === 'downgrade'
? 'running-downgrade-warn'
: 'running-same'
: 'running'
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
branch,
existingStatus: existing?.status ?? null,
versionComparison,
})
if (!existing) {
@@ -101,28 +76,11 @@ export class VmRuntime {
(await this.needsContainerdReprovision())
) {
branch = 'recreate-legacy-runtime'
shouldWriteInstalledManifest = true
await this.recreateForContainerd(onLog)
} else if (versionComparison === 'upgrade') {
logger.warn(VM_TELEMETRY_EVENTS.upgradeDetected, {
from: installed?.updatedAt ?? null,
to: cached.updatedAt,
})
} else if (versionComparison === 'downgrade') {
logger.warn(VM_TELEMETRY_EVENTS.downgradeDetected, {
from: installed?.updatedAt ?? null,
to: cached.updatedAt,
})
}
}
await this.waitForRootlessNerdctl(this.readinessTimeoutMs)
if (shouldWriteInstalledManifest) {
await writeInstalledManifest(cached, this.deps.browserosRoot)
logger.debug(VM_TELEMETRY_EVENTS.manifestWritten, {
updatedAt: cached.updatedAt,
})
}
logger.info(VM_TELEMETRY_EVENTS.ensureReadyOk, {
durationMs: Date.now() - started,
@@ -220,14 +178,6 @@ export class VmRuntime {
})
}
private async ensureCacheAvailable(): Promise<void> {
if (this.deps.ensureCacheAvailable) {
await this.deps.ensureCacheAvailable()
return
}
await ensureVmCacheAvailable({ browserosRoot: this.deps.browserosRoot })
}
private async recreateForContainerd(onLog?: LogFn): Promise<void> {
onLog?.('Recreating BrowserOS VM for containerd runtime...')
try {
@@ -271,7 +221,6 @@ export class VmRuntime {
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
vmStateDir: getVmStateDir(this.deps.browserosRoot),
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
})
}

View File

@@ -35,7 +35,6 @@ import { metrics } from './lib/metrics'
import { isPortInUseError } from './lib/port-binding'
import { Sentry } from './lib/sentry'
import { seedSoulTemplate } from './lib/soul'
import { prefetchVmCache } from './lib/vm/cache-sync'
import { migrateBuiltinSkills } from './skills/migrate'
import {
startSkillSync,
@@ -61,7 +60,7 @@ export class Application {
})
const resourcesDir = path.resolve(this.config.resourcesDir)
configureVmRuntime({ resourcesDir, vmCache: this.vmCacheConfig() })
configureVmRuntime({ resourcesDir })
await this.initCoreServices()
if (!this.config.cdpPort) {
@@ -132,17 +131,20 @@ export class Application {
// handles async throws inside auto-start. Wrap both in try/catch so the
// process keeps running even when OpenClaw can't initialize at all.
try {
configureOpenClawService({
const openClawService = configureOpenClawService({
browserosServerPort: this.config.serverPort,
resourcesDir,
vmCache: this.vmCacheConfig(),
})
.tryAutoStart()
.catch((err) =>
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
void openClawService.prewarm().catch((err) =>
logger.warn('OpenClaw prewarm failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
void openClawService.tryAutoStart().catch((err) =>
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
} catch (err) {
logger.warn('OpenClaw configuration failed, continuing without it', {
error: err instanceof Error ? err.message : String(err),
@@ -174,7 +176,6 @@ export class Application {
private async initCoreServices(): Promise<void> {
this.configureLogDirectory()
await ensureBrowserosDir()
this.startVmCachePrefetch()
await cleanOldSessions()
await seedSoulTemplate()
await migrateBuiltinSkills()
@@ -223,25 +224,6 @@ export class Application {
})
}
private startVmCachePrefetch(): void {
if (!this.config.vmCachePrefetch) return
void prefetchVmCache({
manifestUrl: this.config.vmCacheManifestUrl,
}).catch((error) => {
logger.warn('BrowserOS VM cache prefetch failed', {
error: error instanceof Error ? error.message : String(error),
})
})
}
private vmCacheConfig(): {
manifestUrl: string
} {
return {
manifestUrl: this.config.vmCacheManifestUrl,
}
}
private configureLogDirectory(): void {
const logDir = this.config.executionDir
const resolvedDir = path.isAbsolute(logDir)

View File

@@ -3,7 +3,7 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
@@ -83,6 +83,10 @@ describe('container-runtime factory', () => {
running: false,
})
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
await expect(runtime.prewarmGatewayImage()).rejects.toThrow(
'supports macOS only',
)
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
@@ -102,24 +106,15 @@ describe('container-runtime factory', () => {
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
})
it('syncs the VM cache before deferred image loading reads the manifest', async () => {
const ensureSynced = mock(async () => {
throw new Error('cache sync sentinel')
})
it('builds a runtime whose image loader pulls directly through nerdctl', async () => {
const runtime = buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'darwin',
vmCache: {
ensureSynced,
},
})
await expect(
runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12'),
).rejects.toThrow('cache sync sentinel')
expect(ensureSynced).toHaveBeenCalledTimes(1)
expect(runtime).toBeDefined()
})
it('leaves both directories in place when new OpenClaw state already exists', async () => {

View File

@@ -4,11 +4,13 @@
*/
import { describe, expect, it, mock } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
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 = {
hostPort: 18789,
hostHome: '/Users/me/.browseros/vm/openclaw',
@@ -41,7 +43,7 @@ describe('ContainerRuntime', () => {
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: GATEWAY_IMAGE_REF,
image: OPENCLAW_IMAGE,
restart: 'unless-stopped',
ports: [
{
@@ -137,7 +139,7 @@ describe('ContainerRuntime', () => {
'/mnt/browseros/vm/openclaw:/home/node',
'--add-host',
'host.containers.internal:192.168.5.2',
GATEWAY_IMAGE_REF,
OPENCLAW_IMAGE,
]),
undefined,
)
@@ -175,6 +177,70 @@ describe('ContainerRuntime', () => {
)
expect(logs).toEqual(['log line'])
})
it('prewarms the gateway image without creating a container', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.prewarmGatewayImage()
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
)
expect(deps.shell.createContainer).not.toHaveBeenCalled()
})
it('detects when the gateway container uses the current image', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(async () => OPENCLAW_IMAGE)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
expect(deps.shell.containerImageRef).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('treats a digest-qualified current image ref as current', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(
async () => `${OPENCLAW_IMAGE}@sha256:${'a'.repeat(64)}`,
)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
})
it('detects when the gateway container uses an old image', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(
async () => 'ghcr.io/openclaw/openclaw:old',
)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
})
})
function createDeps() {
@@ -190,6 +256,7 @@ function createDeps() {
startContainer: mock(async () => {}),
stopContainer: mock(async () => {}),
removeContainer: mock(async () => {}),
containerImageRef: mock(async () => OPENCLAW_IMAGE),
exec: mock(async () => 0),
runCommand: mock(
async (_args: string[], onLog?: (line: string) => void) => {
@@ -201,7 +268,7 @@ function createDeps() {
},
loader: {
ensureImageLoaded: mock(async () => {}),
ensureAgentImageLoaded: mock(async () => GATEWAY_IMAGE_REF),
ensureAgentImageLoaded: mock(async () => OPENCLAW_IMAGE),
},
}
}

View File

@@ -8,7 +8,10 @@ import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import {
resolveSupportedOpenClawProvider,
UnsupportedOpenClawProviderError,
@@ -23,11 +26,13 @@ type MutableOpenClawService = OpenClawService & {
token: string
restart: ReturnType<typeof mock>
runtime: {
ensureReady?: () => Promise<void>
ensureReady?: (_onLog?: (_line: string) => void) => Promise<void>
isPodmanAvailable?: () => Promise<boolean>
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
isHealthy?: (_hostPort?: number) => Promise<boolean>
isReady: (_hostPort?: number) => Promise<boolean>
prewarmGatewayImage?: (_onLog?: (_line: string) => void) => Promise<void>
isGatewayCurrent?: () => Promise<boolean>
pullImage?: (
_image: string,
_onLog?: (_line: string) => void,
@@ -87,6 +92,60 @@ describe('OpenClawService', () => {
return forced >= 65000 ? forced - 10 : forced + 10
}
it('prewarms the VM and gateway image', async () => {
const ensureReady = mock(async () => {})
const prewarmGatewayImage = mock(async () => {})
const logs: string[] = []
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
ensureReady,
isReady: async () => false,
prewarmGatewayImage,
}
await service.prewarm((line) => logs.push(line))
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(prewarmGatewayImage).toHaveBeenCalledTimes(1)
expect(ensureReady.mock.calls[0]?.length).toBe(0)
expect(prewarmGatewayImage.mock.calls[0]?.length).toBe(0)
expect(logs).toContain('OpenClaw prewarm: ensuring BrowserOS VM is ready')
expect(logs).toContain(
`OpenClaw prewarm: ensuring image ${OPENCLAW_IMAGE} is available`,
)
expect(logs).toContain('OpenClaw prewarm: ready')
})
it('logs the overridden image ref during prewarm', async () => {
const originalImage = process.env.OPENCLAW_IMAGE
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
const ensureReady = mock(async () => {})
const prewarmGatewayImage = mock(async () => {})
const logs: string[] = []
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
ensureReady,
isReady: async () => false,
prewarmGatewayImage,
}
try {
await service.prewarm((line) => logs.push(line))
} finally {
if (originalImage === undefined) {
delete process.env.OPENCLAW_IMAGE
} else {
process.env.OPENCLAW_IMAGE = originalImage
}
}
expect(logs).toContain(
'OpenClaw prewarm: ensuring image localhost/openclaw:test is available',
)
})
it('creates agents through the cli client without role bootstrap files', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
const createAgent = mock(async () => ({
@@ -657,6 +716,7 @@ describe('OpenClawService', () => {
service.runtime = {
ensureReady,
isReady: async () => gatewayReady,
isGatewayCurrent: mock(async () => true),
startGateway,
waitForReady,
}
@@ -700,6 +760,7 @@ describe('OpenClawService', () => {
service.runtime = {
ensureReady,
isReady: async () => true,
isGatewayCurrent: mock(async () => true),
startGateway,
waitForReady,
}
@@ -948,6 +1009,7 @@ describe('OpenClawService', () => {
isPodmanAvailable: async () => true,
ensureReady,
isReady,
isGatewayCurrent: mock(async () => true),
startGateway,
waitForReady,
}
@@ -971,6 +1033,71 @@ describe('OpenClawService', () => {
expect(isReady).toHaveBeenCalledTimes(2)
})
it('tryAutoStart reuses a ready gateway when the image is current', 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 () => true)
const isGatewayCurrent = mock(async () => true)
const startGateway = mock(async () => {})
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady,
isGatewayCurrent,
startGateway,
}
service.cliClient = { probe }
mockGatewayAuth()
await service.tryAutoStart()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(isGatewayCurrent).toHaveBeenCalledTimes(1)
expect(startGateway).not.toHaveBeenCalled()
expect(probe).toHaveBeenCalledTimes(1)
})
it('tryAutoStart recreates a ready gateway when the image is stale', 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 () => true)
const isGatewayCurrent = 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 = {
ensureReady,
isReady,
isGatewayCurrent,
startGateway,
waitForReady,
}
service.cliClient = { probe }
mockGatewayAuth()
await service.tryAutoStart()
expect(startGateway).toHaveBeenCalledTimes(1)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
})
it('keeps openrouter model refs verbatim without rewriting dots', () => {
const provider = resolveSupportedOpenClawProvider({
providerType: 'openrouter',

View File

@@ -8,7 +8,6 @@ import { homedir } from 'node:os'
import { join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import {
getAgentCacheDir,
getBrowserosDir,
getCacheDir,
getVmCacheDir,
@@ -106,12 +105,4 @@ describe('getBrowserosDir', () => {
join(homedir(), '.browseros-dev', 'cache', 'vm'),
)
})
it('uses an agent image cache directory below vm cache', () => {
process.env.NODE_ENV = 'development'
expect(getAgentCacheDir()).toBe(
join(homedir(), '.browseros-dev', 'cache', 'vm', 'images'),
)
})
})

View File

@@ -34,8 +34,6 @@ const REQUIRED_INLINE_ENV_KEYS = [
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'SENTRY_DSN',
'BROWSEROS_VM_CACHE_PREFETCH',
'BROWSEROS_VM_CACHE_MANIFEST_URL',
] as const
const R2_ENV_KEYS = [
@@ -52,8 +50,6 @@ const INLINE_ENV_STUBS: Record<string, string> = {
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
POSTHOG_API_KEY: 'phc_test_stub',
SENTRY_DSN: 'https://stub@sentry.test/0',
BROWSEROS_VM_CACHE_PREFETCH: 'true',
BROWSEROS_VM_CACHE_MANIFEST_URL: 'https://stub.test/vm/manifest.json',
}
const R2_ENV_STUBS: Record<string, string> = {

View File

@@ -28,8 +28,6 @@ describe('loadServerConfig', () => {
delete process.env.BROWSEROS_INSTALL_ID
delete process.env.BROWSEROS_CLIENT_ID
delete process.env.BROWSEROS_AI_SDK_DEVTOOLS
delete process.env.BROWSEROS_VM_CACHE_PREFETCH
delete process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
})
afterEach(() => {
@@ -446,75 +444,6 @@ describe('loadServerConfig', () => {
if (!result.ok) return
assert.strictEqual(result.value.aiSdkDevtoolsEnabled, false)
})
it('defaults VM cache runtime sync settings', () => {
const result = loadServerConfig([
'bun',
'src/index.ts',
'--server-port=3000',
])
assert.strictEqual(result.ok, true)
if (!result.ok) return
assert.strictEqual(result.value.vmCachePrefetch, true)
assert.strictEqual(
result.value.vmCacheManifestUrl,
'https://cdn.browseros.com/vm/manifest.json',
)
})
})
describe('VM cache runtime sync', () => {
it('reads VM cache settings from env', () => {
process.env.BROWSEROS_VM_CACHE_PREFETCH = 'false'
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
' https://manifest.test/vm.json '
const result = loadServerConfig([
'bun',
'src/index.ts',
'--server-port=3000',
])
assert.strictEqual(result.ok, true)
if (!result.ok) return
assert.strictEqual(result.value.vmCachePrefetch, false)
assert.strictEqual(
result.value.vmCacheManifestUrl,
'https://manifest.test/vm.json',
)
})
it('reads VM cache settings from config with file precedence over env', () => {
process.env.BROWSEROS_VM_CACHE_PREFETCH = 'false'
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
'https://env.test/manifest.json'
const configPath = path.join(tempDir, 'config.json')
fs.writeFileSync(
configPath,
JSON.stringify({
ports: { server: 3000 },
vm_cache: {
prefetch: true,
manifest_url: ' https://config.test/vm/manifest.json ',
},
}),
)
const result = loadServerConfig([
'bun',
'src/index.ts',
`--config=${configPath}`,
])
assert.strictEqual(result.ok, true)
if (!result.ok) return
assert.strictEqual(result.value.vmCachePrefetch, true)
assert.strictEqual(
result.value.vmCacheManifestUrl,
'https://config.test/vm/manifest.json',
)
})
})
describe('AI SDK DevTools', () => {

View File

@@ -5,15 +5,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { ContainerCli } from '../../src/lib/container'
import { LimaCli, type VmManifest, VmRuntime } from '../../src/lib/vm'
import {
getCachedManifestPath,
getContainerdSocketPath,
VM_NAME,
} from '../../src/lib/vm/paths'
import { LimaCli, VmRuntime } from '../../src/lib/vm'
import { getContainerdSocketPath, VM_NAME } from '../../src/lib/vm/paths'
const LIVE_VM_SMOKE_TIMEOUT_MS = 10 * 60 * 1000
const liveIt = process.env.LIVE_VM_SMOKE === '1' ? it : it.skip
@@ -23,12 +19,6 @@ const templatePath = resolve(
'../../../../packages/build-tools/template/browseros-vm.yaml',
)
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {},
}
describe('BrowserOS VM live smoke', () => {
let root: string
let limaHome: string
@@ -36,9 +26,6 @@ describe('BrowserOS VM live smoke', () => {
beforeEach(async () => {
root = await mkdtemp('/tmp/bovm-')
limaHome = join(root, 'lima')
const manifestPath = getCachedManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
})
afterEach(async () => {

View File

@@ -42,6 +42,35 @@ describe('ContainerCli', () => {
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(false)
})
it('reads a container configured image ref', async () => {
const sshPath = await fakeSsh(
{ stdout: 'ghcr.io/openclaw/openclaw:2026.4.12\n' },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.containerImageRef('gateway')).resolves.toBe(
'ghcr.io/openclaw/openclaw:2026.4.12',
)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'inspect' '--format' '{{.Config.Image}}' 'gateway'`,
)
})
it('returns null when reading a missing container image ref', async () => {
const sshPath = await fakeSsh(
{
stderr: 'no such container',
exit: 1,
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.containerImageRef('missing')).resolves.toBeNull()
})
it('pulls images with progress and throws typed command errors', async () => {
const sshPath = await fakeSsh(
{ stdout: 'pulling\n', stderr: 'denied', exit: 2 },
@@ -61,21 +90,6 @@ describe('ContainerCli', () => {
expect(lines).toContain('denied')
})
it('loads images from guest tarballs and returns loaded refs', async () => {
const sshPath = await fakeSsh(
{ stdout: 'Loaded image(s): openclaw:v1\n' },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(
cli.loadImage('/mnt/browseros/cache/images/openclaw.tar.gz'),
).resolves.toEqual(['openclaw:v1'])
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'load' '-i' '/mnt/browseros/cache/images/openclaw.tar.gz'`,
)
})
it('creates containers from typed specs', async () => {
const sshPath = await fakeSsh({}, logPath)
const cli = await createCli(sshPath, tempDir)

View File

@@ -3,197 +3,83 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_IMAGE } from '@browseros/shared/constants/openclaw'
import type { ContainerCli } from '../../../src/lib/container/container-cli'
import { ImageLoader } from '../../../src/lib/container/image-loader'
import { ContainerCliError, ImageLoadError } from '../../../src/lib/vm/errors'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import * as paths from '../../../src/lib/vm/paths'
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
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,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'agent-x64',
sizeBytes: 1,
},
},
},
},
}
describe('ImageLoader', () => {
afterEach(() => {
mock.restore()
})
it('returns without loading when the image already exists', async () => {
it('returns without pulling when the image already exists', async () => {
const cli = new FakeContainerCli([true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const loader = new ImageLoader(cli as never)
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
await loader.ensureImageLoaded(OPENCLAW_IMAGE)
expect(cli.loadCalls).toEqual([])
expect(cli.pullCalls).toEqual([])
expect(cli.existsCalls).toEqual([OPENCLAW_IMAGE])
})
it('loads a missing image from the guest cache and verifies it exists', async () => {
it('pulls a missing image and verifies it exists', async () => {
const cli = new FakeContainerCli([false, true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const loader = new ImageLoader(cli as never)
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
await loader.ensureImageLoaded(OPENCLAW_IMAGE)
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',
])
expect(cli.pullCalls).toEqual([OPENCLAW_IMAGE])
expect(cli.existsCalls).toEqual([OPENCLAW_IMAGE, OPENCLAW_IMAGE])
})
it('loads an agent image by manifest name and returns its image ref', async () => {
it('loads the OpenClaw agent image by manifest name', async () => {
const cli = new FakeContainerCli([false, true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const loader = new ImageLoader(cli as never)
await expect(loader.ensureAgentImageLoaded('openclaw')).resolves.toBe(
'ghcr.io/openclaw/openclaw:2026.4.12',
OPENCLAW_IMAGE,
)
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',
])
expect(cli.pullCalls).toEqual([OPENCLAW_IMAGE])
})
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 () => {
it('throws ImageLoadError for unknown agent names', async () => {
const cli = new FakeContainerCli([])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const loader = new ImageLoader(cli as never)
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'
const loader = new ImageLoader(
cli as never,
manifest,
'arm64',
browserosRoot,
)
const getImageCacheDir = spyOn(paths, 'getImageCacheDir')
const hostPathToGuest = spyOn(paths, 'hostPathToGuest')
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
expect(getImageCacheDir).toHaveBeenCalledWith(browserosRoot)
expect(hostPathToGuest).toHaveBeenCalledWith(
'/tmp/browseros-custom-root/cache/vm/images/openclaw-2026.4.12-arm64.tar.gz',
browserosRoot,
)
})
it('throws ImageLoadError when a loaded image is still absent', async () => {
const cli = new FakeContainerCli([false, false])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(
loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12'),
).rejects.toThrow(ImageLoadError)
})
it('throws ImageLoadError for unknown refs without loading', async () => {
const cli = new FakeContainerCli([false])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(loader.ensureImageLoaded('missing:v1')).rejects.toThrow(
await expect(loader.ensureAgentImageLoaded('missing')).rejects.toThrow(
ImageLoadError,
)
expect(cli.loadCalls).toEqual([])
expect(cli.pullCalls).toEqual([])
})
it('wraps ContainerCliError load failures as ImageLoadError', async () => {
it('throws ImageLoadError when pull succeeds but image is still absent', async () => {
const cli = new FakeContainerCli([false, false])
const loader = new ImageLoader(cli as never)
await expect(loader.ensureImageLoaded(OPENCLAW_IMAGE)).rejects.toThrow(
ImageLoadError,
)
})
it('wraps ContainerCliError pull failures as ImageLoadError', async () => {
const cli = new FakeContainerCli([false])
cli.loadError = new ContainerCliError('nerdctl load', 125, 'bad archive')
const loader = new ImageLoader(cli as never, manifest, 'arm64')
cli.pullError = new ContainerCliError('nerdctl pull', 1, 'network failed')
const loader = new ImageLoader(cli as never)
const error = await loader
.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
.ensureImageLoaded(OPENCLAW_IMAGE)
.catch((err) => err)
expect(error).toBeInstanceOf(ImageLoadError)
expect(error.cause).toBe(cli.loadError)
expect(error.cause).toBe(cli.pullError)
})
})
class FakeContainerCli
implements Pick<ContainerCli, 'imageExists' | 'loadImage'>
implements Pick<ContainerCli, 'imageExists' | 'pullImage'>
{
existsCalls: string[] = []
loadCalls: string[] = []
loadError: Error | null = null
pullCalls: string[] = []
pullError: Error | null = null
constructor(private readonly existsResponses: boolean[]) {}
@@ -202,9 +88,8 @@ class FakeContainerCli
return this.existsResponses.shift() ?? false
}
async loadImage(path: string): Promise<string[]> {
this.loadCalls.push(path)
if (this.loadError) throw this.loadError
return ['loaded']
async pullImage(ref: string): Promise<void> {
this.pullCalls.push(ref)
if (this.pullError) throw this.pullError
}
}

View File

@@ -1,431 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
ensureVmCacheAvailable,
ensureVmCacheSynced,
prefetchVmCache,
} from '../../../src/lib/vm/cache-sync'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import { getCachedManifestPath } from '../../../src/lib/vm/paths'
const CDN_BASE = 'https://cdn.test'
const MANIFEST_URL = `${CDN_BASE}/vm/manifest.json`
const TARBALL_KEY = 'vm/images/openclaw-2026.4.12-arm64.tar.gz'
const TARBALL_BYTES = new TextEncoder().encode('openclaw-tarball')
const TARBALL_SHA = sha256(TARBALL_BYTES)
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-24T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: TARBALL_KEY,
sha256: TARBALL_SHA,
sizeBytes: TARBALL_BYTES.byteLength,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'unused',
sizeBytes: 1,
},
},
},
},
}
describe('runtime VM cache sync', () => {
let root: string
let originalManifestUrl: string | undefined
beforeEach(async () => {
root = await mkdtemp('/tmp/browseros-vm-cache-sync-')
originalManifestUrl = process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
delete process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
})
afterEach(async () => {
restoreEnv('BROWSEROS_VM_CACHE_MANIFEST_URL', originalManifestUrl)
await rm(root, { recursive: true, force: true })
})
it('downloads the host-arch tarball, verifies it, and writes the manifest last', async () => {
const calls: string[] = []
const fetchImpl = fakeVmCacheFetch(calls)
const result = await ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
expect(result).toEqual({
downloaded: [TARBALL_KEY],
manifestPath: getCachedManifestPath(root),
skipped: false,
})
expect(
JSON.parse(await readFile(getCachedManifestPath(root), 'utf8')),
).toEqual(manifest)
expect(await readFile(join(root, 'cache', TARBALL_KEY), 'utf8')).toBe(
'openclaw-tarball',
)
await expect(
stat(join(root, 'cache', `${TARBALL_KEY}.partial`)),
).rejects.toThrow()
})
it('uses the runtime env manifest URL and resolves artifacts beside it', async () => {
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
'https://artifacts.test/vm/manifest.json'
const calls: string[] = []
const fetchImpl = fakeVmCacheFetch(calls, {
manifestUrl: 'https://artifacts.test/vm/manifest.json',
tarballUrl: `https://artifacts.test/${TARBALL_KEY}`,
})
await ensureVmCacheSynced({
browserosRoot: root,
fetchImpl,
rawHostArch: 'arm64',
})
expect(calls).toEqual([
'https://artifacts.test/vm/manifest.json',
`https://artifacts.test/${TARBALL_KEY}`,
])
})
it('skips downloads when the matching manifest and tarball already exist', async () => {
await writeLocalManifest(root)
await writeLocalTarball(root)
const calls: string[] = []
const result = await ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl: fakeVmCacheFetch(calls),
rawHostArch: 'arm64',
})
expect(calls).toEqual([MANIFEST_URL])
expect(result.downloaded).toEqual([])
expect(result.skipped).toBe(true)
})
it('downloads a tarball when the manifest matches but the file is missing', async () => {
await writeLocalManifest(root)
const calls: string[] = []
const result = await ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl: fakeVmCacheFetch(calls),
rawHostArch: 'arm64',
})
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
expect(result.downloaded).toEqual([TARBALL_KEY])
expect(await readFile(join(root, 'cache', TARBALL_KEY), 'utf8')).toBe(
'openclaw-tarball',
)
})
it('uses an existing tarball when the local manifest is missing but the hash matches', async () => {
await writeLocalTarball(root)
const calls: string[] = []
const result = await ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl: fakeVmCacheFetch(calls),
rawHostArch: 'arm64',
})
expect(calls).toEqual([MANIFEST_URL])
expect(result.downloaded).toEqual([])
expect(result.skipped).toBe(true)
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
`${JSON.stringify(manifest, null, 2)}\n`,
)
})
it('shares concurrent prefetch calls through one in-flight sync', async () => {
const calls: string[] = []
let resolveManifest: (response: Response) => void = () => {}
const manifestResponse = new Promise<Response>((resolve) => {
resolveManifest = resolve
})
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (url === MANIFEST_URL) return manifestResponse
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}
const first = prefetchVmCache({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
const second = prefetchVmCache({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
expect(second).toBe(first)
expect(calls).toEqual([MANIFEST_URL])
resolveManifest(jsonResponse(manifest))
await expect(first).resolves.toEqual({
downloaded: [TARBALL_KEY],
manifestPath: getCachedManifestPath(root),
skipped: false,
})
await expect(second).resolves.toEqual({
downloaded: [TARBALL_KEY],
manifestPath: getCachedManifestPath(root),
skipped: false,
})
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
})
it('syncs different roots independently while another sync is in flight', async () => {
const otherRoot = await mkdtemp('/tmp/browseros-vm-cache-sync-other-')
try {
const calls: string[] = []
let resolveManifest: (response: Response) => void = () => {}
const manifestResponse = new Promise<Response>((resolve) => {
resolveManifest = resolve
})
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (calls.length === 1 && url === MANIFEST_URL) return manifestResponse
if (url === MANIFEST_URL) return jsonResponse(manifest)
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}
const first = prefetchVmCache({
browserosRoot: otherRoot,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
const second = ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
expect(second).not.toBe(first)
await second
resolveManifest(jsonResponse(manifest))
await first
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
`${JSON.stringify(manifest, null, 2)}\n`,
)
await expect(
readFile(getCachedManifestPath(otherRoot), 'utf8'),
).resolves.toBe(`${JSON.stringify(manifest, null, 2)}\n`)
expect(calls).toEqual([
MANIFEST_URL,
MANIFEST_URL,
`${CDN_BASE}/${TARBALL_KEY}`,
`${CDN_BASE}/${TARBALL_KEY}`,
])
} finally {
await rm(otherRoot, { recursive: true, force: true })
}
})
it('retries on-demand availability after an in-flight prefetch fails', async () => {
const calls: string[] = []
let resolveManifest: (response: Response) => void = () => {}
const manifestResponse = new Promise<Response>((resolve) => {
resolveManifest = resolve
})
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (calls.length === 1 && url === MANIFEST_URL) return manifestResponse
if (url === MANIFEST_URL) return jsonResponse(manifest)
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}
const first = prefetchVmCache({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
}).catch((error) => error)
const available = ensureVmCacheAvailable({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
resolveManifest(new Response('', { status: 503 }))
await expect(first).resolves.toBeInstanceOf(Error)
await available
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
`${JSON.stringify(manifest, null, 2)}\n`,
)
expect(calls).toEqual([
MANIFEST_URL,
MANIFEST_URL,
`${CDN_BASE}/${TARBALL_KEY}`,
])
})
it('clears failed in-flight syncs so a later call can retry', async () => {
const calls: string[] = []
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (calls.length === 1) return new Response('', { status: 503 })
if (url === MANIFEST_URL) return jsonResponse(manifest)
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}
await expect(
ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
}),
).rejects.toThrow('manifest fetch failed')
await expect(
ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
}),
).resolves.toEqual({
downloaded: [TARBALL_KEY],
manifestPath: getCachedManifestPath(root),
skipped: false,
})
expect(calls).toEqual([
MANIFEST_URL,
MANIFEST_URL,
`${CDN_BASE}/${TARBALL_KEY}`,
])
})
it('removes the partial file when sha256 verification fails', async () => {
const badBytes = new TextEncoder().encode('bad-tarball')
const fetchImpl = (async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
if (url === MANIFEST_URL) return jsonResponse(manifest)
if (url === `${CDN_BASE}/${TARBALL_KEY}`) return new Response(badBytes)
return new Response('', { status: 404 })
}) as typeof fetch
await expect(
ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
}),
).rejects.toThrow('sha256 mismatch')
await expect(stat(join(root, 'cache', TARBALL_KEY))).rejects.toThrow()
await expect(
stat(join(root, 'cache', `${TARBALL_KEY}.partial`)),
).rejects.toThrow()
})
it('rejects unsupported host architectures before fetching', async () => {
const calls: string[] = []
await expect(
ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl: fakeVmCacheFetch(calls),
rawHostArch: 'arm',
}),
).rejects.toThrow('unsupported host arch: arm')
expect(calls).toEqual([])
})
})
function fakeVmCacheFetch(
calls: string[],
opts?: { manifestUrl?: string; tarballUrl?: string },
): typeof fetch {
const manifestUrl = opts?.manifestUrl ?? MANIFEST_URL
const tarballUrl = opts?.tarballUrl ?? `${CDN_BASE}/${TARBALL_KEY}`
return (async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (url === manifestUrl) return jsonResponse(manifest)
if (url === tarballUrl) return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}) as typeof fetch
}
function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), {
headers: { 'content-type': 'application/json' },
})
}
async function writeLocalManifest(root: string): Promise<void> {
const path = getCachedManifestPath(root)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`)
}
async function writeLocalTarball(root: string): Promise<void> {
const path = join(root, 'cache', TARBALL_KEY)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, TARBALL_BYTES)
}
function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex')
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}

View File

@@ -8,7 +8,6 @@ import {
ContainerCliError,
ImageLoadError,
LimaCommandError,
ManifestMissingError,
VmError,
VmNotReadyError,
VmStateCorruptedError,
@@ -24,7 +23,6 @@ describe('VM errors', () => {
new LimaCommandError('limactl start', 7, 'bad lima'),
new ContainerCliError('nerdctl pull', 8, 'bad nerdctl'),
new ImageLoadError('openclaw:v1', 'bad image'),
new ManifestMissingError('/tmp/manifest.json'),
]
for (const error of errors) {
@@ -48,8 +46,30 @@ describe('VM errors', () => {
})
it('exports VM telemetry event names', () => {
expect(Object.keys(VM_TELEMETRY_EVENTS)).toEqual([
'ensureReadyStart',
'ensureReadyOk',
'ensureReadyBranch',
'create',
'start',
'stop',
'resetDetected',
'resetOk',
'nerdctlWaitStart',
'nerdctlWaitOk',
'nerdctlWaitPoll',
'nerdctlWaitTimeout',
'migrationOpenClawMoved',
'limaSpawn',
'limaExit',
'limaStderrChunk',
'provisionYamlWrite',
'provisionCreateStart',
'provisionCreateOk',
'provisionStartBegin',
'provisionStartOk',
])
expect(VM_TELEMETRY_EVENTS.ensureReadyStart).toBe('vm.ensure_ready.start')
expect(VM_TELEMETRY_EVENTS.downgradeDetected).toBe('vm.downgrade.detected')
expect(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout).toBe(
'vm.nerdctl_wait.timeout',
)

View File

@@ -12,14 +12,11 @@ describe('renderLimaTemplate', () => {
'minimumLimaVersion: 2.0.0\nmounts: []\nprobes: []\n',
{
vmStateDir: '/Users/me/.browseros/vm',
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
},
)
expect(yaml).toContain('mountPoint: "/mnt/browseros/vm"')
expect(yaml).toContain('location: "/Users/me/.browseros/vm"')
expect(yaml).toContain('mountPoint: "/mnt/browseros/cache/images"')
expect(yaml).toContain('location: "/Users/me/.browseros/cache/vm/images"')
expect(yaml).toContain('probes: []')
})
@@ -27,7 +24,6 @@ describe('renderLimaTemplate', () => {
expect(() =>
renderLimaTemplate('minimumLimaVersion: 2.0.0\n', {
vmStateDir: '/state',
imageCacheDir: '/images',
}),
).toThrow('mounts: [] marker')
})

View File

@@ -1,137 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { ManifestMissingError } from '../../../src/lib/vm/errors'
import {
agentForArch,
compareVersions,
readCachedManifest,
readInstalledManifest,
type VmManifest,
writeInstalledManifest,
} from '../../../src/lib/vm/manifest'
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'c',
sizeBytes: 3,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'd',
sizeBytes: 4,
},
},
},
},
}
describe('VM manifest helpers', () => {
let root: string
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'browseros-vm-manifest-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('reads the cached manifest', async () => {
const manifestPath = join(root, 'cache', 'vm', 'manifest.json')
await mkdir(dirname(manifestPath), { recursive: true })
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
await expect(readCachedManifest(root)).resolves.toEqual(manifest)
})
it('throws ManifestMissingError when cached manifest is absent', async () => {
await expect(readCachedManifest(root)).rejects.toThrow(ManifestMissingError)
})
it('returns null for a missing installed manifest', async () => {
await expect(readInstalledManifest(root)).resolves.toBeNull()
})
it('reads the installed manifest', async () => {
const manifestPath = join(root, 'vm', 'manifest.json')
await mkdir(dirname(manifestPath), { recursive: true })
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
await expect(readInstalledManifest(root)).resolves.toEqual(manifest)
})
it('throws on malformed installed manifest JSON', async () => {
const manifestPath = join(root, 'vm', 'manifest.json')
await mkdir(dirname(manifestPath), { recursive: true })
await Bun.write(manifestPath, '{not-json')
await expect(readInstalledManifest(root)).rejects.toThrow()
})
it('writes the installed manifest atomically', async () => {
await writeInstalledManifest(manifest, root)
const raw = await readFile(join(root, 'vm', 'manifest.json'), 'utf8')
expect(JSON.parse(raw)).toEqual(manifest)
})
it('compares installed and cached versions', () => {
const older = { ...manifest, updatedAt: '2026-04-21T00:00:00.000Z' }
const newer = { ...manifest, updatedAt: '2026-04-23T00:00:00.000Z' }
expect(compareVersions(null, manifest)).toBe('fresh')
expect(compareVersions(manifest, manifest)).toBe('same')
expect(compareVersions(older, manifest)).toBe('upgrade')
expect(compareVersions(newer, manifest)).toBe('downgrade')
})
it('compares ISO timestamp versions with time-of-day precision', () => {
const morning = {
...manifest,
updatedAt: '2026-04-22T10:00:00.000Z',
}
const afternoon = {
...manifest,
updatedAt: '2026-04-22T15:00:00.000Z',
}
expect(compareVersions(morning, afternoon)).toBe('upgrade')
expect(compareVersions(afternoon, morning)).toBe('downgrade')
})
it('returns the requested agent tarball for an arch', () => {
expect(agentForArch(manifest, 'openclaw', 'arm64')).toEqual({
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarball: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'c',
sizeBytes: 3,
},
})
})
it('throws when an agent or arch is absent', () => {
expect(() => agentForArch(manifest, 'missing', 'arm64')).toThrow(
'missing agent',
)
expect(() =>
agentForArch(manifest, 'openclaw', 'x64' as never),
).not.toThrow()
})
})

View File

@@ -14,10 +14,7 @@ import {
} from '../../../src/lib/browseros-dir'
import {
detectArch,
getCachedManifestPath,
getContainerdSocketPath,
getImageCacheDir,
getInstalledManifestPath,
getLimaHomeDir,
getVmCacheDir,
getVmStateDir,
@@ -81,17 +78,10 @@ describe('VM paths', () => {
)
})
it('builds cached and installed manifest paths', () => {
it('builds VM storage paths', () => {
const root = '/Users/foo/.browseros'
expect(getVmCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm')
expect(getImageCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm/images')
expect(getCachedManifestPath(root)).toBe(
'/Users/foo/.browseros/cache/vm/manifest.json',
)
expect(getInstalledManifestPath(root)).toBe(
'/Users/foo/.browseros/vm/manifest.json',
)
expect(getContainerdSocketPath(root)).toBe(
'/Users/foo/.browseros/lima/browseros-vm/sock/containerd.sock',
)
@@ -103,9 +93,6 @@ describe('VM paths', () => {
expect(hostPathToGuest('/Users/foo/.browseros/vm/openclaw/x', root)).toBe(
'/mnt/browseros/vm/openclaw/x',
)
expect(
hostPathToGuest('/Users/foo/.browseros/cache/vm/images/a.tar.gz', root),
).toBe('/mnt/browseros/cache/images/a.tar.gz')
})
it('rejects unmapped host paths', () => {

View File

@@ -3,7 +3,7 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
chmod,
mkdir,
@@ -12,43 +12,13 @@ import {
rm,
writeFile,
} from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { logger } from '../../../src/lib/logger'
import { join } from 'node:path'
import { VmNotReadyError } from '../../../src/lib/vm/errors'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import {
getCachedManifestPath,
getInstalledManifestPath,
VM_NAME,
} from '../../../src/lib/vm/paths'
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
import { VM_NAME } from '../../../src/lib/vm/paths'
import { VmRuntime } from '../../../src/lib/vm/vm-runtime'
import { fakeLimactl } from '../../__helpers__/fake-limactl'
import { fakeSsh } from '../../__helpers__/fake-ssh'
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
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,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'agent-x64',
sizeBytes: 1,
},
},
},
},
}
describe('VmRuntime', () => {
let root: string
let limaHome: string
@@ -60,7 +30,6 @@ describe('VmRuntime', () => {
limaHome = join(root, 'lima')
logPath = join(root, 'limactl.log')
templatePath = join(root, 'browseros-vm.yaml')
await writeCachedManifest(root)
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
})
@@ -68,7 +37,7 @@ describe('VmRuntime', () => {
await rm(root, { recursive: true, force: true })
})
it('provisions a fresh VM, waits for rootless nerdctl, and installs the manifest', async () => {
it('provisions a fresh VM and waits for rootless nerdctl', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
@@ -88,59 +57,12 @@ describe('VmRuntime', () => {
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
await expect(
readFile(getInstalledManifestPath(root), 'utf8'),
).resolves.toContain(manifest.updatedAt)
await expect(
readFile(join(limaHome, `${VM_NAME}.yaml`), 'utf8'),
).resolves.toContain('mountPoint: "/mnt/browseros/vm"')
})
it('fills a missing VM cache before reading the cached manifest', async () => {
await rm(getCachedManifestPath(root), { force: true })
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const ensureCacheAvailable = mock(async () => {
await writeCachedManifest(root)
})
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
ensureCacheAvailable,
})
await runtime.ensureReady()
expect(ensureCacheAvailable).toHaveBeenCalledTimes(1)
await expect(
readFile(getInstalledManifestPath(root), 'utf8'),
).resolves.toContain(manifest.updatedAt)
})
it('surfaces cache sync failures before reading a missing manifest', async () => {
await rm(getCachedManifestPath(root), { force: true })
const ensureCacheAvailable = mock(async () => {
throw new Error('cache offline')
})
const runtime = new VmRuntime({
limactlPath: 'unused',
limaHome,
browserosRoot: root,
ensureCacheAvailable,
})
await expect(runtime.ensureReady()).rejects.toThrow('cache offline')
expect(ensureCacheAvailable).toHaveBeenCalledTimes(1)
})
it('returns fast when the VM is already running and manifests match', async () => {
await writeInstalledManifest(root)
it('returns fast when the VM is already running', async () => {
const limactlPath = await fakeLimactl(
{
list: {
@@ -170,7 +92,6 @@ describe('VmRuntime', () => {
})
it('starts an existing stopped VM without recreating it', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
@@ -198,7 +119,6 @@ describe('VmRuntime', () => {
})
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
@@ -293,92 +213,6 @@ describe('VmRuntime', () => {
)
})
it('logs upgrade mismatch and preserves the installed manifest until upgrade happens', async () => {
await writeInstalledManifest(root, '2026-04-21T00:00:00.000Z')
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
const originalWarn = logger.warn
const warnings: Array<{
message: string
meta?: Record<string, unknown>
}> = []
logger.warn = (message, meta) => warnings.push({ message, meta })
try {
await runtime.ensureReady()
} finally {
logger.warn = originalWarn
}
expect(warnings).toContainEqual({
message: VM_TELEMETRY_EVENTS.upgradeDetected,
meta: {
from: '2026-04-21T00:00:00.000Z',
to: '2026-04-22T00:00:00.000Z',
},
})
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-21T00:00:00.000Z')
})
it('logs downgrade mismatch and preserves a newer installed manifest', async () => {
await writeInstalledManifest(root, '2026-04-23T00:00:00.000Z')
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
const originalWarn = logger.warn
const warnings: Array<{
message: string
meta?: Record<string, unknown>
}> = []
logger.warn = (message, meta) => warnings.push({ message, meta })
try {
await runtime.ensureReady()
} finally {
logger.warn = originalWarn
}
expect(warnings).toContainEqual({
message: VM_TELEMETRY_EVENTS.downgradeDetected,
meta: {
from: '2026-04-23T00:00:00.000Z',
to: '2026-04-22T00:00:00.000Z',
},
})
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-23T00:00:00.000Z')
})
it('does not auto-reset when rootless nerdctl readiness fails', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
@@ -450,29 +284,6 @@ describe('VmRuntime', () => {
})
})
async function writeCachedManifest(root: string): Promise<void> {
const manifestPath = getCachedManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`)
}
async function writeInstalledManifest(
root: string,
updatedAt = manifest.updatedAt,
): Promise<void> {
const manifestPath = getInstalledManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(
manifestPath,
`${JSON.stringify({ ...manifest, updatedAt })}\n`,
)
}
async function readInstalledUpdatedAt(root: string): Promise<string> {
const raw = await readFile(getInstalledManifestPath(root), 'utf8')
return (JSON.parse(raw) as VmManifest).updatedAt
}
async function prepareReadySsh(
limaHome: string,
logPath: string,

View File

@@ -14,8 +14,6 @@ const config = {
executionDir: '/tmp/browseros-execution',
mcpAllowRemote: false,
aiSdkDevtoolsEnabled: false,
vmCachePrefetch: true,
vmCacheManifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
}
describe('Application.start', () => {
@@ -51,70 +49,45 @@ describe('Application.start', () => {
expect(loggerError).not.toHaveBeenCalled()
})
it('starts VM cache prefetch without blocking HTTP startup', async () => {
const { Application, createHttpServer, prefetchVmCache } =
it('starts OpenClaw prewarm without blocking HTTP startup', async () => {
const { Application, createHttpServer, openClawService } =
await setupApplicationTest()
let resolvePrefetch: (value: {
downloaded: string[]
manifestPath: string
skipped: boolean
}) => void = () => {}
const pendingPrefetch = new Promise<{
downloaded: string[]
manifestPath: string
skipped: boolean
}>((resolve) => {
resolvePrefetch = resolve
let resolvePrewarm: () => void = () => {}
const pendingPrewarm = new Promise<void>((resolve) => {
resolvePrewarm = resolve
})
prefetchVmCache.mockImplementation(() => pendingPrefetch)
openClawService.prewarm.mockImplementation(() => pendingPrewarm)
const app = new Application(config)
const startPromise = app.start()
const completedBeforePrefetch = await Promise.race([
const completedBeforePrewarm = await Promise.race([
startPromise.then(() => true),
Bun.sleep(25).then(() => false),
])
resolvePrefetch({
downloaded: [],
manifestPath: '/tmp/manifest.json',
skipped: true,
})
resolvePrewarm()
await startPromise
expect(completedBeforePrefetch).toBe(true)
expect(completedBeforePrewarm).toBe(true)
expect(createHttpServer).toHaveBeenCalledTimes(1)
expect(prefetchVmCache).toHaveBeenCalledWith({
manifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
})
expect(openClawService.prewarm).toHaveBeenCalledTimes(1)
expect(openClawService.tryAutoStart).toHaveBeenCalledTimes(1)
})
it('logs VM cache prefetch failures without failing startup', async () => {
const { Application, createHttpServer, loggerWarn, prefetchVmCache } =
it('logs and continues when OpenClaw prewarm fails', async () => {
const { Application, createHttpServer, loggerWarn, openClawService } =
await setupApplicationTest()
prefetchVmCache.mockImplementation(() =>
Promise.reject(new Error('cache offline')),
)
openClawService.prewarm.mockImplementation(async () => {
throw new Error('registry offline')
})
const app = new Application(config)
await app.start()
await Bun.sleep(0)
expect(createHttpServer).toHaveBeenCalledTimes(1)
expect(loggerWarn).toHaveBeenCalledWith(
'BrowserOS VM cache prefetch failed',
{
error: 'cache offline',
},
)
})
it('skips VM cache prefetch when disabled', async () => {
const { Application, prefetchVmCache } = await setupApplicationTest()
const app = new Application({ ...config, vmCachePrefetch: false })
await app.start()
expect(prefetchVmCache).not.toHaveBeenCalled()
expect(loggerWarn).toHaveBeenCalledWith('OpenClaw prewarm failed', {
error: 'registry offline',
})
})
})
@@ -126,7 +99,6 @@ async function setupApplicationTest() {
'../src/api/services/openclaw/openclaw-service'
)
const browserosDir = await import('../src/lib/browseros-dir')
const cacheSync = await import('../src/lib/vm/cache-sync')
const dbModule = await import('../src/lib/db')
const identityModule = await import('../src/lib/identity')
const loggerModule = await import('../src/lib/logger')
@@ -185,26 +157,24 @@ async function setupApplicationTest() {
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
const prewarm = mock(async () => {})
const tryAutoStart = mock(async () => {})
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
() =>
({
tryAutoStart: async () => {},
prewarm,
tryAutoStart,
}) as never,
)
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
() =>
({
tryAutoStart: async () => {},
prewarm,
tryAutoStart,
}) as never,
)
const prefetchVmCache = spyOn(cacheSync, 'prefetchVmCache')
prefetchVmCache.mockImplementation(async () => ({
downloaded: [],
manifestPath: '/tmp/manifest.json',
skipped: true,
}))
const { Application } = await import('../src/main')
return {
Application,
@@ -214,6 +184,6 @@ async function setupApplicationTest() {
loggerError,
loggerInfo,
loggerWarn,
prefetchVmCache,
openClawService: { prewarm, tryAutoStart },
}
}

View File

@@ -4,8 +4,4 @@ R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=browseros
# Public CDN base - used by cache:sync to GET manifest and artifacts
R2_PUBLIC_BASE_URL=https://cdn.browseros.com
# Dev mode routes cache to ~/.browseros-dev/cache/; unset for ~/.browseros/cache/
NODE_ENV=development

View File

@@ -1,6 +1,10 @@
# @browseros/build-tools
Builds agent image tarballs, publishes release artifacts to R2, and hydrates the local dev cache for agent tarballs.
Publishes BrowserOS release artifacts to R2 and owns the Lima VM template used by the server.
OpenClaw images are no longer repackaged by BrowserOS. The server pulls
`ghcr.io/openclaw/openclaw:<version>` directly into the BrowserOS Lima VM's
rootless containerd cache using `nerdctl pull`.
The BrowserOS VM is defined by a committed Lima template at `template/browseros-vm.yaml`. There is no custom disk build step; `limactl` consumes the template directly at runtime.
@@ -29,9 +33,6 @@ limactl shell browseros-vm-dev nerdctl info
SOCK="$(limactl list browseros-vm-dev --format '{{.Dir}}')/sock/containerd.sock"
test -S "$SOCK"
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
limactl shell browseros-vm-dev nerdctl load -i "$(ls dist/images/openclaw-*-arm64.tar.gz | head -1)"
limactl delete --force browseros-vm-dev
```
@@ -86,45 +87,3 @@ LIMA_HOME="$TMP_HOME" "$TMP_PREFIX/bin/limactl" delete --force browseros-smoke
rm -rf "$TMP_PREFIX" "$TMP_HOME"
```
## Build an agent tarball
The BrowserOS VM uses containerd + nerdctl. This host-side tarball builder still requires `podman` to pull and save OCI archives for release packaging.
```bash
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
```
## Smoke test an agent tarball
```bash
bun run --filter @browseros/build-tools smoke:tarball -- --agent openclaw --arch arm64 --tarball ./dist/images/openclaw-2026.4.12-arm64.tar.gz
```
## Emit a manifest
```bash
bun run --filter @browseros/build-tools emit-manifest -- --dist-dir packages/build-tools/dist
```
Publish workflows can update one agent slice at a time. Sliced publishing requires an existing R2 `vm/manifest.json` baseline; bootstrap first releases with `--slice full`.
```bash
bun run --filter @browseros/build-tools emit-manifest -- --slice agents:openclaw --merge-from https://cdn.browseros.com/vm/manifest.json
```
## Sync the dev cache
```bash
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
```
Pulls the published manifest and tarballs from R2 (`cdn.browseros.com/vm/`). Development cache files land under `~/.browseros-dev/cache/vm/images/`. Production-mode cache files land under `~/.browseros/cache/vm/images/`.
## Seed the dev cache from a local build
```bash
NODE_ENV=development bun run --filter @browseros/build-tools dev:seed:tarball
```
`dev:seed:tarball` hardcodes `arm64` (all devs are on Apple Silicon), builds the configured agent tarball, skips R2 entirely, and writes an arm64-only manifest + tarball into `~/.browseros-dev/cache/vm/`. It refuses to run unless `NODE_ENV=development`. Use this when you want to test the server against the latest configured agent tarball without publishing.

View File

@@ -1,9 +0,0 @@
{
"agents": [
{
"name": "openclaw",
"image": "ghcr.io/openclaw/openclaw",
"version": "2026.4.12"
}
]
}

View File

@@ -3,15 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "BrowserOS release artifact producer and dev cache sync",
"description": "BrowserOS release artifact producer",
"scripts": {
"build:tarball": "bun run scripts/build-tarball.ts",
"emit-manifest": "bun run scripts/emit-manifest.ts",
"upload": "bun run scripts/upload-to-r2.ts",
"download": "bun run scripts/download-from-r2.ts",
"cache:sync": "bun run scripts/cache-sync.ts",
"dev:seed:tarball": "bun run scripts/seed-dev-agent-tarball.ts",
"smoke:tarball": "bun run scripts/smoke-tarball.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},

View File

@@ -1,92 +0,0 @@
#!/usr/bin/env bun
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { parseArch, podmanArch } from './common/arch'
import { type Bundle, tarballKey } from './common/manifest'
import { sha256File } from './common/sha256'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
agent: { type: 'string' },
arch: { type: 'string' },
'output-dir': { type: 'string', default: './dist/images' },
},
})
if (!values.agent || !values.arch) {
console.error(
'usage: build:tarball -- --agent <name> --arch <arm64|x64> [--output-dir ./dist/images]',
)
process.exit(1)
}
const arch = parseArch(values.arch)
const outDir = values['output-dir']
await mkdir(outDir, { recursive: true })
const pkgRoot = path.resolve(import.meta.dir, '..')
const bundle = JSON.parse(
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
) as Bundle
const agent = bundle.agents.find(({ name }) => name === values.agent)
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
const ref = `${agent.image}:${agent.version}`
const tarballPath = path.join(
outDir,
path.basename(tarballKey(agent.name, agent.version, arch)),
)
const tarPath = tarballPath.slice(0, -'.gz'.length)
await rm(tarballPath, { force: true })
await rm(`${tarballPath}.sha256`, { force: true })
await rm(tarPath, { force: true })
await spawnChecked([
'podman',
'pull',
'--os',
'linux',
'--arch',
podmanArch(arch),
ref,
])
await spawnChecked([
'podman',
'save',
'--format=oci-archive',
'--output',
tarPath,
ref,
])
await spawnChecked(['gzip', '-9', '-f', tarPath])
const sha = await sha256File(tarballPath)
const size = (await stat(tarballPath)).size
await writeFile(
`${tarballPath}.sha256`,
`${sha} ${path.basename(tarballPath)}\n`,
)
console.log(
JSON.stringify(
{
key: tarballKey(agent.name, agent.version, arch),
path: tarballPath,
sha256: sha,
sizeBytes: size,
},
null,
2,
),
)
async function spawnChecked(argv: string[]): Promise<void> {
const proc = Bun.spawn(argv, {
stdout: 'inherit',
stderr: 'inherit',
})
const code = await proc.exited
if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
}

View File

@@ -1,155 +0,0 @@
#!/usr/bin/env bun
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { homedir, arch as hostArch } from 'node:os'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { PATHS } from '@browseros/shared/constants/paths'
import { ARCHES, type Arch } from './common/arch'
import { fetchWithTimeout } from './common/fetch'
import type { AgentManifest, Artifact } from './common/manifest'
import { verifySha256 } from './common/sha256'
type ChunkSink = ReturnType<ReturnType<typeof Bun.file>['writer']>
export interface PlanItem {
key: string
destPath: string
sha256: string
}
export function planSync(opts: {
local: AgentManifest | null
remote: AgentManifest
cacheRoot: string
arches: Arch[]
}): PlanItem[] {
const out: PlanItem[] = []
for (const arch of opts.arches) {
for (const [name, agent] of Object.entries(opts.remote.agents)) {
maybeAdd(
out,
agent.tarballs[arch],
opts.local?.agents[name]?.tarballs[arch],
opts.cacheRoot,
)
}
}
return out
}
export function selectSyncArches(
allArches: boolean,
rawHostArch = hostArch(),
): Arch[] {
if (allArches) return [...ARCHES]
if (rawHostArch === 'arm64') return ['arm64']
if (rawHostArch === 'x64' || rawHostArch === 'ia32') return ['x64']
throw new Error(`unsupported host arch: ${rawHostArch}`)
}
if (import.meta.main) {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
'manifest-url': { type: 'string' },
'all-arches': { type: 'boolean' },
'cache-dir': { type: 'string' },
},
})
const cdnBase =
process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
const manifestUrl = values['manifest-url'] ?? `${cdnBase}/vm/manifest.json`
const cacheRoot = values['cache-dir'] ?? getCacheDir()
const arches = selectSyncArches(values['all-arches'] ?? false)
const response = await fetchWithTimeout(manifestUrl)
if (!response.ok) {
throw new Error(
`manifest fetch failed: ${manifestUrl} (${response.status})`,
)
}
const remote = (await response.json()) as AgentManifest
const localManifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
const local = await readLocalManifest(localManifestPath)
const plan = planSync({ local, remote, cacheRoot, arches })
if (plan.length === 0) {
console.log('agent cache up to date')
process.exit(0)
}
console.log(`syncing ${plan.length} agent artifact(s)`)
for (const item of plan) {
await mkdir(path.dirname(item.destPath), { recursive: true })
const partial = `${item.destPath}.partial`
await downloadToFile(`${cdnBase}/${item.key}`, partial)
await verifySha256(partial, item.sha256)
await rename(partial, item.destPath)
console.log(`synced ${item.key}`)
}
await mkdir(path.dirname(localManifestPath), { recursive: true })
await writeFile(localManifestPath, `${JSON.stringify(remote, null, 2)}\n`)
console.log(`manifest written to ${localManifestPath}`)
}
function maybeAdd(
out: PlanItem[],
remote: Artifact,
local: Artifact | undefined,
cacheRoot: string,
): void {
if (local?.sha256 === remote.sha256) return
out.push({
key: remote.key,
destPath: path.join(cacheRoot, remote.key),
sha256: remote.sha256,
})
}
function getCacheDir(): string {
const dirName =
process.env.NODE_ENV === 'development'
? PATHS.DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return path.join(homedir(), dirName, PATHS.CACHE_DIR_NAME)
}
export async function readLocalManifest(
manifestPath: string,
): Promise<AgentManifest | null> {
try {
return JSON.parse(await readFile(manifestPath, 'utf8')) as AgentManifest
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
async function downloadToFile(url: string, dest: string): Promise<void> {
const response = await fetchWithTimeout(url)
if (!response.ok || !response.body) {
throw new Error(`download failed: ${url} (${response.status})`)
}
const sink = Bun.file(dest).writer()
const reader = response.body.getReader()
try {
await pumpStream(reader, sink)
} finally {
await sink.end()
}
}
async function pumpStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
sink: ChunkSink,
): Promise<void> {
for (;;) {
const { done, value } = await reader.read()
if (done) break
sink.write(value)
}
}

View File

@@ -1,12 +0,0 @@
export type Arch = 'arm64' | 'x64'
export const ARCHES: readonly Arch[] = ['arm64', 'x64']
export function parseArch(raw: string): Arch {
if (raw === 'arm64' || raw === 'x64') return raw
throw new Error(`unknown arch: ${raw} (expected arm64|x64)`)
}
export function podmanArch(arch: Arch): 'arm64' | 'amd64' {
return arch === 'x64' ? 'amd64' : 'arm64'
}

View File

@@ -1,22 +0,0 @@
export async function fetchWithTimeout(
url: string,
init: RequestInit = {},
timeoutMs = 30_000,
): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, {
...init,
signal: init.signal ?? controller.signal,
})
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
}
throw error
} finally {
clearTimeout(timer)
}
}

View File

@@ -1,75 +0,0 @@
import { ARCHES, type Arch } from './arch'
export interface Artifact {
key: string
sha256: string
sizeBytes: number
}
export interface AgentEntry {
image: string
version: string
tarballs: Record<Arch, Artifact>
}
export interface AgentManifest {
schemaVersion: 2
updatedAt: string
agents: Record<string, AgentEntry>
}
export interface BundleAgent {
name: string
image: string
version: string
}
export interface Bundle {
agents: BundleAgent[]
}
export interface ArtifactInput {
sha256: string
sizeBytes: number
}
export interface ArtifactInputs {
agents: Record<string, Record<Arch, ArtifactInput>>
}
export function tarballKey(name: string, version: string, arch: Arch): string {
return `vm/images/${name}-${version}-${arch}.tar.gz`
}
export function buildManifest(
bundle: Bundle,
inputs: ArtifactInputs,
now: Date = new Date(),
): AgentManifest {
const agents: Record<string, AgentEntry> = {}
for (const agent of bundle.agents) {
const tarballs = {} as Record<Arch, Artifact>
for (const arch of ARCHES) {
const entry = inputs.agents[agent.name]?.[arch]
if (!entry) {
throw new Error(`missing tarball inputs for ${agent.name}/${arch}`)
}
tarballs[arch] = {
key: tarballKey(agent.name, agent.version, arch),
sha256: entry.sha256,
sizeBytes: entry.sizeBytes,
}
}
agents[agent.name] = {
image: agent.image,
version: agent.version,
tarballs,
}
}
return {
schemaVersion: 2,
updatedAt: now.toISOString(),
agents,
}
}

View File

@@ -1,4 +1,5 @@
import { createReadStream } from 'node:fs'
import { once } from 'node:events'
import { createReadStream, type ReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { setTimeout as sleep } from 'node:timers/promises'
import {
@@ -6,7 +7,6 @@ import {
type CompletedPart,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
UploadPartCommand,
@@ -45,10 +45,6 @@ export function getBucket(): string {
return required('R2_BUCKET')
}
export function getCdnBase(): string {
return process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
}
/** Uploads a file to R2, using multipart uploads for large artifacts so failed parts can be retried. */
export async function putFile(
client: S3Client,
@@ -75,16 +71,7 @@ export async function putFile(
}
await sendWithRetry(
() =>
client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: createReadStream(filePath),
ContentLength: size,
ContentType: contentType,
}),
),
() => putObjectFromFile(client, bucket, key, filePath, contentType, size),
opts,
)
}
@@ -124,15 +111,16 @@ async function putFileMultipart(
const contentLength = end - start + 1
const result = await sendWithRetry(
() =>
client.send(
new UploadPartCommand({
Bucket: bucket,
Key: key,
UploadId,
PartNumber: partNumber,
Body: createReadStream(filePath, { start, end }),
ContentLength: contentLength,
}),
uploadPartFromFile(
client,
bucket,
key,
filePath,
UploadId,
partNumber,
start,
end,
contentLength,
),
opts,
)
@@ -161,6 +149,66 @@ async function putFileMultipart(
}
}
async function putObjectFromFile(
client: S3Client,
bucket: string,
key: string,
filePath: string,
contentType: string,
contentLength: number,
): Promise<unknown> {
const body = createReadStream(filePath)
try {
return await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentLength: contentLength,
ContentType: contentType,
}),
)
} catch (error) {
await destroyReadStream(body)
throw error
}
}
async function uploadPartFromFile(
client: S3Client,
bucket: string,
key: string,
filePath: string,
uploadId: string,
partNumber: number,
start: number,
end: number,
contentLength: number,
): Promise<{ ETag?: string }> {
const body = createReadStream(filePath, { start, end })
try {
return await client.send(
new UploadPartCommand({
Bucket: bucket,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
Body: body,
ContentLength: contentLength,
}),
)
} catch (error) {
await destroyReadStream(body)
throw error
}
}
async function destroyReadStream(stream: ReadStream): Promise<void> {
stream.destroy()
if (stream.closed) return
await once(stream, 'close').catch(() => undefined)
}
/** Retries part uploads by rerunning the command factory, which recreates consumed request bodies. */
async function sendWithRetry<T>(
send: () => Promise<T>,
@@ -183,48 +231,3 @@ async function sendWithRetry<T>(
throw lastError
}
export async function putBody(
client: S3Client,
bucket: string,
key: string,
body: string,
contentType: string,
): Promise<void> {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentLength: Buffer.byteLength(body),
ContentType: contentType,
}),
)
}
export async function getBody(
client: S3Client,
bucket: string,
key: string,
): Promise<string | null> {
try {
const response = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
)
const body = response.Body as
| { transformToByteArray(): Promise<Uint8Array> }
| undefined
if (!body) throw new Error(`missing response body for R2 key: ${key}`)
const bytes = await body.transformToByteArray()
return new TextDecoder().decode(bytes)
} catch (error) {
const cause = error as {
name?: string
$metadata?: { httpStatusCode?: number }
}
if (cause.name === 'NoSuchKey' || cause.$metadata?.httpStatusCode === 404) {
return null
}
throw error
}
}

View File

@@ -1,22 +0,0 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
export async function sha256File(path: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) {
hash.update(chunk)
}
return hash.digest('hex')
}
export async function verifySha256(
path: string,
expected: string,
): Promise<void> {
const actual = await sha256File(path)
if (actual !== expected) {
throw new Error(
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
)
}
}

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bun
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { createR2Client, getBody, getBucket } from './common/r2'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
key: { type: 'string' },
out: { type: 'string' },
},
})
if (!values.key || !values.out) {
console.error('usage: download -- --key <r2-key> --out <path>')
process.exit(1)
}
const body = await getBody(createR2Client(), getBucket(), values.key)
if (body === null) {
throw new Error(
`R2 key not found: ${values.key}. Publish a full manifest before publishing slices.`,
)
}
await mkdir(path.dirname(values.out), { recursive: true })
await writeFile(values.out, body)
console.log(`downloaded ${values.key} to ${values.out}`)

View File

@@ -1,142 +0,0 @@
#!/usr/bin/env bun
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { ARCHES } from './common/arch'
import { fetchWithTimeout } from './common/fetch'
import {
type AgentEntry,
type AgentManifest,
type ArtifactInputs,
type Bundle,
type BundleAgent,
buildManifest,
tarballKey,
} from './common/manifest'
import { sha256File } from './common/sha256'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
'dist-dir': { type: 'string', default: './dist' },
out: { type: 'string' },
slice: { type: 'string', default: 'full' },
'merge-from': { type: 'string' },
},
})
const distDir = values['dist-dir']
const slice = values.slice
const pkgRoot = path.resolve(import.meta.dir, '..')
const bundle = JSON.parse(
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
) as Bundle
if (slice !== 'full' && !slice.startsWith('agents:')) {
throw new Error(`unknown slice: ${slice}`)
}
const baseline = values['merge-from']
? await loadBaseline(values['merge-from'])
: null
if (slice !== 'full' && !baseline) {
throw new Error(`--slice ${slice} requires --merge-from`)
}
const manifest = await buildSlicedManifest({ bundle, distDir, slice, baseline })
const outPath = values.out ?? path.join(distDir, 'manifest.json')
await mkdir(path.dirname(outPath), { recursive: true })
await writeFile(outPath, `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`wrote ${outPath} (slice=${slice})`)
async function buildSlicedManifest(opts: {
bundle: Bundle
distDir: string
slice: string
baseline: AgentManifest | null
}): Promise<AgentManifest> {
if (opts.slice === 'full') {
return buildManifest(
opts.bundle,
await readAllInputs(opts.bundle, opts.distDir),
)
}
const baseline = opts.baseline
if (!baseline) throw new Error(`--slice ${opts.slice} requires --merge-from`)
const updatedAt = new Date().toISOString()
if (opts.slice.startsWith('agents:')) {
const name = opts.slice.slice('agents:'.length)
const agent = opts.bundle.agents.find((entry) => entry.name === name)
if (!agent) throw new Error(`unknown agent: ${name}`)
return {
...baseline,
schemaVersion: 2,
updatedAt,
agents: {
...baseline.agents,
[name]: await readAgentEntry(agent, opts.distDir),
},
}
}
throw new Error(`unknown slice: ${opts.slice}`)
}
async function readAllInputs(
bundle: Bundle,
distDir: string,
): Promise<ArtifactInputs> {
const agents: ArtifactInputs['agents'] = {}
for (const agent of bundle.agents) {
agents[agent.name] = {} as ArtifactInputs['agents'][string]
for (const arch of ARCHES) {
const artifactPath = path.join(
distDir,
'images',
path.basename(tarballKey(agent.name, agent.version, arch)),
)
agents[agent.name][arch] = await readArtifactInput(artifactPath)
}
}
return {
agents,
}
}
async function readAgentEntry(
agent: BundleAgent,
distDir: string,
): Promise<AgentEntry> {
const tarballs = {} as AgentEntry['tarballs']
for (const arch of ARCHES) {
const key = tarballKey(agent.name, agent.version, arch)
const artifactPath = path.join(distDir, 'images', path.basename(key))
tarballs[arch] = { key, ...(await readArtifactInput(artifactPath)) }
}
return { image: agent.image, version: agent.version, tarballs }
}
async function readArtifactInput(
filePath: string,
): Promise<{ sha256: string; sizeBytes: number }> {
return {
sha256: await sha256File(filePath),
sizeBytes: (await stat(filePath)).size,
}
}
async function loadBaseline(src: string): Promise<AgentManifest> {
if (src.startsWith('http://') || src.startsWith('https://')) {
const response = await fetchWithTimeout(src)
if (!response.ok) {
throw new Error(`baseline fetch failed: ${src} (${response.status})`)
}
return (await response.json()) as AgentManifest
}
return JSON.parse(await readFile(src, 'utf8')) as AgentManifest
}

View File

@@ -1,206 +0,0 @@
#!/usr/bin/env bun
import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import path from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import type { Arch } from './common/arch'
import {
type AgentEntry,
type AgentManifest,
type Artifact,
type Bundle,
type BundleAgent,
tarballKey,
} from './common/manifest'
import { sha256File, verifySha256 } from './common/sha256'
export const DEV_ARCH: Arch = 'arm64'
export interface BuiltAgentArtifact {
agent: BundleAgent
key: string
path: string
sha256: string
sizeBytes: number
}
export interface DevAgentEntry extends Omit<AgentEntry, 'tarballs'> {
tarballs: Partial<Record<Arch, Artifact>>
}
export interface DevAgentManifest extends Omit<AgentManifest, 'agents'> {
agents: Record<string, DevAgentEntry>
}
if (import.meta.main) {
await seedDevAgentTarballs()
}
export async function seedDevAgentTarballs(): Promise<void> {
assertDevelopment()
const pkgRoot = path.resolve(import.meta.dir, '..')
const bundle = await readBundle(pkgRoot)
const distImagesDir = path.join(pkgRoot, 'dist', 'images')
const cacheRoot = devCacheRoot()
const artifacts: BuiltAgentArtifact[] = []
for (const agent of bundle.agents) {
await buildTarball(pkgRoot, agent, distImagesDir)
const artifact = await readBuiltArtifact(agent, distImagesDir)
await seedArtifact(cacheRoot, artifact)
artifacts.push(artifact)
}
const manifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
await mkdir(path.dirname(manifestPath), { recursive: true })
await writeFile(
manifestPath,
`${JSON.stringify(buildDevManifest(artifacts), null, 2)}\n`,
)
console.log(`manifest written to ${manifestPath}`)
}
export function buildDevManifest(
artifacts: BuiltAgentArtifact[],
now: Date = new Date(),
): DevAgentManifest {
const agents: Record<string, DevAgentEntry> = {}
for (const artifact of artifacts) {
agents[artifact.agent.name] = {
image: artifact.agent.image,
version: artifact.agent.version,
tarballs: {
[DEV_ARCH]: {
key: artifact.key,
sha256: artifact.sha256,
sizeBytes: artifact.sizeBytes,
},
},
}
}
return {
schemaVersion: 2,
updatedAt: now.toISOString(),
agents,
}
}
async function readBundle(pkgRoot: string): Promise<Bundle> {
return JSON.parse(
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
) as Bundle
}
async function buildTarball(
pkgRoot: string,
agent: BundleAgent,
outputDir: string,
): Promise<void> {
console.log(`building ${agent.name} ${DEV_ARCH} tarball`)
await spawnChecked(
[
'bun',
'run',
'scripts/build-tarball.ts',
'--',
'--agent',
agent.name,
'--arch',
DEV_ARCH,
'--output-dir',
outputDir,
],
pkgRoot,
)
}
async function readBuiltArtifact(
agent: BundleAgent,
distImagesDir: string,
): Promise<BuiltAgentArtifact> {
const key = tarballKey(agent.name, agent.version, DEV_ARCH)
const filePath = path.join(distImagesDir, path.basename(key))
await assertExists(filePath, agent.name)
return {
agent,
key,
path: filePath,
sha256: await sha256File(filePath),
sizeBytes: (await stat(filePath)).size,
}
}
async function seedArtifact(
cacheRoot: string,
artifact: BuiltAgentArtifact,
): Promise<void> {
const dest = path.join(cacheRoot, artifact.key)
if (await matchesExisting(dest, artifact.sha256)) {
console.log(`cache hit: ${artifact.key}`)
return
}
await mkdir(path.dirname(dest), { recursive: true })
await copyFile(artifact.path, dest)
await verifySha256(dest, artifact.sha256)
console.log(`seeded ${artifact.key}`)
}
function assertDevelopment(): void {
if (process.env.NODE_ENV === 'development') {
return
}
throw new Error(
'dev:seed:tarball refuses to run without NODE_ENV=development; it writes to ~/.browseros-dev/cache/vm/',
)
}
function devCacheRoot(): string {
return path.join(
homedir(),
PATHS.DEV_BROWSEROS_DIR_NAME,
PATHS.CACHE_DIR_NAME,
)
}
async function assertExists(
filePath: string,
agentName: string,
): Promise<void> {
try {
await stat(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
throw new Error(`build did not produce ${agentName} tarball at ${filePath}`)
}
}
async function matchesExisting(
filePath: string,
expectedSha: string,
): Promise<boolean> {
try {
return (await sha256File(filePath)) === expectedSha
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false
}
throw error
}
}
async function spawnChecked(argv: string[], cwd: string): Promise<void> {
const proc = Bun.spawn(argv, {
cwd,
stdout: 'inherit',
stderr: 'inherit',
})
const code = await proc.exited
if (code !== 0) {
throw new Error(`${argv.join(' ')} exited with code ${code}`)
}
}

View File

@@ -1,108 +0,0 @@
#!/usr/bin/env bun
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
import { parseArgs } from 'node:util'
import { createGunzip } from 'node:zlib'
import { parseArch, podmanArch } from './common/arch'
import type { Bundle } from './common/manifest'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
agent: { type: 'string' },
arch: { type: 'string' },
tarball: { type: 'string' },
},
})
if (!values.agent || !values.arch || !values.tarball) {
console.error(
'usage: smoke:tarball -- --agent <name> --arch <arm64|x64> --tarball <path.tar.gz>',
)
process.exit(1)
}
const arch = parseArch(values.arch)
const pkgRoot = path.resolve(import.meta.dir, '..')
const bundle = JSON.parse(
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
) as Bundle
const agent = bundle.agents.find(({ name }) => name === values.agent)
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
const ref = `${agent.image}:${agent.version}`
const tarball = await maybeDecompress(values.tarball)
try {
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
await spawnChecked(['podman', 'load', '--input', tarball.path])
const inspected = await inspectImage(ref)
if (inspected.Os !== 'linux') {
throw new Error(`expected linux image, got ${inspected.Os ?? '<missing>'}`)
}
if (inspected.Architecture !== podmanArch(arch)) {
throw new Error(
`expected ${podmanArch(arch)} image, got ${inspected.Architecture ?? '<missing>'}`,
)
}
} finally {
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
if (tarball.cleanupDir) {
await rm(tarball.cleanupDir, { recursive: true, force: true })
}
}
console.log('tarball smoke test passed')
async function maybeDecompress(
tarballPath: string,
): Promise<{ path: string; cleanupDir?: string }> {
if (!tarballPath.endsWith('.gz')) return { path: tarballPath }
const cleanupDir = await mkdtemp(path.join(tmpdir(), 'browseros-tar-smoke-'))
const tarPath = path.join(cleanupDir, 'image.tar')
await pipeline(
createReadStream(tarballPath),
createGunzip(),
createWriteStream(tarPath),
)
return { path: tarPath, cleanupDir }
}
async function inspectImage(ref: string): Promise<{
Architecture?: string
Os?: string
}> {
const stdout = await spawnCapture([
'podman',
'inspect',
'--type',
'image',
'--format',
'{{json .}}',
ref,
])
return JSON.parse(stdout) as { Architecture?: string; Os?: string }
}
async function spawnCapture(argv: string[]): Promise<string> {
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' })
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) {
throw new Error(
`${argv[0]} exited ${code}\n${stderr.trim() || stdout.trim()}`,
)
}
return stdout.trim()
}
async function spawnChecked(argv: string[]): Promise<void> {
await spawnCapture(argv)
}

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env bun
import { parseArgs } from 'node:util'
import { createR2Client, getBucket, putBody, putFile } from './common/r2'
import { sha256File } from './common/sha256'
import { createR2Client, getBucket, putFile } from './common/r2'
const { values } = parseArgs({
args: Bun.argv.slice(2),
@@ -9,7 +8,6 @@ const { values } = parseArgs({
file: { type: 'string' },
key: { type: 'string' },
'content-type': { type: 'string' },
'sidecar-sha': { type: 'boolean' },
},
})
@@ -24,19 +22,6 @@ const bucket = getBucket()
try {
await putFile(client, bucket, values.key, values.file, contentType)
console.log(`uploaded ${values.file} to ${bucket}/${values.key}`)
if (values['sidecar-sha']) {
const sha = await sha256File(values.file)
const filename = values.file.split('/').pop() ?? values.file
await putBody(
client,
bucket,
`${values.key}.sha256`,
`${sha} ${filename}\n`,
'text/plain; charset=utf-8',
)
console.log(`uploaded sha256 to ${bucket}/${values.key}.sha256`)
}
} finally {
client.destroy()
}

View File

@@ -1,18 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { parseArch, podmanArch } from '../scripts/common/arch'
describe('arch helpers', () => {
it('normalizes BrowserOS arches for podman', () => {
expect(podmanArch('arm64')).toBe('arm64')
expect(podmanArch('x64')).toBe('amd64')
})
it('parses supported release arches', () => {
expect(parseArch('arm64')).toBe('arm64')
expect(parseArch('x64')).toBe('x64')
})
it('rejects unsupported release arches', () => {
expect(() => parseArch('amd64')).toThrow('unknown arch: amd64')
})
})

View File

@@ -1,296 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
type PlanItem,
planSync,
readLocalManifest,
selectSyncArches,
} from '../scripts/cache-sync'
import type { AgentManifest } from '../scripts/common/manifest'
import { sha256File } from '../scripts/common/sha256'
import { buildDevManifest } from '../scripts/seed-dev-agent-tarball'
const openclaw = {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
}
const claudeCode = {
image: 'ghcr.io/anthropics/claude-code',
version: '2026.4.10',
}
function manifest(tarSha: string, includeSecondAgent = false): AgentManifest {
const agents: AgentManifest['agents'] = {
openclaw: {
...openclaw,
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: `${tarSha}-arm64`,
sizeBytes: 201,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: `${tarSha}-x64`,
sizeBytes: 202,
},
},
},
}
if (includeSecondAgent) {
agents['claude-code'] = {
...claudeCode,
tarballs: {
arm64: {
key: 'vm/images/claude-code-2026.4.10-arm64.tar.gz',
sha256: `${tarSha}-claude-arm64`,
sizeBytes: 301,
},
x64: {
key: 'vm/images/claude-code-2026.4.10-x64.tar.gz',
sha256: `${tarSha}-claude-x64`,
sizeBytes: 302,
},
},
}
}
return {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents,
}
}
function keys(plan: PlanItem[]): string[] {
return plan.map((item) => item.key)
}
describe('planSync', () => {
it('downloads every selected-arch agent artifact for a fresh cache', () => {
const remote = manifest('t1')
expect(
keys(planSync({ local: null, remote, cacheRoot: '/c', arches: ['x64'] })),
).toEqual(['vm/images/openclaw-2026.4.12-x64.tar.gz'])
})
it('does nothing when the local manifest matches the remote manifest', () => {
const remote = manifest('t1')
expect(
planSync({ local: remote, remote, cacheRoot: '/c', arches: ['x64'] }),
).toEqual([])
})
it('downloads only agent artifacts whose sha256 changed', () => {
const local = manifest('old-tar')
const remote = manifest('new-tar')
expect(
keys(planSync({ local, remote, cacheRoot: '/c', arches: ['x64'] })),
).toEqual(['vm/images/openclaw-2026.4.12-x64.tar.gz'])
})
it('supports syncing all release arches', () => {
const remote = manifest('t1')
expect(
planSync({
local: null,
remote,
cacheRoot: '/c',
arches: ['arm64', 'x64'],
}),
).toHaveLength(2)
})
it('selects host arch by default and both arches when requested', () => {
expect(selectSyncArches(false, 'x64')).toEqual(['x64'])
expect(selectSyncArches(true, 'x64')).toEqual(['arm64', 'x64'])
})
})
describe('readLocalManifest', () => {
let dir: string | null = null
afterEach(async () => {
if (!dir) return
await rm(dir, { recursive: true, force: true })
dir = null
})
it('returns null only when the local manifest is absent', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'browseros-cache-manifest-'))
await expect(
readLocalManifest(path.join(dir, 'missing.json')),
).resolves.toBeNull()
})
it('surfaces corrupt local manifest files', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'browseros-cache-manifest-'))
const manifestPath = path.join(dir, 'manifest.json')
await writeFile(manifestPath, '{not json')
await expect(readLocalManifest(manifestPath)).rejects.toThrow()
})
})
describe('buildDevManifest', () => {
it('builds an arm64-only dev manifest from freshly built artifacts', () => {
const manifest = buildDevManifest(
[
{
agent: {
name: 'openclaw',
image: openclaw.image,
version: openclaw.version,
},
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
path: '/tmp/openclaw.tar.gz',
sha256: 'fresh-arm64',
sizeBytes: 404,
},
],
new Date('2026-04-23T00:00:00.000Z'),
)
expect(manifest.schemaVersion).toBe(2)
expect(manifest.updatedAt).toBe('2026-04-23T00:00:00.000Z')
expect(manifest.agents.openclaw.image).toBe(openclaw.image)
expect(manifest.agents.openclaw.version).toBe(openclaw.version)
expect(manifest.agents.openclaw.tarballs.arm64).toEqual({
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'fresh-arm64',
sizeBytes: 404,
})
expect(Object.hasOwn(manifest.agents.openclaw.tarballs, 'x64')).toBe(false)
})
})
describe('emit-manifest', () => {
let dir: string | null = null
afterEach(async () => {
if (!dir) return
await rm(dir, { recursive: true, force: true })
dir = null
})
it('rejects the retired vm slice', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-vm-'))
const result = await runEmitManifest(
[
'--slice',
'vm',
'--dist-dir',
path.join(dir, 'dist'),
'--out',
path.join(dir, 'manifest.json'),
],
false,
)
expect(result.code).toBe(1)
expect(result.stderr).toContain('unknown slice: vm')
})
it('merges an agent slice while preserving other agents from the baseline', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-agent-'))
const distDir = path.join(dir, 'dist')
await writeAgentFiles(distDir)
const baseline = manifest('old-tar', true)
const baselinePath = path.join(dir, 'baseline.json')
const outPath = path.join(dir, 'manifest.json')
await writeJson(baselinePath, baseline)
await runEmitManifest([
'--slice',
'agents:openclaw',
'--dist-dir',
distDir,
'--merge-from',
baselinePath,
'--out',
outPath,
])
const merged = JSON.parse(await readFile(outPath, 'utf8')) as AgentManifest
expect(merged.schemaVersion).toBe(2)
expect(merged.agents['claude-code']).toEqual(baseline.agents['claude-code'])
expect(merged.agents.openclaw.tarballs.arm64.sha256).toBe(
await sha256File(
path.join(distDir, 'images/openclaw-2026.4.12-arm64.tar.gz'),
),
)
})
it('fails slice emission without a merge baseline', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-fail-'))
const result = await runEmitManifest(
[
'--slice',
'agents:openclaw',
'--dist-dir',
path.join(dir, 'dist'),
'--out',
path.join(dir, 'out.json'),
],
false,
)
expect(result.code).toBe(1)
expect(result.stderr).toContain(
'--slice agents:openclaw requires --merge-from',
)
})
})
async function writeAgentFiles(distDir: string): Promise<void> {
await mkdir(path.join(distDir, 'images'), { recursive: true })
await writeFile(
path.join(distDir, 'images/openclaw-2026.4.12-arm64.tar.gz'),
'arm tarball',
)
await writeFile(
path.join(distDir, 'images/openclaw-2026.4.12-x64.tar.gz'),
'x64 tarball',
)
}
async function writeJson(filePath: string, value: unknown): Promise<void> {
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
async function runEmitManifest(
args: string[],
expectSuccess = true,
): Promise<{ code: number; stdout: string; stderr: string }> {
const proc = Bun.spawn(
['bun', 'run', 'scripts/emit-manifest.ts', '--', ...args],
{
cwd: path.join(import.meta.dir, '..'),
stdout: 'pipe',
stderr: 'pipe',
},
)
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (expectSuccess && code !== 0) {
throw new Error(`emit-manifest failed: ${stderr || stdout}`)
}
return { code, stdout, stderr }
}

View File

@@ -1,101 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
type ArtifactInputs,
type Bundle,
buildManifest,
tarballKey,
} from '../scripts/common/manifest'
import { verifySha256 } from '../scripts/common/sha256'
const bundle: Bundle = {
agents: [
{
name: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
},
],
}
const inputs: ArtifactInputs = {
agents: {
openclaw: {
arm64: { sha256: 'tar-arm', sizeBytes: 21 },
x64: { sha256: 'tar-x64', sizeBytes: 22 },
},
},
}
describe('manifest helpers', () => {
it('builds release artifact keys', () => {
expect(tarballKey('openclaw', '2026.4.12', 'x64')).toBe(
'vm/images/openclaw-2026.4.12-x64.tar.gz',
)
})
it('builds an agents-only manifest from bundle metadata and artifact inputs', () => {
const manifest = buildManifest(
bundle,
inputs,
new Date('2026-04-22T00:00:00.000Z'),
)
for (const field of ['vm' + 'Version', 'vm' + 'Disk']) {
expect(Object.hasOwn(manifest, field)).toBe(false)
}
expect(manifest).toMatchObject({
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'tar-x64',
sizeBytes: 22,
},
},
},
},
})
})
it('fails when required tarball inputs are missing', () => {
expect(() =>
buildManifest(bundle, {
agents: { openclaw: { arm64: inputs.agents.openclaw.arm64 } },
} as unknown as ArtifactInputs),
).toThrow('missing tarball inputs for openclaw/x64')
})
})
describe('sha256 helpers', () => {
let dir: string | null = null
afterEach(async () => {
if (!dir) return
await rm(dir, { recursive: true, force: true })
dir = null
})
it('verifies matching file content and rejects mismatches', async () => {
dir = await mkdtemp(join(tmpdir(), 'browseros-build-tools-'))
const filePath = join(dir, 'artifact.txt')
await writeFile(filePath, 'browseros\n')
await expect(
verifySha256(
filePath,
'8e4e07174da39a48ab7aa9a1bebd3adcddff43172c0b19fcbe921cc47c599f62',
),
).resolves.toBeUndefined()
await expect(verifySha256(filePath, 'bad')).rejects.toThrow(
'sha256 mismatch',
)
})
})

View File

@@ -4,5 +4,5 @@
"rootDir": ".",
"resolveJsonModule": true
},
"include": ["scripts/**/*", "tests/**/*", "package.json", "bundle.json"]
"include": ["scripts/**/*", "tests/**/*", "package.json"]
}

View File

@@ -1,4 +1,5 @@
export const OPENCLAW_AGENT_NAME = 'openclaw'
export const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:2026.4.12'
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'

View File

@@ -13,8 +13,6 @@ export const EXTERNAL_URLS = {
OPENAI_AUTH: 'https://auth.openai.com/oauth/authorize',
OPENAI_TOKEN: 'https://auth.openai.com/oauth/token',
SKILLS_CATALOG: 'https://cdn.browseros.com/skills/v1/catalog.json',
VM_CACHE_CDN_BASE: 'https://cdn.browseros.com',
VM_CACHE_MANIFEST: 'https://cdn.browseros.com/vm/manifest.json',
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
GITHUB_OAUTH_TOKEN: 'https://github.com/login/oauth/access_token',
GITHUB_COPILOT_API: 'https://api.githubcopilot.com',

View File

@@ -10,16 +10,12 @@ const REQUIRED_PROD_VARS = [
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'SENTRY_DSN',
'BROWSEROS_VM_CACHE_PREFETCH',
'BROWSEROS_VM_CACHE_MANIFEST_URL',
]
const INLINED_ENV_VARS = [
...REQUIRED_PROD_VARS,
'NODE_ENV',
'LOG_LEVEL',
] as const
const BOOLEAN_PROD_VARS = ['BROWSEROS_VM_CACHE_PREFETCH'] as const
const URL_PROD_VARS = ['BROWSEROS_VM_CACHE_MANIFEST_URL'] as const
const PROD_ENV_PATH = join('apps', 'server', '.env.production')
const PROD_ENV_TEMPLATE_PATH = join('apps', 'server', '.env.production.example')
@@ -76,28 +72,6 @@ function validateProductionEnv(envVars: Record<string, string>): void {
`Production build requires variables: ${missing.join(', ')} (set them in ${PROD_ENV_PATH} or process env).`,
)
}
const invalidBooleans = BOOLEAN_PROD_VARS.filter((name) => {
const value = envVars[name]
return value !== 'true' && value !== 'false'
})
if (invalidBooleans.length > 0) {
throw new Error(
`Production build requires boolean variables to be "true" or "false": ${invalidBooleans.join(', ')}.`,
)
}
const invalidUrls = URL_PROD_VARS.filter((name) => {
try {
new URL(envVars[name])
return false
} catch {
return true
}
})
if (invalidUrls.length > 0) {
throw new Error(
`Production build requires absolute URL variables: ${invalidUrls.join(', ')}.`,
)
}
}
export interface LoadBuildConfigOptions {

View File

@@ -40,9 +40,6 @@ func runWatch(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
if err := ensureDevCachePresent(); err != nil {
return err
}
if err := ensureLimactlPresent(); err != nil {
return err
}
@@ -69,6 +66,13 @@ func runWatch(cmd *cobra.Command, args []string) error {
if err := os.MkdirAll(userDataDir, 0o755); err != nil {
return fmt.Errorf("creating user-data dir: %w", err)
}
stopped, err := proc.StopExistingWatchProcesses(3 * time.Second)
if err != nil {
return err
}
if stopped > 0 {
proc.LogMsgf(proc.TagInfo, "Stopped %d existing dev watch process group(s)", stopped)
}
proc.LogMsg(proc.TagInfo, "Killing processes on preferred ports...")
if err := proc.KillPortsAndWait(defaultPorts, 3*time.Second); err != nil {
return err
@@ -188,21 +192,6 @@ func runWatch(cmd *cobra.Command, args []string) error {
return nil
}
func ensureDevCachePresent() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
manifestPath := filepath.Join(home, ".browseros-dev", "cache", "vm", "manifest.json")
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
return fmt.Errorf("%s %s",
proc.ErrorColor.Sprint("VM cache is missing."),
proc.DimColor.Sprintf("Run %s once.", proc.BoldColor.Sprint("bun run dev:setup")),
)
}
return nil
}
func ensureLimactlPresent() error {
if _, err := exec.LookPath("limactl"); err != nil {
return fmt.Errorf("%s %s",

View File

@@ -7,27 +7,6 @@ import (
"testing"
)
func TestEnsureDevCachePresentMissingMessage(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
err := ensureDevCachePresent()
if err == nil {
t.Fatal("expected missing cache error")
}
msg := err.Error()
if !strings.Contains(msg, "VM cache is missing.") {
t.Fatalf("expected missing cache message, got %q", msg)
}
if strings.Count(msg, "dev:setup") != 1 {
t.Fatalf("expected dev:setup once, got %q", msg)
}
if strings.Contains(msg, home) {
t.Fatalf("expected cache path to be hidden, got %q", msg)
}
}
func TestEnsureLimactlPresentMissingMessage(t *testing.T) {
t.Setenv("PATH", t.TempDir())

View File

@@ -0,0 +1,116 @@
package proc
import (
"fmt"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// StopExistingWatchProcesses terminates older default-profile watch supervisors.
// Port cleanup cannot see a previous watch process while it is still waiting
// for CDP, but that process will wake up later and race the new supervisor.
func StopExistingWatchProcesses(timeout time.Duration) (int, error) {
currentPGID, err := syscall.Getpgid(0)
if err != nil {
return 0, fmt.Errorf("reading current process group: %w", err)
}
groups, err := currentWatchProcessGroups(currentPGID)
if err != nil {
return 0, err
}
if len(groups) == 0 {
return 0, nil
}
for _, pgid := range groups {
if err := signalProcessGroup(pgid, syscall.SIGTERM); err != nil {
return 0, err
}
}
deadline := time.Now().Add(timeout)
for {
remaining, err := currentWatchProcessGroups(currentPGID)
if err != nil {
return 0, err
}
if len(remaining) == 0 {
return len(groups), nil
}
if time.Now().After(deadline) {
for _, pgid := range remaining {
if err := signalProcessGroup(pgid, syscall.SIGKILL); err != nil {
return 0, err
}
}
return len(groups), nil
}
time.Sleep(100 * time.Millisecond)
}
}
func currentWatchProcessGroups(currentPGID int) ([]int, error) {
output, err := exec.Command("ps", "-axo", "pid=,pgid=,command=").Output()
if err != nil {
return nil, fmt.Errorf("listing processes: %w", err)
}
return watchProcessGroupsFromPS(string(output), currentPGID), nil
}
func watchProcessGroupsFromPS(output string, currentPGID int) []int {
seen := map[int]struct{}{}
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
pgid, err := strconv.Atoi(fields[1])
if err != nil || pgid == currentPGID {
continue
}
if isDefaultWatchCommand(fields[2:]) {
seen[pgid] = struct{}{}
}
}
groups := make([]int, 0, len(seen))
for pgid := range seen {
groups = append(groups, pgid)
}
sort.Ints(groups)
return groups
}
func isDefaultWatchCommand(commandFields []string) bool {
if len(commandFields) < 2 {
return false
}
if filepath.Base(commandFields[0]) != "browseros-dev" {
return false
}
if commandFields[1] != "watch" {
return false
}
for _, field := range commandFields[2:] {
if field == "--new" {
return false
}
}
return true
}
func signalProcessGroup(pgid int, signal syscall.Signal) error {
if pgid <= 0 {
return nil
}
if err := syscall.Kill(-pgid, signal); err != nil && err != syscall.ESRCH {
return fmt.Errorf("signaling process group %d: %w", pgid, err)
}
return nil
}

View File

@@ -0,0 +1,32 @@
package proc
import "testing"
func TestWatchProcessGroupsFromPSSelectsOtherWatchGroups(t *testing.T) {
output := `
111 111 /tmp/one/browseros-dev watch
222 222 /tmp/two/browseros-dev watch --new
333 333 /tmp/one/browseros-dev cleanup
444 444 rg browseros-dev watch
555 555 bun run dev:watch
`
groups := watchProcessGroupsFromPS(output, 999)
if len(groups) != 1 || groups[0] != 111 {
t.Fatalf("expected only pgid 111, got %#v", groups)
}
}
func TestWatchProcessGroupsFromPSDedupesProcessGroups(t *testing.T) {
output := `
111 111 /tmp/one/browseros-dev watch
112 111 /tmp/one/browseros-dev watch
`
groups := watchProcessGroupsFromPS(output, 999)
if len(groups) != 1 || groups[0] != 111 {
t.Fatalf("expected one pgid 111, got %#v", groups)
}
}

View File

@@ -12,8 +12,4 @@ bun install --frozen-lockfile
echo "[setup] Generating agent code..."
bun run codegen:agent
echo "[setup] Syncing VM cache..."
printf '\033[31m[setup] First VM cache sync can take about 5 minutes.\033[0m\n'
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
echo "[setup] Ready"

View File

@@ -198,20 +198,18 @@ func ExpandTilde(path string, home string) string {
func DefaultProductionEnv() ProductionEnv {
return ProductionEnv{
Server: map[string]string{
"BROWSEROS_CONFIG_URL": "https://llm.browseros.com/api/browseros-server/config",
"CODEGEN_SERVICE_URL": "",
"POSTHOG_API_KEY": "",
"SENTRY_DSN": "",
"BROWSEROS_VM_CACHE_PREFETCH": "true",
"BROWSEROS_VM_CACHE_MANIFEST_URL": "https://cdn.browseros.com/vm/manifest.json",
"R2_ACCOUNT_ID": "",
"R2_ACCESS_KEY_ID": "",
"R2_SECRET_ACCESS_KEY": "",
"R2_BUCKET": "",
"R2_DOWNLOAD_PREFIX": "artifacts/vendor",
"R2_UPLOAD_PREFIX": "artifacts/server",
"NODE_ENV": "production",
"LOG_LEVEL": "debug",
"BROWSEROS_CONFIG_URL": "https://llm.browseros.com/api/browseros-server/config",
"CODEGEN_SERVICE_URL": "",
"POSTHOG_API_KEY": "",
"SENTRY_DSN": "",
"R2_ACCOUNT_ID": "",
"R2_ACCESS_KEY_ID": "",
"R2_SECRET_ACCESS_KEY": "",
"R2_BUCKET": "",
"R2_DOWNLOAD_PREFIX": "artifacts/vendor",
"R2_UPLOAD_PREFIX": "artifacts/server",
"NODE_ENV": "production",
"LOG_LEVEL": "debug",
},
CLI: map[string]string{
"POSTHOG_API_KEY": "",