mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
4 Commits
fix/tests-
...
fix/proces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad716fca78 | ||
|
|
df8ff02b8f | ||
|
|
492f3fcdf2 | ||
|
|
cb0c0dd0c1 |
176
.github/workflows/publish-vm-agent-cache.yml
vendored
176
.github/workflows/publish-vm-agent-cache.yml
vendored
@@ -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"
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -63,15 +63,15 @@ jobs:
|
||||
junit_path: test-results/server-root.xml
|
||||
needs_browser: false
|
||||
- suite: agent
|
||||
command: bun run test:agent
|
||||
command: (cd apps/agent && bun run test)
|
||||
junit_path: test-results/agent.xml
|
||||
needs_browser: false
|
||||
- suite: eval
|
||||
command: bun run test:eval
|
||||
command: (cd apps/eval && bun run test)
|
||||
junit_path: test-results/eval.xml
|
||||
needs_browser: false
|
||||
- suite: build
|
||||
command: bun run test:build
|
||||
command: bun run ./scripts/run-bun-test.ts ./scripts/build
|
||||
junit_path: test-results/build.xml
|
||||
needs_browser: false
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -156,9 +157,14 @@ bun run build:server # Build production server resource artifacts and u
|
||||
bun run build:agent # Build agent extension
|
||||
|
||||
# Test
|
||||
bun run test # Run standard tests
|
||||
bun run test:cdp # Run CDP-based tests
|
||||
bun run test:integration # Run integration tests
|
||||
bun run test # Run all tests
|
||||
bun run test:all # Run all tests
|
||||
bun run test:main # Run key server tools and integration tests
|
||||
|
||||
# App-specific test groups (from packages/browseros-agent)
|
||||
cd apps/server && bun run test:tools
|
||||
cd apps/server && bun run test:cdp
|
||||
cd apps/server && bun run test:integration
|
||||
|
||||
# Quality
|
||||
bun run lint # Check with Biome
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"build": "bun run codegen && wxt build",
|
||||
"build:dev": "bun --env-file=.env.development wxt build --mode development",
|
||||
"zip": "wxt zip",
|
||||
"test": "bun run ../../scripts/run-bun-test.ts ./apps/agent",
|
||||
"compile": "bun --env-file=.env.development wxt prepare && tsgo --noEmit",
|
||||
"lint": "bunx biome check",
|
||||
"typecheck": "bun --env-file=.env.development wxt prepare && tsgo --noEmit",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eval": "bun --env-file=.env.development run src/index.ts",
|
||||
"test": "bun run ../../scripts/run-bun-test.ts ./apps/eval/tests",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)[]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: []')) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,8 @@
|
||||
"build:agent": "bun run codegen:agent && bun run --filter @browseros/agent build",
|
||||
"codegen:agent": "bun run --filter @browseros/agent codegen",
|
||||
"test": "bun run test:all",
|
||||
"test:all": "bun run test:server && bun run test:agent && bun run test:eval && bun run test:build",
|
||||
"test:server": "bun run --filter @browseros/server test",
|
||||
"test:tools": "bun run --filter @browseros/server test:tools",
|
||||
"test:cdp": "bun run --filter @browseros/server test:cdp",
|
||||
"test:integration": "bun run --filter @browseros/server test:integration",
|
||||
"test:agent": "bun run ./scripts/run-bun-test.ts ./apps/agent",
|
||||
"test:eval": "bun run ./scripts/run-bun-test.ts ./apps/eval/tests",
|
||||
"test:build": "bun run ./scripts/run-bun-test.ts ./scripts/build",
|
||||
"test:all": "bun run ./scripts/run-test-suite.ts all",
|
||||
"test:main": "bun run ./scripts/run-test-suite.ts main",
|
||||
"typecheck": "bun run --filter '*' typecheck",
|
||||
"lint": "bunx biome check",
|
||||
"lint:fix": "bunx biome check --write --unsafe",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "openclaw",
|
||||
"image": "ghcr.io/openclaw/openclaw",
|
||||
"version": "2026.4.12"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["scripts/**/*", "tests/**/*", "package.json", "bundle.json"]
|
||||
"include": ["scripts/**/*", "tests/**/*", "package.json"]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
110
packages/browseros-agent/scripts/run-test-suite.ts
Normal file
110
packages/browseros-agent/scripts/run-test-suite.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
type TestCommand = {
|
||||
label: string
|
||||
cwd?: string
|
||||
argv: readonly [string, ...string[]]
|
||||
}
|
||||
|
||||
const projectRoot = resolve(import.meta.dir, '..')
|
||||
const bun = process.execPath
|
||||
|
||||
const testSuites = {
|
||||
all: [
|
||||
{
|
||||
label: 'server tests',
|
||||
cwd: resolve(projectRoot, 'apps/server'),
|
||||
argv: [bun, 'run', 'test'],
|
||||
},
|
||||
{
|
||||
label: 'agent tests',
|
||||
cwd: resolve(projectRoot, 'apps/agent'),
|
||||
argv: [bun, 'run', 'test'],
|
||||
},
|
||||
{
|
||||
label: 'eval tests',
|
||||
cwd: resolve(projectRoot, 'apps/eval'),
|
||||
argv: [bun, 'run', 'test'],
|
||||
},
|
||||
{
|
||||
label: 'build script tests',
|
||||
argv: [bun, 'run', './scripts/run-bun-test.ts', './scripts/build'],
|
||||
},
|
||||
],
|
||||
main: [
|
||||
{
|
||||
label: 'server tools tests',
|
||||
cwd: resolve(projectRoot, 'apps/server'),
|
||||
argv: [bun, 'run', 'test:tools'],
|
||||
},
|
||||
{
|
||||
label: 'server integration tests',
|
||||
cwd: resolve(projectRoot, 'apps/server'),
|
||||
argv: [bun, 'run', 'test:integration'],
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, readonly TestCommand[]>
|
||||
|
||||
type TestSuiteName = keyof typeof testSuites
|
||||
|
||||
function isTestSuiteName(value: string): value is TestSuiteName {
|
||||
return value in testSuites
|
||||
}
|
||||
|
||||
/** Prevents multi-step suites from overwriting a single shared JUnit report path. */
|
||||
function buildCommandEnv(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env }
|
||||
delete env.BROWSEROS_JUNIT_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
function runCommand(command: TestCommand): number {
|
||||
console.log(`\n==> ${command.label}`)
|
||||
const result = spawnSync(command.argv[0], command.argv.slice(1), {
|
||||
cwd: command.cwd ?? projectRoot,
|
||||
env: buildCommandEnv(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.signal) {
|
||||
console.error(
|
||||
`Command terminated by signal ${result.signal}: ${command.label}`,
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const status = result.status ?? 1
|
||||
if (status !== 0) {
|
||||
console.error(`Command failed with exit code ${status}: ${command.label}`)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
/** Runs a named test suite without shell chaining so each step reports its own status. */
|
||||
function runSuite(suiteName: TestSuiteName): number {
|
||||
let exitCode = 0
|
||||
for (const command of testSuites[suiteName]) {
|
||||
const status = runCommand(command)
|
||||
if (status !== 0 && exitCode === 0) {
|
||||
exitCode = status
|
||||
}
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.error(
|
||||
`Usage: bun run ./scripts/run-test-suite.ts <${Object.keys(testSuites).join('|')}>`,
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const requestedSuite = process.argv[2]
|
||||
if (!requestedSuite || !isTestSuiteName(requestedSuite)) {
|
||||
printUsage()
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(runSuite(requestedSuite))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -51,6 +48,26 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
p := defaultPorts
|
||||
var reservations *proc.PortReservations
|
||||
userDataDir := "/tmp/browseros-dev"
|
||||
mode := "watch"
|
||||
if watchManual {
|
||||
mode = "manual"
|
||||
}
|
||||
var runLock *proc.WatchRunLock
|
||||
acquireRunLock := func(ports proc.Ports) error {
|
||||
lock, stopped, err := proc.AcquireWatchRunLock(proc.WatchRunIdentity{
|
||||
Mode: mode,
|
||||
Profile: userDataDir,
|
||||
Ports: ports,
|
||||
}, 3*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runLock = lock
|
||||
if stopped {
|
||||
proc.LogMsgf(proc.TagInfo, "Stopped existing dev watch for profile %s", userDataDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if watchNew {
|
||||
proc.LogMsg(proc.TagInfo, "Selecting random available ports...")
|
||||
@@ -65,10 +82,16 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
userDataDir = dir
|
||||
proc.LogMsgf(proc.TagInfo, "Created fresh profile: %s", userDataDir)
|
||||
if err := acquireRunLock(p); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := os.MkdirAll(userDataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating user-data dir: %w", err)
|
||||
}
|
||||
if err := acquireRunLock(p); err != nil {
|
||||
return err
|
||||
}
|
||||
proc.LogMsg(proc.TagInfo, "Killing processes on preferred ports...")
|
||||
if err := proc.KillPortsAndWait(defaultPorts, 3*time.Second); err != nil {
|
||||
return err
|
||||
@@ -85,13 +108,14 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
p.CDP, p.Server, p.Extension)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if err := runLock.Close(); err != nil {
|
||||
proc.LogMsgf(proc.TagInfo, "Warning: closing run lock: %v", err)
|
||||
}
|
||||
}()
|
||||
defer reservations.ReleaseAll()
|
||||
|
||||
fmt.Println()
|
||||
mode := "watch"
|
||||
if watchManual {
|
||||
mode = "manual"
|
||||
}
|
||||
proc.LogMsgf(proc.TagInfo, "Mode: %s", proc.BoldColor.Sprint(mode))
|
||||
proc.LogMsgf(proc.TagInfo, "Ports: CDP=%d Server=%d Extension=%d", p.CDP, p.Server, p.Extension)
|
||||
proc.LogMsgf(proc.TagInfo, "Profile: %s", userDataDir)
|
||||
@@ -188,21 +212,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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
281
packages/browseros-agent/tools/dev/proc/process.go
Normal file
281
packages/browseros-agent/tools/dev/proc/process.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errWatchRunLocked = errors.New("dev watch run is already locked")
|
||||
|
||||
const maxTCPPort = 65535
|
||||
|
||||
type WatchRunIdentity struct {
|
||||
Mode string `json:"mode"`
|
||||
Profile string `json:"profile"`
|
||||
Ports Ports `json:"ports"`
|
||||
}
|
||||
|
||||
type WatchRunState struct {
|
||||
PID int `json:"pid"`
|
||||
PGID int `json:"pgid"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
Identity WatchRunIdentity `json:"identity"`
|
||||
}
|
||||
|
||||
type WatchRunLock struct {
|
||||
file *os.File
|
||||
statePath string
|
||||
}
|
||||
|
||||
type watchRunPathsResult struct {
|
||||
Lock string
|
||||
State string
|
||||
}
|
||||
|
||||
// AcquireWatchRunLock claims ownership of the current dev watch identity.
|
||||
// If the same run identity is already active, it terminates the recorded
|
||||
// process group from the state file and waits for the OS lock to be released.
|
||||
func AcquireWatchRunLock(identity WatchRunIdentity, timeout time.Duration) (*WatchRunLock, bool, error) {
|
||||
baseDir, err := DefaultWatchRunBaseDir()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return AcquireWatchRunLockInDir(baseDir, identity, timeout)
|
||||
}
|
||||
|
||||
// AcquireWatchRunLockInDir is AcquireWatchRunLock with an explicit base
|
||||
// directory so tests can exercise flock behavior without touching user state.
|
||||
func AcquireWatchRunLockInDir(baseDir string, identity WatchRunIdentity, timeout time.Duration) (*WatchRunLock, bool, error) {
|
||||
identity = normalizeWatchRunIdentity(identity)
|
||||
if err := validateWatchRunIdentity(identity); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if baseDir == "" {
|
||||
return nil, false, fmt.Errorf("watch run base dir is empty")
|
||||
}
|
||||
|
||||
paths := watchRunPaths(baseDir, identity)
|
||||
lock, err := tryAcquireWatchRunLock(paths.Lock, paths.State)
|
||||
if err == nil {
|
||||
if err := lock.writeState(identity); err != nil {
|
||||
lock.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
return lock, false, nil
|
||||
}
|
||||
if !errors.Is(err, errWatchRunLocked) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
state, err := readWatchRunStateWithRetry(paths.State, 250*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("dev watch lock is held but state is unreadable at %s: %w", paths.State, err)
|
||||
}
|
||||
if state.Identity != identity {
|
||||
return nil, false, fmt.Errorf("dev watch lock state identity mismatch at %s", paths.State)
|
||||
}
|
||||
if state.PGID <= 0 {
|
||||
return nil, false, fmt.Errorf("dev watch lock state is missing a process group at %s", paths.State)
|
||||
}
|
||||
|
||||
if err := signalProcessGroup(state.PGID, syscall.SIGTERM); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
lock, err = waitForWatchRunLock(paths, identity, timeout)
|
||||
if err == nil {
|
||||
return lock, true, nil
|
||||
}
|
||||
if !errors.Is(err, errWatchRunLocked) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if err := signalProcessGroup(state.PGID, syscall.SIGKILL); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
lock, err = waitForWatchRunLock(paths, identity, time.Second)
|
||||
if err != nil {
|
||||
if errors.Is(err, errWatchRunLocked) {
|
||||
return nil, false, fmt.Errorf("previous dev watch process group %d did not exit after SIGKILL; inspect %s before retrying", state.PGID, paths.Lock)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return lock, true, nil
|
||||
}
|
||||
|
||||
// DefaultWatchRunBaseDir returns the shared location for dev watch lock files.
|
||||
// Individual runs are separated by a hash of profile, ports, and mode.
|
||||
func DefaultWatchRunBaseDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".browseros-dev", "runs"), nil
|
||||
}
|
||||
|
||||
func (l *WatchRunLock) Close() error {
|
||||
if l == nil || l.file == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keep the lock file path stable. Unlinking it during handoff can let
|
||||
// another opener lock a different inode while an owner still holds this one.
|
||||
removeErr := os.Remove(l.statePath)
|
||||
unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
|
||||
closeErr := l.file.Close()
|
||||
l.file = nil
|
||||
if removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
return removeErr
|
||||
}
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// ReadWatchRunState reads the metadata used to terminate a previous owner.
|
||||
// The state file is not the lock; it is only trusted after flock says a run is active.
|
||||
func ReadWatchRunState(path string) (WatchRunState, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return WatchRunState{}, err
|
||||
}
|
||||
var state WatchRunState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return WatchRunState{}, fmt.Errorf("parse watch run state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func readWatchRunStateWithRetry(path string, timeout time.Duration) (WatchRunState, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for {
|
||||
state, err := ReadWatchRunState(path)
|
||||
if err == nil {
|
||||
return state, nil
|
||||
}
|
||||
lastErr = err
|
||||
if time.Now().After(deadline) {
|
||||
return WatchRunState{}, lastErr
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func watchRunPaths(baseDir string, identity WatchRunIdentity) watchRunPathsResult {
|
||||
identity = normalizeWatchRunIdentity(identity)
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%d\x00%d",
|
||||
identity.Mode,
|
||||
identity.Profile,
|
||||
identity.Ports.CDP,
|
||||
identity.Ports.Server,
|
||||
identity.Ports.Extension,
|
||||
)))
|
||||
key := hex.EncodeToString(sum[:])
|
||||
return watchRunPathsResult{
|
||||
Lock: filepath.Join(baseDir, "watch-"+key+".lock"),
|
||||
State: filepath.Join(baseDir, "watch-"+key+".json"),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeWatchRunIdentity(identity WatchRunIdentity) WatchRunIdentity {
|
||||
identity.Profile = filepath.Clean(identity.Profile)
|
||||
return identity
|
||||
}
|
||||
|
||||
func tryAcquireWatchRunLock(lockPath string, statePath string) (*WatchRunLock, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
file.Close()
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return nil, errWatchRunLocked
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &WatchRunLock{file: file, statePath: statePath}, nil
|
||||
}
|
||||
|
||||
func (l *WatchRunLock) writeState(identity WatchRunIdentity) error {
|
||||
pgid, err := syscall.Getpgid(0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading current process group: %w", err)
|
||||
}
|
||||
state := WatchRunState{
|
||||
PID: os.Getpid(),
|
||||
PGID: pgid,
|
||||
StartedAt: time.Now(),
|
||||
Identity: identity,
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
tmp := l.statePath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, l.statePath)
|
||||
}
|
||||
|
||||
func waitForWatchRunLock(paths watchRunPathsResult, identity WatchRunIdentity, timeout time.Duration) (*WatchRunLock, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
lock, err := tryAcquireWatchRunLock(paths.Lock, paths.State)
|
||||
if err == nil {
|
||||
if err := lock.writeState(identity); err != nil {
|
||||
lock.Close()
|
||||
return nil, err
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
if !errors.Is(err, errWatchRunLocked) {
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, errWatchRunLocked
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func validateWatchRunIdentity(identity WatchRunIdentity) error {
|
||||
if identity.Mode == "" {
|
||||
return fmt.Errorf("watch run mode is empty")
|
||||
}
|
||||
if identity.Profile == "" {
|
||||
return fmt.Errorf("watch run profile is empty")
|
||||
}
|
||||
if !isValidTCPPort(identity.Ports.CDP) || !isValidTCPPort(identity.Ports.Server) || !isValidTCPPort(identity.Ports.Extension) {
|
||||
return fmt.Errorf("watch run ports are invalid: %+v", identity.Ports)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidTCPPort(port int) bool {
|
||||
return port > 0 && port <= maxTCPPort
|
||||
}
|
||||
|
||||
func signalProcessGroup(pgid int, signal syscall.Signal) error {
|
||||
if pgid <= 0 {
|
||||
return fmt.Errorf("invalid process group %d", pgid)
|
||||
}
|
||||
if err := syscall.Kill(-pgid, signal); err != nil && err != syscall.ESRCH {
|
||||
return fmt.Errorf("signaling process group %d: %w", pgid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
188
packages/browseros-agent/tools/dev/proc/process_test.go
Normal file
188
packages/browseros-agent/tools/dev/proc/process_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const watchLockHelperEnv = "BROWSEROS_DEV_WATCH_LOCK_HELPER"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv(watchLockHelperEnv) == "1" {
|
||||
runWatchLockHelper()
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestWatchRunPathsStableAndDistinct(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
identity := WatchRunIdentity{
|
||||
Mode: "watch",
|
||||
Profile: "/tmp/browseros-dev",
|
||||
Ports: Ports{CDP: 9005, Server: 9105, Extension: 9305},
|
||||
}
|
||||
|
||||
first := watchRunPaths(baseDir, identity)
|
||||
second := watchRunPaths(baseDir, identity)
|
||||
if first != second {
|
||||
t.Fatalf("expected stable paths, got %#v and %#v", first, second)
|
||||
}
|
||||
|
||||
withDifferentPort := identity
|
||||
withDifferentPort.Ports.Server = 9106
|
||||
third := watchRunPaths(baseDir, withDifferentPort)
|
||||
if third.Lock == first.Lock || third.State == first.State {
|
||||
t.Fatalf("expected distinct paths for different ports, got %#v and %#v", first, third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireWatchRunLockWritesStateAndReleases(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
identity := WatchRunIdentity{
|
||||
Mode: "watch",
|
||||
Profile: "/tmp/browseros-dev",
|
||||
Ports: Ports{CDP: 9005, Server: 9105, Extension: 9305},
|
||||
}
|
||||
|
||||
lock, stopped, err := AcquireWatchRunLockInDir(baseDir, identity, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireWatchRunLockInDir returned error: %v", err)
|
||||
}
|
||||
if stopped {
|
||||
t.Fatal("expected first acquisition not to stop another run")
|
||||
}
|
||||
|
||||
paths := watchRunPaths(baseDir, identity)
|
||||
state, err := ReadWatchRunState(paths.State)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadWatchRunState returned error: %v", err)
|
||||
}
|
||||
if state.PID != os.Getpid() {
|
||||
t.Fatalf("expected state PID %d, got %d", os.Getpid(), state.PID)
|
||||
}
|
||||
if state.PGID <= 0 {
|
||||
t.Fatalf("expected positive PGID, got %d", state.PGID)
|
||||
}
|
||||
if state.Identity != identity {
|
||||
t.Fatalf("expected identity %#v, got %#v", identity, state.Identity)
|
||||
}
|
||||
if err := lock.Close(); err != nil {
|
||||
t.Fatalf("closing lock: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths.State); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected state file to be removed on close, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths.Lock); err != nil {
|
||||
t.Fatalf("expected lock file path to remain reusable, got %v", err)
|
||||
}
|
||||
|
||||
lock, stopped, err = AcquireWatchRunLockInDir(baseDir, identity, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("reacquiring lock returned error: %v", err)
|
||||
}
|
||||
if stopped {
|
||||
t.Fatal("expected reacquisition after close not to stop another run")
|
||||
}
|
||||
if err := lock.Close(); err != nil {
|
||||
t.Fatalf("closing reacquired lock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireWatchRunLockRejectsInvalidPorts(t *testing.T) {
|
||||
identity := WatchRunIdentity{
|
||||
Mode: "watch",
|
||||
Profile: "/tmp/browseros-dev",
|
||||
Ports: Ports{CDP: 9005, Server: 65536, Extension: 9305},
|
||||
}
|
||||
|
||||
if _, _, err := AcquireWatchRunLockInDir(t.TempDir(), identity, time.Second); err == nil {
|
||||
t.Fatal("expected invalid port error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireWatchRunLockStopsExistingOwnerByStatePGID(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
readyPath := filepath.Join(baseDir, "ready")
|
||||
identity := WatchRunIdentity{
|
||||
Mode: "watch",
|
||||
Profile: "/tmp/browseros-dev",
|
||||
Ports: Ports{CDP: 9005, Server: 9105, Extension: 9305},
|
||||
}
|
||||
identityJSON, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestMain")
|
||||
cmd.Env = append(os.Environ(),
|
||||
watchLockHelperEnv+"=1",
|
||||
"BROWSEROS_DEV_WATCH_LOCK_BASE="+baseDir,
|
||||
"BROWSEROS_DEV_WATCH_LOCK_READY="+readyPath,
|
||||
"BROWSEROS_DEV_WATCH_LOCK_IDENTITY="+string(identityJSON),
|
||||
)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("starting helper: %v", err)
|
||||
}
|
||||
defer cmd.Process.Kill()
|
||||
|
||||
waitForFile(t, readyPath, 3*time.Second)
|
||||
|
||||
lock, stopped, err := AcquireWatchRunLockInDir(baseDir, identity, 3*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireWatchRunLockInDir returned error: %v", err)
|
||||
}
|
||||
defer lock.Close()
|
||||
if !stopped {
|
||||
t.Fatal("expected takeover to stop existing owner")
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("expected helper process to exit after takeover")
|
||||
}
|
||||
}
|
||||
|
||||
func runWatchLockHelper() {
|
||||
baseDir := os.Getenv("BROWSEROS_DEV_WATCH_LOCK_BASE")
|
||||
readyPath := os.Getenv("BROWSEROS_DEV_WATCH_LOCK_READY")
|
||||
var identity WatchRunIdentity
|
||||
if err := json.Unmarshal([]byte(os.Getenv("BROWSEROS_DEV_WATCH_LOCK_IDENTITY")), &identity); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
lock, _, err := AcquireWatchRunLockInDir(baseDir, identity, time.Second)
|
||||
if err != nil {
|
||||
os.Exit(3)
|
||||
}
|
||||
defer lock.Close()
|
||||
if err := os.WriteFile(readyPath, []byte("ready\n"), 0o644); err != nil {
|
||||
os.Exit(4)
|
||||
}
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out waiting for %s", path)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user