Compare commits

...

19 Commits

Author SHA1 Message Date
Nikhil Sonti
a3f5643875 ci: make build-agent workflow manual-only 2026-04-23 17:20:54 -07:00
Nikhil
c6c902a4ab feat: improve dev watch Lima preflights (#802)
* feat: improve dev watch lima preflights

* fix: note vm cache sync duration

* fix: address review feedback for PR #802
2026-04-23 17:16:50 -07:00
Nikhil
6e37742a5a feat: reuse agent command chat for agents page (#803) 2026-04-23 17:09:49 -07:00
Nikhil
1186c2c0d7 merge: feat/new-lima-vm
feat: new vm integration
2026-04-23 16:41:14 -07:00
Nikhil
0288cc040d feat: use rootless nerdctl in BrowserOS VM (#800)
* feat: use rootless nerdctl in BrowserOS VM

* fix: validate openclaw gateway auth before reuse

* fix: forward rootless containerd socket

* fix: address VM review comments
2026-04-23 16:36:51 -07:00
Nikhil
07b7bf5977 feat(build-tools): seed dev agent tarballs (#799)
* feat(build-tools): seed dev agent tarballs

* fix: address review comments for 0423-build_agent_tarball_dev_sync

* chore(build-tools): remove dev cache sync alias
2026-04-23 15:47:00 -07:00
Nikhil
d1a3d67e29 chore(dev): add VM cache setup flow (#798) 2026-04-23 15:47:00 -07:00
Nikhil
35134518f0 fix(vm): use system nerdctl in Lima runtime (#797) 2026-04-23 15:47:00 -07:00
Nikhil Sonti
4083155e81 feat(container): migrate container runtime to nerdctl over Lima VM
Replace the podman-based runtime with nerdctl running inside the Lima
VM introduced in the previous commit. OpenClaw is cut over to the new
VM-backed container runtime; legacy podman code paths are removed.

- New container CLI (lib/container): nerdctl ContainerCli, ImageLoader
  with cache-tarball fallback, shared types
- OpenClaw: container-runtime-factory orchestrates VM lifecycle + gateway
  startup; container-runtime.ts rewritten to speak nerdctl; Linux test
  startup kept disabled behind the factory
- Terminal: session + routes moved onto Lima shell transport; server
  wires the VM-backed runtime via main.ts
- Agent UI: simplify AgentsPage/useOpenClaw after route consolidation
- Remove podman-runtime, podman-overrides, and their tests
- Tests: container-cli, image-loader, container-runtime-factory, and
  updated openclaw/terminal/main suites
2026-04-23 15:46:50 -07:00
Nikhil Sonti
72ef4f068e feat(vm): add Lima-based BrowserOS VM runtime
Introduce a new VM runtime layer using Lima for running containerised
workloads on macOS. Lifecycle covers decompress/create/start/stop with
stubs for upgrade/reset plus version-mismatch warnings.

- Foundation modules: paths, errors, manifest, telemetry
- lima.yaml generator + typed limactl wrapper with structured debug logging
- ssh ControlMaster transport for fast in-VM commands
- Ubuntu 24.04 minimal template, containerd default, 30GiB overlay disk
- browseros-dir helpers (getLimaHomeDir, getVmStateDir, getVmDisksDir);
  OpenClaw dir moves into VM state dir
- Test helpers (fake-limactl, fake-ssh, test-env), vm-smoke integration
  coverage, NODE_ENV propagation for spawned server test groups
2026-04-23 15:46:25 -07:00
Nikhil
6b6ed1582c feat(openclaw): HTTP session history endpoint (JSON + SSE) (#795)
* refactor(openclaw): rename http chat client to http client

Session history is about to land on the same HTTP client. 'Chat client'
will no longer describe it, so rename the class, file, and service field
up front. No behavior change.

* feat(openclaw): add session history fetch + sse stream to http client

Adds getSessionHistory (JSON) and streamSessionHistory (SSE) to the
OpenClaw HTTP client. Both target GET /sessions/<key>/history on the
loopback gateway, reusing the same bearer-token auth as streamChat.

- 404 from the gateway surfaces as OpenClawSessionNotFoundError so
  callers can map it to a typed HTTP status.
- The SSE path parses named 'history', 'message', and 'error' events
  into a typed OpenClawSessionHistoryEvent union.
- AbortSignal propagates to fetch and cancels the reader mid-stream.

* feat(openclaw): expose session history over GET /claw/session/:key/history

Wire the new getSessionHistory / streamSessionHistory service methods
through a route that defaults to JSON and upgrades to SSE when the
client sends Accept: text/event-stream.

- OpenClawSessionNotFoundError lives in errors.ts alongside the other
  OpenClaw errors so routes can import it from one place.
- The route propagates c.req.raw.signal into streamSessionHistory so
  client disconnects cancel the upstream fetch.
- Route tests cover the JSON path (with query param forwarding), the
  404 path, and the SSE framing.

* chore(openclaw): drop NaN from session history route limit param
2026-04-23 11:19:16 -07:00
Nikhil
a3764e7599 feat(build-tools): add cache:sync:dev for local tarball seeding (#794)
Seeds ~/.browseros-dev/cache/vm/ from ./dist/ without touching R2, so
devs can test the server against a freshly-built tarball before anything
is published to cdn.browseros.com. Hardcodes arm64 since all devs are on
Apple Silicon; refuses to run unless NODE_ENV=development; idempotent
(skips copy on sha256 match).

Also fixes the R2_BUCKET default in .env.sample from browseros-artifacts
to browseros to match the actual bucket.
2026-04-23 10:33:51 -07:00
Nikhil
c656f6236c feat: ship Lima template for BrowserOS VM (#787)
* feat(build-tools): add Lima template for BrowserOS VM

* feat(build-tools): remove build-disk pipeline and recipe directory

Task 2 verification removed the scripts, recipe directory, workflow, and package scripts. Typecheck remains green here because manifest disk fields are removed in the next task, so the plan's expected missing-import failure does not apply yet.

* feat(build-tools): rename VmManifest to AgentManifest, drop disk fields

* feat(build): stage Lima template into server resources

Verified local-resource staging with: bun scripts/build/server.ts --target=darwin-arm64 --ci. The template was copied to dist/prod/server/darwin-arm64/resources/vm/browseros-vm.yaml and included in the zip. bun run build:server:test still fails on the pre-existing R2 limactl resource with: The specified key does not exist.

* docs(build-tools): Lima template dev loop + record D9

Updated the build-tools README in this worktree. Also recorded D9 in the canonical external spec file at /Users/shadowfax/llm/code/browseros-project/grove-ref/browseros-main/specs/decisions.md, which is outside this git checkout.

* chore(build-tools): sweep orphaned references to retired disk pipeline

* chore: self-review fixes
2026-04-22 17:17:12 -07:00
Nikhil
4d660874ad feat: consolidate build tools package (#785)
* feat(build-tools): scaffold package + cache dir helpers

* feat(build-tools): manifest types + R2 helper

* feat(build-tools): build-disk script with virt-customize + zstd

* feat(build-tools): build-tarball script

* feat(build-tools): emit-manifest + cache:sync

* ci(build-tools): independent build-vm + build-agent workflows

* chore: remove legacy container packages + workflows

* fix: address review feedback for PR #785

* fix: stabilize VM build DNS in CI

* fix: prioritize arm64 build workflows

* fix: keep arm64 VM recipe simple

* fix: set VM build DNS in apt command

* fix: avoid guest DNS for VM package install

* fix: limit VM PR checks to build-tools validation
2026-04-22 16:23:11 -07:00
Nikhil
819887a2c5 feat(vm-container): WS1 VM disk image pipeline (#783)
* feat(vm-container): ship the WS1 VM disk image pipeline

New Bun/TS workspace package @browseros/vm-container that produces a
reproducible, versioned Debian 12 + Podman qcow2 disk image for arm64 and
x64, and publishes it to Cloudflare R2 under vm/<version>/ with a per-
version manifest.json and a latest.json pointer.

- virt-customize-driven build with a git-tracked recipe DSL.
- zstd-compressed artifacts; sha256 sidecars for compressed + uncompressed.
- Public surface at @browseros/vm-container/schema exposes zod-validated
  VmManifest + R2 key helpers for WS4 to import; /download is a stub
  landing pad for WS4 to fill in.
- Rollback on partial upload failure: any exception after the first
  successful put deletes all previously uploaded keys for that version.
- GHA workflow build-vm-container.yml runs a matrix build per arch on
  native runners, an x64 Lima boot smoke test, and a gated publish job.
- Full unit coverage for arch, r2-keys, manifest, recipe parser, and
  publish (rollback + happy path via aws-sdk-client-mock).

* fix(vm-container): address review comments

- Split buildDisk into prepareCustomizedDisk + finalizeArtifacts for
  testability.
- Replace resolvePinnedSha's sentinel-prefix check with a positive
  sha256-hex regex test, switch base-image.ts placeholder to empty string.
- Drop unused R2_VM_PREFIX from .env.example; document CDN_BASE_URL
  override precedence in README.
- Replace SSH host-key explicit list in recipe with `ssh_host_*` glob so
  .pub keys and future key types are also removed.
- lima-boot: introduce BunRequestInit type for the unix fetch option and
  reject empty limactlPath loudly.
- Extend publish test suite: mid-manifest-upload failure path verifies
  both arches' qcow+sha are rolled back and latest.json is never written.
- Add missing tests: parseArch('ARM64') case-sensitivity rejection,
  composeVirtCustomizeArgv unresolved-substitution pass-through.

* fix(vm-container): pin a real Debian snapshot, switch verify to SHA-512, streaming download

- Pin Debian base to bookworm/20260413-2447 with real SHA-512 values
  from upstream SHA512SUMS (the sentinel placeholder never corresponded
  to a real build). Debian cloud images only publish SHA512SUMS today,
  so switch base-image verification to SHA-512 throughout: rename
  BaseImage.sha256 → sha512, manifest field base_image_sha256 →
  base_image_sha512, base_image.sha256_url → sha512_url,
  debianSha256SumsUrl → debianSha512SumsUrl. Our own artifact hashes
  (compressed_sha256, uncompressed_sha256, recipe_sha256) stay SHA-256.
- Fix downloadTo: previous Bun.write(dest, response) buffered the
  entire 300 MB response before writing (100% CPU, empty dir). Replace
  with a getReader() loop that streams chunks through Bun.file().writer().
- build CLI now auto-derives --version from today's date when omitted
  (defaults to YYYY.MM.DD-dev1); explicit --version still overrides.
  Broaden CALVER_REGEX to accept alphanumeric suffixes so -dev1/-rc1
  tags are valid. New todayCalver() helper.
- Update GHA workflow fallback to github.run_number (shorter) instead
  of run_id.

* fix(vm-container): resolve copy-in paths against recipeDir after substitution

The copy-in path resolver checked op.src.startsWith('/') before running
the {placeholder} substitution, so an absolute-after-substitution path
like {manifest_tmp} → /tmp/vm-dist/manifest-stub-arm64.json was treated
as relative and joined against recipeDir, producing a nonexistent path.
Check the *substituted* value for absoluteness via path.isAbsolute.

* fix: address review comments for 0422-ws1_vm_disk_pipeline

* fix(ci): repair vm-container workflow

* fix(ci): expose vm build logs on failure

* fix(vm-container): expose base_image_sha256 in manifest per PRD

The published manifest contract (consumed by WS4) now uses base_image_sha256
as the PRD specified. Internally the build still verifies the downloaded
Debian base against the pinned sha512 (that's what Debian actually signs in
SHA512SUMS) — then hashes the same bytes as sha256 and records that in the
manifest. One extra digest pass of a ~300 MB file; negligible.

- manifest.json: base_image_sha256 replaces base_image_sha512; sha512_url
  removed (not needed — sha256 is the consumer-facing hash).
- CLI: --base-image-sha256 override validates against the locally-computed
  sha256 after download.
- BuildResult.baseImage gains sha256 alongside sha512.
- Tests updated to the new field.

The auth.json bug (reviewer #2) is resolved: the source file is
recipe/auth.json and the recipe emits `copy-in auth.json:/etc/containers/`
so libguestfs writes /etc/containers/auth.json.

* ci(vm-container): fix supermin kernel-read + rename sha512 inputs to sha256

- Ubuntu 24.04 GHA runners ship /boot/vmlinuz-* as mode 0600, which blocks
  libguestfs's supermin appliance builder when virt-customize runs as a
  non-root user. Chmod 0644 before the build — canonical CI workaround.
- Rename workflow_dispatch input base_image_sha512 → base_image_sha256
  and CLI flag --base-image-sha512 → --base-image-sha256 to match the
  orchestrator's renamed override.

* ci(vm-container): give runner KVM access + install passt for libguestfs

The supermin fix got us past appliance-build, but virt-customize then hit
"passt exited with status 1". The passt networking helper misbehaves when
libguestfs falls back to TCG emulation, which happens because the runner
user isn't in the kvm group even though /dev/kvm exists on the GHA host.

- chmod 0666 /dev/kvm → libguestfs uses hardware acceleration, avoids TCG.
- install passt explicitly so the networking helper is present and current.

* ci(vm-container): disable passt to force libguestfs slirp fallback

libguestfs 1.54+ prefers passt for guest networking, but the passt binary
on GHA ubuntu-24.04 exits with status 1 when invoked from the appliance
— an AppArmor/capability issue that doesn't surface a useful diagnostic.
The reliable workaround is to remove passt so libguestfs picks QEMU's
built-in user-mode SLIRP as the network backend. SLIRP is slower but
functional and doesn't require escalated privileges.
2026-04-22 14:04:00 -07:00
Nikhil
114d5e3a9f feat: add agent container tarball pipeline (#782)
* feat: add agent container tarball pipeline

* docs: add agent-container env sample

* refactor: simplify agent container pipeline

* fix: address review feedback for PR #782

* fix: emit clean matrix JSON in CI

* fix: align agent container artifact paths
2026-04-22 13:14:27 -07:00
Nikhil
ecba7de221 fix: address PR review comments for 0422-ws3_lima_resources (#780)
- Guard uploaded_keys append with !dry_run so the rollback list
  never contains keys for objects that were never written.
- Prefer GITHUB_ACTOR over local OS username for manifest.uploaded_by;
  manifest.json is CDN-fronted so leaking a developer's login is
  unnecessary (falls back to 'local').
- Extend test_windows_has_no_stale_third_party to cover bun.exe/rg.exe
  too, matching the macOS forbidden-set pattern.
2026-04-22 10:47:25 -07:00
Nikhil
123a13fe62 feat(build): swap podman server resources for Lima (WS3) (#778)
* feat(build): swap podman server resources for Lima (WS3)

- Upload limactl (arm64 + x64) to R2 via new 'browseros upload lima' CLI.
- Rewrite scripts/build/config/server-prod-resources.json: 2 Lima entries,
  12 podman-family entries removed.
- Update codesign metadata (server_binaries.py) to add limactl, drop podman
  family. Sign modules need no edits (data-driven).
- Delete orphaned podman-{vfkit,krunkit} entitlement plists.
- Release-gating note in browseros-agent/CLAUDE.md: don't cut releases off
  dev between this commit and WS6 landing (OpenClaw still invokes podman).

* fix: address review comments for 0422-ws3_lima_resources

- Tighten _find_limactl_member to match exactly .../bin/limactl via
  Path.parts, avoiding incidental matches like 'xbin/limactl'.
- Fall back USER -> USERNAME -> 'unknown' for uploaded_by so Windows
  shells don't all record 'unknown'.
- Comment the broad except in upload_lima to explain why rollback
  must fire for any mid-loop failure.

* chore: drop bun + rg from Windows sign list

These executables are already absent from server-prod-resources.json (no
Windows entries shipped); keeping them in the sign list produces
"Binary not found" warnings on every Windows build.
2026-04-22 10:40:53 -07:00
Nikhil
5ccdbaf87f feat(openclaw): lifecycle progress banner + live podman readiness (#772)
* fix(openclaw): serialize lifecycle operations

* feat(openclaw): lifecycle progress banner and live podman readiness check

* fix: address review comments for openclaw-lifecycle-progress
2026-04-21 07:59:33 -07:00
98 changed files with 8579 additions and 2806 deletions

153
.github/workflows/build-agent.yml vendored Normal file
View File

@@ -0,0 +1,153 @@
name: build-agent
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
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@v4
- 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
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- 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@v4
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
path: dist/images/
retention-days: 7
smoke:
needs: build
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v4
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-arm64
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
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
run: |
set -euo pipefail
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-arm64.tar.gz" -print -quit)"
if [ -z "$tarball" ]; then
echo "missing arm64 tarball artifact for ${AGENT}" >&2
exit 1
fi
bun run smoke:tarball -- --agent "$AGENT" --arch arm64 --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@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v4
with:
pattern: tarball-*
path: dist/images
merge-multiple: true
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Upload tarballs to R2
working-directory: ${{ env.PKG_DIR }}
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
base="$(basename "$file")"
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
done
- name: Merge agent slice into manifest
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
mkdir -p dist/images
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
bun run emit-manifest -- \
--slice "agents:${AGENT}" \
--dist-dir dist \
--merge-from dist/baseline-manifest.json \
--out dist/manifest.json
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"

View File

@@ -14,6 +14,7 @@ lerna-debug.log*
# Ignore all .env files except .env.example
**/.env.*
!**/.env.example
!**/.env.sample
!**/.env.production.example

View File

@@ -218,3 +218,9 @@ This uses the same element resolution as the server's MCP tools — no coordinat
The `<target>` argument can be:
- An **index** from the `targets` output (e.g., `3`)
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)
## Release gating — bundled-VM runtime migration (2026-Q2)
Between the Lima server-prod-resources cutover (WS3) and the ContainerRuntime migration (WS6) landing, `resources/bin/third_party/` ships `limactl` instead of `podman`. The current OpenClaw runtime (`apps/server/src/api/services/openclaw/podman-runtime.ts`, `container-runtime.ts`) still invokes `podman`; it will fail to find the binary on builds cut from `dev`.
Do **not** cut a release branch off `dev` during this window. Track WS6 progress before any release cut. See `specs/bundled-vm-runtime-spec.md` + `specs/workstreams.md` for context.

View File

@@ -75,26 +75,20 @@ packages/
### Setup
Requires [process-compose](https://github.com/F1bonacc1/process-compose):
```bash
brew install process-compose
```
```bash
# Copy environment files for each package
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
bun run dev:setup
# Start the full dev environment
process-compose up
bun run dev:watch
```
The `process-compose up` command runs the following in order:
1. `bun install` — installs dependencies
2. `bun --cwd apps/agent codegen` — generates agent code
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
### Environment Variables

View File

@@ -74,6 +74,18 @@ const primaryNavItems: NavItem[] = [
{ name: 'Settings', to: '/settings/ai', icon: Settings },
]
function isNavItemActive(item: NavItem, pathname: string): boolean {
if (item.to === '/settings/ai') {
return pathname.startsWith('/settings')
}
if (item.to === '/agents') {
return pathname === '/agents' || pathname.startsWith('/agents/')
}
return pathname === item.to
}
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
expanded = true,
}) => {
@@ -90,10 +102,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
<nav className="space-y-1">
{filteredItems.map((item) => {
const Icon = item.icon
const isActive =
item.to === '/settings/ai'
? location.pathname.startsWith('/settings')
: location.pathname === item.to
const isActive = isNavItemActive(item, location.pathname)
const navItem = (
<NavLink

View File

@@ -113,7 +113,22 @@ export const App: FC = () => {
<Route path="connect-apps" element={<ConnectMCP />} />
<Route path="scheduled" element={<ScheduledTasksPage />} />
{alphaEnabled ? (
<Route path="agents" element={<AgentsPage />} />
<>
<Route path="agents" element={<AgentsPage />} />
<Route element={<AgentCommandLayout />}>
<Route
path="agents/:agentId"
element={
<AgentCommandConversation
variant="page"
backPath="/agents"
agentPathPrefix="/agents"
createAgentPath="/agents"
/>
}
/>
</Route>
</>
) : null}
{alphaEnabled ? (
<Route path="admin" element={<AdminDashboardPage />} />

View File

@@ -1,4 +1,4 @@
import { Bot, Home, RotateCcw } from 'lucide-react'
import { ArrowLeft, Bot, Home, RotateCcw } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
@@ -11,15 +11,21 @@ import { useAgentConversation } from './useAgentConversation'
function ConversationHeader({
agentName,
backLabel,
backTarget,
status,
onGoHome,
onNavigateBack,
onReset,
}: {
agentName: string
backLabel: string
backTarget: 'home' | 'page'
status: string
onGoHome: () => void
onNavigateBack: () => void
onReset: () => void
}) {
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
return (
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
<div className="flex items-center justify-between gap-3 px-5 py-4">
@@ -27,11 +33,11 @@ function ConversationHeader({
<Button
variant="ghost"
size="icon"
onClick={onGoHome}
onClick={onNavigateBack}
className="rounded-xl"
title="Back to home"
title={backLabel}
>
<Home className="size-4" />
<BackIcon className="size-4" />
</Button>
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Bot className="size-5" />
@@ -85,7 +91,19 @@ function getConversationStatusCopy(
return 'Open agent setup to continue'
}
export const AgentCommandConversation: FC = () => {
interface AgentCommandConversationProps {
variant?: 'command' | 'page'
backPath?: string
agentPathPrefix?: string
createAgentPath?: string
}
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
variant = 'command',
backPath = '/home',
agentPathPrefix = '/home/agents',
createAgentPath = '/agents',
}) => {
const { agentId } = useParams<{ agentId: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
@@ -100,6 +118,8 @@ export const AgentCommandConversation: FC = () => {
useAgentConversation(resolvedAgentId, agentName)
const lastTurn = turns[turns.length - 1]
const lastTurnPartCount = lastTurn?.parts.length ?? 0
const isPageVariant = variant === 'page'
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
useEffect(() => {
if (shouldRedirectHome) return
@@ -131,18 +151,32 @@ export const AgentCommandConversation: FC = () => {
}
const handleSelectAgent = (entry: AgentEntry) => {
navigate(`/home/agents/${entry.agentId}`)
navigate(`${agentPathPrefix}/${entry.agentId}`)
}
const statusCopy = getConversationStatusCopy(status?.status, streaming)
return (
<div className="absolute inset-0 overflow-hidden">
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
<div
className={cn(
'overflow-hidden',
isPageVariant
? 'h-[calc(100vh-7rem)] min-h-[620px]'
: 'absolute inset-0',
)}
>
<div
className={cn(
'fade-in slide-in-from-bottom-5 flex h-full w-full animate-in flex-col gap-3 duration-300',
isPageVariant ? 'mx-auto' : 'mx-auto max-w-3xl px-4 pt-4 pb-2',
)}
>
<ConversationHeader
agentName={agentName}
backLabel={backLabel}
backTarget={isPageVariant ? 'page' : 'home'}
status={statusCopy}
onGoHome={() => navigate('/home')}
onNavigateBack={() => navigate(backPath)}
onReset={resetConversation}
/>
@@ -181,7 +215,7 @@ export const AgentCommandConversation: FC = () => {
onSend={(text) => {
void send(text)
}}
onCreateAgent={() => navigate('/agents')}
onCreateAgent={() => navigate(createAgentPath)}
streaming={streaming}
disabled={status?.status !== 'running'}
status={status?.status}

View File

@@ -1,399 +0,0 @@
import {
ArrowLeft,
Bot,
CheckCircle2,
Loader2,
Send,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { consumeSSEStream } from '@/lib/sse'
import {
buildChatHistoryFromTurns,
chatWithAgent,
type OpenClawStreamEvent,
} from './useOpenClaw'
interface ToolEntry {
id: string
name: string
status: 'running' | 'completed' | 'error'
durationMs?: number
}
type AssistantPart =
| { kind: 'thinking'; text: string; done: boolean }
| { kind: 'tool-batch'; tools: ToolEntry[] }
| { kind: 'text'; text: string }
interface ChatTurn {
id: string
userText: string
parts: AssistantPart[]
done: boolean
}
interface AgentChatProps {
agentId: string
agentName: string
onBack: () => void
}
export const AgentChat: FC<AgentChatProps> = ({
agentId,
agentName,
onBack,
}) => {
const [turns, setTurns] = useState<ChatTurn[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sessionKeyRef = useRef(crypto.randomUUID())
const streamAbortRef = useRef<AbortController | null>(null)
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const scrollToBottom = () => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
useEffect(() => {
scrollToBottom()
}, [turns])
useEffect(() => {
return () => {
streamAbortRef.current?.abort()
}
}, [])
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
})
}
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool: ToolEntry = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running',
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const status =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId
? {
...t,
status: status as ToolEntry['status'],
durationMs,
}
: t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
}
const handleSend = async () => {
const text = input.trim()
if (!text || streaming) return
const history = buildChatHistoryFromTurns(turns)
const turn: ChatTurn = {
id: crypto.randomUUID(),
userText: text,
parts: [],
done: false,
}
setTurns((prev) => [...prev, turn])
setInput('')
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
const abortController = new AbortController()
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
text,
sessionKeyRef.current,
history,
abortController.signal,
)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
return
}
await consumeSSEStream(
response,
processStreamEvent,
abortController.signal,
)
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
} finally {
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
setStreaming(false)
}
}
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<h2 className="font-semibold text-lg">{agentName}</h2>
</div>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.map((turn) => (
<div key={turn.id} className="space-y-3">
{/* User message */}
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{/* Assistant response — all parts grouped */}
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
case 'tool-batch':
return (
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">
{tool.name}
</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
))}
</div>
)
case 'text':
return (
<MessageResponse key={key}>
{part.text}
</MessageResponse>
)
default:
return null
}
})}
</MessageContent>
</Message>
)}
{/* Streaming indicator when waiting for first part */}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
</div>
))}
</div>
<div className="border-t p-4">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder="Send a message..."
className="min-h-[44px] resize-none"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || streaming}
size="icon"
>
{streaming ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,7 +1,5 @@
import {
AlertCircle,
ChevronDown,
ChevronRight,
Cpu,
Loader2,
MessageSquare,
@@ -15,6 +13,7 @@ import {
Wrench,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -34,18 +33,25 @@ import {
SelectValue,
} from '@/components/ui/select'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { AgentChat } from './AgentChat'
import { AgentTerminal } from './AgentTerminal'
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
import {
type AgentEntry,
type GatewayLifecycleAction,
type OpenClawStatus,
useOpenClawAgents,
useOpenClawMutations,
useOpenClawStatus,
usePodmanOverrides,
} from './useOpenClaw'
const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
setup: 'Setting up OpenClaw...',
start: 'Starting gateway...',
stop: 'Stopping gateway...',
restart: 'Restarting gateway...',
reconnect: 'Restoring gateway connection...',
}
const CONTROL_PLANE_COPY: Record<
OpenClawStatus['controlPlaneStatus'],
{
@@ -229,123 +235,366 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
)
}
const PodmanOverridesCard: FC = () => {
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
usePodmanOverrides()
interface AgentsPageHeaderProps {
actionInProgress: boolean
canManageAgents: boolean
controlPlaneBusy: boolean
reconnecting: boolean
status: OpenClawStatus | null
onCreateAgent: () => void
onOpenTerminal: () => void
onReconnect: () => void
onRestart: () => void
onStop: () => void
}
const [value, setValue] = useState('')
const [touched, setTouched] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [localError, setLocalError] = useState<string | null>(null)
useEffect(() => {
if (!touched && overrides) setValue(overrides.podmanPath ?? '')
}, [overrides, touched])
const handleSave = async () => {
const trimmed = value.trim()
if (!trimmed) return
setLocalError(null)
try {
await saveOverrides(trimmed)
setTouched(false)
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err))
}
}
const handleClear = async () => {
setLocalError(null)
try {
await clearOverrides()
setValue('')
setTouched(false)
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err))
}
}
const hasOverride = !!overrides?.podmanPath
const effective = overrides?.effectivePodmanPath ?? null
const inlineErrorMessage = localError ?? error?.message ?? null
const body = (
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor="podman-path" className="font-medium text-sm">
Podman binary path
</label>
<Input
id="podman-path"
value={value}
onChange={(event) => {
setTouched(true)
setValue(event.target.value)
}}
placeholder="/opt/homebrew/bin/podman"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
/>
<p className="text-muted-foreground text-xs">
Install Podman yourself (e.g. <code>brew install podman</code>) and
paste the absolute path to the binary. Restart the gateway after
saving.
</p>
</div>
{effective && (
<p className="text-muted-foreground text-xs">
Currently using: <code className="break-all">{effective}</code>
</p>
)}
{inlineErrorMessage && (
<p className="text-destructive text-xs">{inlineErrorMessage}</p>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={saving || loading || !value.trim()}
>
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
Save
</Button>
<Button
size="sm"
variant="outline"
onClick={handleClear}
disabled={saving || loading || !hasOverride}
>
Clear
</Button>
</div>
const AgentsPageHeader: FC<AgentsPageHeaderProps> = ({
actionInProgress,
canManageAgents,
controlPlaneBusy,
reconnecting,
status,
onCreateAgent,
onOpenTerminal,
onReconnect,
onRestart,
onStop,
}) => (
<div className="flex items-center justify-between">
<div>
<h1 className="font-bold text-2xl">Agents</h1>
<p className="text-muted-foreground text-sm">
OpenClaw agents running in a local container
</p>
</div>
{status && (
<div className="flex items-center gap-2">
<StatusBadge status={status.status} />
{status.status !== 'uninitialized' && (
<ControlPlaneBadge status={status.controlPlaneStatus} />
)}
{status.status === 'running' && (
<>
{status.controlPlaneStatus !== 'connected' && (
<Button
variant="outline"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={onRestart}
disabled={actionInProgress}
title="Restart gateway"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onStop}
disabled={actionInProgress}
title="Stop gateway"
>
<Square className="size-4" />
</Button>
<Button variant="outline" onClick={onOpenTerminal}>
<TerminalSquare className="mr-1 size-4" />
Terminal
</Button>
<Button onClick={onCreateAgent} disabled={!canManageAgents}>
<Plus className="mr-1 size-4" />
New Agent
</Button>
</>
)}
</div>
)}
</div>
)
function LifecycleAlert({ message }: { message: string }) {
return (
<Alert>
<Loader2 className="animate-spin" />
<AlertTitle>{message}</AlertTitle>
</Alert>
)
}
function InlineErrorAlert({
message,
onDismiss,
}: {
message: string
onDismiss: () => void
}) {
return (
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>OpenClaw action failed</AlertTitle>
<AlertDescription>
<p>{message}</p>
<div className="mt-2">
<Button variant="outline" size="sm" onClick={onDismiss}>
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
)
}
interface ControlPlaneAlertProps {
actionInProgress: boolean
controlPlaneBusy: boolean
controlPlaneCopy: ReturnType<typeof getControlPlaneCopy>
reconnecting: boolean
recoveryDetail: string | null
status: OpenClawStatus
onReconnect: () => void
onRestart: () => void
}
const ControlPlaneAlert: FC<ControlPlaneAlertProps> = ({
actionInProgress,
controlPlaneBusy,
controlPlaneCopy,
reconnecting,
recoveryDetail,
status,
onReconnect,
onRestart,
}) => (
<Alert
variant={status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'}
>
{status.controlPlaneStatus === 'failed' ? (
<ShieldAlert />
) : status.controlPlaneStatus === 'recovering' ? (
<Wrench />
) : (
<WifiOff />
)}
<AlertTitle>{controlPlaneCopy.title}</AlertTitle>
<AlertDescription>
<p>{controlPlaneCopy.description}</p>
{recoveryDetail && <p>{recoveryDetail}</p>}
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
<Button
variant="outline"
size="sm"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</AlertDescription>
</Alert>
)
interface GatewayStateCardsProps {
actionInProgress: boolean
status: OpenClawStatus | null
onOpenSetup: () => void
onRestart: () => void
onStart: () => void
}
const GatewayStateCards: FC<GatewayStateCardsProps> = ({
actionInProgress,
status,
onOpenSetup,
onRestart,
onStart,
}) => (
<>
{status?.status === 'uninitialized' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
: 'BrowserOS VM runtime is unavailable on this system.'}
</p>
</div>
{status.podmanAvailable && (
<Button onClick={onOpenSetup}>Set Up Now</Button>
)}
</CardContent>
</Card>
)}
{status?.status === 'stopped' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
<p className="text-muted-foreground text-sm">
The OpenClaw gateway is not running.
</p>
</div>
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
</CardContent>
</Card>
)}
{status?.status === 'error' && (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="size-12 text-destructive" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Error</h3>
<p className="text-muted-foreground text-sm">
{status.error ?? status.lastGatewayError}
</p>
</div>
<div className="flex gap-2">
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
<Button
variant="outline"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</CardContent>
</Card>
)}
</>
)
interface RunningAgentsSectionProps {
agents: AgentEntry[]
agentsLoading: boolean
canManageAgents: boolean
deleting: boolean
status: OpenClawStatus | null
onChatAgent: (agentId: string) => void
onCreateAgent: () => void
onDeleteAgent: (agentId: string) => void
}
const RunningAgentsSection: FC<RunningAgentsSectionProps> = ({
agents,
agentsLoading,
canManageAgents,
deleting,
status,
onChatAgent,
onCreateAgent,
onDeleteAgent,
}) => {
if (status?.status !== 'running') return null
if (agentsLoading) {
return (
<div className="flex justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
if (agents.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8">
<p className="text-muted-foreground text-sm">
No agents yet. Create one to get started.
</p>
<Button
variant="outline"
onClick={onCreateAgent}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
</Button>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader
className="cursor-pointer py-3"
onClick={() => setCollapsed((prev) => !prev)}
>
<CardTitle className="flex items-center gap-2 text-base">
{collapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
Advanced: Podman binary path
</CardTitle>
</CardHeader>
{!collapsed && <CardContent className="pt-0">{body}</CardContent>}
</Card>
<div className="space-y-3">
{agents.map((agent) => (
<Card key={agent.agentId}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="flex items-center gap-3">
<Cpu className="size-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-base">{agent.name}</CardTitle>
</div>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onChatAgent(agent.agentId)}
disabled={!canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
</Button>
{agent.agentId !== 'main' && (
<Button
variant="ghost"
size="icon"
onClick={() => onDeleteAgent(agent.agentId)}
disabled={!canManageAgents || deleting}
>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
</div>
</CardHeader>
</Card>
))}
</div>
)
}
export const AgentsPage: FC = () => {
const navigate = useNavigate()
const {
status,
loading: statusLoading,
@@ -372,6 +621,7 @@ export const AgentsPage: FC = () => {
creating,
deleting,
reconnecting,
pendingGatewayAction,
} = useOpenClawMutations()
const [setupOpen, setSetupOpen] = useState(false)
@@ -380,7 +630,6 @@ export const AgentsPage: FC = () => {
const [newName, setNewName] = useState('')
const [createProviderId, setCreateProviderId] = useState('')
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
const [showTerminal, setShowTerminal] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -408,8 +657,13 @@ export const AgentsPage: FC = () => {
setNewName((current) => current || 'agent')
}, [createOpen])
const inlineError =
error ?? statusError?.message ?? agentsError?.message ?? null
const lifecyclePending = pendingGatewayAction !== null
const inlineError = lifecyclePending
? null
: (error ?? statusError?.message ?? agentsError?.message ?? null)
const lifecycleBanner = pendingGatewayAction
? LIFECYCLE_BANNER_COPY[pendingGatewayAction]
: null
const gatewayUiState = useMemo(() => {
if (!status) {
@@ -438,10 +692,14 @@ export const AgentsPage: FC = () => {
}
}, [status])
const canManageAgents = gatewayUiState.canManageAgents && !lifecyclePending
const showControlPlaneDegraded =
!lifecyclePending && gatewayUiState.controlPlaneDegraded
const recoveryDetail = status ? getRecoveryDetail(status) : null
const controlPlaneCopy = status
? getControlPlaneCopy(status.controlPlaneStatus)
: null
: FALLBACK_CONTROL_PLANE_COPY
const runWithErrorHandling = async (fn: () => Promise<unknown>) => {
setError(null)
@@ -524,16 +782,6 @@ export const AgentsPage: FC = () => {
return <AgentTerminal onBack={() => setShowTerminal(false)} />
}
if (chatAgent) {
return (
<AgentChat
agentId={chatAgent.agentId}
agentName={chatAgent.name}
onBack={() => setChatAgent(null)}
/>
)
}
if (statusLoading && !status) {
return (
<div className="flex items-center justify-center py-20">
@@ -544,267 +792,61 @@ export const AgentsPage: FC = () => {
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<div className="flex items-center justify-between">
<div>
<h1 className="font-bold text-2xl">Agents</h1>
<p className="text-muted-foreground text-sm">
OpenClaw agents running in a local container
</p>
</div>
<AgentsPageHeader
actionInProgress={actionInProgress}
canManageAgents={canManageAgents}
controlPlaneBusy={gatewayUiState.controlPlaneBusy}
reconnecting={reconnecting}
status={status}
onCreateAgent={() => setCreateOpen(true)}
onOpenTerminal={() => setShowTerminal(true)}
onReconnect={handleReconnect}
onRestart={handleRestart}
onStop={handleStop}
/>
{status && (
<div className="flex items-center gap-2">
<StatusBadge status={status.status} />
{status.status !== 'uninitialized' && (
<ControlPlaneBadge status={status.controlPlaneStatus} />
)}
{status.status === 'running' && (
<>
{status.controlPlaneStatus !== 'connected' && (
<Button
variant="outline"
onClick={handleReconnect}
disabled={
actionInProgress || gatewayUiState.controlPlaneBusy
}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={handleRestart}
disabled={actionInProgress}
title="Restart gateway"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleStop}
disabled={actionInProgress}
title="Stop gateway"
>
<Square className="size-4" />
</Button>
<Button variant="outline" onClick={() => setShowTerminal(true)}>
<TerminalSquare className="mr-1 size-4" />
Terminal
</Button>
<Button
onClick={() => setCreateOpen(true)}
disabled={!gatewayUiState.canManageAgents}
>
<Plus className="mr-1 size-4" />
New Agent
</Button>
</>
)}
</div>
)}
</div>
{lifecycleBanner && <LifecycleAlert message={lifecycleBanner} />}
{inlineError && (
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>OpenClaw action failed</AlertTitle>
<AlertDescription>
<p>{inlineError}</p>
<div className="mt-2">
<Button
variant="outline"
size="sm"
onClick={() => setError(null)}
>
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
<InlineErrorAlert
message={inlineError}
onDismiss={() => setError(null)}
/>
)}
{status && gatewayUiState.controlPlaneDegraded && (
<Alert
variant={
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
}
>
{status.controlPlaneStatus === 'failed' ? (
<ShieldAlert />
) : status.controlPlaneStatus === 'recovering' ? (
<Wrench />
) : (
<WifiOff />
)}
<AlertTitle>{controlPlaneCopy?.title}</AlertTitle>
<AlertDescription>
<p>{controlPlaneCopy?.description}</p>
{recoveryDetail && <p>{recoveryDetail}</p>}
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={handleReconnect}
disabled={actionInProgress || gatewayUiState.controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</AlertDescription>
</Alert>
{status && showControlPlaneDegraded && (
<ControlPlaneAlert
actionInProgress={actionInProgress}
controlPlaneBusy={gatewayUiState.controlPlaneBusy}
controlPlaneCopy={controlPlaneCopy}
reconnecting={reconnecting}
recoveryDetail={recoveryDetail}
status={status}
onReconnect={handleReconnect}
onRestart={handleRestart}
/>
)}
{status?.status === 'uninitialized' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local container to run autonomous agents with full tool access.'
: 'Podman is required to run OpenClaw agents. Install Podman first.'}
</p>
</div>
{status.podmanAvailable && (
<Button onClick={() => setSetupOpen(true)}>Set Up Now</Button>
)}
</CardContent>
</Card>
)}
<GatewayStateCards
actionInProgress={actionInProgress}
status={status}
onOpenSetup={() => setSetupOpen(true)}
onRestart={handleRestart}
onStart={handleStart}
/>
{status?.status === 'stopped' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
<p className="text-muted-foreground text-sm">
The OpenClaw gateway is not running.
</p>
</div>
<Button onClick={handleStart} disabled={actionInProgress}>
Start Gateway
</Button>
</CardContent>
</Card>
)}
{status?.status === 'error' && (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="size-12 text-destructive" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Error</h3>
<p className="text-muted-foreground text-sm">
{status.error ?? status.lastGatewayError}
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleStart} disabled={actionInProgress}>
Start Gateway
</Button>
<Button
variant="outline"
onClick={handleRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</CardContent>
</Card>
)}
{status?.status === 'running' && (
<div className="space-y-3">
{agentsLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : agents.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8">
<p className="text-muted-foreground text-sm">
No agents yet. Create one to get started.
</p>
<Button
variant="outline"
onClick={() => setCreateOpen(true)}
disabled={!gatewayUiState.canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
</Button>
</CardContent>
</Card>
) : (
agents.map((agent) => (
<Card key={agent.agentId}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="flex items-center gap-3">
<Cpu className="size-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-base">
{agent.name}
</CardTitle>
</div>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setChatAgent(agent)}
disabled={!gatewayUiState.canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
</Button>
{agent.agentId !== 'main' && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(agent.agentId)}
disabled={!gatewayUiState.canManageAgents || deleting}
>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
</div>
</CardHeader>
</Card>
))
)}
</div>
)}
<PodmanOverridesCard />
<RunningAgentsSection
agents={agents}
agentsLoading={agentsLoading}
canManageAgents={canManageAgents}
deleting={deleting}
status={status}
onChatAgent={(agentId) => navigate(`/agents/${agentId}`)}
onCreateAgent={() => setCreateOpen(true)}
onDeleteAgent={(agentId) => {
void handleDelete(agentId)
}}
/>
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
<DialogContent>
@@ -875,7 +917,7 @@ export const AgentsPage: FC = () => {
disabled={
!newName.trim() ||
creating ||
!gatewayUiState.canManageAgents ||
!canManageAgents ||
compatibleProviders.length === 0
}
className="w-full"

View File

@@ -59,13 +59,14 @@ export function getModelDisplayName(model: unknown): string | undefined {
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
podmanOverrides: 'openclaw-podman-overrides',
} as const
export interface PodmanOverrides {
podmanPath: string | null
effectivePodmanPath: string
}
export type GatewayLifecycleAction =
| 'setup'
| 'start'
| 'stop'
| 'restart'
| 'reconnect'
async function clawFetch<T>(
baseUrl: string,
@@ -224,6 +225,13 @@ export function useOpenClawMutations() {
onSuccess,
})
let pendingGatewayAction: GatewayLifecycleAction | null = null
if (setupMutation.isPending) pendingGatewayAction = 'setup'
else if (restartMutation.isPending) pendingGatewayAction = 'restart'
else if (stopMutation.isPending) pendingGatewayAction = 'stop'
else if (startMutation.isPending) pendingGatewayAction = 'start'
else if (reconnectMutation.isPending) pendingGatewayAction = 'reconnect'
return {
setupOpenClaw: setupMutation.mutateAsync,
createAgent: createMutation.mutateAsync,
@@ -244,50 +252,7 @@ export function useOpenClawMutations() {
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
}
}
export function usePodmanOverrides() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const queryClient = useQueryClient()
const query = useQuery<PodmanOverrides, Error>({
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides, baseUrl],
queryFn: () =>
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides'),
enabled: !!baseUrl && !urlLoading,
})
const saveMutation = useMutation({
mutationFn: async (podmanPath: string | null) =>
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath }),
}),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.status],
}),
])
},
})
return {
overrides: query.data ?? null,
loading: query.isLoading || urlLoading,
error: (query.error ?? urlError) as Error | null,
saving: saveMutation.isPending,
saveOverrides: (podmanPath: string) => saveMutation.mutateAsync(podmanPath),
clearOverrides: () => saveMutation.mutateAsync(null),
pendingGatewayAction,
}
}

View File

@@ -142,7 +142,7 @@ cp .env.example .env.development
bun run start
```
See the [agent monorepo README](../../README.md) for full environment variable reference and `process-compose` setup.
See the [agent monorepo README](../../README.md) for full environment variable reference and `dev:watch` setup.
### Testing

View File

@@ -7,8 +7,6 @@
* Thin layer delegating to OpenClawService.
*/
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
import path from 'node:path'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { logger } from '../../lib/logger'
@@ -19,6 +17,7 @@ import {
OpenClawAgentNotFoundError,
OpenClawInvalidAgentNameError,
OpenClawProtectedAgentError,
OpenClawSessionNotFoundError,
} from '../services/openclaw/errors'
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
import { getOpenClawService } from '../services/openclaw/openclaw-service'
@@ -30,27 +29,6 @@ function getCreateAgentValidationError(body: { name?: string }): string | null {
return null
}
function getPodmanOverrideValidationError(body: {
podmanPath?: string | null
}): string | null {
if (body.podmanPath === null) return null
if (typeof body.podmanPath !== 'string' || !body.podmanPath.trim()) {
return 'podmanPath must be a non-empty absolute path or null'
}
if (!path.isAbsolute(body.podmanPath)) {
return 'podmanPath must be an absolute path'
}
if (!existsSync(body.podmanPath)) {
return `File does not exist: ${body.podmanPath}`
}
try {
accessSync(body.podmanPath, fsConstants.X_OK)
} catch {
return `File is not executable: ${body.podmanPath}`
}
return null
}
export function createOpenClawRoutes() {
return new Hono()
.get('/status', async (c) => {
@@ -102,7 +80,7 @@ export function createOpenClawRoutes() {
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
if (message.includes('Podman is not available')) {
if (message.includes('VM runtime is not available')) {
return c.json({ error: message }, 503)
}
return c.json({ error: message }, 500)
@@ -344,6 +322,61 @@ export function createOpenClawRoutes() {
}
})
.get('/session/:key/history', async (c) => {
const key = c.req.param('key')
const limitRaw = c.req.query('limit')
const cursor = c.req.query('cursor')
const limitParsed =
limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : Number.NaN
const limit = Number.isFinite(limitParsed) ? limitParsed : undefined
const wantsStream = (c.req.header('accept') ?? '').includes(
'text/event-stream',
)
try {
if (!wantsStream) {
const history = await getOpenClawService().getSessionHistory(key, {
limit,
cursor,
})
return c.json(history)
}
const eventStream = await getOpenClawService().streamSessionHistory(
key,
{ limit, cursor, signal: c.req.raw.signal },
)
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
c.header('X-Session-Key', key)
return stream(c, async (s) => {
const reader = eventStream.getReader()
const encoder = new TextEncoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
await s.write(
encoder.encode(
`event: ${value.type}\ndata: ${JSON.stringify(value.data)}\n\n`,
),
)
}
} finally {
await reader.cancel()
}
})
} catch (err) {
if (err instanceof OpenClawSessionNotFoundError) {
return c.json({ error: err.message }, 404)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/logs', async (c) => {
try {
const logs = await getOpenClawService().getLogs()
@@ -383,37 +416,4 @@ export function createOpenClawRoutes() {
return c.json({ error: message }, 500)
}
})
.get('/podman-overrides', async (c) => {
try {
const overrides = await getOpenClawService().getPodmanOverrides()
return c.json(overrides)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error('Podman overrides read failed', { error: message })
return c.json({ error: message }, 500)
}
})
.post('/podman-overrides', async (c) => {
const body = await c.req.json<{ podmanPath: string | null }>()
const validationError = getPodmanOverrideValidationError(body)
if (validationError) {
return c.json({ error: validationError }, 400)
}
try {
logger.info('OpenClaw podman override requested', {
podmanPath: body.podmanPath,
})
const result = await getOpenClawService().applyPodmanOverrides({
podmanPath: body.podmanPath,
})
return c.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error('Podman overrides apply failed', { error: message })
return c.json({ error: message }, 500)
}
})
}

View File

@@ -16,7 +16,9 @@ export const TERMINAL_WS_PATH = '/terminal/ws'
interface TerminalRouteDeps {
containerName: string
podmanPath: string
limaHome: string
limactlPath: string
vmName: string
}
function safeSend(ws: { send(data: string): void }, data: string): void {
@@ -45,7 +47,9 @@ function createSocketEvents(deps: TerminalRouteDeps) {
try {
session = createTerminalSession({
containerName: deps.containerName,
podmanPath: deps.podmanPath,
limaHome: deps.limaHome,
limactlPath: deps.limactlPath,
vmName: deps.vmName,
workingDir: TERMINAL_HOME_DIR,
onOutput(data) {
sendOutput(ws, data)

View File

@@ -22,6 +22,7 @@ import { initializeOAuth } from '../lib/clients/oauth'
import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
import { getLimaHomeDir, resolveBundledLimactl, VM_NAME } from '../lib/vm'
import { createAclRoutes } from './routes/acl'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
@@ -45,7 +46,6 @@ import {
connectKlavisInBackground,
type KlavisProxyRef,
} from './services/klavis/strata-proxy'
import { getPodmanRuntime } from './services/openclaw/podman-runtime'
import type { Env, HttpServerConfig } from './types'
import { defaultCorsConfig } from './utils/cors'
import { requireTrustedAppOrigin } from './utils/request-auth'
@@ -114,7 +114,9 @@ export async function createHttpServer(config: HttpServerConfig) {
'/',
createTerminalRoutes({
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
podmanPath: getPodmanRuntime().getPodmanPath(),
limaHome: getLimaHomeDir(),
limactlPath: resolveBundledLimactl(resourcesDir),
vmName: VM_NAME,
}),
)

View File

@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cpSync, existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
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 { readCachedManifest } from '../../../lib/vm/manifest'
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
import { ContainerRuntime } from './container-runtime'
const UNSUPPORTED_PLATFORM_MESSAGE =
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
export interface ContainerRuntimeFactoryInput {
resourcesDir?: string
projectDir: string
browserosRoot?: string
platform?: NodeJS.Platform
}
export function buildContainerRuntime(
input: ContainerRuntimeFactoryInput,
): ContainerRuntime {
const platform = input.platform ?? process.platform
if (platform !== 'darwin') {
if (process.env.NODE_ENV === 'test') {
return new UnsupportedPlatformTestRuntime(input.projectDir)
}
throw unsupportedPlatformError()
}
const browserosRoot = input.browserosRoot ?? getBrowserosDir()
if (input.resourcesDir) {
migrateLegacyOpenClawDirSync(browserosRoot)
}
const limactlPath = input.resourcesDir
? resolveBundledLimactl(input.resourcesDir)
: 'limactl'
const limaHome = getLimaHomeDir(browserosRoot)
const vm = new VmRuntime({
limactlPath,
limaHome,
templatePath: input.resourcesDir
? resolveBundledLimaTemplate(input.resourcesDir)
: undefined,
browserosRoot,
})
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
const loader = new DeferredImageLoader(shell, browserosRoot)
return new ContainerRuntime({
vm,
shell,
loader,
projectDir: input.projectDir,
})
}
export async function migrateLegacyOpenClawDir(
browserosRoot = getBrowserosDir(),
): Promise<void> {
migrateLegacyOpenClawDirSync(browserosRoot)
}
function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
const legacyDir = join(browserosRoot, 'openclaw')
const nextDir = join(browserosRoot, 'vm', 'openclaw')
if (!existsSync(legacyDir)) return
if (existsSync(nextDir)) {
logger.warn('OpenClaw legacy and VM state directories both exist', {
legacyDir,
nextDir,
})
return
}
mkdirSync(dirname(nextDir), { recursive: true })
cpSync(legacyDir, nextDir, { recursive: true })
logger.info(VM_TELEMETRY_EVENTS.migrationOpenClawMoved, {
from: legacyDir,
to: nextDir,
})
}
class DeferredImageLoader {
constructor(
private readonly shell: ContainerCli,
private readonly browserosRoot: string,
) {}
async ensureImageLoaded(ref: string, onLog?: (msg: string) => void) {
const manifest = await readCachedManifest(this.browserosRoot)
const loader = new ImageLoader(
this.shell,
manifest,
detectArch(),
this.browserosRoot,
)
await loader.ensureImageLoaded(ref, onLog)
}
}
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
constructor(projectDir: string) {
super({
vm: {} as VmRuntime,
shell: {} as ContainerCli,
loader: { ensureImageLoaded: rejectUnsupportedPlatform },
projectDir,
})
}
override async ensureReady(): Promise<void> {
throw unsupportedPlatformError()
}
override async isPodmanAvailable(): Promise<boolean> {
return false
}
override async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
return { initialized: false, running: false }
}
override async pullImage(): Promise<void> {
throw unsupportedPlatformError()
}
override async startGateway(): Promise<void> {
throw unsupportedPlatformError()
}
override async stopGateway(): Promise<void> {}
override async restartGateway(): Promise<void> {
throw unsupportedPlatformError()
}
override async getGatewayLogs(): Promise<string[]> {
return []
}
override async isHealthy(): Promise<boolean> {
return false
}
override async isReady(): Promise<boolean> {
return false
}
override async waitForReady(): Promise<boolean> {
return false
}
override async stopVm(): Promise<void> {}
override async execInContainer(): Promise<number> {
throw unsupportedPlatformError()
}
override async runGatewaySetupCommand(): Promise<number> {
throw unsupportedPlatformError()
}
override tailGatewayLogs(): () => void {
return () => {}
}
}
async function rejectUnsupportedPlatform(): Promise<never> {
throw unsupportedPlatformError()
}
function unsupportedPlatformError(): Error {
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
}

View File

@@ -2,19 +2,23 @@
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* OpenClaw container lifecycle abstraction over PodmanRuntime.
*/
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
} from '@browseros/shared/constants/openclaw'
import type { ContainerCli, ContainerSpec, LogFn } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import type { LogFn, PodmanRuntime } from './podman-runtime'
import {
GUEST_VM_STATE,
hostPathToGuest,
type VmRuntime,
} from '../../../lib/vm'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
export type GatewayContainerSpec = {
image: string
@@ -25,78 +29,63 @@ export type GatewayContainerSpec = {
timezone: string
}
export interface ContainerRuntimeConfig {
vm: VmRuntime
shell: ContainerCli
loader: { ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> }
projectDir: string
}
export class ContainerRuntime {
constructor(
private podman: PodmanRuntime,
private projectDir: string,
) {}
private readonly vm: VmRuntime
private readonly shell: ContainerCli
private readonly loader: {
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
}
private readonly projectDir: string
constructor(config: ContainerRuntimeConfig) {
this.vm = config.vm
this.shell = config.shell
this.loader = config.loader
this.projectDir = config.projectDir
}
async ensureReady(onLog?: LogFn): Promise<void> {
logger.info('Ensuring Podman runtime readiness')
return this.podman.ensureReady(onLog)
logger.info('Ensuring BrowserOS VM runtime readiness')
await this.vm.ensureReady(onLog)
await this.vm.getDefaultGateway()
}
async isPodmanAvailable(): Promise<boolean> {
return this.podman.isPodmanAvailable()
return true
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
return this.podman.getMachineStatus()
const running = await this.vm.isReady()
return { initialized: running, running }
}
async pullImage(image: string, onLog?: LogFn): Promise<void> {
const code = await this.runPodmanCommand(['pull', image], onLog)
if (code !== 0) throw new Error(`image pull failed with code ${code}`)
await this.loader.ensureImageLoaded(image, onLog)
}
async startGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.ensureGatewayRemoved(onLog)
const containerPort = String(OPENCLAW_GATEWAY_CONTAINER_PORT)
const code = await this.runPodmanCommand(
[
'run',
'-d',
'--name',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'--restart',
'unless-stopped',
'-p',
`127.0.0.1:${input.hostPort}:${containerPort}`,
...this.buildGatewayContainerRuntimeArgs(input),
'--health-cmd',
`curl -sf http://127.0.0.1:${containerPort}/healthz`,
'--health-interval',
'30s',
'--health-timeout',
'10s',
'--health-retries',
'3',
input.image,
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
containerPort,
'--allow-unconfigured',
],
onLog,
)
if (code !== 0) throw new Error(`gateway start failed with code ${code}`)
await this.removeGatewayContainer(onLog)
await this.loader.ensureImageLoaded(input.image, onLog)
const container = await this.buildGatewayContainerSpec(input)
await this.shell.createContainer(container, onLog)
await this.shell.startContainer(container.name)
}
async stopGateway(onLog?: LogFn): Promise<void> {
const code = await this.removeGatewayContainer(onLog)
if (code !== 0) {
throw new Error(`gateway stop failed with code ${code}`)
}
await this.removeGatewayContainer(onLog)
}
async restartGateway(
@@ -108,8 +97,8 @@ export class ContainerRuntime {
async getGatewayLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.runPodmanCommand(
['logs', '--tail', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
await this.shell.runCommand(
['logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
(line) => lines.push(line),
)
return lines
@@ -140,13 +129,7 @@ export class ContainerRuntime {
})
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.isReady(hostPort)) {
logger.info('OpenClaw gateway became ready', {
hostPort,
waitMs: Date.now() - start,
})
return true
}
if (await this.isReady(hostPort)) return true
await Bun.sleep(1000)
}
logger.error('Timed out waiting for OpenClaw gateway readiness', {
@@ -156,35 +139,12 @@ export class ContainerRuntime {
return false
}
/**
* Stops the Podman machine only if no non-BrowserOS containers are running.
* Prevents killing the user's own Podman workloads.
*/
async stopMachineIfSafe(): Promise<void> {
const status = await this.podman.getMachineStatus()
if (!status.running) return
try {
const containers = await this.podman.listRunningContainers()
const allOurs = containers.every(
(name) => name === OPENCLAW_GATEWAY_CONTAINER_NAME,
)
if (containers.length === 0 || allOurs) {
await this.podman.stopMachine()
}
} catch {
// Best effort — don't stop machine if we can't check
}
async stopVm(): Promise<void> {
await this.vm.stopVm()
}
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
return this.podman.runCommand(
['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, ...command],
{
onOutput: onLog,
},
)
return this.shell.exec(OPENCLAW_GATEWAY_CONTAINER_NAME, command, onLog)
}
async runGatewaySetupCommand(
@@ -193,103 +153,134 @@ export class ContainerRuntime {
onLog?: LogFn,
): Promise<number> {
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.runPodmanCommand(
['rm', '-f', '--ignore', setupContainerName],
onLog,
)
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
await this.loader.ensureImageLoaded(spec.image, onLog)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
return this.runPodmanCommand(
const createResult = await this.shell.runCommand(
[
'run',
'--rm',
'create',
'--name',
setupContainerName,
...this.buildGatewayContainerRuntimeArgs(spec),
...(await this.buildGatewayRunArgs(spec)),
spec.image,
'node',
...setupArgs,
],
onLog,
)
if (createResult.exitCode !== 0) {
await this.shell.removeContainer(
setupContainerName,
{ force: true },
onLog,
)
return createResult.exitCode
}
try {
const startResult = await this.shell.runCommand(
['start', '-a', setupContainerName],
onLog,
)
return startResult.exitCode
} finally {
await this.shell.removeContainer(
setupContainerName,
{ force: true },
onLog,
)
}
}
tailGatewayLogs(onLine: LogFn): () => void {
return this.podman.tailContainerLogs(
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
}
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
await this.shell.removeContainer(
OPENCLAW_GATEWAY_CONTAINER_NAME,
onLine,
)
}
private async runPodmanCommand(
args: string[],
onLog?: LogFn,
): Promise<number> {
const lines: string[] = []
const command = ['podman', ...args].join(' ')
logger.info('Running OpenClaw podman command', {
command,
})
const code = await this.podman.runCommand(args, {
cwd: this.projectDir,
onOutput: (line) => {
lines.push(line)
onLog?.(line)
},
})
if (code !== 0) {
logger.error('OpenClaw podman command failed', {
command,
exitCode: code,
output: lines,
})
} else {
logger.info('OpenClaw podman command succeeded', {
command,
})
}
return code
}
private async ensureGatewayRemoved(onLog?: LogFn): Promise<void> {
await this.removeGatewayContainer(onLog)
}
private async removeGatewayContainer(onLog?: LogFn): Promise<number> {
return this.runPodmanCommand(
['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
{ force: true },
onLog,
)
}
private buildGatewayContainerRuntimeArgs(
private async buildGatewayContainerSpec(
input: GatewayContainerSpec,
): string[] {
return [
): Promise<ContainerSpec> {
return {
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: input.image,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort: input.hostPort,
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
},
],
envFile: this.translateHostPath(input.envFilePath, input.hostHome),
env: this.buildGatewayEnv(input),
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
addHosts: [await this.hostContainersInternalEntry()],
health: {
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
interval: '30s',
timeout: '10s',
retries: 3,
},
command: [
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
'--allow-unconfigured',
],
}
}
private async buildGatewayRunArgs(
input: GatewayContainerSpec,
): Promise<string[]> {
const args = [
'--env-file',
input.envFilePath,
'-e',
`HOME=${GATEWAY_CONTAINER_HOME}`,
'-e',
`OPENCLAW_HOME=${GATEWAY_CONTAINER_HOME}`,
'-e',
`OPENCLAW_STATE_DIR=${GATEWAY_STATE_DIR}`,
'-e',
'OPENCLAW_NO_RESPAWN=1',
'-e',
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
'-e',
'NODE_ENV=production',
'-e',
`TZ=${input.timezone}`,
this.translateHostPath(input.envFilePath, input.hostHome),
'-v',
`${input.hostHome}:${GATEWAY_CONTAINER_HOME}`,
'--add-host',
'host.containers.internal:host-gateway',
...(input.gatewayToken
? ['-e', `OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`]
: []),
`${GUEST_OPENCLAW_HOME}:${GATEWAY_CONTAINER_HOME}`,
]
for (const [key, value] of Object.entries(this.buildGatewayEnv(input))) {
args.push('-e', `${key}=${value}`)
}
args.push('--add-host', await this.hostContainersInternalEntry())
return args
}
private async hostContainersInternalEntry(): Promise<string> {
return `host.containers.internal:${await this.vm.getDefaultGateway()}`
}
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
OPENCLAW_NO_RESPAWN: '1',
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
NODE_ENV: 'production',
TZ: input.timezone,
...(input.gatewayToken
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
: {}),
}
}
private translateHostPath(path: string, openclawHostDir: string): string {
if (path === openclawHostDir) return GUEST_OPENCLAW_HOME
if (path.startsWith(`${openclawHostDir}/`)) {
return `${GUEST_OPENCLAW_HOME}${path.slice(openclawHostDir.length)}`
}
return hostPathToGuest(path)
}
}

View File

@@ -27,3 +27,10 @@ export class OpenClawProtectedAgentError extends Error {
this.name = 'OpenClawProtectedAgentError'
}
}
export class OpenClawSessionNotFoundError extends Error {
constructor(public readonly sessionKey: string) {
super(`OpenClaw session not found: ${sessionKey}`)
this.name = 'OpenClawSessionNotFoundError'
}
}

View File

@@ -5,6 +5,7 @@
*/
import { createParser, type EventSourceMessage } from 'eventsource-parser'
import { OpenClawSessionNotFoundError } from './errors'
import type { OpenClawStreamEvent } from './openclaw-types'
export interface OpenClawChatHistoryMessage {
@@ -20,7 +21,42 @@ export interface OpenClawChatRequest {
signal?: AbortSignal
}
export class OpenClawHttpChatClient {
export interface OpenClawSessionHistoryMessage {
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
messageId?: string
messageSeq?: number
timestamp?: number
}
export interface OpenClawSessionHistory {
sessionKey: string
messages: OpenClawSessionHistoryMessage[]
cursor?: string | null
hasMore?: boolean
truncated?: boolean
}
export interface OpenClawSessionHistoryInput {
limit?: number
cursor?: string
signal?: AbortSignal
}
export type OpenClawSessionHistoryEvent =
| { type: 'history'; data: OpenClawSessionHistory }
| {
type: 'message'
data: {
sessionKey: string
message: OpenClawSessionHistoryMessage
messageId?: string
messageSeq: number
}
}
| { type: 'error'; data: { message: string } }
export class OpenClawHttpClient {
constructor(
private readonly hostPort: number,
private readonly getToken: () => Promise<string>,
@@ -39,6 +75,46 @@ export class OpenClawHttpChatClient {
return createEventStream(body, input.signal)
}
async getSessionHistory(
sessionKey: string,
input: OpenClawSessionHistoryInput = {},
): Promise<OpenClawSessionHistory> {
const response = await this.fetchSessionHistory(sessionKey, input, {})
return (await response.json()) as OpenClawSessionHistory
}
async streamSessionHistory(
sessionKey: string,
input: OpenClawSessionHistoryInput = {},
): Promise<ReadableStream<OpenClawSessionHistoryEvent>> {
const response = await this.fetchSessionHistory(sessionKey, input, {
Accept: 'text/event-stream',
})
const body = response.body
if (!body) {
throw new Error('OpenClaw session history stream had no body')
}
return createHistoryEventStream(body, input.signal)
}
async isAuthenticated(): Promise<boolean> {
try {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.hostPort}/v1/models`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
},
)
return response.ok
} catch {
return false
}
}
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
const token = await this.getToken()
const response = await fetch(
@@ -71,6 +147,50 @@ export class OpenClawHttpChatClient {
detail || `OpenClaw chat failed with status ${response.status}`,
)
}
private async fetchSessionHistory(
sessionKey: string,
input: OpenClawSessionHistoryInput,
extraHeaders: Record<string, string>,
): Promise<Response> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
...extraHeaders,
},
signal: input.signal,
},
)
if (response.status === 404) {
throw new OpenClawSessionNotFoundError(sessionKey)
}
if (!response.ok) {
const detail = await response.text()
throw new Error(
detail ||
`OpenClaw session history failed with status ${response.status}`,
)
}
return response
}
}
function buildHistoryPath(
sessionKey: string,
input: OpenClawSessionHistoryInput,
): string {
const qs = new URLSearchParams()
if (input.limit !== undefined) qs.set('limit', String(input.limit))
if (input.cursor !== undefined) qs.set('cursor', input.cursor)
const suffix = qs.toString()
return `/sessions/${encodeURIComponent(sessionKey)}/history${
suffix ? `?${suffix}` : ''
}`
}
function resolveAgentModel(agentId: string): string {
@@ -262,3 +382,104 @@ function parseChunk(data: string): Record<string, unknown> | null {
return null
}
}
function createHistoryEventStream(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): ReadableStream<OpenClawSessionHistoryEvent> {
return new ReadableStream<OpenClawSessionHistoryEvent>({
start(controller) {
void pumpHistoryEvents(body, controller, signal)
},
})
}
async function pumpHistoryEvents(
body: ReadableStream<Uint8Array>,
controller: ReadableStreamDefaultController<OpenClawSessionHistoryEvent>,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let closed = false
const close = () => {
if (closed) return
closed = true
controller.close()
}
const parser = createParser({
onEvent(message) {
if (closed) return
const event = toHistoryEvent(message)
if (!event) return
controller.enqueue(event)
if (event.type === 'error') close()
},
})
const onAbort = () => {
void reader.cancel().catch(() => {})
close()
}
signal?.addEventListener('abort', onAbort, { once: true })
try {
while (true) {
if (signal?.aborted) {
await reader.cancel()
close()
return
}
const { done, value } = await reader.read()
if (done) break
parser.feed(decoder.decode(value, { stream: true }))
}
} catch (error) {
if (!closed) {
controller.enqueue({
type: 'error',
data: {
message: error instanceof Error ? error.message : String(error),
},
})
close()
}
} finally {
signal?.removeEventListener('abort', onAbort)
close()
reader.releaseLock()
}
}
function toHistoryEvent(
message: EventSourceMessage,
): OpenClawSessionHistoryEvent | null {
if (!message.event) return null
const payload = parseChunk(message.data)
if (!payload) return null
if (message.event === 'history') {
return {
type: 'history',
data: payload as unknown as OpenClawSessionHistory,
}
}
if (message.event === 'message') {
return {
type: 'message',
data: payload as unknown as {
sessionKey: string
message: OpenClawSessionHistoryMessage
messageId?: string
messageSeq: number
},
}
}
if (message.event === 'error') {
const errMessage =
typeof payload.message === 'string'
? payload.message
: 'OpenClaw session history stream error'
return { type: 'error', data: { message: errMessage } }
}
return null
}

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Main orchestrator for OpenClaw integration.
* Container lifecycle via Podman, agent CRUD via in-container CLI,
* Container lifecycle via the VM runtime, agent CRUD via in-container CLI,
* chat via HTTP /v1/chat/completions proxy.
*/
@@ -18,10 +18,11 @@ import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import type { MonitoringChatTurn } from '../../../monitoring/types'
import {
import type {
ContainerRuntime,
type GatewayContainerSpec,
GatewayContainerSpec,
} from './container-runtime'
import { buildContainerRuntime } from './container-runtime-factory'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
@@ -40,14 +41,16 @@ import {
getOpenClawStateEnvPath,
mergeEnvContent,
} from './openclaw-env'
import { OpenClawHttpChatClient } from './openclaw-http-chat-client'
import {
OpenClawHttpClient,
type OpenClawSessionHistory,
type OpenClawSessionHistoryEvent,
} from './openclaw-http-client'
import {
type ResolvedOpenClawProviderConfig,
resolveSupportedOpenClawProvider,
} from './openclaw-provider-map'
import type { OpenClawStreamEvent } from './openclaw-types'
import { loadPodmanOverrides, savePodmanOverrides } from './podman-overrides'
import { configurePodmanRuntime, getPodmanRuntime } from './podman-runtime'
import { allocateGatewayPort, readPersistedGatewayPort } from './runtime-state'
const READY_TIMEOUT_MS = 30_000
@@ -108,18 +111,14 @@ export interface OpenClawProviderUpdateResult {
export interface OpenClawServiceConfig {
browserosServerPort?: number
resourcesDir?: string
}
export interface OpenClawPodmanOverridesResponse {
podmanPath: string | null
effectivePodmanPath: string
browserosDir?: string
}
export class OpenClawService {
private runtime: ContainerRuntime
private cliClient: OpenClawCliClient
private bootstrapCliClient: OpenClawCliClient
private chatClient: OpenClawHttpChatClient
private httpClient: OpenClawHttpClient
private openclawDir: string
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
private token: string
@@ -127,32 +126,55 @@ export class OpenClawService {
private lastError: string | null = null
private browserosServerPort: number
private resourcesDir: string | null
private browserosDir: string | undefined
private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected'
private lastGatewayError: string | null = null
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
private stopLogTail: (() => void) | null = null
private lifecycleLock: Promise<void> = Promise.resolve()
constructor(config: OpenClawServiceConfig = {}) {
this.openclawDir = getOpenClawDir()
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
this.runtime = buildContainerRuntime({
resourcesDir: config.resourcesDir,
projectDir: this.openclawDir,
browserosRoot: config.browserosDir,
})
this.token = crypto.randomUUID()
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
this.chatClient = new OpenClawHttpChatClient(
this.httpClient = new OpenClawHttpClient(
this.hostPort,
async () => this.token,
)
this.browserosServerPort =
config.browserosServerPort ?? DEFAULT_PORTS.server
this.resourcesDir = config.resourcesDir ?? null
this.browserosDir = config.browserosDir
}
configure(config: OpenClawServiceConfig): void {
if (config.browserosServerPort !== undefined) {
this.browserosServerPort = config.browserosServerPort
}
if (config.resourcesDir !== undefined) {
let runtimeChanged = false
if (
config.resourcesDir !== undefined &&
config.resourcesDir !== this.resourcesDir
) {
this.resourcesDir = config.resourcesDir
runtimeChanged = true
}
if (
config.browserosDir !== undefined &&
config.browserosDir !== this.browserosDir
) {
this.browserosDir = config.browserosDir
runtimeChanged = true
}
if (runtimeChanged) {
this.rebuildRuntimeClients()
}
}
@@ -163,213 +185,236 @@ export class OpenClawService {
// ── Lifecycle ────────────────────────────────────────────────────────
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
const provider = resolveSupportedOpenClawProvider(input)
logger.info('Starting OpenClaw setup', {
hostPort: this.hostPort,
browserosServerPort: this.browserosServerPort,
providerType: input.providerType,
providerName: input.providerName,
hasBaseUrl: !!input.baseUrl,
hasModel: !!input.modelId,
hasApiKey: !!input.apiKey,
})
return this.withLifecycleLock('setup', async () => {
const logProgress = this.createProgressLogger(onLog)
const provider = resolveSupportedOpenClawProvider(input)
logger.info('Starting OpenClaw setup', {
hostPort: this.hostPort,
browserosServerPort: this.browserosServerPort,
providerType: input.providerType,
providerName: input.providerName,
hasBaseUrl: !!input.baseUrl,
hasModel: !!input.modelId,
hasApiKey: !!input.apiKey,
})
logProgress('Checking container runtime...')
const available = await this.runtime.isPodmanAvailable()
if (!available) {
throw new Error(
'Podman is not available. Install Podman to use OpenClaw agents.',
await this.runtime.ensureReady(logProgress)
logProgress('Container runtime ready')
await mkdir(this.openclawDir, { recursive: true })
await mkdir(this.getStateDir(), { recursive: true })
await mkdir(this.getHostWorkspaceDir('main'), { recursive: true })
await this.ensureStateEnvFile()
await this.writeStateEnv(provider.envValues)
logger.info('Updated OpenClaw state env', {
providerKeyCount: Object.keys(provider.envValues).length,
})
await this.refreshGatewayAuthToken()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Bootstrapping OpenClaw config...')
await this.bootstrapCliClient.runOnboard({
acceptRisk: true,
authChoice: 'skip',
gatewayAuth: 'token',
gatewayBind: 'lan',
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
installDaemon: false,
mode: 'local',
nonInteractive: true,
skipHealth: true,
})
await this.applyBrowserosConfig()
await this.mergeProviderConfigIfChanged(provider)
if (provider.model) {
await this.bootstrapCliClient.setDefaultModel(provider.model)
}
logProgress('Validating OpenClaw config...')
await this.assertConfigValid(this.bootstrapCliClient)
await this.refreshGatewayAuthToken()
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
}
await this.runtime.ensureReady(logProgress)
logProgress('Container runtime ready')
await mkdir(this.openclawDir, { recursive: true })
await mkdir(this.getStateDir(), { recursive: true })
await mkdir(this.getHostWorkspaceDir('main'), { recursive: true })
await this.ensureStateEnvFile()
await this.writeStateEnv(provider.envValues)
logger.info('Updated OpenClaw state env', {
providerKeyCount: Object.keys(provider.envValues).length,
})
logProgress('Pulling OpenClaw image...')
await this.runtime.pullImage(this.getGatewayImage(), logProgress)
logProgress('Image ready')
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Bootstrapping OpenClaw config...')
await this.bootstrapCliClient.runOnboard({
acceptRisk: true,
authChoice: 'skip',
gatewayAuth: 'token',
gatewayBind: 'lan',
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
installDaemon: false,
mode: 'local',
nonInteractive: true,
skipHealth: true,
})
await this.applyBrowserosConfig()
await this.mergeProviderConfigIfChanged(provider)
if (provider.model) {
await this.bootstrapCliClient.setDefaultModel(provider.model)
}
logProgress('Validating OpenClaw config...')
await this.assertConfigValid(this.bootstrapCliClient)
this.tokenLoaded = false
await this.loadTokenFromConfig()
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(this.buildGatewayRuntimeSpec(), logProgress)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready within 30 seconds'
const logs = await this.runtime.getGatewayLogs()
logger.error('Gateway readiness check failed', { logs })
throw new Error(this.lastError)
}
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
const existingAgents = await this.listAgents()
logger.info('Fetched existing OpenClaw agents after setup', {
count: existingAgents.length,
names: existingAgents.map((agent) => agent.name),
})
if (existingAgents.some((agent) => agent.agentId === 'main')) {
logProgress('Main agent detected')
} else {
logProgress('Creating main agent...')
await this.runControlPlaneCall(() =>
this.cliClient.createAgent({
name: 'main',
model: provider.model,
}),
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
}
if (!ready) {
this.lastError = 'Gateway did not become ready within 30 seconds'
const logs = await this.runtime.getGatewayLogs()
logger.error('Gateway readiness check failed', { logs })
throw new Error(this.lastError)
}
this.lastError = null
logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`)
logger.info('OpenClaw setup complete', { hostPort: this.hostPort })
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
const existingAgents = await this.listAgents()
logger.info('Fetched existing OpenClaw agents after setup', {
count: existingAgents.length,
names: existingAgents.map((agent) => agent.name),
})
if (existingAgents.some((agent) => agent.agentId === 'main')) {
logProgress('Main agent detected')
} else {
logProgress('Creating main agent...')
await this.runControlPlaneCall(() =>
this.cliClient.createAgent({
name: 'main',
model: provider.model,
}),
)
}
this.lastError = null
logProgress(
`OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`,
)
logger.info('OpenClaw setup complete', { hostPort: this.hostPort })
})
}
async start(onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
logger.info('Starting OpenClaw service', {
hostPort: this.hostPort,
return this.withLifecycleLock('start', async () => {
const logProgress = this.createProgressLogger(onLog)
logger.info('Starting OpenClaw service', {
hostPort: this.hostPort,
})
await this.runtime.ensureReady(logProgress)
logProgress('Refreshing gateway auth token...')
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
if (await this.isGatewayAvailable(this.hostPort)) {
this.startGatewayLogTail()
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
try {
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logger.info('OpenClaw gateway already running', {
hostPort: this.hostPort,
})
return
} catch (error) {
logger.warn('OpenClaw control plane probe failed during start', {
hostPort: this.hostPort,
error: error instanceof Error ? error.message : String(error),
})
}
}
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready after start'
throw new Error(this.lastError)
}
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logger.info('OpenClaw gateway started', { hostPort: this.hostPort })
})
await this.runtime.ensureReady(logProgress)
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(this.buildGatewayRuntimeSpec(), logProgress)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready after start'
throw new Error(this.lastError)
}
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logger.info('OpenClaw gateway started', { hostPort: this.hostPort })
}
async stop(): Promise<void> {
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
await this.runtime.stopGateway()
logger.info('OpenClaw container stopped')
return this.withLifecycleLock('stop', async () => {
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
await this.runtime.stopGateway()
logger.info('OpenClaw container stopped')
})
}
async restart(onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
logger.info('Restarting OpenClaw service', {
hostPort: this.hostPort,
return this.withLifecycleLock('restart', async () => {
const logProgress = this.createProgressLogger(onLog)
logger.info('Restarting OpenClaw service', {
hostPort: this.hostPort,
})
this.controlPlaneStatus = 'reconnecting'
await this.runtime.ensureReady(logProgress)
this.stopGatewayLogTail()
logProgress('Refreshing gateway auth token...')
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Restarting OpenClaw gateway...')
await this.runtime.restartGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready after restart'
throw new Error(this.lastError)
}
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logProgress('Gateway restarted successfully')
logger.info('OpenClaw gateway restarted', { hostPort: this.hostPort })
})
this.controlPlaneStatus = 'reconnecting'
this.stopGatewayLogTail()
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Restarting OpenClaw gateway...')
await this.runtime.restartGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready after restart'
throw new Error(this.lastError)
}
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logProgress('Gateway restarted successfully')
logger.info('OpenClaw gateway restarted', { hostPort: this.hostPort })
}
async reconnectControlPlane(onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
logger.info('Reconnecting OpenClaw control plane', {
hostPort: this.hostPort,
return this.withLifecycleLock('reconnect', async () => {
const logProgress = this.createProgressLogger(onLog)
logger.info('Reconnecting OpenClaw control plane', {
hostPort: this.hostPort,
})
logProgress('Checking gateway readiness...')
const ready = await this.runtime.isReady(this.hostPort)
if (!ready) {
this.controlPlaneStatus = 'failed'
this.lastGatewayError = 'OpenClaw gateway is not ready'
this.lastRecoveryReason = 'container_not_ready'
throw new Error('OpenClaw gateway is not ready')
}
logProgress('Reloading gateway auth token...')
await this.refreshGatewayAuthToken()
this.controlPlaneStatus = 'reconnecting'
logProgress('Reconnecting control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
logProgress('Control plane connected')
})
logProgress('Checking gateway readiness...')
const ready = await this.runtime.isReady(this.hostPort)
if (!ready) {
this.controlPlaneStatus = 'failed'
this.lastGatewayError = 'OpenClaw gateway is not ready'
this.lastRecoveryReason = 'container_not_ready'
throw new Error('OpenClaw gateway is not ready')
}
logProgress('Reloading gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
this.controlPlaneStatus = 'reconnecting'
logProgress('Reconnecting control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
logProgress('Control plane connected')
}
async shutdown(): Promise<void> {
@@ -380,28 +425,13 @@ export class OpenClawService {
} catch {
// Best effort during shutdown
}
await this.runtime.stopMachineIfSafe()
await this.runtime.stopVm()
logger.info('OpenClaw shutdown complete')
}
// ── Status ───────────────────────────────────────────────────────────
async getStatus(): Promise<OpenClawStatusResponse> {
const podmanAvailable = await this.runtime.isPodmanAvailable()
if (!podmanAvailable) {
return {
status: 'uninitialized',
podmanAvailable: false,
machineReady: false,
port: null,
agentCount: 0,
error: null,
controlPlaneStatus: 'disconnected',
lastGatewayError: null,
lastRecoveryReason: null,
}
}
const isSetUp = existsSync(this.getStateConfigPath())
if (!isSetUp) {
const machineStatus = await this.runtime.getMachineStatus()
@@ -551,7 +581,7 @@ export class OpenClawService {
historyLength: history.length,
})
return this.runControlPlaneCall(() =>
this.chatClient.streamChat({
this.httpClient.streamChat({
agentId,
sessionKey,
message,
@@ -560,46 +590,29 @@ export class OpenClawService {
)
}
// ── Podman Overrides ─────────────────────────────────────────────────
// ── Session History (HTTP) ───────────────────────────────────────────
async applyPodmanOverrides(input: {
podmanPath: string | null
}): Promise<OpenClawPodmanOverridesResponse> {
await savePodmanOverrides(this.openclawDir, {
podmanPath: input.podmanPath,
})
// Intentionally mutates the module-level PodmanRuntime singleton so every
// consumer (including future service instances) sees the new path.
configurePodmanRuntime({
resourcesDir: this.resourcesDir ?? undefined,
podmanPath: input.podmanPath ?? undefined,
})
this.rebuildRuntimeClients()
const effectivePodmanPath = getPodmanRuntime().getPodmanPath()
logger.info('Applied Podman overrides', {
podmanPath: input.podmanPath,
effectivePodmanPath,
})
return {
podmanPath: input.podmanPath,
effectivePodmanPath,
}
async getSessionHistory(
sessionKey: string,
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
): Promise<OpenClawSessionHistory> {
await this.assertGatewayReady()
return this.runControlPlaneCall(() =>
this.httpClient.getSessionHistory(sessionKey, input),
)
}
async getPodmanOverrides(): Promise<OpenClawPodmanOverridesResponse> {
const { podmanPath } = await loadPodmanOverrides(this.openclawDir)
return {
podmanPath,
effectivePodmanPath: getPodmanRuntime().getPodmanPath(),
}
async streamSessionHistory(
sessionKey: string,
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
): Promise<ReadableStream<OpenClawSessionHistoryEvent>> {
await this.assertGatewayReady()
return this.runControlPlaneCall(() =>
this.httpClient.streamSessionHistory(sessionKey, input),
)
}
// ── Provider Keys ────────────────────────────────────────────────────
async updateProviderKeys(input: {
providerType: string
providerName?: string
@@ -639,47 +652,46 @@ export class OpenClawService {
// ── Auto-start on BrowserOS boot ────────────────────────────────────
async tryAutoStart(): Promise<void> {
const isSetUp = existsSync(this.getStateConfigPath())
if (!isSetUp) return
return this.withLifecycleLock('auto-start', async () => {
const isSetUp = existsSync(this.getStateConfigPath())
if (!isSetUp) return
const available = await this.runtime.isPodmanAvailable()
if (!available) return
logger.info('Attempting OpenClaw auto-start', {
hostPort: this.hostPort,
})
try {
await this.runtime.ensureReady()
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
if (persistedPort !== null) {
this.setPort(persistedPort)
}
if (!(await this.runtime.isReady(this.hostPort))) {
await this.ensureGatewayPortAllocated()
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
logger.warn('OpenClaw gateway failed to become ready on auto-start')
return
}
}
await this.runControlPlaneCall(() => this.cliClient.probe())
logger.info('OpenClaw gateway auto-started')
} catch (err) {
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
logger.info('Attempting OpenClaw auto-start', {
hostPort: this.hostPort,
})
}
try {
await this.runtime.ensureReady()
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
if (persistedPort !== null) {
this.setPort(persistedPort)
}
if (!(await this.isGatewayAvailable(this.hostPort))) {
await this.ensureGatewayPortAllocated()
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
logger.warn('OpenClaw gateway failed to become ready on auto-start')
return
}
}
await this.runControlPlaneCall(() => this.cliClient.probe())
logger.info('OpenClaw gateway auto-started')
} catch (err) {
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
})
}
})
}
// ── Internal ─────────────────────────────────────────────────────────
@@ -697,7 +709,11 @@ export class OpenClawService {
private rebuildRuntimeClients(): void {
this.stopGatewayLogTail()
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
this.runtime = buildContainerRuntime({
resourcesDir: this.resourcesDir ?? undefined,
projectDir: this.openclawDir,
browserosRoot: this.browserosDir,
})
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
}
@@ -705,7 +721,7 @@ export class OpenClawService {
private setPort(hostPort: number): void {
if (hostPort === this.hostPort) return
this.hostPort = hostPort
this.chatClient = new OpenClawHttpChatClient(
this.httpClient = new OpenClawHttpClient(
this.hostPort,
async () => this.token,
)
@@ -714,6 +730,13 @@ export class OpenClawService {
private async ensureGatewayPortAllocated(
logProgress?: (msg: string) => void,
): Promise<void> {
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
if (persistedPort !== null) {
this.setPort(persistedPort)
}
if (await this.isGatewayAvailable(this.hostPort)) {
return
}
const hostPort = await allocateGatewayPort(this.openclawDir)
if (hostPort !== this.hostPort) {
logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`)
@@ -722,6 +745,44 @@ export class OpenClawService {
}
}
private async isGatewayAvailable(hostPort: number): Promise<boolean> {
if (!(await this.isGatewayPortReady(hostPort))) return false
if (!this.tokenLoaded) {
logger.debug(
'OpenClaw gateway port is ready before auth token is loaded',
{
hostPort,
},
)
return false
}
const client =
hostPort === this.hostPort
? this.httpClient
: new OpenClawHttpClient(hostPort, async () => this.token)
const authenticated = await client.isAuthenticated()
if (!authenticated) {
logger.warn('OpenClaw gateway port rejected current auth token', {
hostPort,
})
}
return authenticated
}
private async isGatewayPortReady(hostPort: number): Promise<boolean> {
if (await this.runtime.isReady(hostPort)) return true
const runtime = this.runtime as {
isHealthy?: (port: number) => Promise<boolean>
}
if (runtime.isHealthy) {
return runtime.isHealthy(hostPort)
}
return false
}
private async assertGatewayReady(): Promise<void> {
const portReady = await this.runtime.isReady(this.hostPort)
logger.debug('Checking OpenClaw gateway readiness before use', {
@@ -1098,6 +1159,15 @@ export class OpenClawService {
await this.loadTokenFromConfig()
}
private async refreshGatewayAuthToken(): Promise<void> {
this.tokenLoaded = false
if (!existsSync(this.getStateConfigPath())) {
return
}
await this.loadTokenFromConfig()
}
private async loadTokenFromConfig(): Promise<void> {
try {
const config = JSON.parse(
@@ -1130,6 +1200,24 @@ export class OpenClawService {
onLog?.(msg)
}
}
private async withLifecycleLock<T>(
operation: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = this.lifecycleLock
let release!: () => void
this.lifecycleLock = new Promise<void>((resolve) => {
release = resolve
})
await previous.catch(() => undefined)
try {
logger.debug('OpenClaw lifecycle operation started', { operation })
return await fn()
} finally {
release()
}
}
}
let service: OpenClawService | null = null
@@ -1146,6 +1234,13 @@ export function configureOpenClawService(
return service
}
export function configureVmRuntime(config: {
resourcesDir?: string
browserosDir?: string
}): OpenClawService {
return configureOpenClawService(config)
}
export function getOpenClawService(): OpenClawService {
if (!service) service = new OpenClawService()
return service

View File

@@ -1,54 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Persistence for user-supplied Podman runtime overrides.
* Temporary escape hatch so users can point BrowserOS at their own Podman
* (e.g. `brew install podman`) when the bundled runtime doesn't resolve helpers.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
export interface PodmanOverrides {
podmanPath: string | null
}
const OVERRIDES_FILE_NAME = 'podman-overrides.json'
export function getPodmanOverridesPath(openclawDir: string): string {
return join(openclawDir, OVERRIDES_FILE_NAME)
}
export async function loadPodmanOverrides(
openclawDir: string,
): Promise<PodmanOverrides> {
const overridesPath = getPodmanOverridesPath(openclawDir)
if (!existsSync(overridesPath)) return { podmanPath: null }
try {
const parsed = JSON.parse(
await readFile(overridesPath, 'utf-8'),
) as Partial<PodmanOverrides>
return {
podmanPath:
typeof parsed.podmanPath === 'string' && parsed.podmanPath.length > 0
? parsed.podmanPath
: null,
}
} catch {
return { podmanPath: null }
}
}
export async function savePodmanOverrides(
openclawDir: string,
overrides: PodmanOverrides,
): Promise<void> {
await mkdir(openclawDir, { recursive: true })
await writeFile(
getPodmanOverridesPath(openclawDir),
`${JSON.stringify(overrides, null, 2)}\n`,
)
}

View File

@@ -1,285 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Abstraction over the Podman CLI for container lifecycle management.
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
* On Linux, machine operations are no-ops since Podman runs natively.
*/
import { existsSync } from 'node:fs'
import { join } from 'node:path'
const isLinux = process.platform === 'linux'
const PODMAN_BUNDLE_PATH = ['bin', 'third_party', 'podman'] as const
export type LogFn = (msg: string) => void
function getPodmanBinaryName(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'podman.exe' : 'podman'
}
export function resolveBundledPodmanPath(
resourcesDir?: string,
platform: NodeJS.Platform = process.platform,
): string | null {
if (!resourcesDir) return null
const bundledPath = join(
resourcesDir,
...PODMAN_BUNDLE_PATH,
getPodmanBinaryName(platform),
)
return existsSync(bundledPath) ? bundledPath : null
}
export class PodmanRuntime {
private podmanPath: string
private machineReady = false
constructor(config?: { podmanPath?: string }) {
this.podmanPath = config?.podmanPath ?? 'podman'
}
getPodmanPath(): string {
return this.podmanPath
}
async isPodmanAvailable(): Promise<boolean> {
try {
const proc = Bun.spawn([this.podmanPath, '--version'], {
stdout: 'ignore',
stderr: 'ignore',
})
return (await proc.exited) === 0
} catch {
return false
}
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
if (isLinux) return { initialized: true, running: true }
try {
const proc = Bun.spawn(
[this.podmanPath, 'machine', 'list', '--format', 'json'],
{ stdout: 'pipe', stderr: 'ignore' },
)
const output = await new Response(proc.stdout).text()
await proc.exited
const machines = JSON.parse(output) as Array<{
Running?: boolean
LastUp?: string
}>
if (!machines.length) return { initialized: false, running: false }
const machine = machines[0]
const running =
machine.Running === true || machine.LastUp === 'Currently running'
return { initialized: true, running }
} catch {
return { initialized: false, running: false }
}
}
async initMachine(onLog?: LogFn): Promise<void> {
if (isLinux) return
const proc = Bun.spawn(
[
this.podmanPath,
'machine',
'init',
'--cpus',
'8',
'--memory',
'8096',
'--disk-size',
'10',
],
{ stdout: 'ignore', stderr: 'pipe' },
)
await this.drainStderr(proc, onLog)
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine init failed with code ${code}`)
}
async startMachine(onLog?: LogFn): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
stdout: 'ignore',
stderr: 'pipe',
})
await this.drainStderr(proc, onLog)
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine start failed with code ${code}`)
}
async stopMachine(): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
stdout: 'ignore',
stderr: 'ignore',
})
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine stop failed with code ${code}`)
this.machineReady = false
}
async ensureReady(onLog?: LogFn): Promise<void> {
if (this.machineReady) return
const status = await this.getMachineStatus()
if (!status.initialized) {
onLog?.('Initializing Podman machine...')
await this.initMachine(onLog)
}
if (!status.running) {
onLog?.('Starting Podman machine...')
await this.startMachine(onLog)
}
this.machineReady = true
}
async runCommand(
args: string[],
options?: {
cwd?: string
env?: Record<string, string>
onOutput?: (line: string) => void
},
): Promise<number> {
const useStreaming = !!options?.onOutput
const proc = Bun.spawn([this.podmanPath, ...args], {
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : undefined,
stdout: useStreaming ? 'pipe' : 'ignore',
stderr: useStreaming ? 'pipe' : 'ignore',
})
if (options?.onOutput) {
await Promise.all([
this.drainStream(proc.stdout ?? null, options.onOutput),
this.drainStream(proc.stderr ?? null, options.onOutput),
])
}
return proc.exited
}
/**
* Follow container logs. Returns a stop function that terminates the
* underlying `podman logs -f` process. Each output line is passed to
* onLine as-is.
*/
tailContainerLogs(containerName: string, onLine: LogFn): () => void {
const proc = Bun.spawn(
[this.podmanPath, 'logs', '-f', '--tail', '0', containerName],
{ stdout: 'pipe', stderr: 'pipe' },
)
void this.drainStream(proc.stdout ?? null, onLine)
void this.drainStream(proc.stderr ?? null, onLine)
let stopped = false
return () => {
if (stopped) return
stopped = true
try {
proc.kill()
} catch {
// process may already be gone
}
}
}
/**
* Lists running container names. Used to check whether non-BrowserOS
* containers are running before stopping the Podman machine.
*/
async listRunningContainers(): Promise<string[]> {
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
stdout: 'pipe',
stderr: 'ignore',
})
const output = await new Response(proc.stdout).text()
await proc.exited
return output
.trim()
.split('\n')
.filter((name) => name.trim())
}
private async drainStderr(
proc: {
stderr: ReadableStream<Uint8Array> | null
exited: Promise<number>
},
onLog?: LogFn,
): Promise<void> {
if (!onLog || !proc.stderr) return
await this.drainStream(proc.stderr, onLog)
}
private async drainStream(
stream: ReadableStream<Uint8Array> | null,
onLine: (line: string) => void,
): Promise<void> {
if (!stream) return
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed) onLine(trimmed)
}
}
if (buffer.trim()) onLine(buffer.trim())
}
}
let runtime: PodmanRuntime | null = null
export function configurePodmanRuntime(config: {
resourcesDir?: string
podmanPath?: string
}): PodmanRuntime {
const podmanPath =
config.podmanPath ??
resolveBundledPodmanPath(config.resourcesDir) ??
'podman'
runtime = new PodmanRuntime({ podmanPath })
return runtime
}
export function getPodmanRuntime(): PodmanRuntime {
if (!runtime) runtime = new PodmanRuntime()
return runtime
}

View File

@@ -2,6 +2,7 @@ import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_TERMINAL_SHELL,
} from '@browseros/shared/constants/openclaw'
import { buildNerdctlCommand } from '../../../lib/container'
import { logger } from '../../../lib/logger'
export const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
@@ -11,7 +12,9 @@ const TERMINAL_NAME = 'xterm-256color'
interface TerminalSessionDeps {
containerName: string
podmanPath: string
limaHome: string
limactlPath: string
vmName: string
workingDir: string
onExit: (exitCode: number) => void
onOutput: (data: string) => void
@@ -24,32 +27,44 @@ export interface TerminalSession {
}
export function buildTerminalExecCommand(
podmanPath: string,
limactlPath: string,
vmName: string,
containerName: string,
workingDir: string,
): string[] {
return [
podmanPath,
'exec',
'-it',
'-w',
workingDir,
containerName,
OPENCLAW_TERMINAL_SHELL,
limactlPath,
'shell',
vmName,
'--',
...buildNerdctlCommand([
'exec',
'-it',
'-w',
workingDir,
containerName,
OPENCLAW_TERMINAL_SHELL,
]),
]
}
export function buildTerminalEnv(limaHome: string): NodeJS.ProcessEnv {
return { ...process.env, LIMA_HOME: limaHome, TERM: TERMINAL_NAME }
}
export function createTerminalSession(
deps: TerminalSessionDeps,
): TerminalSession {
const decoder = new TextDecoder()
const proc = Bun.spawn(
buildTerminalExecCommand(
deps.podmanPath,
deps.limactlPath,
deps.vmName,
deps.containerName,
deps.workingDir,
),
{
cwd: '/',
terminal: {
cols: DEFAULT_COLS,
rows: DEFAULT_ROWS,
@@ -58,7 +73,7 @@ export function createTerminalSession(
if (chunk) deps.onOutput(chunk)
},
},
env: { ...process.env, TERM: TERMINAL_NAME },
env: buildTerminalEnv(deps.limaHome),
},
)
let closed = false

View File

@@ -6,12 +6,10 @@ import { PATHS } from '@browseros/shared/constants/paths'
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
import { logger } from './logger'
const DEV_BROWSEROS_DIR_NAME = '.browseros-dev'
export function getBrowserosDir(): string {
const dirName =
process.env.NODE_ENV === 'development'
? DEV_BROWSEROS_DIR_NAME
? PATHS.DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return join(homedir(), dirName)
}
@@ -46,9 +44,37 @@ export function getBuiltinSkillsDir(): string {
}
export function getOpenClawDir(): string {
return join(getVmStateDir(), PATHS.OPENCLAW_DIR_NAME)
}
export function getLegacyOpenClawDir(): string {
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
}
export function getCacheDir(): string {
return join(getBrowserosDir(), PATHS.CACHE_DIR_NAME)
}
export function getVmCacheDir(): string {
return join(getCacheDir(), 'vm')
}
export function getLimaHomeDir(): string {
return join(getBrowserosDir(), 'lima')
}
export function getVmStateDir(): string {
return join(getBrowserosDir(), 'vm')
}
export function getVmDisksDir(): string {
return getVmCacheDir()
}
export function getAgentCacheDir(): string {
return join(getVmCacheDir(), 'images')
}
export function getLazyMonitoringDir(): string {
return join(getBrowserosDir(), 'lazy-monitoring')
}
@@ -86,6 +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 })
}
export async function cleanOldSessions(): Promise<void> {

View File

@@ -0,0 +1,209 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { ContainerCliError } from '../vm/errors'
import { LimaCli } from '../vm/lima-cli'
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
export function buildNerdctlCommand(args: string[]): string[] {
return ['nerdctl', ...args]
}
export interface ContainerCliConfig {
limactlPath: string
limaHome: string
vmName: string
sshPath?: string
}
export interface ContainerCommandResult {
exitCode: number
stdout: string
stderr: string
}
export class ContainerCli {
private readonly lima: LimaCli
constructor(private readonly cfg: ContainerCliConfig) {
this.lima = new LimaCli({
limactlPath: cfg.limactlPath,
limaHome: cfg.limaHome,
sshPath: cfg.sshPath,
})
}
async imageExists(ref: string): Promise<boolean> {
const result = await this.runCommand(['image', 'inspect', ref])
return result.exitCode === 0
}
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
await this.runRequired(['pull', ref], onLog)
}
async loadImage(tarballPath: string, onLog?: LogFn): Promise<string[]> {
const result = await this.runRequired(['load', '-i', tarballPath], onLog)
return parseLoadedImageRefs(result.stdout)
}
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
await this.runRequired(buildCreateArgs(spec), onLog)
}
async startContainer(name: string, onLog?: LogFn): Promise<void> {
await this.runRequired(['start', name], onLog)
}
async stopContainer(name: string, onLog?: LogFn): Promise<void> {
const result = await this.runCommand(['stop', name], onLog)
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
throw this.commandError(['stop', name], result)
}
async removeContainer(
name: string,
opts?: { force?: boolean },
onLog?: LogFn,
): Promise<void> {
const args = ['rm']
if (opts?.force) args.push('-f')
args.push(name)
const result = await this.runCommand(args, onLog)
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
throw this.commandError(args, result)
}
async exec(name: string, cmd: string[], onLog?: LogFn): Promise<number> {
const result = await this.runCommand(['exec', name, ...cmd], onLog)
return result.exitCode
}
async ps(opts?: { namesOnly?: boolean }): Promise<string[]> {
const args = opts?.namesOnly ? ['ps', '--format', '{{.Names}}'] : ['ps']
const result = await this.runRequired(args)
return result.stdout
.trim()
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
tailLogs(name: string, onLine: LogFn): () => void {
const proc = this.lima.spawnShell(
this.cfg.vmName,
buildNerdctlCommand(['logs', '-f', '-n', '0', name]),
{ onStdout: onLine, onStderr: onLine },
)
let stopped = false
return () => {
if (stopped) return
stopped = true
proc.kill()
}
}
async runCommand(
args: string[],
onLog?: LogFn,
): Promise<ContainerCommandResult> {
const stdoutLines: string[] = []
const stderrLines: string[] = []
const exitCode = await this.lima.shell(
this.cfg.vmName,
buildNerdctlCommand(args),
{
onStdout: (line) => {
stdoutLines.push(line)
onLog?.(line)
},
onStderr: (line) => {
stderrLines.push(line)
onLog?.(line)
},
},
)
return {
exitCode,
stdout: linesToOutput(stdoutLines),
stderr: stderrLines.join('\n'),
}
}
private async runRequired(
args: string[],
onLog?: LogFn,
): Promise<ContainerCommandResult> {
const result = await this.runCommand(args, onLog)
if (result.exitCode === 0) return result
throw this.commandError(args, result)
}
private commandError(
args: string[],
result: ContainerCommandResult,
): ContainerCliError {
return new ContainerCliError(
`nerdctl ${args.join(' ')}`,
result.exitCode,
result.stderr.trim(),
)
}
}
function buildCreateArgs(spec: ContainerSpec): string[] {
const args = ['create', '--name', spec.name]
if (spec.restart) args.push('--restart', spec.restart)
for (const port of spec.ports ?? []) args.push('-p', portArg(port))
if (spec.envFile) args.push('--env-file', spec.envFile)
for (const [key, value] of Object.entries(spec.env ?? {})) {
args.push('-e', `${key}=${value}`)
}
for (const mount of spec.mounts ?? []) args.push('-v', mountArg(mount))
for (const host of spec.addHosts ?? []) args.push('--add-host', host)
if (spec.health) {
args.push('--health-cmd', spec.health.cmd)
if (spec.health.interval)
args.push('--health-interval', spec.health.interval)
if (spec.health.timeout) args.push('--health-timeout', spec.health.timeout)
if (spec.health.retries !== undefined) {
args.push('--health-retries', String(spec.health.retries))
}
}
args.push(spec.image)
args.push(...(spec.command ?? []))
return args
}
function portArg(port: PortMapping): string {
const host = port.hostIp ? `${port.hostIp}:${port.hostPort}` : port.hostPort
return `${host}:${port.containerPort}`
}
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')
}
function linesToOutput(lines: string[]): string {
if (lines.length === 0) return ''
return `${lines.join('\n')}\n`
}

View File

@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { basename, join } from 'node:path'
import { ContainerCliError, ImageLoadError } from '../vm/errors'
import type { 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,
) {}
async ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> {
if (await this.cli.imageExists(ref)) return
const tarball = this.resolveTarball(ref)
const hostPath = join(
getImageCacheDir(this.browserosRoot),
basename(tarball.key),
)
const guestPath = hostPathToGuest(hostPath, this.browserosRoot)
try {
await this.cli.loadImage(guestPath, onLog)
} catch (error) {
if (error instanceof ContainerCliError) {
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
}
throw error
}
if (!(await this.cli.imageExists(ref))) {
throw new ImageLoadError(
ref,
`image not present after successful load of ${guestPath}`,
)
}
}
private resolveTarball(
ref: string,
): VmManifest['agents'][string]['tarballs'][Arch] {
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
}
throw new ImageLoadError(ref, `no agent in manifest matches ${ref}`)
}
}

View File

@@ -0,0 +1,9 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export * from './container-cli'
export * from './image-loader'
export * from './types'

View File

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export type LogFn = (msg: string) => void
export interface PortMapping {
hostIp?: string
hostPort: number
containerPort: number
}
export interface MountSpec {
source: string
target: string
readonly?: boolean
}
export interface HealthConfig {
cmd: string
interval?: string
timeout?: string
retries?: number
}
export interface ContainerSpec {
name: string
image: string
restart?: 'no' | 'unless-stopped' | 'always'
ports?: PortMapping[]
env?: Record<string, string>
envFile?: string
mounts?: MountSpec[]
addHosts?: string[]
health?: HealthConfig
command?: string[]
}
export interface LogLine {
stream: 'stdout' | 'stderr'
line: string
}

View File

@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export class VmError extends Error {
constructor(message: string) {
super(message)
this.name = new.target.name
}
}
export class VmNotReadyError extends VmError {}
export class VmStateCorruptedError extends VmError {}
export class LimaCommandError extends VmError {
constructor(
command: string,
public readonly exitCode: number,
public readonly stderr: string,
) {
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
}
}
export class ContainerCliError extends VmError {
constructor(
command: string,
public readonly exitCode: number,
public readonly stderr: string,
) {
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
}
}
export class ImageLoadError extends VmError {
constructor(
public readonly imageRef: string,
message: string,
public override readonly cause?: unknown,
) {
super(`failed to load image ${imageRef}: ${message}`)
}
}
export class ManifestMissingError extends VmError {
constructor(public readonly manifestPath: string) {
super(manifestMissingMessage(manifestPath))
}
}
function manifestMissingMessage(manifestPath: string): string {
const message = `VM manifest is missing at ${manifestPath}`
if (process.env.NODE_ENV === 'development') {
return `${message}; run bun run dev:setup before starting the server`
}
return message
}

View File

@@ -0,0 +1,13 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export * from './errors'
export * from './lima-cli'
export * from './lima-config'
export * from './manifest'
export * from './paths'
export * from './telemetry'
export * from './vm-runtime'

View File

@@ -0,0 +1,270 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { logger } from '../logger'
import { LimaCommandError, VmNotReadyError } from './errors'
import { getLimaSshConfigPath } from './paths'
import { VM_TELEMETRY_EVENTS } from './telemetry'
export interface LimaListEntry {
name: string
status: string
dir: string
}
export interface LimaCliConfig {
limactlPath: string
limaHome: string
sshPath?: string
}
export interface LimaShellStreams {
onStdout?: (line: string) => void
onStderr?: (line: string) => void
}
export interface LimaShellProcess {
kill: () => void
exited: Promise<number>
}
const LIMA_VERBOSE_LOGGING = false
export class LimaCli {
constructor(private readonly cfg: LimaCliConfig) {}
async list(): Promise<LimaListEntry[]> {
const result = await this.run(['list', '--format', 'json'])
if (!result.stdout.trim()) {
logger.debug('Lima list returned no instances', {
limaHome: this.cfg.limaHome,
})
return []
}
const entries = parseLimaList(result.stdout)
logger.debug('Lima list parsed', {
limaHome: this.cfg.limaHome,
count: entries.length,
entries: entries.map((e) => ({ name: e.name, status: e.status })),
})
return entries
}
async create(name: string, yamlPath: string): Promise<void> {
await this.runChecked('create', [
'create',
'--tty=false',
`--name=${name}`,
yamlPath,
])
}
async start(name: string): Promise<void> {
logger.info('Invoking limactl start', {
vmName: name,
limaHome: this.cfg.limaHome,
note: 'this command blocks until boot reaches READY; may take 40-120s on first boot',
})
await this.runChecked('start', ['start', '--tty=false', name])
}
async stop(name: string): Promise<void> {
await this.runChecked('stop', ['stop', name])
}
async delete(name: string): Promise<void> {
await this.runChecked('delete', ['delete', '--force', name])
}
async shell(
name: string,
args: string[],
streams?: LimaShellStreams,
): Promise<number> {
const proc = this.spawnShell(name, args, streams)
return proc.exited
}
spawnShell(
name: string,
args: string[],
streams?: LimaShellStreams,
): LimaShellProcess {
const configPath = getLimaSshConfigPath(this.cfg.limaHome, name)
if (!existsSync(configPath)) {
throw new VmNotReadyError(
`lima ssh.config not found at ${configPath}; VM has not been started`,
)
}
const proc = Bun.spawn(
[
this.cfg.sshPath ?? 'ssh',
'-F',
configPath,
`lima-${name}`,
shellQuoteCommand(args),
],
{
cwd: '/',
env: this.env(),
stdout: streams?.onStdout ? 'pipe' : 'ignore',
stderr: streams?.onStderr ? 'pipe' : 'ignore',
},
)
const drained = Promise.all([
drainStream(proc.stdout ?? null, streams?.onStdout),
drainStream(proc.stderr ?? null, streams?.onStderr),
])
const exited = drained.then(() => proc.exited)
return {
exited,
kill: () => {
try {
proc.kill()
} catch {
return
}
},
}
}
private async runChecked(command: string, args: string[]): Promise<void> {
const result = await this.run(args)
if (result.exitCode !== 0) {
throw new LimaCommandError(
`limactl ${command}`,
result.exitCode,
result.stderr,
)
}
}
private async run(args: string[]): Promise<{
exitCode: number
stdout: string
stderr: string
}> {
const started = Date.now()
const proc = Bun.spawn([this.cfg.limactlPath, ...args], {
env: this.env(),
stdout: 'pipe',
stderr: 'pipe',
})
logger.debug(VM_TELEMETRY_EVENTS.limaSpawn, {
pid: proc.pid,
args,
limaHome: this.cfg.limaHome,
})
const stderrLogger = LIMA_VERBOSE_LOGGING
? (line: string) => {
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
pid: proc.pid,
firstArg: args[0],
line,
})
}
: undefined
const [stdout, stderr, exitCode] = await Promise.all([
drainToString(proc.stdout),
drainToString(proc.stderr, stderrLogger),
proc.exited,
])
const durationMs = Date.now() - started
logger.debug(VM_TELEMETRY_EVENTS.limaExit, {
pid: proc.pid,
firstArg: args[0],
exitCode,
durationMs,
stdoutLen: stdout.length,
stderrLen: stderr.length,
})
return { exitCode, stdout, stderr }
}
private env(): NodeJS.ProcessEnv {
return { ...process.env, LIMA_HOME: this.cfg.limaHome }
}
}
async function drainToString(
stream: ReadableStream<Uint8Array> | null,
onLine?: (line: string) => void,
): Promise<string> {
if (!stream) return ''
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let output = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
output += chunk
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed && onLine) onLine(trimmed)
}
}
if (buffer.trim() && onLine) onLine(buffer.trim())
return output
}
function parseLimaList(output: string): LimaListEntry[] {
const trimmed = output.trim()
try {
const parsed = JSON.parse(trimmed) as unknown
if (Array.isArray(parsed)) return parsed.map(toLimaListEntry)
return [toLimaListEntry(parsed)]
} catch {
return trimmed.split('\n').map((line) => toLimaListEntry(JSON.parse(line)))
}
}
function toLimaListEntry(input: unknown): LimaListEntry {
const entry = input as Partial<LimaListEntry>
return {
name: entry.name ?? '',
status: entry.status ?? '',
dir: entry.dir ?? '',
}
}
function shellQuoteCommand(args: string[]): string {
return args.map(shellQuote).join(' ')
}
function shellQuote(arg: string): string {
return `'${arg.replaceAll("'", "'\\''")}'`
}
async function drainStream(
stream: ReadableStream<Uint8Array> | null,
onLine?: (line: string) => void,
): Promise<void> {
if (!stream || !onLine) return
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.trim()) onLine(line.trim())
}
}
if (buffer.trim()) onLine(buffer.trim())
}

View File

@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export function renderLimaTemplate(
template: string,
cfg: {
vmStateDir: string
imageCacheDir: string
},
): string {
const mounts = [
'mounts:',
`- 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: []')) {
throw new Error('BrowserOS VM Lima template is missing mounts: [] marker')
}
return template.replace('mounts: []', mounts)
}

View File

@@ -0,0 +1,102 @@
/**
* @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 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: VmManifest['agents'][string]['tarballs'][Arch]
} {
const agent = manifest.agents[name]
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)
const tarball = agent.tarballs[arch]
if (!tarball) throw new Error(`missing ${arch} tarball for agent ${name}`)
return {
image: agent.image,
version: agent.version,
tarball,
}
}
async function readManifest(path: string): Promise<VmManifest> {
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
}
function compareVersionStrings(left: string, right: string): number {
if (left < right) return -1
if (left > right) return 1
return 0
}

View File

@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { accessSync, constants, existsSync } from 'node:fs'
import { homedir, arch as osArch } from 'node:os'
import {
delimiter,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from 'node:path'
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'
function rootDir(): string {
const base =
process.env.NODE_ENV === 'development'
? PATHS.DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return join(homedir(), base)
}
export function detectArch(arch: NodeJS.Architecture = osArch()): Arch {
if (arch === 'arm64') return 'arm64'
if (arch === 'x64') return 'x64'
throw new Error(`unsupported host arch: ${arch}`)
}
export function getLimaHomeDir(browserosRoot = rootDir()): string {
return join(browserosRoot, 'lima')
}
export function getVmStateDir(browserosRoot = rootDir()): string {
return join(browserosRoot, 'vm')
}
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')
}
export function getLimaSocketPath(browserosRoot = rootDir()): string {
return getContainerdSocketPath(browserosRoot)
}
export function getLimaSshConfigPath(limaHome: string, name: string): string {
return join(limaHome, name, 'ssh.config')
}
export function compressedDiskPath(
version: string,
arch: Arch,
browserosRoot = rootDir(),
): string {
return join(
getVmCacheDir(browserosRoot),
`browseros-vm-${version}-${arch}.qcow2.zst`,
)
}
export function decompressedDiskPath(
version: string,
arch: Arch,
browserosRoot = rootDir(),
): string {
return join(
getVmCacheDir(browserosRoot),
`browseros-vm-${version}-${arch}.qcow2`,
)
}
export function resolveBundledLimactl(resourcesDir: string): string {
if (usesHostVmTools()) return resolveHostLimactl()
const candidate = join(resourcesDir, 'bin', 'third_party', 'lima', 'limactl')
if (!existsSync(candidate)) {
throw new Error(
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
)
}
return candidate
}
function resolveHostLimactl(): string {
const resolved = findExecutableOnPath(HOST_LIMACTL_BINARY)
if (resolved) return resolved
throw new Error(
'Lima is not installed or limactl is not on PATH. Install with brew install lima.',
)
}
export function resolveBundledLimaTemplate(resourcesDir: string): string {
if (usesHostVmTools()) {
const sourceTemplate = findSourceLimaTemplate(resourcesDir)
if (sourceTemplate) return sourceTemplate
}
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`,
)
}
return candidate
}
function usesHostVmTools(): boolean {
return (
process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
)
}
function findExecutableOnPath(binary: string): string | null {
const pathEnv = process.env.PATH
if (!pathEnv) return null
for (const dir of pathEnv.split(delimiter)) {
if (!dir) continue
const candidate = join(dir, binary)
try {
accessSync(candidate, constants.X_OK)
return candidate
} catch {}
}
return null
}
function findSourceLimaTemplate(resourcesDir: string): string | null {
let current = resolve(resourcesDir)
while (true) {
const rootCandidate = join(
current,
'packages',
'build-tools',
'template',
'browseros-vm.yaml',
)
if (existsSync(rootCandidate)) return rootCandidate
const packageCandidate = join(
current,
'build-tools',
'template',
'browseros-vm.yaml',
)
if (existsSync(packageCandidate)) return packageCandidate
const parent = dirname(current)
if (parent === current) return null
current = parent
}
}
export function hostPathToGuest(
hostPath: string,
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`)
}
function mountedRelativePath(parent: string, child: string): string | null {
const path = relative(parent, child)
if (path === '') return ''
if (path.startsWith('..') || isAbsolute(path)) return null
return path
}
function guestPath(root: string, relativePath: string): string {
if (!relativePath) return root
return `${root}/${relativePath.split(sep).join('/')}`
}

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const VM_TELEMETRY_EVENTS = {
ensureReadyStart: 'vm.ensure_ready.start',
ensureReadyOk: 'vm.ensure_ready.ok',
ensureReadyBranch: 'vm.ensure_ready.branch',
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',
limaStderrChunk: 'vm.lima.stderr_chunk',
provisionYamlWrite: 'vm.provision.yaml_write',
provisionCreateStart: 'vm.provision.create.start',
provisionCreateOk: 'vm.provision.create.ok',
provisionStartBegin: 'vm.provision.start.begin',
provisionStartOk: 'vm.provision.start.ok',
} as const

View File

@@ -0,0 +1,325 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { logger } from '../logger'
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 { VM_TELEMETRY_EVENTS } from './telemetry'
export type LogFn = (msg: string) => void
const ROOTLESS_CONTAINERD_MARKER = 'runtime:containerd-rootless'
export interface VmRuntimeDeps {
limactlPath: string
limaHome: string
sshPath?: string
templatePath?: string
browserosRoot?: string
readinessTimeoutMs?: number
readinessPollMs?: number
}
export class VmRuntime {
private readonly cli: LimaCli
private readonly readinessTimeoutMs: number
private readonly readinessPollMs: number
private defaultGateway: string | null = null
constructor(private readonly deps: VmRuntimeDeps) {
this.cli = new LimaCli({
limactlPath: deps.limactlPath,
limaHome: deps.limaHome,
sshPath: deps.sshPath,
})
this.readinessTimeoutMs = deps.readinessTimeoutMs ?? 60_000
this.readinessPollMs = deps.readinessPollMs ?? 500
}
async ensureReady(onLog?: LogFn): Promise<void> {
const started = Date.now()
logger.info(VM_TELEMETRY_EVENTS.ensureReadyStart, {
limaHome: this.deps.limaHome,
browserosRoot: this.deps.browserosRoot,
templatePath: this.deps.templatePath,
limactlPath: this.deps.limactlPath,
})
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'
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
branch,
existingStatus: existing?.status ?? null,
versionComparison,
})
if (!existing) {
await this.provisionFresh(onLog)
} else {
if (existing.status !== 'Running') {
onLog?.('Starting BrowserOS VM...')
await this.cli.start(VM_NAME)
}
if (
!(await this.isReady()) &&
(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,
branch,
})
}
async stopVm(): Promise<void> {
try {
await this.cli.stop(VM_NAME)
} catch (error) {
if (error instanceof LimaCommandError && isAlreadyStopped(error.stderr)) {
return
}
throw error
}
}
async runCommand(
args: string[],
opts?: { onOutput?: LogFn },
): Promise<number> {
return this.cli.shell(VM_NAME, args, {
onStdout: opts?.onOutput,
onStderr: opts?.onOutput,
})
}
async reset(_reason: string): Promise<never> {
throw notImplemented('VmRuntime.reset')
}
async performUpgrade(): Promise<never> {
throw notImplemented('VmRuntime.performUpgrade')
}
async getDefaultGateway(): Promise<string> {
if (this.defaultGateway) return this.defaultGateway
const lines: string[] = []
const exitCode = await this.runCommand(
['ip', '-4', 'route', 'show', 'default'],
{
onOutput: (line) => lines.push(line),
},
)
if (exitCode !== 0) {
throw new VmNotReadyError(
`failed to resolve VM default gateway; ip route exited ${exitCode}`,
)
}
const gateway = parseDefaultGateway(lines.join('\n'))
if (!gateway) {
throw new VmNotReadyError('failed to resolve VM default gateway')
}
this.defaultGateway = gateway
return gateway
}
async isReady(): Promise<boolean> {
return this.isRootlessNerdctlReady()
}
getLimactlPath(): string {
return this.deps.limactlPath
}
private async provisionFresh(onLog?: LogFn): Promise<void> {
this.defaultGateway = null
const yaml = await this.buildLimaYaml()
const yamlPath = join(this.deps.limaHome, `${VM_NAME}.yaml`)
await mkdir(dirname(yamlPath), { recursive: true })
await writeFile(yamlPath, yaml)
logger.info(VM_TELEMETRY_EVENTS.provisionYamlWrite, {
yamlPath,
yamlBytes: yaml.length,
templatePath: this.deps.templatePath,
})
onLog?.('Creating BrowserOS VM...')
logger.info(VM_TELEMETRY_EVENTS.provisionCreateStart, { yamlPath })
const createStarted = Date.now()
await this.cli.create(VM_NAME, yamlPath)
logger.info(VM_TELEMETRY_EVENTS.provisionCreateOk, {
durationMs: Date.now() - createStarted,
})
onLog?.('Starting BrowserOS VM...')
logger.info(VM_TELEMETRY_EVENTS.provisionStartBegin, {})
const startStarted = Date.now()
await this.cli.start(VM_NAME)
logger.info(VM_TELEMETRY_EVENTS.provisionStartOk, {
durationMs: Date.now() - startStarted,
})
}
private async recreateForContainerd(onLog?: LogFn): Promise<void> {
onLog?.('Recreating BrowserOS VM for containerd runtime...')
try {
await this.cli.stop(VM_NAME)
} catch (error) {
if (
!(error instanceof LimaCommandError) ||
!isAlreadyStopped(error.stderr)
) {
throw error
}
}
await this.cli.delete(VM_NAME)
await this.provisionFresh(onLog)
}
private async needsContainerdReprovision(): Promise<boolean> {
const lines: string[] = []
try {
const exitCode = await this.runCommand(
['sh', '-lc', 'cat /etc/browseros-vm-version 2>/dev/null || true'],
{ onOutput: (line) => lines.push(line) },
)
if (exitCode !== 0) return false
} catch (error) {
logger.warn('Failed to inspect BrowserOS VM runtime marker', {
error: error instanceof Error ? error.message : String(error),
})
return false
}
return !lines.some((line) => line.trim() === ROOTLESS_CONTAINERD_MARKER)
}
private async buildLimaYaml(): Promise<string> {
if (!this.deps.templatePath) {
throw new Error(
'BrowserOS VM Lima template path is missing; configure VmRuntime with resourcesDir',
)
}
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
vmStateDir: getVmStateDir(this.deps.browserosRoot),
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
})
}
private async waitForRootlessNerdctl(timeoutMs: number): Promise<void> {
const started = Date.now()
const deadline = started + timeoutMs
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitStart, {
timeoutMs,
pollMs: this.readinessPollMs,
})
let pollCount = 0
while (Date.now() < deadline) {
pollCount += 1
if (await this.isReady()) {
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitOk, {
pollCount,
waitMs: Date.now() - started,
})
return
}
if (pollCount === 1 || pollCount % 10 === 0) {
logger.debug(VM_TELEMETRY_EVENTS.nerdctlWaitPoll, {
pollCount,
elapsedMs: Date.now() - started,
})
}
await Bun.sleep(this.readinessPollMs)
}
logger.error(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout, {
timeoutMs,
pollCount,
})
throw new VmNotReadyError('rootless nerdctl never became ready')
}
private async isRootlessNerdctlReady(): Promise<boolean> {
try {
return (await this.runCommand(['nerdctl', 'info'])) === 0
} catch {
return false
}
}
}
function notImplemented(feature: string): VmError {
return new VmError(
`${feature} is not implemented yet - see WS4 follow-up plan`,
)
}
function isAlreadyStopped(stderr: string): boolean {
const lower = stderr.toLowerCase()
return (
lower.includes('not running') ||
lower.includes('already stopped') ||
lower.includes('not found')
)
}
function parseDefaultGateway(output: string): string | null {
return output.match(/\bdefault\s+via\s+(\d+\.\d+\.\d+\.\d+)\b/)?.[1] ?? null
}

View File

@@ -15,10 +15,9 @@ import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
import { createHttpServer } from './api/server'
import {
configureOpenClawService,
configureVmRuntime,
getOpenClawService,
} from './api/services/openclaw/openclaw-service'
import { loadPodmanOverrides } from './api/services/openclaw/podman-overrides'
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
import { CdpBackend } from './browser/backends/cdp'
import { Browser } from './browser/browser'
import type { ServerConfig } from './config'
@@ -26,7 +25,6 @@ import { INLINED_ENV } from './env'
import {
cleanOldSessions,
ensureBrowserosDir,
getOpenClawDir,
removeServerConfigSync,
writeServerConfig,
} from './lib/browseros-dir'
@@ -62,16 +60,7 @@ export class Application {
})
const resourcesDir = path.resolve(this.config.resourcesDir)
const podmanOverrides = await loadPodmanOverrides(getOpenClawDir())
configurePodmanRuntime({
resourcesDir,
podmanPath: podmanOverrides.podmanPath ?? undefined,
})
if (podmanOverrides.podmanPath) {
logger.info('Using user-overridden Podman binary', {
podmanPath: podmanOverrides.podmanPath,
})
}
configureVmRuntime({ resourcesDir })
await this.initCoreServices()
if (!this.config.cdpPort) {

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export interface FakeLimactlResponse {
stdout?: string
stderr?: string
exit?: number
}
export async function fakeLimactl(
canned: Record<string, FakeLimactlResponse>,
logPath?: string,
): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'fake-limactl-'))
const path = join(dir, 'limactl')
const limaHomeExpansion = '$' + '{LIMA_HOME-}'
const cases = Object.entries(canned)
.map(([command, response]) =>
[
` ${JSON.stringify(command)})`,
` echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"`,
` echo "LIMA_HOME:${limaHomeExpansion}" >> "${logPath ?? '/dev/null'}"`,
` printf %b ${JSON.stringify(response.stdout ?? '')}`,
` printf %b ${JSON.stringify(response.stderr ?? '')} >&2`,
` exit ${response.exit ?? 0}`,
' ;;',
].join('\n'),
)
.join('\n')
const body = `#!/usr/bin/env bash
set -u
case "$1" in
${cases}
*)
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
echo "unexpected subcommand: $1" >&2
exit 99
;;
esac
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export interface FakeSshResponse {
stdout?: string
stderr?: string
exit?: number
}
export async function fakeSsh(
response: FakeSshResponse = {},
logPath?: string,
): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'fake-ssh-'))
const path = join(dir, 'ssh')
const body = `#!/usr/bin/env bash
set -u
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
printf %b ${JSON.stringify(response.stdout ?? '')}
printf %b ${JSON.stringify(response.stderr ?? '')} >&2
exit ${response.exit ?? 0}
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}

View File

@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path'
const projectRoot = resolve(import.meta.dir, '..', '..')
const testsRoot = resolve(projectRoot, 'tests')
const cleanupScript = resolve(testsRoot, '__helpers__/cleanup.sh')
const testPreloadPath = './tests/__helpers__/test-env.ts'
const preferredDirectoryGroups = [
'agent',
'api',
@@ -96,7 +97,7 @@ function runCommand(cmd: string[], label: string): number {
console.log(`\n==> ${label}`)
const result = spawnSync(cmd[0], cmd.slice(1), {
cwd: projectRoot,
env: process.env,
env: withTestEnv(process.env),
stdio: 'inherit',
})
@@ -107,6 +108,30 @@ function runCommand(cmd: string[], label: string): number {
return result.status ?? 1
}
export function withTestEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (env.NODE_ENV) return env
return { ...env, NODE_ENV: 'test' }
}
export function buildTestCommand(
targets: string[],
junitPath?: string,
): string[] {
const cmd = [
process.execPath,
'--env-file=.env.development',
'test',
`--preload=${testPreloadPath}`,
]
if (junitPath) {
const outputPath = resolve(projectRoot, junitPath)
mkdirSync(dirname(outputPath), { recursive: true })
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
}
cmd.push(...targets)
return cmd
}
function runAtomicGroup(group: string): number {
const targets = getAtomicGroupTargets(group)
if (targets.length === 0) {
@@ -116,13 +141,7 @@ function runAtomicGroup(group: string): number {
}
runCommand(['bash', cleanupScript], `Cleaning up test resources for ${group}`)
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
const cmd = [process.execPath, '--env-file=.env.development', 'test']
if (junitPath) {
const outputPath = resolve(projectRoot, junitPath)
mkdirSync(dirname(outputPath), { recursive: true })
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
}
cmd.push(...targets)
const cmd = buildTestCommand(targets, junitPath)
return runCommand(cmd, `Running ${group} tests`)
}
@@ -141,6 +160,7 @@ function runGroup(group: string): number {
return runAtomicGroup(group)
}
const requestedGroup = process.argv[2] ?? 'all'
process.exit(runGroup(requestedGroup))
if (import.meta.main) {
const requestedGroup = process.argv[2] ?? 'all'
process.exit(runGroup(requestedGroup))
}

View File

@@ -0,0 +1,6 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
process.env.NODE_ENV = 'test'

View File

@@ -4,9 +4,7 @@
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { OpenClawSessionNotFoundError } from '../../../src/api/services/openclaw/errors'
import { UnsupportedOpenClawProviderError } from '../../../src/api/services/openclaw/openclaw-provider-map'
describe('createOpenClawRoutes', () => {
@@ -264,124 +262,6 @@ describe('createOpenClawRoutes', () => {
expect(response.status).toBe(404)
})
it('returns the current podman overrides on GET', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const getPodmanOverrides = mock(async () => ({
podmanPath: '/opt/homebrew/bin/podman',
effectivePodmanPath: '/opt/homebrew/bin/podman',
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ getPodmanOverrides }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
podmanPath: '/opt/homebrew/bin/podman',
effectivePodmanPath: '/opt/homebrew/bin/podman',
})
})
it('rejects a relative podman path on POST', async () => {
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath: 'podman' }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'podmanPath must be an absolute path',
})
})
it('rejects a nonexistent podman path on POST', async () => {
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath: '/does/not/exist/podman' }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'File does not exist: /does/not/exist/podman',
})
})
it('rejects a non-executable podman path on POST', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaw-route-'))
const nonExec = join(tempDir, 'podman')
writeFileSync(nonExec, 'not a binary')
chmodSync(nonExec, 0o644)
try {
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath: nonExec }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: `File is not executable: ${nonExec}`,
})
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
it('applies and echoes when POST clears the override', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const applyPodmanOverrides = mock(async () => ({
podmanPath: null,
effectivePodmanPath: 'podman',
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ applyPodmanOverrides }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath: null }),
})
expect(response.status).toBe(200)
expect(applyPodmanOverrides).toHaveBeenCalledWith({ podmanPath: null })
expect(await response.json()).toEqual({
podmanPath: null,
effectivePodmanPath: 'podman',
})
})
it('ignores role fields when creating agents', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
@@ -434,4 +314,124 @@ describe('createOpenClawRoutes', () => {
modelId: 'gpt-5.4-mini',
})
})
it('returns JSON history from the session history route and forwards query params', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const getSessionHistory = mock(async () => ({
sessionKey: 'agent:main:main',
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
cursor: null,
hasMore: false,
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ getSessionHistory }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request(
'/session/agent%3Amain%3Amain/history?limit=25&cursor=next',
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('application/json')
expect(getSessionHistory).toHaveBeenCalledWith('agent:main:main', {
limit: 25,
cursor: 'next',
})
expect(await response.json()).toEqual({
sessionKey: 'agent:main:main',
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
cursor: null,
hasMore: false,
})
})
it('returns 404 when the service reports a missing session', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const getSessionHistory = mock(async () => {
throw new OpenClawSessionNotFoundError('missing')
})
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ getSessionHistory }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/session/missing/history')
expect(response.status).toBe(404)
expect(await response.json()).toEqual({
error: 'OpenClaw session not found: missing',
})
})
it('streams named SSE frames when Accept: text/event-stream', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const streamSessionHistory = mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'history',
data: {
sessionKey: 'k',
messages: [],
cursor: null,
hasMore: false,
},
})
controller.enqueue({
type: 'message',
data: {
sessionKey: 'k',
messageSeq: 2,
message: { role: 'assistant', content: 'hi', messageSeq: 2 },
},
})
controller.close()
},
}),
)
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ streamSessionHistory }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/session/k/history', {
headers: { Accept: 'text/event-stream' },
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
expect(response.headers.get('X-Session-Key')).toBe('k')
expect(streamSessionHistory).toHaveBeenCalledTimes(1)
expect(streamSessionHistory.mock.calls[0]?.[0]).toBe('k')
expect(await response.text()).toBe(
'event: history\ndata: {"sessionKey":"k","messages":[],"cursor":null,"hasMore":false}\n\n' +
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hi","messageSeq":2}}\n\n',
)
})
})

View File

@@ -10,6 +10,7 @@ import {
serializeTerminalServerMessage,
} from '../../../src/api/services/terminal/terminal-protocol'
import {
buildTerminalEnv,
buildTerminalExecCommand,
TERMINAL_HOME_DIR,
} from '../../../src/api/services/terminal/terminal-session'
@@ -50,15 +51,20 @@ describe('terminal protocol', () => {
).toBe('{"type":"output","data":"hello"}')
})
it('builds a podman exec command rooted in the container home dir', () => {
it('builds a limactl shell command rooted in the container home dir', () => {
expect(
buildTerminalExecCommand(
'podman',
'limactl',
'browseros-vm',
OPENCLAW_GATEWAY_CONTAINER_NAME,
TERMINAL_HOME_DIR,
),
).toEqual([
'podman',
'limactl',
'shell',
'browseros-vm',
'--',
'nerdctl',
'exec',
'-it',
'-w',
@@ -67,4 +73,13 @@ describe('terminal protocol', () => {
'/bin/sh',
])
})
it('sets LIMA_HOME for terminal limactl sessions', () => {
expect(buildTerminalEnv('/tmp/browseros-lima')).toEqual(
expect.objectContaining({
LIMA_HOME: '/tmp/browseros-lima',
TERM: 'xterm-256color',
}),
)
})
})

View File

@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
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 {
buildContainerRuntime,
migrateLegacyOpenClawDir,
} from '../../../../src/api/services/openclaw/container-runtime-factory'
import { logger } from '../../../../src/lib/logger'
describe('container-runtime factory', () => {
let root: string
let resourcesDir: string
let originalNodeEnv: string | undefined
beforeEach(async () => {
root = await mkdtemp('/tmp/openclaw-runtime-factory-')
resourcesDir = join(root, 'resources')
await mkdir(join(resourcesDir, 'bin', 'third_party', 'lima'), {
recursive: true,
})
await mkdir(join(resourcesDir, 'vm'), { recursive: true })
await writeFile(
join(resourcesDir, 'bin', 'third_party', 'lima', 'limactl'),
'#!/bin/sh\n',
)
await writeFile(
join(resourcesDir, 'vm', 'browseros-vm.yaml'),
'mounts: []\n',
)
originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
})
afterEach(async () => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
await rm(root, { recursive: true, force: true })
})
it('rejects non-macOS platforms', () => {
expect(() =>
buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'linux',
}),
).toThrow('supports macOS only')
})
it('returns a disabled runtime on non-macOS platforms in test mode', async () => {
process.env.NODE_ENV = 'test'
const runtime = buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'linux',
})
await expect(runtime.getMachineStatus()).resolves.toEqual({
initialized: false,
running: false,
})
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
it('migrates legacy OpenClaw state into the VM state directory', async () => {
const legacyFile = join(root, 'openclaw', '.openclaw', 'openclaw.json')
await mkdir(dirname(legacyFile), { recursive: true })
await writeFile(legacyFile, '{"ok":true}\n')
await migrateLegacyOpenClawDir(root)
await expect(
readFile(
join(root, 'vm', 'openclaw', '.openclaw', 'openclaw.json'),
'utf8',
),
).resolves.toBe('{"ok":true}\n')
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
})
it('leaves both directories in place when new OpenClaw state already exists', async () => {
const legacyFile = join(root, 'openclaw', 'legacy.txt')
const newFile = join(root, 'vm', 'openclaw', 'new.txt')
await mkdir(dirname(legacyFile), { recursive: true })
await mkdir(dirname(newFile), { recursive: true })
await writeFile(legacyFile, 'legacy')
await writeFile(newFile, 'new')
const originalWarn = logger.warn
const warnings: string[] = []
logger.warn = (message) => warnings.push(message)
try {
await migrateLegacyOpenClawDir(root)
} finally {
logger.warn = originalWarn
}
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('legacy')
await expect(readFile(newFile, 'utf8')).resolves.toBe('new')
expect(warnings).toContain(
'OpenClaw legacy and VM state directories both exist',
)
})
})

View File

@@ -3,7 +3,7 @@
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { describe, expect, it, mock } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
@@ -11,135 +11,85 @@ const PROJECT_DIR = '/tmp/openclaw'
const defaultSpec = {
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: 18789,
hostHome: '/tmp/openclaw',
envFilePath: '/tmp/openclaw/.openclaw/.env',
hostHome: '/Users/me/.browseros/vm/openclaw',
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
gatewayToken: 'token-123',
timezone: 'America/Los_Angeles',
}
function createRuntime(
runCommand: (
args: string[],
options?: { cwd?: string; onOutput?: (line: string) => void },
) => Promise<number>,
listRunningContainers: () => Promise<string[]> = async () => [],
stopMachine: () => Promise<void> = async () => {},
): ContainerRuntime {
return new ContainerRuntime(
{
ensureReady: async () => {},
isPodmanAvailable: async () => true,
getMachineStatus: async () => ({ initialized: true, running: true }),
runCommand,
tailContainerLogs: () => () => {},
listRunningContainers,
stopMachine,
} as never,
PROJECT_DIR,
)
}
function expectedGatewayRuntimeArgs(spec: typeof defaultSpec): string[] {
return [
'--env-file',
spec.envFilePath,
'-e',
'HOME=/home/node',
'-e',
'OPENCLAW_HOME=/home/node',
'-e',
'OPENCLAW_STATE_DIR=/home/node/.openclaw',
'-e',
'OPENCLAW_NO_RESPAWN=1',
'-e',
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
'-e',
'NODE_ENV=production',
'-e',
`TZ=${spec.timezone}`,
'-v',
`${spec.hostHome}:/home/node`,
'--add-host',
'host.containers.internal:host-gateway',
'-e',
`OPENCLAW_GATEWAY_TOKEN=${spec.gatewayToken}`,
]
}
function expectedStartGatewayRunArgs(spec: typeof defaultSpec): string[] {
return [
'run',
'-d',
'--name',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'--restart',
'unless-stopped',
'-p',
`127.0.0.1:${spec.hostPort}:18789`,
...expectedGatewayRuntimeArgs(spec),
'--health-cmd',
'curl -sf http://127.0.0.1:18789/healthz',
'--health-interval',
'30s',
'--health-timeout',
'10s',
'--health-retries',
'3',
spec.image,
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
'18789',
'--allow-unconfigured',
]
}
describe('ContainerRuntime', () => {
it('pullImage runs podman pull for the requested image', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12')
expect(calls).toEqual([
{
args: ['pull', 'ghcr.io/openclaw/openclaw:2026.4.12'],
cwd: PROJECT_DIR,
},
])
})
it('startGateway removes any existing gateway and runs a fresh container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
it('starts the gateway by loading the image, creating, and starting a container', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway(defaultSpec)
expect(calls).toHaveLength(2)
expect(calls[0]).toEqual({
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
})
expect(calls[1]).toEqual({
cwd: PROJECT_DIR,
args: expectedStartGatewayRunArgs(defaultSpec),
})
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
undefined,
)
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
defaultSpec.image,
undefined,
)
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: defaultSpec.image,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort: 18789,
containerPort: 18789,
},
],
envFile: '/mnt/browseros/vm/openclaw/.openclaw/.env',
mounts: [
{
source: '/mnt/browseros/vm/openclaw',
target: '/home/node',
},
],
addHosts: ['host.containers.internal:192.168.5.2'],
}),
undefined,
)
expect(deps.shell.startContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('runGatewaySetupCommand in direct mode builds a one-off podman run command', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
it('delegates ensureReady and stopVm to VmRuntime', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.ensureReady()
await runtime.stopVm()
expect(deps.vm.ensureReady).toHaveBeenCalled()
expect(deps.vm.getDefaultGateway).toHaveBeenCalled()
expect(deps.vm.stopVm).toHaveBeenCalled()
})
it('runs setup commands with guest paths', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.runGatewaySetupCommand(
@@ -147,180 +97,80 @@ describe('ContainerRuntime', () => {
defaultSpec,
)
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: [
'rm',
'-f',
'--ignore',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
],
},
{
cwd: PROJECT_DIR,
args: [
'run',
'--rm',
'--name',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
...expectedGatewayRuntimeArgs(defaultSpec),
defaultSpec.image,
'node',
'dist/index.js',
'agents',
'list',
'--json',
],
},
])
})
it('stopGateway removes the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.stopGateway()
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('stopGateway is idempotent when the managed container is already absent', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
options?.onOutput?.(
`Error: no container with name "${OPENCLAW_GATEWAY_CONTAINER_NAME}" found`,
)
return 0
})
await expect(runtime.stopGateway()).resolves.toBeUndefined()
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('getGatewayLogs tails logs from the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
options?.onOutput?.('first')
options?.onOutput?.('second')
return 0
})
const logs = await runtime.getGatewayLogs(25)
expect(logs).toEqual(['first', 'second'])
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['logs', '--tail', '25', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('restartGateway recreates and launches the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.restartGateway(defaultSpec)
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
{
cwd: PROJECT_DIR,
args: expectedStartGatewayRunArgs(defaultSpec),
},
])
})
it('stopMachineIfSafe allows the managed gateway container', async () => {
let stopCalls = 0
const runtime = createRuntime(
async () => 0,
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME],
async () => {
stopCalls += 1
},
expect(deps.shell.runCommand).toHaveBeenCalledWith(
expect.arrayContaining([
'create',
'--name',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
'--env-file',
'/mnt/browseros/vm/openclaw/.openclaw/.env',
'-v',
'/mnt/browseros/vm/openclaw:/home/node',
'--add-host',
'host.containers.internal:192.168.5.2',
]),
undefined,
)
await runtime.stopMachineIfSafe()
expect(stopCalls).toBe(1)
})
it('stopMachineIfSafe does not stop machine if non-BrowserOS containers are running', async () => {
let stopCalls = 0
const runtime = createRuntime(
async () => 0,
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME, 'postgres-dev'],
async () => {
stopCalls += 1
},
expect(deps.shell.runCommand).toHaveBeenCalledWith(
['start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
undefined,
)
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
{ force: true },
undefined,
)
await runtime.stopMachineIfSafe()
expect(stopCalls).toBe(0)
})
it('execInContainer targets the shared gateway container name', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
it('tails and fetches gateway logs through the new transport', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.execInContainer(['node', '--version'])
expect(calls).toEqual([
{
cwd: undefined,
args: ['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, 'node', '--version'],
},
])
})
it('tailGatewayLogs targets the shared gateway container name', () => {
const names: string[] = []
const runtime = new ContainerRuntime(
{
ensureReady: async () => {},
isPodmanAvailable: async () => true,
getMachineStatus: async () => ({ initialized: true, running: true }),
runCommand: async () => 0,
tailContainerLogs: (containerName: string) => {
names.push(containerName)
return () => {}
},
listRunningContainers: async () => [],
stopMachine: async () => {},
} as never,
PROJECT_DIR,
)
const stop = runtime.tailGatewayLogs(() => {})
const logs = await runtime.getGatewayLogs(10)
stop()
expect(names).toEqual([OPENCLAW_GATEWAY_CONTAINER_NAME])
expect(deps.shell.tailLogs).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
expect.any(Function),
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
['logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
expect.any(Function),
)
expect(logs).toEqual(['log line'])
})
})
function createDeps() {
return {
vm: {
ensureReady: mock(async () => {}),
getDefaultGateway: mock(async () => '192.168.5.2'),
stopVm: mock(async () => {}),
isReady: mock(async () => true),
},
shell: {
createContainer: mock(async () => {}),
startContainer: mock(async () => {}),
stopContainer: mock(async () => {}),
removeContainer: mock(async () => {}),
exec: mock(async () => 0),
runCommand: mock(
async (_args: string[], onLog?: (line: string) => void) => {
onLog?.('log line')
return { exitCode: 0, stdout: 'log line\n', stderr: '' }
},
),
tailLogs: mock(() => () => {}),
},
loader: {
ensureImageLoaded: mock(async () => {}),
},
}
}

View File

@@ -1,244 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { OpenClawHttpChatClient } from '../../../../src/api/services/openclaw/openclaw-http-chat-client'
describe('OpenClawHttpChatClient', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it('maps chat completion deltas into BrowserOS stream events', async () => {
const fetchMock = mock((_url: string | URL, _init?: RequestInit) =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
),
)
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
),
)
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
),
)
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
const stream = await client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
history: [{ role: 'assistant', content: 'Earlier reply' }],
})
const events = await readEvents(stream)
const call = fetchMock.mock.calls[0]
expect(call?.[0]).toBe('http://127.0.0.1:18789/v1/chat/completions')
expect(call?.[1]).toMatchObject({
method: 'POST',
headers: {
Authorization: 'Bearer gateway-token',
'Content-Type': 'application/json',
},
})
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
model: 'openclaw/research',
stream: true,
messages: [
{ role: 'assistant', content: 'Earlier reply' },
{ role: 'user', content: 'hi' },
],
user: 'browseros:research:session-123',
})
expect(events).toEqual([
{ type: 'text-delta', data: { text: 'Hello' } },
{ type: 'text-delta', data: { text: ' world' } },
{ type: 'done', data: { text: 'Hello world' } },
])
})
it('uses openclaw for the main agent', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
await client.streamChat({
agentId: 'main',
sessionKey: 'session-123',
message: 'hi',
})
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as {
model: string
}
expect(body.model).toBe('openclaw')
})
it('throws on non-success HTTP responses', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('Unauthorized', { status: 401 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
await expect(
client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
}),
).rejects.toThrow('Unauthorized')
})
it('surfaces an error when OpenClaw finishes without assistant text', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
),
)
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
const stream = await client.streamChat({
agentId: 'main',
sessionKey: 'session-123',
message: 'hi',
})
await expect(readEvents(stream)).resolves.toEqual([
{
type: 'error',
data: {
message: "Agent couldn't generate a response. Please try again.",
},
},
])
})
it('stops processing batched SSE events after a malformed chunk closes the stream', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' +
'data: not-json\n\n' +
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
),
)
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpChatClient(
18789,
async () => 'gateway-token',
)
const stream = await client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
})
await expect(readEvents(stream)).resolves.toEqual([
{ type: 'text-delta', data: { text: 'Hello' } },
{
type: 'error',
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
},
])
})
})
async function readEvents(
stream: ReadableStream<{ type: string; data: Record<string, unknown> }>,
): Promise<Array<{ type: string; data: Record<string, unknown> }>> {
const reader = stream.getReader()
const events: Array<{ type: string; data: Record<string, unknown> }> = []
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}

View File

@@ -0,0 +1,516 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { OpenClawSessionNotFoundError } from '../../../../src/api/services/openclaw/errors'
import { OpenClawHttpClient } from '../../../../src/api/services/openclaw/openclaw-http-client'
describe('OpenClawHttpClient', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it('maps chat completion deltas into BrowserOS stream events', async () => {
const fetchMock = mock((_url: string | URL, _init?: RequestInit) =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
),
)
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
),
)
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
),
)
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
history: [{ role: 'assistant', content: 'Earlier reply' }],
})
const events = await readEvents(stream)
const call = fetchMock.mock.calls[0]
expect(call?.[0]).toBe('http://127.0.0.1:18789/v1/chat/completions')
expect(call?.[1]).toMatchObject({
method: 'POST',
headers: {
Authorization: 'Bearer gateway-token',
'Content-Type': 'application/json',
},
})
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
model: 'openclaw/research',
stream: true,
messages: [
{ role: 'assistant', content: 'Earlier reply' },
{ role: 'user', content: 'hi' },
],
user: 'browseros:research:session-123',
})
expect(events).toEqual([
{ type: 'text-delta', data: { text: 'Hello' } },
{ type: 'text-delta', data: { text: ' world' } },
{ type: 'done', data: { text: 'Hello world' } },
])
})
it('uses openclaw for the main agent', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await client.streamChat({
agentId: 'main',
sessionKey: 'session-123',
message: 'hi',
})
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as {
model: string
}
expect(body.model).toBe('openclaw')
})
it('throws on non-success HTTP responses', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('Unauthorized', { status: 401 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(
client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
}),
).rejects.toThrow('Unauthorized')
})
it('checks gateway authentication with the current bearer token', async () => {
const fetchMock = mock(() => Promise.resolve(new Response('{}')))
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.isAuthenticated()).resolves.toBe(true)
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/v1/models',
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: {
Authorization: 'Bearer gateway-token',
},
})
})
it('treats rejected gateway authentication as unavailable', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('Unauthorized', { status: 401 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.isAuthenticated()).resolves.toBe(false)
})
it('treats failed gateway authentication probes as unavailable', async () => {
globalThis.fetch = mock(() =>
Promise.reject(new Error('connect ECONNREFUSED')),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.isAuthenticated()).resolves.toBe(false)
})
it('surfaces an error when OpenClaw finishes without assistant text', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
),
)
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamChat({
agentId: 'main',
sessionKey: 'session-123',
message: 'hi',
})
await expect(readEvents(stream)).resolves.toEqual([
{
type: 'error',
data: {
message: "Agent couldn't generate a response. Please try again.",
},
},
])
})
it('stops processing batched SSE events after a malformed chunk closes the stream', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' +
'data: not-json\n\n' +
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
),
)
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
})
await expect(readEvents(stream)).resolves.toEqual([
{ type: 'text-delta', data: { text: 'Hello' } },
{
type: 'error',
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
},
])
})
describe('getSessionHistory', () => {
it('sends GET with bearer auth and forwards limit/cursor as query params', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
sessionKey: 'agent:main:main',
messages: [
{ role: 'user', content: 'hi', messageSeq: 1 },
{ role: 'assistant', content: 'hello', messageSeq: 2 },
],
cursor: null,
hasMore: false,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const result = await client.getSessionHistory('agent:main:main', {
limit: 50,
cursor: 'abc',
})
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/sessions/agent%3Amain%3Amain/history?limit=50&cursor=abc',
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: { Authorization: 'Bearer gateway-token' },
})
expect(result).toEqual({
sessionKey: 'agent:main:main',
messages: [
{ role: 'user', content: 'hi', messageSeq: 1 },
{ role: 'assistant', content: 'hello', messageSeq: 2 },
],
cursor: null,
hasMore: false,
})
})
it('omits limit and cursor from the query when undefined', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
status: 200,
}),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await client.getSessionHistory('k')
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/sessions/k/history',
)
})
it('throws OpenClawSessionNotFoundError on 404', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('not found', { status: 404 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(
client.getSessionHistory('missing-key'),
).rejects.toBeInstanceOf(OpenClawSessionNotFoundError)
})
it('surfaces the response body on other non-2xx responses', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('boom', { status: 500 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.getSessionHistory('k')).rejects.toThrow('boom')
})
it('propagates the abort signal to fetch', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
status: 200,
}),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const controller = new AbortController()
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await client.getSessionHistory('k', { signal: controller.signal })
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal)
})
})
describe('streamSessionHistory', () => {
it('parses named history/message SSE events into typed events', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'event: history\ndata: {"sessionKey":"k","messages":[{"role":"user","content":"hi","messageSeq":1}],"cursor":null,"hasMore":false}\n\n',
),
)
controller.enqueue(
encoder.encode(
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hey","messageSeq":2}}\n\n',
),
)
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamSessionHistory('k', { limit: 20 })
const events = await readEvents(stream)
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/sessions/k/history?limit=20',
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: {
Accept: 'text/event-stream',
Authorization: 'Bearer gateway-token',
},
})
expect(events).toEqual([
{
type: 'history',
data: {
sessionKey: 'k',
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
cursor: null,
hasMore: false,
},
},
{
type: 'message',
data: {
sessionKey: 'k',
messageSeq: 2,
message: { role: 'assistant', content: 'hey', messageSeq: 2 },
},
},
])
})
it('forwards upstream error frames and closes', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'event: error\ndata: {"message":"upstream exploded"}\n\n',
),
)
controller.close()
},
}),
{ status: 200 },
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamSessionHistory('k')
await expect(readEvents(stream)).resolves.toEqual([
{ type: 'error', data: { message: 'upstream exploded' } },
])
})
it('throws OpenClawSessionNotFoundError on 404', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('not found', { status: 404 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.streamSessionHistory('k')).rejects.toBeInstanceOf(
OpenClawSessionNotFoundError,
)
})
it('closes when the abort signal fires mid-stream', async () => {
const ac = new AbortController()
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'event: history\ndata: {"sessionKey":"k","messages":[]}\n\n',
),
)
// Keep the stream open; abort should close it from our side.
await new Promise((resolve) => {
ac.signal.addEventListener(
'abort',
() => resolve(undefined),
{
once: true,
},
)
})
controller.close()
},
}),
{ status: 200 },
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamSessionHistory('k', {
signal: ac.signal,
})
const reader = stream.getReader()
const first = await reader.read()
expect(first.done).toBe(false)
expect(first.value).toMatchObject({ type: 'history' })
ac.abort()
const next = await reader.read()
expect(next.done).toBe(true)
})
})
})
async function readEvents(
stream: ReadableStream<{ type: string; data: Record<string, unknown> }>,
): Promise<Array<{ type: string; data: Record<string, unknown> }>> {
const reader = stream.getReader()
const events: Array<{ type: string; data: Record<string, unknown> }> = []
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}

View File

@@ -6,6 +6,7 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
@@ -23,7 +24,8 @@ type MutableOpenClawService = OpenClawService & {
ensureReady?: () => Promise<void>
isPodmanAvailable?: () => Promise<boolean>
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
isReady: () => Promise<boolean>
isHealthy?: (_hostPort?: number) => Promise<boolean>
isReady: (_hostPort?: number) => Promise<boolean>
pullImage?: (
_image: string,
_onLog?: (_line: string) => void,
@@ -39,7 +41,7 @@ type MutableOpenClawService = OpenClawService & {
stopGateway?: (_onLog?: (_line: string) => void) => Promise<void>
getGatewayLogs?: (_tail?: number) => Promise<string[]>
waitForReady?: () => Promise<boolean>
stopMachineIfSafe?: () => Promise<void>
stopVm?: () => Promise<void>
}
cliClient: {
probe?: ReturnType<typeof mock>
@@ -58,9 +60,11 @@ type MutableOpenClawService = OpenClawService & {
describe('OpenClawService', () => {
let tempDir: string | null = null
const originalFetch = globalThis.fetch
afterEach(async () => {
mock.restore()
globalThis.fetch = originalFetch
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = null
@@ -210,9 +214,6 @@ describe('OpenClawService', () => {
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
const pullImage = mock(async () => {
steps.push('pull')
})
const restartGateway = mock(async () => {
steps.push('restart')
})
@@ -223,7 +224,6 @@ describe('OpenClawService', () => {
isPodmanAvailable: async () => true,
ensureReady: async () => {},
isReady: async () => true,
pullImage,
restartGateway,
startGateway,
waitForReady: mock(async () => {
@@ -277,18 +277,7 @@ describe('OpenClawService', () => {
name: 'main',
model: undefined,
})
expect(steps).toEqual([
'pull',
'onboard',
'batch',
'validate',
'start',
'ready',
])
expect(pullImage).toHaveBeenCalledWith(
'ghcr.io/openclaw/openclaw:2026.4.12',
expect.any(Function),
)
expect(steps).toEqual(['onboard', 'batch', 'validate', 'start', 'ready'])
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
@@ -573,7 +562,7 @@ describe('OpenClawService', () => {
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady: async () => true,
isReady: async () => false,
startGateway,
waitForReady,
}
@@ -598,6 +587,101 @@ describe('OpenClawService', () => {
expect(probe).toHaveBeenCalledTimes(1)
})
it('serializes concurrent start calls and only starts the gateway once', 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',
},
},
}),
)
let gatewayReady = false
let releaseStartGateway!: () => void
let notifyStartGatewayEntered!: () => void
const startGatewayEntered = new Promise<void>((resolve) => {
notifyStartGatewayEntered = resolve
})
const unblockStartGateway = new Promise<void>((resolve) => {
releaseStartGateway = resolve
})
const ensureReady = mock(async () => {})
const startGateway = mock(async () => {
notifyStartGatewayEntered()
await unblockStartGateway
gatewayReady = true
})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady: async () => gatewayReady,
startGateway,
waitForReady,
}
service.cliClient = {
probe,
}
mockGatewayAuth()
const firstStart = service.start()
await startGatewayEntered
const secondStart = service.start()
releaseStartGateway()
await Promise.all([firstStart, secondStart])
expect(ensureReady).toHaveBeenCalledTimes(2)
expect(startGateway).toHaveBeenCalledTimes(1)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(2)
})
it('does not restart a ready gateway when start is called again', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
token: 'cli-token',
},
},
}),
)
const ensureReady = mock(async () => {})
const startGateway = mock(async () => {})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady: async () => true,
startGateway,
waitForReady,
}
service.cliClient = {
probe,
}
mockGatewayAuth()
await service.start()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(startGateway).not.toHaveBeenCalled()
expect(waitForReady).not.toHaveBeenCalled()
expect(probe).toHaveBeenCalledTimes(1)
})
it('restart uses the direct runtime restartGateway flow', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
@@ -611,6 +695,7 @@ describe('OpenClawService', () => {
},
}),
)
const ensureReady = mock(async () => {})
const restartGateway = mock(async () => {})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
@@ -618,6 +703,7 @@ describe('OpenClawService', () => {
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady: async () => true,
restartGateway,
waitForReady,
@@ -625,9 +711,11 @@ describe('OpenClawService', () => {
service.cliClient = {
probe,
}
mockGatewayAuth()
await service.restart()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
@@ -642,6 +730,149 @@ describe('OpenClawService', () => {
expect(probe).toHaveBeenCalledTimes(1)
})
it('restart keeps the persisted gateway port when the current gateway already owns it', 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 occupiedServer = createServer()
const occupiedPort = await new Promise<number>((resolve, reject) => {
occupiedServer.once('error', reject)
occupiedServer.listen(0, '127.0.0.1', () => {
const address = occupiedServer.address()
if (!address || typeof address === 'string') {
reject(new Error('failed to allocate test port'))
return
}
resolve(address.port)
})
})
await writeFile(
join(tempDir, '.openclaw', 'runtime-state.json'),
`${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`,
)
const ensureReady = mock(async () => {})
const restartGateway = mock(async () => {})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady: async (hostPort?: number) => hostPort === occupiedPort,
restartGateway,
waitForReady,
}
service.cliClient = {
probe,
}
mockGatewayAuth()
try {
await service.restart()
} finally {
await new Promise<void>((resolve, reject) => {
occupiedServer.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: occupiedPort,
}),
expect.any(Function),
)
expect(ensureReady).toHaveBeenCalledTimes(1)
})
it('restart moves off a persisted ready port when auth rejects the current token', 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 occupiedServer = createServer()
const occupiedPort = await new Promise<number>((resolve, reject) => {
occupiedServer.once('error', reject)
occupiedServer.listen(0, '127.0.0.1', () => {
const address = occupiedServer.address()
if (!address || typeof address === 'string') {
reject(new Error('failed to allocate test port'))
return
}
resolve(address.port)
})
})
await writeFile(
join(tempDir, '.openclaw', 'runtime-state.json'),
`${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`,
)
const ensureReady = mock(async () => {})
const restartGateway = mock(async () => {})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady: async (hostPort?: number) => hostPort === occupiedPort,
restartGateway,
waitForReady,
}
service.cliClient = {
probe,
}
mockGatewayAuth(401)
try {
await service.restart()
} finally {
await new Promise<void>((resolve, reject) => {
occupiedServer.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
hostPort: expect.any(Number),
}),
expect.any(Function),
)
expect(
(restartGateway.mock.calls[0]?.[0] as { hostPort: number }).hostPort,
).not.toBe(occupiedPort)
expect(ensureReady).toHaveBeenCalledTimes(1)
})
it('stop calls runtime.stopGateway', async () => {
const stopGateway = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
@@ -669,40 +900,40 @@ describe('OpenClawService', () => {
expect(getGatewayLogs).toHaveBeenCalledWith(25)
})
it('shutdown stops gateway and then stops machine when safe', async () => {
it('shutdown stops gateway and then stops the VM', async () => {
const stopGateway = mock(async () => {})
const stopMachineIfSafe = mock(async () => {})
const stopVm = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
isReady: async () => true,
stopGateway,
stopMachineIfSafe,
stopVm,
}
await service.shutdown()
expect(stopGateway).toHaveBeenCalledTimes(1)
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
expect(stopVm).toHaveBeenCalledTimes(1)
})
it('shutdown still stops machine when stopGateway fails', async () => {
it('shutdown still stops the VM when stopGateway fails', async () => {
const stopGateway = mock(async () => {
throw new Error('stop failed')
})
const stopMachineIfSafe = mock(async () => {})
const stopVm = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
isReady: async () => true,
stopGateway,
stopMachineIfSafe,
stopVm,
}
await expect(service.shutdown()).resolves.toBeUndefined()
expect(stopGateway).toHaveBeenCalledTimes(1)
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
expect(stopVm).toHaveBeenCalledTimes(1)
})
it('tryAutoStart uses direct-runtime startGateway when gateway is not ready', async () => {
@@ -751,7 +982,7 @@ describe('OpenClawService', () => {
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
expect(isReady).toHaveBeenCalledTimes(1)
expect(isReady).toHaveBeenCalledTimes(2)
})
it('keeps openrouter model refs verbatim without rewriting dots', () => {
@@ -1262,61 +1493,10 @@ describe('OpenClawService', () => {
'OPENAI_API_KEY=sk-test\n',
)
})
it('applyPodmanOverrides persists the override and refreshes the runtime', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
const result = await service.applyPodmanOverrides({
podmanPath: '/opt/homebrew/bin/podman',
})
expect(result.podmanPath).toBe('/opt/homebrew/bin/podman')
expect(result.effectivePodmanPath).toBe('/opt/homebrew/bin/podman')
const persisted = JSON.parse(
await readFile(join(tempDir, 'podman-overrides.json'), 'utf-8'),
)
expect(persisted).toEqual({ podmanPath: '/opt/homebrew/bin/podman' })
const reloaded = await service.getPodmanOverrides()
expect(reloaded.podmanPath).toBe('/opt/homebrew/bin/podman')
expect(reloaded.effectivePodmanPath).toBe('/opt/homebrew/bin/podman')
})
it('applyPodmanOverrides with null clears the override and falls back', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
const service = new OpenClawService({
resourcesDir: tempDir,
}) as MutableOpenClawService
service.openclawDir = tempDir
await service.applyPodmanOverrides({
podmanPath: '/opt/homebrew/bin/podman',
})
const cleared = await service.applyPodmanOverrides({ podmanPath: null })
expect(cleared.podmanPath).toBeNull()
// resourcesDir has no bundled binary, so the runtime falls through to 'podman'
expect(cleared.effectivePodmanPath).toBe('podman')
const persisted = JSON.parse(
await readFile(join(tempDir, 'podman-overrides.json'), 'utf-8'),
)
expect(persisted).toEqual({ podmanPath: null })
})
it('applyPodmanOverrides rebuilds ContainerRuntime so it picks up the new Podman reference', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
const before = service.runtime
await service.applyPodmanOverrides({
podmanPath: '/opt/homebrew/bin/podman',
})
expect(service.runtime).not.toBe(before)
})
})
function mockGatewayAuth(status = 200): ReturnType<typeof mock> {
const fetchMock = mock(() => Promise.resolve(new Response('', { status })))
globalThis.fetch = fetchMock as typeof globalThis.fetch
return fetchMock
}

View File

@@ -1,71 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
getPodmanOverridesPath,
loadPodmanOverrides,
savePodmanOverrides,
} from '../../../../src/api/services/openclaw/podman-overrides'
describe('podman overrides', () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-ovr-'))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
it('returns null podmanPath when the overrides file is missing', async () => {
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
})
it('round-trips save and load', async () => {
await savePodmanOverrides(tempDir, {
podmanPath: '/opt/homebrew/bin/podman',
})
expect(await loadPodmanOverrides(tempDir)).toEqual({
podmanPath: '/opt/homebrew/bin/podman',
})
})
it('returns null when the overrides file is malformed JSON', async () => {
fs.writeFileSync(getPodmanOverridesPath(tempDir), '{not json')
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
})
it('treats empty string and wrong types as null', async () => {
fs.writeFileSync(
getPodmanOverridesPath(tempDir),
JSON.stringify({ podmanPath: '' }),
)
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
fs.writeFileSync(
getPodmanOverridesPath(tempDir),
JSON.stringify({ podmanPath: 42 }),
)
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
})
it('persists an explicit null', async () => {
await savePodmanOverrides(tempDir, { podmanPath: null })
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
expect(fs.existsSync(getPodmanOverridesPath(tempDir))).toBe(true)
})
it('creates the openclaw directory if it does not exist', async () => {
const nested = path.join(tempDir, 'does-not-exist')
await savePodmanOverrides(nested, { podmanPath: '/usr/local/bin/podman' })
expect(fs.existsSync(getPodmanOverridesPath(nested))).toBe(true)
})
})

View File

@@ -1,83 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
configurePodmanRuntime,
getPodmanRuntime,
resolveBundledPodmanPath,
} from '../../../../src/api/services/openclaw/podman-runtime'
describe('podman runtime', () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-test-'))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
configurePodmanRuntime({ podmanPath: 'podman' })
})
it('returns the bundled podman path when the executable exists', () => {
const bundledPath = path.join(
tempDir,
'bin',
'third_party',
'podman',
'podman',
)
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
fs.writeFileSync(bundledPath, 'podman')
expect(resolveBundledPodmanPath(tempDir, 'darwin')).toBe(bundledPath)
})
it('uses the windows executable name for bundled podman', () => {
const bundledPath = path.join(
tempDir,
'bin',
'third_party',
'podman',
'podman.exe',
)
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
fs.writeFileSync(bundledPath, 'podman')
expect(resolveBundledPodmanPath(tempDir, 'win32')).toBe(bundledPath)
})
it('returns null when no bundled podman executable exists', () => {
expect(resolveBundledPodmanPath(tempDir, 'darwin')).toBeNull()
})
it('configures the runtime to prefer the bundled podman path', () => {
const bundledPath = path.join(
tempDir,
'bin',
'third_party',
'podman',
'podman',
)
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
fs.writeFileSync(bundledPath, 'podman')
const runtime = configurePodmanRuntime({ resourcesDir: tempDir })
expect(runtime.getPodmanPath()).toBe(bundledPath)
expect(getPodmanRuntime().getPodmanPath()).toBe(bundledPath)
})
it('falls back to PATH podman when no bundled executable is present', () => {
const runtime = configurePodmanRuntime({ resourcesDir: tempDir })
expect(runtime.getPodmanPath()).toBe('podman')
})
})

View File

@@ -8,7 +8,10 @@ import { homedir } from 'node:os'
import { join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import {
getAgentCacheDir,
getBrowserosDir,
getCacheDir,
getVmCacheDir,
logDevelopmentBrowserosDir,
} from '../src/lib/browseros-dir'
import { logger } from '../src/lib/logger'
@@ -72,4 +75,34 @@ describe('getBrowserosDir', () => {
logger.info = originalInfo
}
})
it('uses the development cache directory in development', () => {
process.env.NODE_ENV = 'development'
expect(getCacheDir()).toBe(join(homedir(), '.browseros-dev', 'cache'))
})
it('uses the standard cache directory outside development', () => {
process.env.NODE_ENV = 'test'
expect(getCacheDir()).toBe(
join(homedir(), PATHS.BROWSEROS_DIR_NAME, 'cache'),
)
})
it('uses a vm cache directory below cache', () => {
process.env.NODE_ENV = 'development'
expect(getVmCacheDir()).toBe(
join(homedir(), '.browseros-dev', 'cache', 'vm'),
)
})
it('uses an agent image cache directory below vm cache', () => {
process.env.NODE_ENV = 'development'
expect(getAgentCacheDir()).toBe(
join(homedir(), '.browseros-dev', 'cache', 'vm', 'images'),
)
})
})

View File

@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
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 { ContainerCli } from '../../src/lib/container'
import { LimaCli, type VmManifest, VmRuntime } from '../../src/lib/vm'
import {
getCachedManifestPath,
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
const limactlPath = process.env.LIMACTL_PATH ?? 'limactl'
const templatePath = resolve(
import.meta.dir,
'../../../../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
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 () => {
if (process.env.LIVE_VM_SMOKE === '1') {
await new LimaCli({ limactlPath, limaHome })
.delete(VM_NAME)
.catch(() => undefined)
}
await rm(root, { recursive: true, force: true })
})
liveIt(
'creates, starts, uses, stops, and deletes the BrowserOS Lima VM',
async () => {
expect(existsSync(templatePath)).toBe(true)
const runtime = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: root,
readinessTimeoutMs: 5 * 60 * 1000,
readinessPollMs: 1000,
})
const cli = new ContainerCli({
limactlPath,
limaHome,
vmName: VM_NAME,
})
await runtime.ensureReady()
expect((await stat(getContainerdSocketPath(root))).isSocket()).toBe(true)
const nerdctlInfoOutput: string[] = []
const nerdctlInfo = await cli.runCommand(['info'], (line) =>
nerdctlInfoOutput.push(line),
)
if (nerdctlInfo.exitCode !== 0) {
throw new Error(
`nerdctl info failed with exit ${nerdctlInfo.exitCode}:\n${nerdctlInfoOutput.join('\n')}`,
)
}
await cli.pullImage('docker.io/library/hello-world:latest')
const secondStart = Date.now()
await runtime.ensureReady()
expect(Date.now() - secondStart).toBeLessThan(10_000)
await runtime.stopVm()
const vm = (await new LimaCli({ limactlPath, limaHome }).list()).find(
(entry) => entry.name === VM_NAME,
)
expect(vm?.status).toBe('Stopped')
},
LIVE_VM_SMOKE_TIMEOUT_MS,
)
})

View File

@@ -0,0 +1,201 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { ContainerCli } from '../../../src/lib/container/container-cli'
import { ContainerCliError } from '../../../src/lib/vm/errors'
import { fakeSsh } from '../../__helpers__/fake-ssh'
describe('ContainerCli', () => {
let tempDir: string
let logPath: string
beforeEach(async () => {
tempDir = await mkdtemp('/tmp/container-cli-')
logPath = join(tempDir, 'ssh.log')
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
it('checks image existence with nerdctl image inspect', async () => {
const sshPath = await fakeSsh({}, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(true)
const sshConfig = sshConfigPath(tempDir)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfig)} 'nerdctl' 'image' 'inspect' 'openclaw:v1'`,
)
})
it('returns false when image inspect exits non-zero', async () => {
const sshPath = await fakeSsh({ stderr: 'missing', exit: 1 }, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(false)
})
it('pulls images with progress and throws typed command errors', async () => {
const sshPath = await fakeSsh(
{ stdout: 'pulling\n', stderr: 'denied', exit: 2 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
const lines: string[] = []
const error = await cli
.pullImage('openclaw:v1', (line) => lines.push(line))
.catch((err) => err)
expect(error).toBeInstanceOf(ContainerCliError)
expect(error.exitCode).toBe(2)
expect(error.stderr).toBe('denied')
expect(lines).toContain('pulling')
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)
await cli.createContainer({
name: 'gateway',
image: 'openclaw:v1',
restart: 'unless-stopped',
ports: [{ hostIp: '127.0.0.1', hostPort: 18789, containerPort: 18789 }],
envFile: '/mnt/browseros/vm/openclaw/.env',
env: { HOME: '/home/node', NODE_ENV: 'production' },
mounts: [
{
source: '/mnt/browseros/vm/openclaw',
target: '/home/node',
readonly: true,
},
],
addHosts: ['host.containers.internal:192.168.5.2'],
health: {
cmd: 'curl -sf http://127.0.0.1:18789/healthz',
interval: '30s',
timeout: '10s',
retries: 3,
},
command: ['node', 'dist/index.js', 'gateway'],
})
await expect(readFile(logPath, 'utf8')).resolves.toContain(
[
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'create'`,
"'--name' 'gateway'",
"'--restart' 'unless-stopped'",
"'-p' '127.0.0.1:18789:18789'",
"'--env-file' '/mnt/browseros/vm/openclaw/.env'",
"'-e' 'HOME=/home/node'",
"'-e' 'NODE_ENV=production'",
"'-v' '/mnt/browseros/vm/openclaw:/home/node:ro'",
"'--add-host' 'host.containers.internal:192.168.5.2'",
"'--health-cmd' 'curl -sf http://127.0.0.1:18789/healthz'",
"'--health-interval' '30s'",
"'--health-timeout' '10s'",
"'--health-retries' '3'",
"'openclaw:v1' 'node' 'dist/index.js' 'gateway'",
].join(' '),
)
})
it('starts, stops, removes, execs, and lists containers', async () => {
const sshPath = await fakeSsh({ stdout: 'gateway\nworker\n' }, logPath)
const cli = await createCli(sshPath, tempDir)
await cli.startContainer('gateway')
await cli.stopContainer('gateway')
await cli.removeContainer('gateway', { force: true })
await expect(cli.exec('gateway', ['node', '--version'])).resolves.toBe(0)
await expect(cli.ps({ namesOnly: true })).resolves.toEqual([
'gateway',
'worker',
])
const log = await readFile(logPath, 'utf8')
expect(log).toContain("lima-browseros-vm 'nerdctl' 'start' 'gateway'")
expect(log).toContain("lima-browseros-vm 'nerdctl' 'stop' 'gateway'")
expect(log).toContain("lima-browseros-vm 'nerdctl' 'rm' '-f' 'gateway'")
expect(log).toContain(
"lima-browseros-vm 'nerdctl' 'exec' 'gateway' 'node' '--version'",
)
expect(log).toContain(
"lima-browseros-vm 'nerdctl' 'ps' '--format' '{{.Names}}'",
)
})
it('tolerates removal when the container is already absent', async () => {
const sshPath = await fakeSsh(
{ stderr: 'no such container', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.removeContainer('gateway', { force: true })).resolves.toBe(
undefined,
)
})
it('tails logs and returns a stop handle', async () => {
const sshPath = await fakeSsh({ stdout: 'line\n' }, logPath)
const cli = await createCli(sshPath, tempDir)
const lines: string[] = []
const stop = cli.tailLogs('gateway', (line) => lines.push(line))
await Bun.sleep(20)
stop()
expect(lines).toEqual(['line'])
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'logs' '-f' '-n' '0' 'gateway'`,
)
})
})
async function createCli(
sshPath: string,
tempDir: string,
): Promise<ContainerCli> {
const configPath = sshConfigPath(tempDir)
await mkdir(join(tempDir, 'lima', 'browseros-vm'), { recursive: true })
await writeFile(configPath, '')
return new ContainerCli({
limactlPath: 'unused',
limaHome: join(tempDir, 'lima'),
sshPath,
vmName: 'browseros-vm',
})
}
function sshConfigPath(tempDir: string): string {
return join(tempDir, 'lima', 'browseros-vm', 'ssh.config')
}
function sshPrefix(configPath: string): string {
return `ARGS:-F ${configPath} lima-browseros-vm`
}

View File

@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
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 () => {
const cli = new FakeContainerCli([true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
expect(cli.loadCalls).toEqual([])
})
it('loads a missing image from the guest cache and verifies it exists', async () => {
const cli = new FakeContainerCli([false, true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
expect(cli.loadCalls).toEqual([
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
])
expect(cli.existsCalls).toEqual([
'ghcr.io/openclaw/openclaw:2026.4.12',
'ghcr.io/openclaw/openclaw:2026.4.12',
])
})
it('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(
ImageLoadError,
)
expect(cli.loadCalls).toEqual([])
})
it('wraps ContainerCliError load 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')
const error = await loader
.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
.catch((err) => err)
expect(error).toBeInstanceOf(ImageLoadError)
expect(error.cause).toBe(cli.loadError)
})
})
class FakeContainerCli
implements Pick<ContainerCli, 'imageExists' | 'loadImage'>
{
existsCalls: string[] = []
loadCalls: string[] = []
loadError: Error | null = null
constructor(private readonly existsResponses: boolean[]) {}
async imageExists(ref: string): Promise<boolean> {
this.existsCalls.push(ref)
return this.existsResponses.shift() ?? false
}
async loadImage(path: string): Promise<string[]> {
this.loadCalls.push(path)
if (this.loadError) throw this.loadError
return ['loaded']
}
}

View File

@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import {
ContainerCliError,
ImageLoadError,
LimaCommandError,
ManifestMissingError,
VmError,
VmNotReadyError,
VmStateCorruptedError,
} from '../../../src/lib/vm/errors'
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
describe('VM errors', () => {
it('keeps all VM domain errors under VmError', () => {
const errors = [
new VmError('base'),
new VmNotReadyError('not ready'),
new VmStateCorruptedError('corrupt'),
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) {
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(VmError)
}
})
it('carries command failure details', () => {
const lima = new LimaCommandError('limactl start', 12, 'stderr text')
const container = new ContainerCliError(
'nerdctl pull',
13,
'nerdctl stderr',
)
expect(lima.exitCode).toBe(12)
expect(lima.stderr).toBe('stderr text')
expect(container.exitCode).toBe(13)
expect(container.stderr).toBe('nerdctl stderr')
})
it('exports VM telemetry event names', () => {
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',
)
expect(VM_TELEMETRY_EVENTS.migrationOpenClawMoved).toBe(
'vm.migration.openclaw_moved',
)
})
})

View File

@@ -0,0 +1,211 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import {
afterEach,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { logger } from '../../../src/lib/logger'
import { LimaCommandError, VmNotReadyError } from '../../../src/lib/vm/errors'
import { LimaCli } from '../../../src/lib/vm/lima-cli'
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
import { fakeLimactl } from '../../__helpers__/fake-limactl'
import { fakeSsh } from '../../__helpers__/fake-ssh'
describe('LimaCli', () => {
let tempDir: string
let logPath: string
let limaHome: string
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'lima-cli-test-'))
logPath = join(tempDir, 'calls.log')
limaHome = join(tempDir, 'lima-home')
})
afterEach(async () => {
mock.restore()
await rm(tempDir, { recursive: true, force: true })
})
it('parses limactl list JSON output', async () => {
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{
name: 'browseros-vm',
status: 'Running',
dir: '/lima/browseros-vm',
},
]),
},
},
logPath,
)
const cli = new LimaCli({ limactlPath, limaHome })
await expect(cli.list()).resolves.toEqual([
{ name: 'browseros-vm', status: 'Running', dir: '/lima/browseros-vm' },
])
})
it('returns an empty VM list when limactl prints no output', async () => {
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })
await expect(cli.list()).resolves.toEqual([])
})
it('creates VMs with LIMA_HOME and the expected argv', async () => {
const limactlPath = await fakeLimactl({ create: {} }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.create('browseros-vm', '/tmp/browseros-vm.yaml')
await expect(readFile(logPath, 'utf8')).resolves.toContain(
'ARGS:create --tty=false --name=browseros-vm /tmp/browseros-vm.yaml',
)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`LIMA_HOME:${limaHome}`,
)
})
it('starts VMs with tty disabled', async () => {
const limactlPath = await fakeLimactl({ start: {} }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.start('browseros-vm')
await expect(readFile(logPath, 'utf8')).resolves.toContain(
'ARGS:start --tty=false browseros-vm',
)
})
it('throws LimaCommandError with stderr on non-zero exit', async () => {
const limactlPath = await fakeLimactl(
{ start: { stderr: 'cannot start', exit: 2 } },
logPath,
)
const cli = new LimaCli({ limactlPath, limaHome })
const error = await cli.start('browseros-vm').catch((err) => err)
expect(error).toBeInstanceOf(LimaCommandError)
expect(error.exitCode).toBe(2)
expect(error.stderr).toBe('cannot start')
})
it('does not log limactl stderr chunks by default', async () => {
const debug = spyOn(logger, 'debug').mockImplementation(() => {})
const limactlPath = await fakeLimactl(
{ start: { stderr: 'boot noise\n' } },
logPath,
)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.start('browseros-vm')
expect(
debug.mock.calls.some(
([message]) => message === VM_TELEMETRY_EVENTS.limaStderrChunk,
),
).toBe(false)
})
it('stops and deletes VMs', async () => {
const limactlPath = await fakeLimactl({ stop: {}, delete: {} }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.stop('browseros-vm')
await cli.delete('browseros-vm')
const log = await readFile(logPath, 'utf8')
expect(log).toContain('ARGS:stop browseros-vm')
expect(log).toContain('ARGS:delete --force browseros-vm')
})
it('runs shell commands and streams stdout and stderr', async () => {
const sshPath = await fakeSsh({ stdout: 'out\n', stderr: 'err\n' }, logPath)
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
await writeFile(sshConfig, '')
const cli = new LimaCli({ limactlPath: 'unused', limaHome, sshPath })
const lines: string[] = []
await expect(
cli.shell('browseros-vm', ['nerdctl', 'ps'], {
onStdout: (line) => lines.push(`stdout:${line}`),
onStderr: (line) => lines.push(`stderr:${line}`),
}),
).resolves.toBe(0)
expect(lines).toContain('stdout:out')
expect(lines).toContain('stderr:err')
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`ARGS:-F ${sshConfig} lima-browseros-vm 'nerdctl' 'ps'`,
)
})
it('shell-quotes remote commands to preserve argument boundaries', async () => {
const sshPath = await fakeSsh({}, logPath)
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
await writeFile(sshConfig, '')
const cli = new LimaCli({ limactlPath: 'unused', limaHome, sshPath })
await expect(
cli.shell('browseros-vm', ['sh', '-lc', "echo 'boundary ok'"]),
).resolves.toBe(0)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`ARGS:-F ${sshConfig} lima-browseros-vm 'sh' '-lc' 'echo '\\''boundary ok'\\'''`,
)
})
it('ignores shell stderr when no stderr stream handler is provided', async () => {
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
await writeFile(sshConfig, '')
const spawn = spyOn(Bun, 'spawn')
spawn.mockImplementation(
() =>
({
stdout: null,
stderr: null,
exited: Promise.resolve(0),
}) as never,
)
const cli = new LimaCli({ limactlPath: 'limactl', limaHome })
await expect(
cli.shell('browseros-vm', ['true'], {
onStdout: () => {},
}),
).resolves.toBe(0)
expect(spawn).toHaveBeenCalledWith(
['ssh', '-F', sshConfig, 'lima-browseros-vm', "'true'"],
expect.objectContaining({
stdout: 'pipe',
stderr: 'ignore',
}),
)
})
it('throws VmNotReadyError when ssh.config is missing', async () => {
const cli = new LimaCli({ limactlPath: 'limactl', limaHome })
const error = await cli.shell('browseros-vm', ['true']).catch((err) => err)
expect(error).toBeInstanceOf(VmNotReadyError)
})
})

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { renderLimaTemplate } from '../../../src/lib/vm/lima-config'
describe('renderLimaTemplate', () => {
it('injects BrowserOS host mounts into the bundled Lima template', () => {
const yaml = 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: []')
})
it('fails loudly if the template no longer has the expected mount marker', () => {
expect(() =>
renderLimaTemplate('minimumLimaVersion: 2.0.0\n', {
vmStateDir: '/state',
imageCacheDir: '/images',
}),
).toThrow('mounts: [] marker')
})
})

View File

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

View File

@@ -0,0 +1,230 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import {
getLegacyOpenClawDir,
getOpenClawDir,
} from '../../../src/lib/browseros-dir'
import {
detectArch,
getCachedManifestPath,
getContainerdSocketPath,
getImageCacheDir,
getInstalledManifestPath,
getLimaHomeDir,
getVmCacheDir,
getVmStateDir,
hostPathToGuest,
resolveBundledLimactl,
resolveBundledLimaTemplate,
} from '../../../src/lib/vm/paths'
describe('VM paths', () => {
const originalNodeEnv = process.env.NODE_ENV
const originalPath = process.env.PATH
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('uses production VM directories below .browseros', () => {
process.env.NODE_ENV = 'production'
expect(getLimaHomeDir()).toBe(join(homedir(), '.browseros', 'lima'))
expect(getVmStateDir()).toBe(join(homedir(), '.browseros', 'vm'))
expect(getOpenClawDir()).toBe(
join(homedir(), '.browseros', 'vm', 'openclaw'),
)
})
it('uses development VM directories below .browseros-dev', () => {
process.env.NODE_ENV = 'development'
expect(getLimaHomeDir()).toBe(join(homedir(), '.browseros-dev', 'lima'))
expect(getVmStateDir()).toBe(join(homedir(), '.browseros-dev', 'vm'))
expect(getOpenClawDir()).toBe(
join(homedir(), '.browseros-dev', 'vm', 'openclaw'),
)
})
it('keeps the legacy OpenClaw directory addressable for migration', () => {
process.env.NODE_ENV = 'production'
expect(getLegacyOpenClawDir()).toBe(
join(homedir(), PATHS.BROWSEROS_DIR_NAME, PATHS.OPENCLAW_DIR_NAME),
)
})
it('builds cached and installed manifest 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',
)
})
it('translates mounted host paths into guest paths', () => {
const root = '/Users/foo/.browseros'
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', () => {
expect(() =>
hostPathToGuest('/tmp/other', '/Users/foo/.browseros'),
).toThrow('not under any known guest mount')
})
it('detects supported host architectures', () => {
expect(detectArch('arm64')).toBe('arm64')
expect(detectArch('x64')).toBe('x64')
})
it('rejects unsupported host architectures', () => {
expect(() => detectArch('ppc64' as NodeJS.Architecture)).toThrow(
'unsupported host arch',
)
})
it('resolves the bundled limactl executable', async () => {
process.env.NODE_ENV = 'production'
const resourcesDir = await mkdtemp(join(tmpdir(), 'limactl-resources-'))
const limactlPath = join(
resourcesDir,
'bin',
'third_party',
'lima',
'limactl',
)
await mkdir(dirname(limactlPath), { recursive: true })
await writeFile(limactlPath, '#!/bin/sh\n')
try {
expect(resolveBundledLimactl(resourcesDir)).toBe(limactlPath)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('uses PATH limactl in development mode', async () => {
process.env.NODE_ENV = 'development'
const binDir = await createFakeLimactlPath()
try {
expect(resolveBundledLimactl('/tmp/missing-dev-resources')).toBe(
join(binDir, 'limactl'),
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('uses PATH limactl in test mode', async () => {
process.env.NODE_ENV = 'test'
const binDir = await createFakeLimactlPath()
try {
expect(resolveBundledLimactl('/tmp/missing-test-resources')).toBe(
join(binDir, 'limactl'),
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('throws with a brew install hint when host limactl is missing', async () => {
process.env.NODE_ENV = 'development'
const binDir = await mkdtemp(join(tmpdir(), 'missing-host-limactl-'))
process.env.PATH = binDir
try {
expect(() => resolveBundledLimactl('/tmp/missing-dev-resources')).toThrow(
'brew install lima',
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('throws with a build-tools hint when bundled limactl is missing', () => {
process.env.NODE_ENV = 'production'
expect(() => resolveBundledLimactl('/tmp/missing-resources')).toThrow(
'build-tools README',
)
})
it('resolves the bundled Lima template', async () => {
process.env.NODE_ENV = 'production'
const resourcesDir = await mkdtemp(join(tmpdir(), 'lima-template-'))
const templatePath = join(resourcesDir, 'vm', 'browseros-vm.yaml')
await mkdir(dirname(templatePath), { recursive: true })
await writeFile(templatePath, 'mounts: []\n')
try {
expect(resolveBundledLimaTemplate(resourcesDir)).toBe(templatePath)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('resolves the source Lima template from a package workspace in test mode', async () => {
process.env.NODE_ENV = 'test'
const workspaceDir = await mkdtemp(join(tmpdir(), 'lima-source-template-'))
const resourcesDir = join(workspaceDir, 'packages', 'browseros-agent')
const templatePath = join(
workspaceDir,
'packages',
'build-tools',
'template',
'browseros-vm.yaml',
)
await mkdir(resourcesDir, { recursive: true })
await mkdir(dirname(templatePath), { recursive: true })
await writeFile(templatePath, 'mounts: []\n')
try {
expect(resolveBundledLimaTemplate(resourcesDir)).toBe(templatePath)
} finally {
await rm(workspaceDir, { recursive: true, force: true })
}
})
})
async function createFakeLimactlPath(): Promise<string> {
const binDir = await mkdtemp(join(tmpdir(), 'host-limactl-'))
const limactlPath = join(binDir, 'limactl')
await writeFile(limactlPath, '#!/bin/sh\n')
await chmod(limactlPath, 0o755)
process.env.PATH = binDir
return binDir
}

View File

@@ -0,0 +1,490 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { logger } from '../../../src/lib/logger'
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 { 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
let logPath: string
let templatePath: string
beforeEach(async () => {
root = await mkdtemp('/tmp/vmrt-')
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')
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('provisions a fresh VM, waits for rootless nerdctl, and installs the manifest', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
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('returns fast when the VM is already running and manifests match', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
create: { stderr: 'should not create', exit: 9 },
start: { stderr: 'should not start', exit: 9 },
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain('ARGS:list --format json')
expect(log).not.toContain('ARGS:create')
expect(log).not.toContain('ARGS:start')
})
it('starts an existing stopped VM without recreating it', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Stopped', dir: limaHome },
]),
},
start: {},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
expect(log).not.toContain('ARGS:create')
})
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
stop: {},
delete: {},
create: {},
start: {},
},
logPath,
)
const sshPath = await fakeRootfulThenReadySsh(root, logPath)
await writeSshConfig(limaHome)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
expect(log).toContain(
`lima-${VM_NAME} 'sh' '-lc' 'cat /etc/browseros-vm-version 2>/dev/null || true'`,
)
expect(log).toContain(`ARGS:stop ${VM_NAME}`)
expect(log).toContain(`ARGS:delete --force ${VM_NAME}`)
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
})
it('treats stopVm as idempotent when the VM is already stopped', async () => {
const limactlPath = await fakeLimactl(
{ stop: { stderr: 'instance is not running', exit: 1 } },
logPath,
)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
it('requires a bundled Lima template for fresh VM provisioning', async () => {
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.ensureReady()).rejects.toThrow('Lima template path')
})
it('throws VmNotReadyError when rootless nerdctl never becomes ready', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareFailingSsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
readinessTimeoutMs: 10,
readinessPollMs: 1,
})
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
})
it('exposes a reset stub with a follow-up-plan message', async () => {
const limactlPath = await fakeLimactl({}, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.reset('bad disk')).rejects.toThrow(
'VmRuntime.reset is not implemented yet',
)
})
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: {} },
logPath,
)
const sshPath = await prepareFailingSsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
readinessTimeoutMs: 10,
readinessPollMs: 1,
})
let resetCalled = false
runtime.reset = async () => {
resetCalled = true
throw new Error('reset called')
}
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
expect(resetCalled).toBe(false)
})
it('delegates runCommand through ssh', async () => {
const sshPath = await fakeSsh({}, logPath)
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(sshConfig, '')
const runtime = new VmRuntime({
limactlPath: 'unused',
limaHome,
sshPath,
browserosRoot: root,
})
await expect(runtime.runCommand(['nerdctl', 'version'])).resolves.toBe(0)
const log = await readFile(logPath, 'utf8')
expect(log).toContain(
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'version'`,
)
})
it('resolves and caches the VM default gateway through ssh', async () => {
const sshPath = await fakeSsh(
{
stdout:
'default via 192.168.5.2 dev eth0 proto dhcp src 192.168.5.15 metric 100\n',
},
logPath,
)
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(sshConfig, '')
const runtime = new VmRuntime({
limactlPath: 'unused',
limaHome,
sshPath,
browserosRoot: root,
})
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
const log = await readFile(logPath, 'utf8')
expect(log.match(/'ip' '-4' 'route' 'show' 'default'/g)).toHaveLength(1)
})
})
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,
): Promise<string> {
await writeSshConfig(limaHome)
return fakeSsh({}, logPath)
}
async function prepareFailingSsh(
limaHome: string,
logPath: string,
): Promise<string> {
await writeSshConfig(limaHome)
return fakeSsh(
{
stderr:
'rootless containerd not running? stat /run/user/501/containerd-rootless: no such file or directory',
exit: 1,
},
logPath,
)
}
async function writeSshConfig(limaHome: string): Promise<void> {
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(join(limaHome, VM_NAME, 'ssh.config'), '')
}
async function fakeRootfulThenReadySsh(
root: string,
logPath: string,
): Promise<string> {
const path = join(root, 'ssh-rootful-then-ready')
const counterPath = join(root, 'ssh-rootful-then-ready.count')
const body = `#!/usr/bin/env bash
set -u
echo "ARGS:$*" >> "${logPath}"
count="$(cat "${counterPath}" 2>/dev/null || echo 0)"
next=$((count + 1))
printf '%s' "$next" > "${counterPath}"
case "$count" in
0)
echo "rootless containerd not running" >&2
exit 1
;;
1)
printf 'runtime:containerd\\n'
exit 0
;;
*)
exit 0
;;
esac
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}

View File

@@ -36,9 +36,6 @@ describe('Application.start', () => {
const openclawService = await import(
'../src/api/services/openclaw/openclaw-service'
)
const podmanRuntime = await import(
'../src/api/services/openclaw/podman-runtime'
)
const migrateModule = await import('../src/skills/migrate')
const remoteSyncModule = await import('../src/skills/remote-sync')
@@ -91,7 +88,12 @@ describe('Application.start', () => {
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
spyOn(podmanRuntime, 'configurePodmanRuntime').mockImplementation(() => {})
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
() =>
({
tryAutoStart: async () => {},
}) as never,
)
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
() =>
({

View File

@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { buildTestCommand, withTestEnv } from './__helpers__/run-test-group'
describe('withTestEnv', () => {
it('defaults NODE_ENV to test when absent', () => {
expect(withTestEnv({ PATH: '/usr/bin' }).NODE_ENV).toBe('test')
})
it('preserves an explicit NODE_ENV', () => {
expect(withTestEnv({ NODE_ENV: 'production' }).NODE_ENV).toBe('production')
})
})
describe('buildTestCommand', () => {
it('preloads the test env bootstrap before running targets', () => {
expect(buildTestCommand(['./tests/api'])).toEqual([
process.execPath,
'--env-file=.env.development',
'test',
'--preload=./tests/__helpers__/test-env.ts',
'./tests/api',
])
})
})

View File

@@ -232,6 +232,17 @@
"zod": "^3.x",
},
},
"packages/build-tools": {
"name": "@browseros/build-tools",
"version": "0.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"@browseros/shared": "workspace:*",
},
"devDependencies": {
"@types/node": "^24.3.3",
},
},
"packages/cdp-protocol": {
"name": "@browseros/cdp-protocol",
"version": "0.0.1",
@@ -463,6 +474,8 @@
"@browseros/agent": ["@browseros/agent@workspace:apps/agent"],
"@browseros/build-tools": ["@browseros/build-tools@workspace:packages/build-tools"],
"@browseros/cdp-protocol": ["@browseros/cdp-protocol@workspace:packages/cdp-protocol"],
"@browseros/eval": ["@browseros/eval@workspace:apps/eval"],
@@ -4489,6 +4502,8 @@
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@browseros/build-tools/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
"@browseros/eval/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
"@browseros/eval/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
@@ -5213,6 +5228,100 @@
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.11", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
@@ -5597,6 +5706,70 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums/@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
@@ -5705,6 +5878,22 @@
"wxt/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
@@ -5733,6 +5922,8 @@
"publish-browser-extension/listr2/cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],

View File

@@ -13,6 +13,7 @@
"dev:watch": "./tools/dev/run.sh watch",
"dev:watch:new": "./tools/dev/run.sh watch --new",
"dev:manual": "./tools/dev/run.sh watch --manual",
"dev:setup": "./tools/dev/setup.sh",
"test:env": "./tools/dev/run.sh test",
"test:cleanup": "./tools/dev/run.sh cleanup",
"start:server": "bun run --filter @browseros/server --elide-lines=0 start",

View File

@@ -0,0 +1,11 @@
# R2 / Cloudflare object storage - required by upload and publish jobs
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=browseros
# Public CDN base - used by cache:sync to GET manifest and artifacts
R2_PUBLIC_BASE_URL=https://cdn.browseros.com
# Dev mode routes cache to ~/.browseros-dev/cache/; unset for ~/.browseros/cache/
NODE_ENV=development

View File

@@ -0,0 +1,78 @@
# @browseros/build-tools
Builds agent image tarballs, publishes release artifacts to R2, and hydrates the local dev cache for agent tarballs.
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.
## Setup
```bash
cp packages/build-tools/.env.sample packages/build-tools/.env
bun install
```
## Dev loop against the Lima template
Requires `limactl` on PATH. It is bundled with the server; for bare-worktree use, install Lima with Homebrew.
```bash
brew install lima
```
```bash
limactl start \
--name browseros-vm-dev \
packages/browseros-agent/packages/build-tools/template/browseros-vm.yaml
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
```
## Build an agent tarball
The BrowserOS VM uses containerd + nerdctl. This host-side tarball builder still requires `podman` to pull and save OCI archives for release packaging.
```bash
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
```
## Smoke test an agent tarball
```bash
bun run --filter @browseros/build-tools smoke:tarball -- --agent openclaw --arch arm64 --tarball ./dist/images/openclaw-2026.4.12-arm64.tar.gz
```
## Emit a manifest
```bash
bun run --filter @browseros/build-tools emit-manifest -- --dist-dir packages/build-tools/dist
```
Publish workflows can update one agent slice at a time. Sliced publishing requires an existing R2 `vm/manifest.json` baseline; bootstrap first releases with `--slice full`.
```bash
bun run --filter @browseros/build-tools emit-manifest -- --slice agents:openclaw --merge-from https://cdn.browseros.com/vm/manifest.json
```
## Sync the dev cache
```bash
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
```
Pulls the published manifest and tarballs from R2 (`cdn.browseros.com/vm/`). Development cache files land under `~/.browseros-dev/cache/vm/images/`. Production-mode cache files land under `~/.browseros/cache/vm/images/`.
## Seed the dev cache from a local build
```bash
NODE_ENV=development bun run --filter @browseros/build-tools dev:seed:tarball
```
`dev:seed:tarball` hardcodes `arm64` (all devs are on Apple Silicon), builds the configured agent tarball, skips R2 entirely, and writes an arm64-only manifest + tarball into `~/.browseros-dev/cache/vm/`. It refuses to run unless `NODE_ENV=development`. Use this when you want to test the server against the latest configured agent tarball without publishing.

View File

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

View File

@@ -0,0 +1,25 @@
{
"name": "@browseros/build-tools",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "BrowserOS release artifact producer and dev cache sync",
"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"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"@browseros/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.3.3"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'
function required(name: string): string {
const value = process.env[name]?.trim()
if (!value) throw new Error(`missing env var: ${name}`)
return value
}
export function createR2Client(): S3Client {
return new S3Client({
region: 'auto',
endpoint: `https://${required('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: required('R2_ACCESS_KEY_ID'),
secretAccessKey: required('R2_SECRET_ACCESS_KEY'),
},
})
}
export function getBucket(): string {
return required('R2_BUCKET')
}
export function getCdnBase(): string {
return process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
}
export async function putFile(
client: S3Client,
bucket: string,
key: string,
filePath: string,
contentType: string,
): Promise<void> {
const { size } = await stat(filePath)
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: createReadStream(filePath),
ContentLength: size,
ContentType: contentType,
}),
)
}
export async function putBody(
client: S3Client,
bucket: string,
key: string,
body: string,
contentType: string,
): Promise<void> {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentLength: Buffer.byteLength(body),
ContentType: contentType,
}),
)
}
export async function getBody(
client: S3Client,
bucket: string,
key: string,
): Promise<string | null> {
try {
const response = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
)
const body = response.Body as
| { transformToByteArray(): Promise<Uint8Array> }
| undefined
if (!body) throw new Error(`missing response body for R2 key: ${key}`)
const bytes = await body.transformToByteArray()
return new TextDecoder().decode(bytes)
} catch (error) {
const cause = error as {
name?: string
$metadata?: { httpStatusCode?: number }
}
if (cause.name === 'NoSuchKey' || cause.$metadata?.httpStatusCode === 404) {
return null
}
throw error
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
# BrowserOS VM -- consumed directly by limactl, no build step.
minimumLimaVersion: 2.0.0
vmType: vz
cpus: 2
memory: 2GiB
disk: 30GiB
images:
- location: "https://cloud-images.ubuntu.com/minimal/releases/noble/release-20260415/ubuntu-24.04-minimal-cloudimg-arm64.img"
arch: aarch64
digest: "sha256:0cc0a529a52109b52bf697a0d90bdd0f252e7ad91b3a67f70879d56d1f64e240"
- location: "https://cloud-images.ubuntu.com/minimal/releases/noble/release-20260415/ubuntu-24.04-minimal-cloudimg-amd64.img"
arch: x86_64
digest: "sha256:7cbfa215a3774c46c6dc29b457f4e9667acda85fc04c7971e1e592b5056e7573"
- location: https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-24.04-minimal-cloudimg-arm64.img
arch: aarch64
- location: https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-24.04-minimal-cloudimg-amd64.img
arch: x86_64
mounts: []
containerd:
system: false
user: true
provision:
- mode: system
script: |
#!/bin/bash
set -eux -o pipefail
if [ -e /etc/browseros-vm-provisioned ]; then exit 0; fi
systemctl disable --now unattended-upgrades.service apt-daily.timer apt-daily-upgrade.timer || true
systemctl disable --now snapd.service snapd.socket snapd.seeded.service || true
printf 'runtime:containerd-rootless\nprovisioned:%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /etc/browseros-vm-version
touch /etc/browseros-vm-provisioned
probes:
- script: |
#!/bin/bash
set -eux -o pipefail
if ! timeout 60s bash -c 'until nerdctl info >/dev/null 2>&1; do sleep 2; done'; then
echo >&2 "nerdctl is not ready after 60s"
exit 1
fi
hint: See /var/log/cloud-init-output.log inside the guest
portForwards:
- guestSocket: "/run/user/{{.UID}}/containerd-rootless/containerd.sock"
hostSocket: "{{.Dir}}/sock/containerd.sock"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'bun:test'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const templatePath = path.resolve(
import.meta.dir,
'../template/browseros-vm.yaml',
)
describe('browseros-vm Lima template', () => {
it('uses Ubuntu minimal with Lima-managed rootless containerd and nerdctl', async () => {
const yaml = await readFile(templatePath, 'utf8')
expect(yaml).toContain('ubuntu-24.04-minimal-cloudimg-arm64.img')
expect(yaml).toContain('ubuntu-24.04-minimal-cloudimg-amd64.img')
expect(yaml).toContain('containerd:')
expect(yaml).toContain('system: false')
expect(yaml).toContain('user: true')
expect(yaml).toContain('until nerdctl info >/dev/null 2>&1')
expect(yaml).toContain('runtime:containerd-rootless')
expect(yaml).toContain(
'guestSocket: "/run/user/{{.UID}}/containerd-rootless/containerd.sock"',
)
expect(yaml).toContain('hostSocket: "{{.Dir}}/sock/containerd.sock"')
expect(yaml).not.toContain('sudo nerdctl')
expect(yaml).not.toContain('/var/run/containerd/containerd.sock')
expect(yaml).not.toContain('podman')
expect(yaml).not.toContain('debian')
})
})

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"resolveJsonModule": true
},
"include": ["scripts/**/*", "tests/**/*", "package.json", "bundle.json"]
}

View File

@@ -9,6 +9,8 @@
export const PATHS = {
DEFAULT_EXECUTION_DIR: process.cwd(),
BROWSEROS_DIR_NAME: '.browseros',
DEV_BROWSEROS_DIR_NAME: '.browseros-dev',
CACHE_DIR_NAME: 'cache',
MEMORY_DIR_NAME: 'memory',
SESSIONS_DIR_NAME: 'sessions',
TOOL_OUTPUT_DIR_NAME: 'tool-output',

View File

@@ -1,134 +1,36 @@
{
"resources": [
{
"name": "Podman CLI - macOS ARM64",
"name": "Lima limactl - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/podman/podman-darwin-arm64"
"key": "third_party/lima/limactl-darwin-arm64"
},
"destination": "resources/bin/third_party/podman/podman",
"destination": "resources/bin/third_party/lima/limactl",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman gvproxy - macOS ARM64",
"name": "Lima limactl - macOS x64",
"source": {
"type": "r2",
"key": "third_party/podman/gvproxy-darwin-arm64"
"key": "third_party/lima/limactl-darwin-x64"
},
"destination": "resources/bin/third_party/podman/gvproxy",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman vfkit - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/podman/vfkit-darwin-arm64"
},
"destination": "resources/bin/third_party/podman/vfkit",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman krunkit - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/podman/krunkit-darwin-arm64"
},
"destination": "resources/bin/third_party/podman/krunkit",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman mac helper - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/podman/podman-mac-helper-darwin-arm64"
},
"destination": "resources/bin/third_party/podman/podman-mac-helper",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman CLI - macOS x64",
"notes": "krunkit is intentionally omitted on macOS x64 because the official amd64 Podman installer ships an arm64-only krunkit helper",
"source": {
"type": "r2",
"key": "third_party/podman/podman-darwin-x64"
},
"destination": "resources/bin/third_party/podman/podman",
"destination": "resources/bin/third_party/lima/limactl",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Podman gvproxy - macOS x64",
"name": "BrowserOS VM Lima template",
"source": {
"type": "r2",
"key": "third_party/podman/gvproxy-darwin-x64"
"type": "local",
"path": "packages/build-tools/template/browseros-vm.yaml"
},
"destination": "resources/bin/third_party/podman/gvproxy",
"destination": "resources/vm/browseros-vm.yaml",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Podman vfkit - macOS x64",
"source": {
"type": "r2",
"key": "third_party/podman/vfkit-darwin-x64"
},
"destination": "resources/bin/third_party/podman/vfkit",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Podman mac helper - macOS x64",
"source": {
"type": "r2",
"key": "third_party/podman/podman-mac-helper-darwin-x64"
},
"destination": "resources/bin/third_party/podman/podman-mac-helper",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Podman CLI - Windows x64",
"source": {
"type": "r2",
"key": "third_party/podman/podman-windows-x64.exe"
},
"destination": "resources/bin/third_party/podman/podman.exe",
"os": ["windows"],
"arch": ["x64"]
},
{
"name": "Podman gvproxy - Windows x64",
"source": {
"type": "r2",
"key": "third_party/podman/gvproxy-windows-x64.exe"
},
"destination": "resources/bin/third_party/podman/gvproxy.exe",
"os": ["windows"],
"arch": ["x64"]
},
{
"name": "Podman win-sshproxy - Windows x64",
"source": {
"type": "r2",
"key": "third_party/podman/win-sshproxy-windows-x64.exe"
},
"destination": "resources/bin/third_party/podman/win-sshproxy.exe",
"os": ["windows"],
"arch": ["x64"]
"arch": ["arm64", "x64"]
}
]
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sync"
@@ -39,6 +40,12 @@ 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
}
defaultPorts := proc.DefaultLocalPorts()
p := defaultPorts
@@ -103,16 +110,7 @@ func runWatch(cmd *cobra.Command, args []string) error {
var wg sync.WaitGroup
var procs []*proc.ManagedProc
// Run agent codegen if generated files don't exist
agentDir := filepath.Join(root, "apps/agent")
if _, err := os.Stat(filepath.Join(agentDir, "generated/graphql")); os.IsNotExist(err) {
proc.LogMsg(proc.TagBuild, "Running agent codegen...")
if err := proc.RunBlocking(ctx, agentDir, proc.TagBuild,
"bun", "--env-file=.env.development", "graphql-codegen", "--config", "codegen.ts"); err != nil {
return fmt.Errorf("agent codegen failed: %w", err)
}
proc.LogMsg(proc.TagBuild, "agent codegen done")
}
if watchManual {
proc.LogMsg(proc.TagBuild, "Building agent (dev)...")
@@ -189,3 +187,28 @@ func runWatch(cmd *cobra.Command, args []string) error {
proc.LogMsg(proc.TagInfo, "All processes stopped")
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",
proc.ErrorColor.Sprint("Lima is not installed."),
proc.DimColor.Sprintf("Install with %s.", proc.BoldColor.Sprint("brew install lima")),
)
}
return nil
}

View File

@@ -0,0 +1,59 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"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())
err := ensureLimactlPresent()
if err == nil {
t.Fatal("expected missing Lima error")
}
msg := err.Error()
if !strings.Contains(msg, "Lima is not installed.") {
t.Fatalf("expected missing Lima message, got %q", msg)
}
if !strings.Contains(msg, "brew install lima") {
t.Fatalf("expected brew install hint, got %q", msg)
}
}
func TestEnsureLimactlPresentFindsPathBinary(t *testing.T) {
binDir := t.TempDir()
limactlPath := filepath.Join(binDir, "limactl")
if err := os.WriteFile(limactlPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir)
if err := ensureLimactlPresent(); err != nil {
t.Fatalf("expected limactl to resolve, got %v", err)
}
}

View File

@@ -0,0 +1,19 @@
#!/bin/bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$DIR/../.." && pwd)"
cd "$ROOT"
echo "[setup] Installing dependencies..."
bun install --frozen-lockfile
echo "[setup] Generating agent code..."
bun run codegen:agent
echo "[setup] Syncing VM cache..."
printf '\033[31m[setup] First VM cache sync can take about 5 minutes.\033[0m\n'
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
echo "[setup] Ready"

View File

@@ -44,6 +44,10 @@ app.add_typer(release.app, name="release", help="Release automation")
from .cli import ota
app.add_typer(ota.app, name="ota", help="OTA update automation")
# Third-party resource uploads (Lima, future VM disk + agent tarballs)
from .cli import storage
app.add_typer(storage.app, name="upload", help="Upload third-party resources to R2")
if __name__ == "__main__":
app()

269
packages/browseros/build/cli/storage.py generated Normal file
View File

@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""Storage CLI - Push third-party resources to R2 for build:server ingestion."""
import hashlib
import json
import os
import tarfile
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
import typer
from ..common.env import EnvConfig
from ..common.utils import log_error, log_info, log_success, log_warning
from ..modules.storage.r2 import (
BOTO3_AVAILABLE,
get_r2_client,
upload_file_to_r2,
)
LIMA_RELEASE_BASE = "https://github.com/lima-vm/lima/releases/download"
LIMA_R2_PREFIX = "third_party/lima"
LIMA_MANIFEST_KEY = f"{LIMA_R2_PREFIX}/manifest.json"
LIMA_HTTP_TIMEOUT_S = 60
@dataclass(frozen=True)
class LimaArch:
"""Arch-pair: the suffix Lima uses upstream and the suffix we use in R2."""
internal: str # "arm64" | "x64" — how our R2 keys name it
upstream: str # "Darwin-arm64" | "Darwin-x86_64" — Lima's tarball suffix
LIMA_ARCHES: Tuple[LimaArch, ...] = (
LimaArch(internal="arm64", upstream="Darwin-arm64"),
LimaArch(internal="x64", upstream="Darwin-x86_64"),
)
app = typer.Typer(
help="Upload third-party resources to Cloudflare R2",
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
)
@app.command("lima")
def upload_lima(
version: str = typer.Option(
...,
"--version",
"-v",
help="Lima release tag, e.g. v1.2.3",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Download + verify only; skip R2 uploads.",
),
) -> None:
"""Download limactl from a Lima GitHub release and push to R2."""
if not BOTO3_AVAILABLE:
log_error("boto3 not installed — run: pip install boto3")
raise typer.Exit(1)
env = EnvConfig()
if not env.has_r2_config():
log_error(
"R2 configuration missing. Required: "
"R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY"
)
raise typer.Exit(1)
tag = _normalize_version_tag(version)
client = None if dry_run else get_r2_client(env)
if not dry_run and client is None:
log_error("Failed to create R2 client")
raise typer.Exit(1)
with tempfile.TemporaryDirectory(prefix="lima-upload-") as tmp:
tmp_dir = Path(tmp)
checksums = _fetch_checksums(tag, tmp_dir)
uploaded_keys: List[str] = []
object_shas: Dict[str, str] = {}
tarball_shas: Dict[str, str] = {}
try:
for arch in LIMA_ARCHES:
tarball_sha, object_sha, r2_key = _process_arch(
tag, arch, tmp_dir, checksums, client, env, dry_run
)
tarball_shas[arch.internal] = tarball_sha
object_shas[arch.internal] = object_sha
if not dry_run:
uploaded_keys.append(r2_key)
manifest = _build_manifest(tag, tarball_shas, object_shas)
_upload_manifest(client, env, manifest, tmp_dir, dry_run)
# Any failure mid-loop (download, sha verify, extract, upload) must
# roll back prior arch uploads so R2 never holds a mixed-version pair.
except Exception as exc:
if not dry_run and uploaded_keys:
log_warning(f"Upload failed — rolling back {len(uploaded_keys)} object(s)")
_rollback(client, env.r2_bucket, uploaded_keys)
log_error(f"Lima upload aborted: {exc}")
raise typer.Exit(1)
log_success(f"Lima {tag} uploaded for {[a.internal for a in LIMA_ARCHES]}")
def _normalize_version_tag(version: str) -> str:
return version if version.startswith("v") else f"v{version}"
def _fetch_checksums(tag: str, tmp_dir: Path) -> Dict[str, str]:
url = f"{LIMA_RELEASE_BASE}/{tag}/SHA256SUMS"
dest = tmp_dir / "SHA256SUMS"
log_info(f"Fetching {url}")
_download(url, dest)
return _parse_checksums(dest.read_text(encoding="utf-8"))
def _parse_checksums(contents: str) -> Dict[str, str]:
"""Parse lines like '<sha256> lima-1.2.3-Darwin-arm64.tar.gz'."""
entries: Dict[str, str] = {}
for raw_line in contents.splitlines():
line = raw_line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
raise RuntimeError(f"Malformed SHA256SUMS line: {raw_line!r}")
sha, name = parts[0].lower(), parts[1].lstrip("*").strip()
if len(sha) != 64 or not all(c in "0123456789abcdef" for c in sha):
raise RuntimeError(f"Invalid sha256 in SHA256SUMS: {raw_line!r}")
entries[name] = sha
return entries
def _process_arch(
tag: str,
arch: LimaArch,
tmp_dir: Path,
checksums: Dict[str, str],
client: Any,
env: EnvConfig,
dry_run: bool,
) -> Tuple[str, str, str]:
version_num = tag.lstrip("v")
tarball_name = f"lima-{version_num}-{arch.upstream}.tar.gz"
expected_sha = checksums.get(tarball_name)
if not expected_sha:
raise RuntimeError(
f"{tarball_name} missing from SHA256SUMS (is the version tag correct?)"
)
tarball_path = tmp_dir / tarball_name
url = f"{LIMA_RELEASE_BASE}/{tag}/{tarball_name}"
log_info(f"Downloading {url}")
_download(url, tarball_path)
actual_sha = _sha256_file(tarball_path)
if actual_sha != expected_sha:
raise RuntimeError(
f"sha256 mismatch for {tarball_name}: "
f"expected {expected_sha}, got {actual_sha}"
)
limactl_path = tmp_dir / f"limactl-darwin-{arch.internal}"
_extract_limactl(tarball_path, limactl_path)
object_sha = _sha256_file(limactl_path)
r2_key = f"{LIMA_R2_PREFIX}/limactl-darwin-{arch.internal}"
if dry_run:
log_info(f"[dry-run] skipped upload of {r2_key}")
else:
if not upload_file_to_r2(client, limactl_path, r2_key, env.r2_bucket):
raise RuntimeError(f"Failed to upload {r2_key}")
return actual_sha, object_sha, r2_key
def _extract_limactl(tarball_path: Path, dest: Path) -> None:
"""Extract the single `bin/limactl` entry to dest."""
with tarfile.open(tarball_path, "r:gz") as tar:
member = _find_limactl_member(tar)
extracted = tar.extractfile(member)
if extracted is None:
raise RuntimeError(f"{member.name} is not a regular file")
with extracted as src, open(dest, "wb") as out:
while chunk := src.read(1024 * 1024):
out.write(chunk)
dest.chmod(0o755)
def _find_limactl_member(tar: tarfile.TarFile) -> tarfile.TarInfo:
for member in tar.getmembers():
if not member.isfile():
continue
parts = Path(member.name).parts
if len(parts) >= 2 and parts[-2:] == ("bin", "limactl"):
return member
raise RuntimeError("bin/limactl not found in Lima tarball")
def _build_manifest(
tag: str,
tarball_shas: Dict[str, str],
object_shas: Dict[str, str],
) -> Dict[str, Any]:
return {
"lima_version": tag,
"tarball_shas_upstream": tarball_shas,
"r2_object_shas": object_shas,
"uploaded_at": datetime.now(timezone.utc).isoformat(),
# Prefer CI context so we don't leak an individual's OS login when
# running locally. manifest.json is surfaced via the public CDN.
"uploaded_by": os.environ.get("GITHUB_ACTOR") or "local",
}
def _upload_manifest(
client: Any,
env: EnvConfig,
manifest: Dict[str, Any],
tmp_dir: Path,
dry_run: bool,
) -> None:
manifest_path = tmp_dir / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
)
if dry_run:
log_info(f"[dry-run] manifest would be: {manifest}")
return
if not upload_file_to_r2(client, manifest_path, LIMA_MANIFEST_KEY, env.r2_bucket):
raise RuntimeError(f"Failed to upload {LIMA_MANIFEST_KEY}")
def _rollback(client: Any, bucket: str, keys: List[str]) -> None:
for key in keys:
try:
client.delete_object(Bucket=bucket, Key=key)
log_info(f"Rolled back {key}")
except Exception as exc:
log_warning(f"Rollback failed for {key}: {exc}")
def _download(url: str, dest: Path, *, timeout: Optional[int] = None) -> None:
response = requests.get(url, stream=True, timeout=timeout or LIMA_HTTP_TIMEOUT_S)
response.raise_for_status()
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as out:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
out.write(chunk)
def _sha256_file(path: Path) -> str:
sha = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
sha.update(chunk)
return sha.hexdigest()

View File

@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Tests for the Lima R2 uploader CLI."""
import hashlib
import io
import tarfile
import tempfile
import unittest
from pathlib import Path
from typing import Any, List, Tuple
from unittest import mock
from build.cli import storage
def _build_lima_tarball(version: str, payload: bytes) -> bytes:
"""Return a gzipped tar containing `lima-<v>/bin/limactl` with `payload`."""
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
info = tarfile.TarInfo(name=f"lima-{version}/bin/limactl")
info.size = len(payload)
info.mode = 0o755
tar.addfile(info, io.BytesIO(payload))
return buffer.getvalue()
class ParseChecksumsTest(unittest.TestCase):
def test_parses_two_column_lines(self) -> None:
contents = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa lima-1.2.3-Darwin-arm64.tar.gz\n"
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb *lima-1.2.3-Darwin-x86_64.tar.gz\n"
)
entries = storage._parse_checksums(contents)
self.assertEqual(
entries["lima-1.2.3-Darwin-arm64.tar.gz"],
"a" * 64,
)
self.assertEqual(
entries["lima-1.2.3-Darwin-x86_64.tar.gz"],
"b" * 64,
)
def test_ignores_blank_lines(self) -> None:
contents = "\n\n" + "c" * 64 + " lima-1.0.0-Darwin-arm64.tar.gz\n\n"
entries = storage._parse_checksums(contents)
self.assertEqual(list(entries), ["lima-1.0.0-Darwin-arm64.tar.gz"])
def test_rejects_malformed_lines(self) -> None:
with self.assertRaisesRegex(RuntimeError, "Malformed"):
storage._parse_checksums("just-one-token\n")
def test_rejects_non_sha256(self) -> None:
with self.assertRaisesRegex(RuntimeError, "Invalid sha256"):
storage._parse_checksums("xyz foo.tar.gz\n")
class NormalizeVersionTagTest(unittest.TestCase):
def test_keeps_existing_v_prefix(self) -> None:
self.assertEqual(storage._normalize_version_tag("v1.2.3"), "v1.2.3")
def test_adds_v_prefix_when_missing(self) -> None:
self.assertEqual(storage._normalize_version_tag("1.2.3"), "v1.2.3")
class ExtractLimactlTest(unittest.TestCase):
def test_extracts_limactl_binary(self) -> None:
payload = b"limactl-bytes-" + b"x" * 100
tarball = _build_lima_tarball("1.2.3", payload)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
tarball_path = tmp_path / "lima.tar.gz"
tarball_path.write_bytes(tarball)
dest = tmp_path / "limactl"
storage._extract_limactl(tarball_path, dest)
self.assertEqual(dest.read_bytes(), payload)
self.assertTrue(dest.stat().st_mode & 0o100, "should be executable")
def test_raises_when_limactl_missing(self) -> None:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
info = tarfile.TarInfo(name="lima-1.2.3/README")
info.size = 5
tar.addfile(info, io.BytesIO(b"hello"))
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
tarball_path = tmp_path / "lima.tar.gz"
tarball_path.write_bytes(buffer.getvalue())
with self.assertRaisesRegex(RuntimeError, "bin/limactl not found"):
storage._extract_limactl(tarball_path, tmp_path / "out")
class RollbackTest(unittest.TestCase):
def test_rollback_deletes_all_keys(self) -> None:
deleted: List[Tuple[str, str]] = []
class FakeClient:
def delete_object(self, **kwargs: str) -> None:
deleted.append((kwargs["Bucket"], kwargs["Key"]))
storage._rollback(FakeClient(), "browseros", ["a", "b", "c"])
self.assertEqual(deleted, [("browseros", "a"), ("browseros", "b"), ("browseros", "c")])
def test_rollback_tolerates_delete_failures(self) -> None:
class FakeClient:
def delete_object(self, **kwargs: str) -> None:
raise RuntimeError("boom")
# Should not raise — it logs a warning and moves on.
storage._rollback(FakeClient(), "browseros", ["a"])
class BuildManifestTest(unittest.TestCase):
def test_manifest_shape(self) -> None:
manifest = storage._build_manifest(
"v1.2.3",
{"arm64": "a" * 64, "x64": "b" * 64},
{"arm64": "c" * 64, "x64": "d" * 64},
)
self.assertEqual(manifest["lima_version"], "v1.2.3")
self.assertEqual(manifest["tarball_shas_upstream"]["arm64"], "a" * 64)
self.assertEqual(manifest["r2_object_shas"]["x64"], "d" * 64)
self.assertIn("uploaded_at", manifest)
self.assertIn("uploaded_by", manifest)
class ProcessArchTest(unittest.TestCase):
"""Covers download + sha verify + extract + upload in one pass."""
def setUp(self) -> None:
self.payload = b"limactl-binary-" + b"z" * 200
self.tarball_bytes = _build_lima_tarball("1.2.3", self.payload)
self.expected_tarball_sha = hashlib.sha256(self.tarball_bytes).hexdigest()
self.expected_object_sha = hashlib.sha256(self.payload).hexdigest()
def _fake_download(self, _url: str, dest: Path, **_kwargs: Any) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(self.tarball_bytes)
def test_happy_path_uploads_and_returns_shas(self) -> None:
uploads: List[Tuple[str, str]] = []
def fake_upload(_client: Any, _local_path: Path, r2_key: str, bucket: str) -> bool:
uploads.append((r2_key, bucket))
return True
env = mock.Mock(r2_bucket="browseros")
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
tarball_sha, object_sha, r2_key = storage._process_arch(
tag="v1.2.3",
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
tmp_dir=tmp_path,
checksums={
"lima-1.2.3-Darwin-arm64.tar.gz": self.expected_tarball_sha
},
client=mock.Mock(),
env=env,
dry_run=False,
)
self.assertEqual(tarball_sha, self.expected_tarball_sha)
self.assertEqual(object_sha, self.expected_object_sha)
self.assertEqual(r2_key, "third_party/lima/limactl-darwin-arm64")
self.assertEqual(uploads, [("third_party/lima/limactl-darwin-arm64", "browseros")])
def test_sha_mismatch_aborts_before_upload(self) -> None:
uploads: List[Tuple[str, str]] = []
def fake_upload(_client: Any, _local_path: Path, r2_key: str, bucket: str) -> bool:
uploads.append((r2_key, bucket))
return True
env = mock.Mock(r2_bucket="browseros")
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
with self.assertRaisesRegex(RuntimeError, "sha256 mismatch"):
storage._process_arch(
tag="v1.2.3",
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
tmp_dir=tmp_path,
checksums={"lima-1.2.3-Darwin-arm64.tar.gz": "0" * 64},
client=mock.Mock(),
env=env,
dry_run=False,
)
self.assertEqual(uploads, [])
def test_missing_checksum_entry_aborts(self) -> None:
env = mock.Mock(r2_bucket="browseros")
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with self.assertRaisesRegex(RuntimeError, "missing from SHA256SUMS"):
storage._process_arch(
tag="v1.2.3",
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
tmp_dir=tmp_path,
checksums={},
client=mock.Mock(),
env=env,
dry_run=False,
)
def test_dry_run_skips_upload(self) -> None:
uploads: List[Tuple[str, str]] = []
def fake_upload(*args: Any, **kwargs: Any) -> bool:
uploads.append(("called", ""))
return True
env = mock.Mock(r2_bucket="browseros")
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
_, _, r2_key = storage._process_arch(
tag="v1.2.3",
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
tmp_dir=tmp_path,
checksums={
"lima-1.2.3-Darwin-arm64.tar.gz": self.expected_tarball_sha
},
client=None,
env=env,
dry_run=True,
)
self.assertEqual(uploads, [])
self.assertEqual(r2_key, "third_party/lima/limactl-darwin-arm64")
if __name__ == "__main__":
unittest.main()

View File

@@ -30,26 +30,17 @@ MACOS_SERVER_BINARIES: Dict[str, SignSpec] = {
),
"bun": SignSpec("bun", "runtime", "browseros-executable-entitlements.plist"),
"rg": SignSpec("rg", "runtime"),
"podman": SignSpec("podman", "runtime"),
"gvproxy": SignSpec("gvproxy", "runtime"),
"vfkit": SignSpec("vfkit", "runtime", "podman-vfkit-entitlements.plist"),
"krunkit": SignSpec("krunkit", "runtime", "podman-krunkit-entitlements.plist"),
"podman-mac-helper": SignSpec("podman_mac_helper", "runtime"),
"limactl": SignSpec("limactl", "runtime"),
}
WINDOWS_SERVER_BINARIES: List[str] = [
"browseros_server.exe",
"third_party/bun.exe",
"third_party/rg.exe",
"third_party/podman/podman.exe",
"third_party/podman/gvproxy.exe",
"third_party/podman/win-sshproxy.exe",
]
def macos_sign_spec_for(binary_path: Path) -> Optional[SignSpec]:
"""Look up sign metadata by file stem (e.g., ``podman-mac-helper``)."""
"""Look up sign metadata by file stem (e.g., ``limactl``)."""
return MACOS_SERVER_BINARIES.get(binary_path.stem)

View File

@@ -28,14 +28,17 @@ class MacosServerBinariesTest(unittest.TestCase):
self.assertTrue(plist.exists(), f"{stem}: entitlements {plist} missing")
def test_macos_sign_spec_for_resolves_by_stem(self):
spec = macos_sign_spec_for(Path("/x/podman-mac-helper"))
spec = macos_sign_spec_for(Path("/x/limactl"))
assert spec is not None
self.assertEqual(spec.identifier_suffix, "podman_mac_helper")
self.assertEqual(spec.identifier_suffix, "limactl")
self.assertIsNone(macos_sign_spec_for(Path("/x/not_a_known_binary")))
def test_matches_podman_bundle_layout(self):
required = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
self.assertTrue(required.issubset(MACOS_SERVER_BINARIES.keys()))
def test_matches_lima_bundle_layout(self):
keys = set(MACOS_SERVER_BINARIES.keys())
self.assertIn("limactl", keys)
forbidden = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
leftover = forbidden & keys
self.assertFalse(leftover, f"podman-era entries still present: {leftover}")
class WindowsServerBinariesTest(unittest.TestCase):
@@ -58,6 +61,17 @@ class WindowsServerBinariesTest(unittest.TestCase):
for rel, abs_path in zip(WINDOWS_SERVER_BINARIES, resolved):
self.assertEqual(abs_path, root / rel)
def test_windows_has_no_stale_third_party(self):
forbidden = {
"third_party/podman/podman.exe",
"third_party/podman/gvproxy.exe",
"third_party/podman/win-sshproxy.exe",
"third_party/bun.exe",
"third_party/rg.exe",
}
leftover = forbidden & set(WINDOWS_SERVER_BINARIES)
self.assertFalse(leftover, f"stale entries still present: {leftover}")
if __name__ == "__main__":
unittest.main()

View File

@@ -239,7 +239,7 @@ def create_server_bundle_zip(resources_dir: Path, output_zip: Path) -> bool:
"""Zip an extracted ``resources/`` tree into a Sparkle payload.
Produces entries like ``resources/bin/browseros_server``,
``resources/bin/third_party/podman/podman`` — mirroring what the agent
``resources/bin/third_party/lima/limactl`` — mirroring what the agent
build staged and what the Chromium build bakes into the installed app.
File modes are preserved by ``ZipFile.write`` so executable bits survive.
"""

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hypervisor</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.virtualization</key>
<true/>
</dict>
</plist>