Compare commits

...

57 Commits

Author SHA1 Message Date
Nikhil Sonti
88a84cf901 chore: merge dev into lima integration 2026-04-23 14:06:33 -07:00
Nikhil Sonti
d7810fc293 fix: address review feedback for PR #788 2026-04-23 13:52:56 -07:00
Nikhil Sonti
46dc7f23e1 docs(vm): amend spec for containerd+nerdctl + Ubuntu 24.04 2026-04-23 13:40:46 -07:00
Nikhil Sonti
d68d139911 fix(vm): recreate legacy VM for containerd runtime 2026-04-23 13:40:30 -07:00
Nikhil Sonti
b710c24e43 test(container): migrate tests from podman to nerdctl 2026-04-23 13:37:49 -07:00
Nikhil Sonti
ffae87a779 feat(openclaw): migrate ContainerRuntime to nerdctl 2026-04-23 13:35:28 -07:00
Nikhil Sonti
dfcae6077e refactor(container): replace PodmanShell with nerdctl ContainerCli 2026-04-23 13:32:47 -07:00
Nikhil Sonti
ef50e6bf9e fix(vm): preserve ssh remote argument boundaries 2026-04-23 13:30:13 -07:00
Nikhil Sonti
b0e95d74ed feat(vm): switch to Ubuntu 24.04 minimal + containerd default 2026-04-23 13:25:52 -07:00
Nikhil Sonti
0dbdf0e4b6 perf(vm): use ssh ControlMaster path for in-VM commands
Swap limactl shell for ssh -F <lima>/<vm>/ssh.config on the per-command
hot path. Lima's generated ssh.config has ControlMaster auto + ControlPersist,
so every call after the first reuses a multiplexed connection (~20ms vs
~500ms for limactl shell's startup). Control-plane calls (list/create/
start/stop/delete) still use limactl.
2026-04-23 13:24:09 -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 Sonti
9d232ecfe4 feat(vm): structured debug logging for lima lifecycle
Add logger debug/info events around every limactl spawn and every
VmRuntime state transition so a stuck 'limactl start' is diagnosable
from the server log:

- LimaCli.run now drains stdout/stderr via a chunked decoder, emits
  vm.lima.spawn / vm.lima.stderr_chunk / vm.lima.exit with pid, args,
  duration, exit code, and buffer sizes. Earlier blob read meant stderr
  was invisible until the subprocess exited.
- VmRuntime.ensureReady logs start, branch taken (provision-fresh vs
  start-existing vs running-upgrade-warn vs running-same), manifest
  comparison, and total duration.
- provisionFresh logs yaml_write, create.start/ok, start.begin/ok.
- waitForSocket logs start, every 10th poll, and ok/timeout.
- Telemetry event catalog extended with the new keys.
2026-04-23 11:17:09 -07:00
Nikhil Sonti
ac7f98b54b fix(vm): size disk to 30GiB and force overlay storage
Default vfs storage driver copies every layer into every child layer, so
the 2.2 GB OpenClaw image explodes to 25+ GB on disk and `podman load`
trips ENOSPC on the previous 10 GiB VM disk. Switch to
overlay + fuse-overlayfs (already installed during provisioning) and bump
the disk to 30 GiB so the bundled image plus one future agent fits
comfortably.
2026-04-23 10:54:31 -07:00
Nikhil Sonti
f8ed761d66 fix(test): force server NODE_ENV inside bun test 2026-04-23 10:34:44 -07:00
Nikhil Sonti
9fae0e419e fix(test): force NODE_ENV for spawned server test groups 2026-04-23 10:34:44 -07:00
Nikhil Sonti
06a88b23c5 fix(vm): ignore unhandled limactl shell stderr 2026-04-23 10:34:43 -07:00
Nikhil Sonti
915c7c0e33 fix(openclaw): keep Linux test startup disabled 2026-04-23 10:34:43 -07:00
Nikhil Sonti
8bd1061d71 fix(vm): preserve manifest version tracking 2026-04-23 10:34:43 -07:00
Nikhil Sonti
fcc7c45c79 fix(openclaw): ensure VM before gateway restart 2026-04-23 10:34:43 -07:00
Nikhil Sonti
7dda000d9c fix(vm): use host Lima assets in test mode 2026-04-23 10:34:43 -07:00
Nikhil Sonti
31d463c441 test(vm): add live Lima smoke coverage 2026-04-23 10:34:43 -07:00
Nikhil Sonti
5a26c1c565 fix(vm): use BrowserOS Lima home for shell transport 2026-04-23 10:34:43 -07:00
Nikhil Sonti
2704d40f4e feat(openclaw): cut over container runtime to BrowserOS VM 2026-04-23 10:34:42 -07:00
Nikhil Sonti
a4ef04ae15 feat(podman): ImageLoader with cache-tarball fallback 2026-04-23 10:34:42 -07:00
Nikhil Sonti
fe2c0da92f feat(podman): PodmanShell transport — limactl + native modes 2026-04-23 10:34:42 -07:00
Nikhil Sonti
b70c2c2e88 feat(vm): stub upgrade + reset; log version-mismatch warning 2026-04-23 10:34:42 -07:00
Nikhil Sonti
c637245b7a feat(vm): VmRuntime core lifecycle — decompress/create/start/stop 2026-04-23 10:34:42 -07:00
Nikhil Sonti
6acdbd5a51 feat(vm): lima.yaml generator + typed limactl wrapper 2026-04-23 10:34:42 -07:00
Nikhil Sonti
d4a22fcc2e feat(vm): foundation layer — paths, errors, manifest, telemetry 2026-04-23 10:34:41 -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
Nikhil
0650f21c80 fix(openclaw): allocate gateway host port dynamically + name the two ports distinctly (#771)
* feat(openclaw): dynamically allocate and persist gateway host port

The gateway container always listens on OPENCLAW_GATEWAY_CONTAINER_PORT
(18789) internally, but that port may be taken on the user's host. Allocate
a free host port on each lifecycle transition, persist it to
~/.browseros/openclaw/.openclaw/runtime-state.json, and prefer the
persisted value on subsequent starts so the mapping is stable.

Split the naming so the two sides of the -p mapping are no longer
ambiguous: the shared constant becomes OPENCLAW_GATEWAY_CONTAINER_PORT
and the service/spec/chat-client/runtime probes all use hostPort for
the mapped host-side port.

* fix(openclaw): remove duplicate Podman overrides card from status panels
2026-04-20 17:32:10 -07:00
Dani Akash
e80ec467f4 feat: wire lazy monitoring to OpenClaw chat handoff (#768)
* feat: add lazy monitoring substrate

* feat: wire lazy monitoring to openclaw chat handoff

* test: cover openclaw chat history handoff

* fix: reject concurrent monitored chats
2026-04-20 21:52:03 +05:30
Dani Akash
41374439c4 feat: add passive lazy monitoring substrate for MCP tool calls (#766)
* feat: add lazy monitoring substrate

* fix: validate monitoring run ids

* fix: harden monitoring storage recovery
2026-04-20 21:10:09 +05:30
Dani Akash
ad99cd6cc1 fix: restore openai-compatible OpenClaw providers (#767)
* fix(openclaw): restore openai-compatible providers

* fix(openclaw): preserve custom provider model lists
2026-04-20 20:25:37 +05:30
Nikhil
47fc9e1292 feat(openclaw): user-supplied Podman binary path override (#759)
* feat(openclaw): user-supplied Podman binary path override

Expose the existing `configurePodmanRuntime({ podmanPath })` knob as a UI
input on the Agents page so users blocked by the bundled gvproxy helper
discovery bug can install their own Podman (e.g. `brew install podman`)
and point BrowserOS at it.

- podman-overrides.ts: persist {podmanPath} at ~/.browseros/.openclaw/
- openclaw-service: applyPodmanOverrides/getPodmanOverrides, rebuilds
  ContainerRuntime + CLI clients in place (no server restart needed)
- routes: GET/POST /claw/podman-overrides with absolute-path + existsSync
  validation
- main: load override on boot, pass resourcesDir into the service so
  clearing the override restores bundled fallback
- AgentsPage: PodmanOverridesCard rendered inline in the degraded /
  uninitialized / error cards and as a collapsible standalone section

Dev mode is unchanged; prod gets the same lever dev has had all along.

* refactor(openclaw): address review comments for podman-path override

- extract getPodmanOverrideValidationError() to mirror the existing
  getCreateAgentValidationError() pattern in openclaw.ts
- extract rebuildRuntimeClients() so applyPodmanOverrides doesn't
  re-spell the three-step runtime/CLI-client reinit
- rename shadowing local path -> overridesPath in loadPodmanOverrides

* fix(openclaw): clear gateway log tail before swapping runtime

rebuildRuntimeClients replaces this.runtime but the cached stopLogTail
still closes over the old runtime's log-tail process. The existing
guard in startGatewayLogTail (if (this.stopLogTail) return) would then
short-circuit the next restart and leave the new runtime without a
tail. Clear it inside the helper so the rebuild is self-consistent
regardless of caller order.

* fix(openclaw): check podmanPath executability and note singleton mutation

- validator: after existsSync, accessSync(X_OK) so a non-executable file
  fails fast at save time with a clear 400 instead of a cryptic spawn
  error later. Added a matching route test.
- applyPodmanOverrides: one-line comment flagging the intentional
  module-level PodmanRuntime singleton mutation so future readers know
  this is by design, not an accident.
2026-04-18 17:27:25 -07:00
Nikhil
2a61dcbc58 fix: remove podman compose from OpenClaw runtime (#758)
* refactor: rename OpenClaw runtime away from compose semantics

* feat: run OpenClaw containers with direct podman commands

* test: assert exact podman run args

* fix: stage direct runtime container migration safely

* refactor: switch OpenClaw service to direct podman runtime

* test: cover direct-runtime lifecycle paths in openclaw service

* fix: handle legacy openclaw gateway container during runtime cutover

* chore: remove OpenClaw compose resources from server build

* refactor: drop obsolete setup-command overload

* fix: remove dead OpenClaw runtime env file flow

* fix: restore scoped OpenClaw gateway container name

* test: assert scoped OpenClaw terminal container name

* fix: make OpenClaw gateway removal idempotent

* fix: harden OpenClaw setup container lifecycle
2026-04-18 13:53:18 -07:00
Nikhil
f5a2b7315c fix: run all browseros-agent tests from root (#750)
* fix: run full browseros-agent test suite

* fix: stabilize server test reporting in CI

* fix: address PR review feedback

* refactor: extract server core test runner

* refactor: group server tests by filesystem

* fix: align CI suites with server test groups

* fix: provision server env for all CI suites

* fix: stabilize ci checks

* fix: report real test counts in ci
2026-04-17 17:26:44 -07:00
Nikhil
6de3b3422c fix: package OpenClaw compose resource (#749)
* fix: package openclaw compose resource

* fix: address PR review comments for docker-compose-missing
2026-04-17 15:01:59 -07:00
Nikhil
224b6cd3a8 chore: remove bun and ripgrep prod resources (#748) 2026-04-17 13:03:42 -07:00
Nikhil
7baee8d57e chore: release server alpha - 0.0.88 (#747) 2026-04-17 12:44:41 -07:00
Nikhil
e8e8c36fdb fix: pin OpenClaw image to 2026.4.12 (#746)
* fix: pin OpenClaw image to 2026.4.12

* fix: address PR review comments for 0417-openclaw-image-pin
2026-04-17 12:14:37 -07:00
Nikhil
3810005457 refactor: stabilize local OpenClaw integration (#741)
* feat(openclaw): add CLI client

* fix(openclaw): swap service to cli client

* fix(openclaw): restore mixed json parsing

* fix(openclaw): validate agent list payloads

* fix(openclaw): simplify cli client boundary

* fix(openclaw): simplify cli client boundary

* fix(openclaw): prefer outer config json payloads

* fix(openclaw): ignore trailing config log payloads

* refactor(openclaw): bootstrap config through cli

* fix(openclaw): narrow bootstrap ownership

* fix(openclaw): avoid noop key restarts

* fix(openclaw): enforce supported provider sync

* refactor(openclaw): remove agent role contract

* fix(openclaw): migrate legacy state and apply model updates

* fix(openclaw): migrate legacy agent state

* fix(openclaw): harden state updates

* refactor: stabilize local OpenClaw bootstrap and chat auth

* fix(openclaw): propagate container env and drop legacy paths

Compose now loads provider creds from .openclaw/.env and passes the
gateway token through, so in-container CLI commands (tui, doctor,
config) authenticate correctly and the gateway process sees
OPENROUTER_API_KEY. Service ensures the state env file exists and
rewrites the compose env with the token before composeUp in setup,
start, and tryAutoStart. Podman machine gets larger defaults and the
container enables NODE_COMPILE_CACHE + OPENCLAW_NO_RESPAWN. Legacy
state migration, the unused WebSocket gateway-client, memorySearch,
and thinking defaults are removed.
2026-04-17 11:00:07 -07:00
Nikhil
688f7962cb fix: rerun dev port cleanup before server restarts (#745) 2026-04-17 08:13:22 -07:00
Felarof
526d784d82 chore: add .auctor entries to gitignore (#739)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 18:00:24 -07:00
Nikhil
331fec07e6 fix: use separate BrowserOS dir in development (#736) 2026-04-16 16:42:06 -07:00
Nikhil
0652ee8ca8 feat: better hidden windows (#730)
* feat: better hidden windows

* fix: addressing review comments
2026-04-16 16:33:12 -07:00
Nikhil
156f5dbc5d feat: redesign OpenClaw control plane around CLI and HTTP (#735)
* feat: move OpenClaw control plane to CLI and HTTP

* fix: address PR review comments for 0416-openclaw_cli_http_redesign
2026-04-16 16:29:26 -07:00
Nikhil
ebd3200cfe feat(build): add arm64-only macOS release config (#728)
Introduces release.macos.arm64.yaml for single-architecture arm64
macOS release builds. Mirrors the windows/linux single-arch pattern
(configure -> compile -> sign_macos -> package_macos -> upload),
skipping the universal_build module to avoid the x64 cross-compile
and lipo merge. Reuses the sparkle_setup step and the same
notarization env vars as the universal macOS config.
2026-04-16 13:09:46 -07:00
Nikhil
4172daa130 chore: bump PATCH and OFFSET (#727) 2026-04-16 13:05:01 -07:00
Nikhil
c1b1e53a86 feat(ota): bundle full server resources tree in Sparkle payload (#726)
* feat(ota): bundle full server resources tree (server + third_party bins)

The OTA Sparkle payload now ships the complete resources/ tree the agent
build produced, not just browseros_server. Every third-party binary (bun,
ripgrep, podman, gvproxy, vfkit, krunkit, podman-mac-helper, win-sshproxy)
flows to OTA-updated installs so podman integration works for users on the
OTA channel, matching fresh Chromium-build installs.

Extract the per-binary sign table into build/common/server_binaries.py so
the Chromium-build sign path (modules/sign/) and OTA sign path (modules/ota/)
share a single source of truth. Adding a new third-party dep is now a
one-file edit that both paths pick up automatically; unknown executables
under resources/bin/ are a hard error at release time.

* fix(ota): address review comments on bundle signing flow

- Avoid double-zipping during notarization: add notarize_macos_zip for
  pre-built Sparkle bundles so notarytool submits the zip directly
  instead of re-wrapping it through ditto --keepParent (Apple's service
  does not descend into nested archives). Keep notarize_macos_binary for
  single-binary callers. Share credential setup + submit logic via
  internal helpers.
- Fail fast on unknown executables in sign_server_bundle_macos: collect
  the unknown-files list before any codesign call so a missing shared-
  table entry aborts in seconds, not after a full signing round.
- Drop dead get_entitlements_path helper (no callers remain after the
  bundle refactor).

* fix(ota): address PR review comments (greptile + claude)

- sign_server_bundle_macos filters to executables only (p.is_file() +
  not p.is_symlink() + os.access X_OK) before applying the unknown-file
  guard. Non-Mach-O files (configs, dylibs, etc.) under resources/bin/
  no longer cause misleading 'unknown executable' hard failures.
- sign_server_bundle_windows now hard-errors on a missing expected
  binary instead of silently skipping it. Symmetric with the macOS
  guard — an incomplete bundle must not publish.
- ServerOTAModule.execute() uses tempfile.TemporaryDirectory context
  managers for both the download and staging roots so they are cleaned
  up on every path, including failures.
- Per-platform sign/notarize/Sparkle-sign failures now raise RuntimeError
  instead of silently skipping the platform — a release pipeline can no
  longer omit a target while reporting success.
- Move import os and import shutil to the top of ota/sign_binary.py.
- Drop unused log_error import from ota/server.py.

* chore: bump server
2026-04-16 12:59:49 -07:00
167 changed files with 14278 additions and 4118 deletions

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

@@ -0,0 +1,157 @@
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
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- ".github/workflows/build-agent.yml"
env:
BUN_VERSION: "1.3.6"
PKG_DIR: packages/browseros-agent/packages/build-tools
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@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

@@ -30,12 +30,54 @@ jobs:
fail-fast: false
matrix:
include:
- suite: tools
test_path: tests/tools
junit_path: test-results/tools.xml
- suite: integration
test_path: tests/server.integration.test.ts
junit_path: test-results/integration.xml
- suite: server-agent
command: (cd apps/server && bun run test:agent)
junit_path: test-results/server-agent.xml
needs_browser: false
- suite: server-api
command: (cd apps/server && bun run test:api)
junit_path: test-results/server-api.xml
needs_browser: false
- suite: server-skills
command: (cd apps/server && bun run test:skills)
junit_path: test-results/server-skills.xml
needs_browser: false
- suite: server-tools
command: (cd apps/server && bun run test:tools)
junit_path: test-results/server-tools.xml
needs_browser: true
- suite: server-browser
command: (cd apps/server && bun run test:browser)
junit_path: test-results/server-browser.xml
needs_browser: false
- suite: server-integration
command: (cd apps/server && bun run test:integration)
junit_path: test-results/server-integration.xml
needs_browser: true
- suite: server-sdk
command: (cd apps/server && bun run test:sdk)
junit_path: test-results/server-sdk.xml
needs_browser: true
- suite: server-root
command: (cd apps/server && bun run test:root)
junit_path: test-results/server-root.xml
needs_browser: false
- suite: agent
command: bun run test:agent
junit_path: test-results/agent.xml
needs_browser: false
- suite: eval
command: bun run test:eval
junit_path: test-results/eval.xml
needs_browser: false
- suite: agent-sdk
command: bun run test:agent-sdk
junit_path: test-results/agent-sdk.xml
needs_browser: false
- suite: build
command: bun run test:build
junit_path: test-results/build.xml
needs_browser: false
steps:
- name: Checkout code
@@ -48,6 +90,7 @@ jobs:
run: bun ci
- name: Resolve BrowserOS cache key
if: matrix.needs_browser == true
id: browseros-cache-key
run: |
set -euo pipefail
@@ -62,6 +105,7 @@ jobs:
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
- name: Restore BrowserOS cache
if: matrix.needs_browser == true
id: browseros-cache
uses: actions/cache@v4
with:
@@ -69,13 +113,14 @@ jobs:
key: ${{ steps.browseros-cache-key.outputs.key }}
- name: Download BrowserOS
if: steps.browseros-cache.outputs.cache-hit != 'true'
if: matrix.needs_browser == true && steps.browseros-cache.outputs.cache-hit != 'true'
run: |
mkdir -p .ci/bin
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
chmod +x .ci/bin/BrowserOS.AppImage
- name: Prepare BrowserOS wrapper
if: matrix.needs_browser == true
run: |
mkdir -p .ci/bin
cat > .ci/bin/browseros <<'EOF'
@@ -96,16 +141,23 @@ jobs:
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
BROWSEROS_TEST_HEADLESS: "true"
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
BROWSEROS_JUNIT_PATH: ${{ github.workspace }}/packages/browseros-agent/${{ matrix.junit_path }}
run: |
set +e
mkdir -p test-results
cd apps/server
bun run test:cleanup
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
${{ matrix.command }}
exit_code=$?
cd ../..
if [ ! -f "${{ matrix.junit_path }}" ]; then
cat > "${{ matrix.junit_path }}" <<EOF
if [ "$exit_code" = "0" ]; then
cat > "${{ matrix.junit_path }}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="0" failures="0">
<testsuite name="${{ matrix.suite }}" tests="0" failures="0">
</testsuite>
</testsuites>
EOF
else
cat > "${{ matrix.junit_path }}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="1" failures="1">
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
@@ -115,6 +167,7 @@ jobs:
</testsuite>
</testsuites>
EOF
fi
fi
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
**/.DS_Store
**.auctor/**
.auctor.json
.gcs_entries
**/dmg
**/env

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

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import {
buildChatHistoryFromTurns,
chatWithAgent,
type OpenClawStreamEvent,
} from '@/entrypoints/app/agents/useOpenClaw'
@@ -187,6 +188,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
const send = async (text: string) => {
if (!text.trim() || streaming) return
const history = buildChatHistoryFromTurns(turns)
const turn: AgentConversationTurn = {
id: crypto.randomUUID(),
@@ -207,6 +209,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
agentId,
text.trim(),
sessionKeyRef.current,
history,
abortController.signal,
)
if (!response.ok) {

View File

@@ -20,7 +20,11 @@ import {
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { consumeSSEStream } from '@/lib/sse'
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
import {
buildChatHistoryFromTurns,
chatWithAgent,
type OpenClawStreamEvent,
} from './useOpenClaw'
interface ToolEntry {
id: string
@@ -204,6 +208,7 @@ export const AgentChat: FC<AgentChatProps> = ({
const handleSend = async () => {
const text = input.trim()
if (!text || streaming) return
const history = buildChatHistoryFromTurns(turns)
const turn: ChatTurn = {
id: crypto.randomUUID(),
@@ -225,6 +230,7 @@ export const AgentChat: FC<AgentChatProps> = ({
agentId,
text,
sessionKeyRef.current,
history,
abortController.signal,
)

View File

@@ -1,7 +1,3 @@
import type {
BrowserOSCustomRoleInput,
BrowserOSRoleBoundary,
} from '@browseros/shared/types/role-aware-agents'
import {
AlertCircle,
Cpu,
@@ -35,53 +31,25 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
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,
type RoleTemplateSummary,
useOpenClawAgents,
useOpenClawMutations,
useOpenClawRoles,
useOpenClawStatus,
} from './useOpenClaw'
const OAUTH_ONLY_TYPES = new Set(['chatgpt-pro', 'github-copilot', 'qwen-code'])
const CUSTOM_ROLE_VALUE = '__custom__'
const PLAIN_AGENT_VALUE = '__plain__'
type AgentCreationMode = 'builtin' | 'custom' | 'plain'
function createDefaultCustomRoleBoundaries(): BrowserOSRoleBoundary[] {
return [
{
key: 'draft-external-comms',
label: 'Draft external communications',
description: 'May prepare outbound messages for review.',
defaultMode: 'allow',
},
{
key: 'send-external-comms',
label: 'Send external communications',
description: 'Should require approval before sending messages.',
defaultMode: 'ask',
},
{
key: 'calendar-mutations',
label: 'Modify calendar events',
description: 'Should ask before moving or creating calendar events.',
defaultMode: 'ask',
},
]
}
function parseCommaSeparatedList(input: string): string[] {
return input
.split(',')
.map((item) => item.trim())
.filter(Boolean)
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<
@@ -281,7 +249,6 @@ export const AgentsPage: FC = () => {
loading: agentsLoading,
error: agentsError,
} = useOpenClawAgents(agentsQueryEnabled)
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
const {
setupOpenClaw,
createAgent,
@@ -295,48 +262,20 @@ export const AgentsPage: FC = () => {
creating,
deleting,
reconnecting,
pendingGatewayAction,
} = useOpenClawMutations()
const [setupOpen, setSetupOpen] = useState(false)
const [setupProviderId, setSetupProviderId] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [selectedRoleValue, setSelectedRoleValue] = useState<
| RoleTemplateSummary['id']
| typeof CUSTOM_ROLE_VALUE
| typeof PLAIN_AGENT_VALUE
>('chief-of-staff')
const [newName, setNewName] = useState('')
const [createProviderId, setCreateProviderId] = useState('')
const [customRole, setCustomRole] = useState<BrowserOSCustomRoleInput>({
name: '',
shortDescription: '',
longDescription: '',
recommendedApps: [],
boundaries: createDefaultCustomRoleBoundaries(),
})
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
const [showTerminal, setShowTerminal] = useState(false)
const [error, setError] = useState<string | null>(null)
const compatibleProviders = providers.filter(
(provider) => provider.apiKey && !OAUTH_ONLY_TYPES.has(provider.type),
)
const creationMode: AgentCreationMode =
selectedRoleValue === CUSTOM_ROLE_VALUE
? 'custom'
: selectedRoleValue === PLAIN_AGENT_VALUE
? 'plain'
: 'builtin'
const isCustomRole = creationMode === 'custom'
const isPlainAgent = creationMode === 'plain'
const selectedRole =
creationMode === 'builtin'
? (roles.find((role) => role.id === selectedRoleValue) ??
roles[0] ??
null)
: null
const compatibleProviders = getOpenClawSupportedProviders(providers)
useEffect(() => {
if (compatibleProviders.length === 0) return
@@ -355,48 +294,18 @@ export const AgentsPage: FC = () => {
defaultProviderId,
])
useEffect(() => {
if (!createOpen || roles.length === 0) return
const defaultRole = roles.find((role) => role.id === 'chief-of-staff')
const nextRole = defaultRole ?? roles[0]
setSelectedRoleValue((current) => {
if (current === CUSTOM_ROLE_VALUE || current === PLAIN_AGENT_VALUE)
return current
const hasCurrent = roles.some((role) => role.id === current)
return hasCurrent ? current : nextRole.id
})
setNewName((current) => current || nextRole.defaultAgentName)
}, [createOpen, roles])
useEffect(() => {
if (!createOpen) return
setNewName((current) => current || 'agent')
}, [createOpen])
if (isCustomRole) {
setNewName(
(current) =>
current || customRole.name.trim().toLowerCase().replace(/\s+/g, '-'),
)
return
}
if (isPlainAgent) {
setNewName((current) => current || 'agent')
return
}
if (selectedRole) {
setNewName((current) => current || selectedRole.defaultAgentName)
}
}, [createOpen, isCustomRole, isPlainAgent, customRole.name, selectedRole])
const inlineError =
error ??
statusError?.message ??
agentsError?.message ??
rolesError?.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) {
@@ -425,6 +334,10 @@ 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)
@@ -462,34 +375,10 @@ export const AgentsPage: FC = () => {
(item) => item.id === createProviderId,
)
const normalizedName = newName.trim().toLowerCase().replace(/\s+/g, '-')
const customRolePayload = isCustomRole
? {
...customRole,
name: customRole.name.trim(),
shortDescription: customRole.shortDescription.trim(),
longDescription: customRole.longDescription.trim(),
}
: undefined
if (
isCustomRole &&
(!customRolePayload?.name ||
!customRolePayload.shortDescription ||
!customRolePayload.longDescription)
) {
setError(
'Custom roles require a role name, short description, and long description.',
)
return
}
if (creationMode === 'builtin' && !selectedRole) return
await runWithErrorHandling(async () => {
await createAgent({
name: normalizedName,
roleId: creationMode === 'builtin' ? selectedRole?.id : undefined,
customRole: isCustomRole ? customRolePayload : undefined,
providerType: provider?.type,
providerName: provider?.name,
baseUrl: provider?.baseUrl,
@@ -498,13 +387,6 @@ export const AgentsPage: FC = () => {
})
setCreateOpen(false)
setNewName('')
setCustomRole({
name: '',
shortDescription: '',
longDescription: '',
recommendedApps: [],
boundaries: createDefaultCustomRoleBoundaries(),
})
})
}
@@ -619,7 +501,7 @@ export const AgentsPage: FC = () => {
</Button>
<Button
onClick={() => setCreateOpen(true)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
New Agent
@@ -630,6 +512,13 @@ export const AgentsPage: FC = () => {
)}
</div>
{lifecycleBanner && (
<Alert>
<Loader2 className="animate-spin" />
<AlertTitle>{lifecycleBanner}</AlertTitle>
</Alert>
)}
{inlineError && (
<Alert variant="destructive">
<AlertCircle />
@@ -649,7 +538,7 @@ export const AgentsPage: FC = () => {
</Alert>
)}
{status && gatewayUiState.controlPlaneDegraded && (
{status && showControlPlaneDegraded && (
<Alert
variant={
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
@@ -701,8 +590,8 @@ export const AgentsPage: FC = () => {
<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.'}
? '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 && (
@@ -770,7 +659,7 @@ export const AgentsPage: FC = () => {
<Button
variant="outline"
onClick={() => setCreateOpen(true)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
@@ -788,20 +677,10 @@ export const AgentsPage: FC = () => {
<CardTitle className="text-base">
{agent.name}
</CardTitle>
{agent.role && (
<Badge variant="secondary">
{agent.role.roleName}
</Badge>
)}
</div>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
{agent.role && (
<p className="text-muted-foreground text-xs">
{agent.role.shortDescription}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
@@ -809,7 +688,7 @@ export const AgentsPage: FC = () => {
variant="ghost"
size="sm"
onClick={() => setChatAgent(agent)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
@@ -819,7 +698,7 @@ export const AgentsPage: FC = () => {
variant="ghost"
size="icon"
onClick={() => handleDelete(agent.agentId)}
disabled={!gatewayUiState.canManageAgents || deleting}
disabled={!canManageAgents || deleting}
>
<Trash2 className="size-4 text-destructive" />
</Button>
@@ -868,246 +747,6 @@ export const AgentsPage: FC = () => {
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="agent-role">
Agent Role
</label>
<Select
value={selectedRoleValue}
onValueChange={(value) => {
if (value === CUSTOM_ROLE_VALUE) {
setSelectedRoleValue(CUSTOM_ROLE_VALUE)
setNewName(
customRole.name
.trim()
.toLowerCase()
.replace(/\s+/g, '-') || 'custom-agent',
)
return
}
if (value === PLAIN_AGENT_VALUE) {
setSelectedRoleValue(PLAIN_AGENT_VALUE)
setNewName('agent')
return
}
const role = roles.find((item) => item.id === value)
if (!role) return
setSelectedRoleValue(role.id)
setNewName(role.defaultAgentName)
}}
disabled={rolesLoading}
>
<SelectTrigger id="agent-role">
<SelectValue
placeholder={
rolesLoading ? 'Loading roles...' : 'Select a role'
}
/>
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
<SelectItem value={PLAIN_AGENT_VALUE}>Plain Agent</SelectItem>
<SelectItem value={CUSTOM_ROLE_VALUE}>Custom Role</SelectItem>
</SelectContent>
</Select>
{selectedRole && !isCustomRole && (
<Card>
<CardContent className="space-y-3 py-4">
<div>
<div className="font-medium text-sm">
{selectedRole.name}
</div>
<p className="text-muted-foreground text-xs">
{selectedRole.shortDescription}
</p>
</div>
<div>
<div className="font-medium text-xs">
Recommended Apps
</div>
<p className="text-muted-foreground text-xs">
{selectedRole.recommendedApps.join(', ')}
</p>
</div>
<div>
<div className="font-medium text-xs">
Default Boundaries
</div>
<ul className="space-y-1 text-muted-foreground text-xs">
{selectedRole.boundaries.map((boundary) => (
<li key={boundary.key}>
{boundary.label}: {boundary.defaultMode}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
)}
{isPlainAgent && (
<Card>
<CardContent className="space-y-2 py-4">
<div className="font-medium text-sm">Plain Agent</div>
<p className="text-muted-foreground text-xs">
No role bootstrap or defaults. Intended for temporary
development and testing only.
</p>
</CardContent>
</Card>
)}
</div>
{isCustomRole && (
<Card>
<CardContent className="space-y-4 py-4">
<div className="space-y-2">
<label
htmlFor="custom-role-name"
className="font-medium text-sm"
>
Custom Role Name
</label>
<Input
id="custom-role-name"
value={customRole.name}
onChange={(event) => {
const name = event.target.value
setCustomRole((current) => ({ ...current, name }))
setNewName(
name.trim().toLowerCase().replace(/\s+/g, '-') ||
'custom-agent',
)
}}
placeholder="Board Prep Operator"
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-short-description"
className="font-medium text-sm"
>
Short Description
</label>
<Input
id="custom-role-short-description"
value={customRole.shortDescription}
onChange={(event) =>
setCustomRole((current) => ({
...current,
shortDescription: event.target.value,
}))
}
placeholder="Prepares executive briefs and weekly follow-ups."
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-long-description"
className="font-medium text-sm"
>
Long Description
</label>
<Textarea
id="custom-role-long-description"
value={customRole.longDescription}
onChange={(event) =>
setCustomRole((current) => ({
...current,
longDescription: event.target.value,
}))
}
placeholder="Describe the role, purpose, and what kinds of outcomes this agent should produce."
rows={4}
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-apps"
className="font-medium text-sm"
>
Recommended Apps
</label>
<Input
id="custom-role-apps"
value={customRole.recommendedApps.join(', ')}
onChange={(event) =>
setCustomRole((current) => ({
...current,
recommendedApps: parseCommaSeparatedList(
event.target.value,
),
}))
}
placeholder="gmail, slack, notion"
/>
<p className="text-muted-foreground text-xs">
Comma-separated. Used as role guidance only in this
milestone.
</p>
</div>
<div className="space-y-3">
<div>
<div className="font-medium text-sm">
Boundary Defaults
</div>
<p className="text-muted-foreground text-xs">
Set the starting behavior for common high-impact
actions.
</p>
</div>
{customRole.boundaries.map((boundary) => (
<div
key={boundary.key}
className="grid gap-2 rounded-lg border p-3"
>
<div>
<div className="font-medium text-sm">
{boundary.label}
</div>
<p className="text-muted-foreground text-xs">
{boundary.description}
</p>
</div>
<Select
value={boundary.defaultMode}
onValueChange={(value) =>
setCustomRole((current) => ({
...current,
boundaries: current.boundaries.map((item) =>
item.key === boundary.key
? {
...item,
defaultMode:
value as BrowserOSRoleBoundary['defaultMode'],
}
: item,
),
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="block">Block</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
)}
<div>
<label
htmlFor="agent-name"
@@ -1141,10 +780,8 @@ export const AgentsPage: FC = () => {
disabled={
!newName.trim() ||
creating ||
rolesLoading ||
!gatewayUiState.canManageAgents ||
compatibleProviders.length === 0 ||
(creationMode === 'builtin' && !selectedRole)
!canManageAgents ||
compatibleProviders.length === 0
}
className="w-full"
>

View File

@@ -0,0 +1,24 @@
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
const OPENCLAW_SUPPORTED_PROVIDER_TYPES: ProviderType[] = [
'openrouter',
'openai',
'openai-compatible',
'anthropic',
'moonshot',
]
export function isOpenClawSupportedProviderType(
providerType: ProviderType,
): boolean {
return OPENCLAW_SUPPORTED_PROVIDER_TYPES.includes(providerType)
}
export function getOpenClawSupportedProviders(
providers: LlmProviderConfig[],
): LlmProviderConfig[] {
return providers.filter(
(provider) =>
!!provider.apiKey && isOpenClawSupportedProviderType(provider.type),
)
}

View File

@@ -1,7 +1,3 @@
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
@@ -11,27 +7,6 @@ export interface AgentEntry {
name: string
workspace: string
model?: unknown
role?: {
roleSource: 'builtin' | 'custom'
roleId?: BrowserOSAgentRoleId
roleName: string
shortDescription: string
}
}
export interface RoleTemplateSummary {
id: BrowserOSAgentRoleId
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
defaultAgentName: string
boundaries: Array<{
key: string
label: string
description: string
defaultMode: 'allow' | 'ask' | 'block'
}>
}
export interface OpenClawStatus {
@@ -61,8 +36,6 @@ export interface OpenClawStatus {
export interface OpenClawAgentMutationInput {
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
@@ -86,9 +59,15 @@ export function getModelDisplayName(model: unknown): string | undefined {
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
roles: 'openclaw-roles',
} as const
export type GatewayLifecycleAction =
| 'setup'
| 'start'
| 'stop'
| 'restart'
| 'reconnect'
async function clawFetch<T>(
baseUrl: string,
path: string,
@@ -117,16 +96,6 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
return data.agents ?? []
}
async function fetchOpenClawRoles(
baseUrl: string,
): Promise<RoleTemplateSummary[]> {
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
baseUrl,
'/roles',
)
return data.roles ?? []
}
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
@@ -179,28 +148,6 @@ export function useOpenClawAgents(enabled = true) {
}
}
export function useOpenClawRoles() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<RoleTemplateSummary[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
queryFn: () => fetchOpenClawRoles(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
staleTime: 60_000,
})
return {
roles: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
@@ -278,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,
@@ -298,6 +252,7 @@ export function useOpenClawMutations() {
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
pendingGatewayAction,
}
}
@@ -314,17 +269,60 @@ export interface OpenClawStreamEvent {
data: Record<string, unknown>
}
export interface OpenClawChatHistoryMessage {
role: 'user' | 'assistant'
content: string
}
interface ChatHistoryTurnLike {
userText: string
parts: Array<{ kind: string; text?: string }>
}
export function buildChatHistoryFromTurns(
turns: ChatHistoryTurnLike[],
): OpenClawChatHistoryMessage[] {
const messages: OpenClawChatHistoryMessage[] = []
for (const turn of turns) {
const userText = turn.userText.trim()
if (userText) {
messages.push({ role: 'user', content: userText })
}
const assistantText = turn.parts
.filter(
(
part,
): part is {
kind: 'text'
text: string
} => part.kind === 'text' && typeof part.text === 'string',
)
.map((part) => part.text.trim())
.filter(Boolean)
.join('\n\n')
if (assistantText) {
messages.push({ role: 'assistant', content: assistantText })
}
}
return messages
}
export async function chatWithAgent(
agentId: string,
message: string,
sessionKey?: string,
history: OpenClawChatHistoryMessage[] = [],
signal?: AbortSignal,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, sessionKey }),
body: JSON.stringify({ message, sessionKey, history }),
signal,
})
}

View File

@@ -7,8 +7,9 @@ import { PRODUCT_WEB_HOST } from './lib/constants/productWebHost'
// biome-ignore lint/style/noProcessEnv: build config file needs env access
const env = process.env
// biome-ignore lint/style/noNonNullAssertion: required env var
const apiUrl = new URL(env.VITE_PUBLIC_BROWSEROS_API!)
const apiUrl = new URL(
env.VITE_PUBLIC_BROWSEROS_API?.trim() || 'https://api.browseros.com',
)
const apiPattern = apiUrl.port
? `${apiUrl.hostname}:${apiUrl.port}`
: apiUrl.hostname

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.85",
"version": "0.0.88",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",
@@ -10,9 +10,21 @@
"scripts": {
"start": "bun --watch --env-file=.env.development src/index.ts",
"build": "bun ../../scripts/build/server.ts --target=all",
"test:tools": "bun run test:cleanup && bun --env-file=.env.development test tests/tools",
"test:integration": "bun run test:cleanup && bun --env-file=.env.development test tests/server.integration.test.ts",
"test:sdk": "echo 'SDK tests disabled: test environment does not provide the extract/verify LLM service'",
"test": "bun run test:all",
"test:all": "bun run ./tests/__helpers__/run-test-group.ts all",
"test:agent": "bun run ./tests/__helpers__/run-test-group.ts agent",
"test:api": "bun run ./tests/__helpers__/run-test-group.ts api",
"test:browser": "bun run ./tests/__helpers__/run-test-group.ts browser",
"test:cdp": "bun run test:browser",
"test:core": "bun run ./tests/__helpers__/run-test-group.ts core",
"test:integration": "bun run ./tests/__helpers__/run-test-group.ts integration",
"test:root": "bun run ./tests/__helpers__/run-test-group.ts root",
"test:sdk": "bun run ./tests/__helpers__/run-test-group.ts sdk",
"test:skills": "bun run ./tests/__helpers__/run-test-group.ts skills",
"test:tools": "bun run ./tests/__helpers__/run-test-group.ts tools",
"test:tools:acl": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/acl-scorer.test.ts",
"test:tools:filesystem": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/filesystem",
"test:tools:input": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/input.test.ts",
"test:cleanup": "./tests/__helpers__/cleanup.sh",
"typecheck": "tsc --noEmit",
"devtools": "bunx @ai-sdk/devtools"

View File

@@ -1,37 +0,0 @@
services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}
ports:
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
environment:
- HOME=/home/node
- NODE_ENV=production
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
- OPENCLAW_GATEWAY_BIND=lan
- TZ=${TZ}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
extra_hosts:
- "host.containers.internal:host-gateway"
command:
- node
- dist/index.js
- gateway
- --bind
- lan
- --port
- "18789"
- --allow-unconfigured
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped

View File

@@ -10,6 +10,7 @@ import type { Browser } from '../../browser/browser'
import { logger } from '../../lib/logger'
import { metrics } from '../../lib/metrics'
import { Sentry } from '../../lib/sentry'
import { getMonitoringService } from '../../monitoring/service'
import type { ToolRegistry } from '../../tools/tool-registry'
import type { GlobalAclPolicyService } from '../services/acl/global-acl-policy'
import { resolveAclPolicyForMcpRequest } from '../services/acl/resolve-acl-policy'
@@ -39,16 +40,35 @@ export function createMcpRoutes(deps: McpRouteDeps) {
app.post('/', async (c) => {
const scopeId = c.req.header('X-BrowserOS-Scope-Id') || 'ephemeral'
const monitoringService = getMonitoringService()
const explicitAgentId =
c.req.query('agentId') ??
c.req.header('X-BrowserOS-Agent-Id') ??
undefined
const activeSession = explicitAgentId
? {
agentId: explicitAgentId,
monitoringSessionId:
monitoringService.getActiveSessionId(explicitAgentId),
}
: monitoringService.getSingleActiveSession()
const agentId = activeSession?.agentId
metrics.log('mcp.request', { scopeId })
const aclRules = await resolveAclPolicyForMcpRequest({
policyService: deps.policyService,
})
const monitoringSessionId = activeSession?.monitoringSessionId
const observer =
monitoringSessionId && agentId
? monitoringService.createObserver(monitoringSessionId, agentId)
: undefined
// Per-request server + transport: no shared state, no race conditions,
// no ID collisions. Required by MCP SDK 1.26.0+ security fix (GHSA-345p-7cg4-v4c7).
const mcpServer = createMcpServer({
...deps,
aclRules,
observer,
})
const transport = new StreamableHTTPTransport({
sessionIdGenerator: undefined,
@@ -62,6 +82,9 @@ export function createMcpRoutes(deps: McpRouteDeps) {
Sentry.withScope((scope) => {
scope.setTag('route', 'mcp')
scope.setTag('scopeId', scopeId)
if (agentId) {
scope.setTag('agentId', agentId)
}
Sentry.captureException(error)
})
logger.error('Error handling MCP request', {

View File

@@ -0,0 +1,113 @@
import { Hono } from 'hono'
import { getMonitoringService } from '../../monitoring/service'
import { isValidMonitoringRunId } from '../../monitoring/storage'
export function createMonitoringRoutes() {
return new Hono()
.get('/runs', async (c) => {
const limitParam = c.req.query('limit')
const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : 50
const limit =
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50
const runs = await getMonitoringService().listRuns(limit)
return c.json({ runs })
})
.get('/runs/:id', async (c) => {
const runId = c.req.param('id')
if (!isValidMonitoringRunId(runId)) {
return c.json({ error: 'Invalid monitoring run id' }, 400)
}
const envelope = await getMonitoringService().getRunEnvelope(runId)
if (!envelope) {
return c.json({ error: 'Monitoring run not found' }, 404)
}
return c.json({ run: envelope })
})
.post('/debug/runs', async (c) => {
const body = await c.req.json<{
agentId?: string
sessionKey?: string
originalPrompt?: string
chatHistory?: Array<{ role?: 'user' | 'assistant'; content?: string }>
}>()
if (!body.agentId?.trim()) {
return c.json({ error: 'agentId is required' }, 400)
}
if (!body.sessionKey?.trim()) {
return c.json({ error: 'sessionKey is required' }, 400)
}
if (!body.originalPrompt?.trim()) {
return c.json({ error: 'originalPrompt is required' }, 400)
}
const chatHistory = Array.isArray(body.chatHistory)
? body.chatHistory
.filter(
(turn): turn is { role: 'user' | 'assistant'; content: string } =>
(turn.role === 'user' || turn.role === 'assistant') &&
typeof turn.content === 'string',
)
.map((turn) => ({
role: turn.role,
content: turn.content,
}))
: []
const session = await getMonitoringService().startSession({
agentId: body.agentId.trim(),
sessionKey: body.sessionKey.trim(),
originalPrompt: body.originalPrompt.trim(),
chatHistory,
source: 'debug',
})
return c.json({ session }, 201)
})
.post('/debug/runs/:id/finalize', async (c) => {
const runId = c.req.param('id')
if (!isValidMonitoringRunId(runId)) {
return c.json({ error: 'Invalid monitoring run id' }, 400)
}
const body = await c.req.json<{
agentId?: string
sessionKey?: string
status?: 'completed' | 'failed' | 'aborted' | 'incomplete'
finalAssistantMessage?: string
error?: string
}>()
if (!body.agentId?.trim()) {
return c.json({ error: 'agentId is required' }, 400)
}
if (!body.sessionKey?.trim()) {
return c.json({ error: 'sessionKey is required' }, 400)
}
if (
body.status !== 'completed' &&
body.status !== 'failed' &&
body.status !== 'aborted' &&
body.status !== 'incomplete'
) {
return c.json({ error: 'status is invalid' }, 400)
}
const envelope = await getMonitoringService().finalizeSession({
monitoringSessionId: runId,
agentId: body.agentId.trim(),
sessionKey: body.sessionKey.trim(),
status: body.status,
finalAssistantMessage: body.finalAssistantMessage,
error: body.error,
})
if (!envelope) {
return c.json({ error: 'Monitoring run not found' }, 404)
}
return c.json({ run: envelope })
})
}

View File

@@ -7,38 +7,26 @@
* Thin layer delegating to OpenClawService.
*/
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { logger } from '../../lib/logger'
import { getMonitoringService } from '../../monitoring/service'
import type { MonitoringChatTurn } from '../../monitoring/types'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
OpenClawInvalidAgentNameError,
OpenClawProtectedAgentError,
OpenClawSessionNotFoundError,
} from '../services/openclaw/errors'
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
import { getOpenClawService } from '../services/openclaw/openclaw-service'
function isValidBoundaryMode(
value: unknown,
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
return value === 'allow' || value === 'ask' || value === 'block'
}
function isValidCustomRoleBoundary(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const boundary = value as Record<string, unknown>
return (
typeof boundary.key === 'string' &&
typeof boundary.label === 'string' &&
typeof boundary.description === 'string' &&
isValidBoundaryMode(boundary.defaultMode)
)
function getCreateAgentValidationError(body: { name?: string }): string | null {
if (!body.name?.trim()) {
return 'Name is required'
}
return null
}
export function createOpenClawRoutes() {
@@ -72,7 +60,7 @@ export function createOpenClawRoutes() {
return c.json(
{
status: 'running',
port: OPENCLAW_GATEWAY_PORT,
port: getOpenClawService().getPort(),
agents: agents.map((a) => ({
agentId: a.agentId,
name: a.name,
@@ -89,7 +77,10 @@ export function createOpenClawRoutes() {
providerType: body.providerType,
providerName: body.providerName,
})
if (message.includes('Podman is not available')) {
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
if (message.includes('VM runtime is not available')) {
return c.json({ error: message }, 503)
}
return c.json({ error: message }, 500)
@@ -154,97 +145,23 @@ export function createOpenClawRoutes() {
}
})
.get('/roles', async (c) => {
return c.json({
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
id: role.id,
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps: role.recommendedApps,
boundaries: role.boundaries,
defaultAgentName: role.defaultAgentName,
})),
})
})
.post('/agents', async (c) => {
const body = await c.req.json<{
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}>()
const name = body.name?.trim()
if (!name) {
return c.json({ error: 'Name is required' }, 400)
}
if (body.roleId && body.customRole) {
return c.json(
{ error: 'Provide either roleId or customRole, not both' },
400,
)
}
if (
body.customRole &&
(!body.customRole.name?.trim() ||
!body.customRole.shortDescription?.trim() ||
!body.customRole.longDescription?.trim())
) {
return c.json(
{
error:
'Custom roles require name, shortDescription, and longDescription',
},
400,
)
}
if (
body.customRole &&
(!Array.isArray(body.customRole.recommendedApps) ||
!Array.isArray(body.customRole.boundaries))
) {
return c.json(
{
error: 'Custom roles require recommendedApps and boundaries arrays',
},
400,
)
}
if (
body.customRole &&
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
) {
return c.json(
{
error: 'Custom role recommendedApps must be an array of strings',
},
400,
)
}
if (
body.customRole &&
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
) {
return c.json(
{
error:
'Custom role boundaries must include key, label, description, and a valid defaultMode',
},
400,
)
const validationError = getCreateAgentValidationError(body)
if (validationError) {
return c.json({ error: validationError }, 400)
}
try {
const agent = await getOpenClawService().createAgent({
name,
roleId: body.roleId,
customRole: body.customRole,
name: body.name.trim(),
providerType: body.providerType,
providerName: body.providerName,
baseUrl: body.baseUrl,
@@ -259,6 +176,9 @@ export function createOpenClawRoutes() {
if (err instanceof OpenClawInvalidAgentNameError) {
return c.json({ error: err.message }, 400)
}
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
@@ -287,6 +207,7 @@ export function createOpenClawRoutes() {
const body = await c.req.json<{
message: string
sessionKey?: string
history?: MonitoringChatTurn[]
}>()
if (!body.message?.trim()) {
@@ -294,12 +215,37 @@ export function createOpenClawRoutes() {
}
const sessionKey = body.sessionKey ?? crypto.randomUUID()
const history = Array.isArray(body.history)
? body.history.filter((entry): entry is MonitoringChatTurn =>
Boolean(
entry &&
(entry.role === 'user' || entry.role === 'assistant') &&
typeof entry.content === 'string',
),
)
: []
if (getMonitoringService().getActiveSessionId(id)) {
return c.json(
{
error:
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
},
409,
)
}
const monitoringContext = await getMonitoringService().startSession({
agentId: id,
sessionKey,
originalPrompt: body.message.trim(),
chatHistory: history,
})
try {
const eventStream = await getOpenClawService().chatStream(
id,
sessionKey,
body.message,
history,
)
c.header('Content-Type', 'text/event-stream')
@@ -309,20 +255,123 @@ export function createOpenClawRoutes() {
return stream(c, async (s) => {
const reader = eventStream.getReader()
const encoder = new TextEncoder()
let finalAssistantMessage: string | undefined
let status: 'completed' | 'failed' | 'aborted' | 'incomplete' =
'incomplete'
let finalError: string | undefined
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (
value.type === 'done' &&
typeof value.data.text === 'string' &&
value.data.text.trim()
) {
finalAssistantMessage = value.data.text
status = 'completed'
}
if (value.type === 'error') {
finalError =
(typeof value.data.message === 'string'
? value.data.message
: typeof value.data.error === 'string'
? value.data.error
: undefined) ?? 'Unknown chat stream error'
status = 'failed'
}
await s.write(
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
)
}
await s.write(encoder.encode('data: [DONE]\n\n'))
} catch (error) {
if (c.req.raw.signal.aborted) {
status = 'aborted'
} else {
status = 'failed'
finalError =
error instanceof Error ? error.message : String(error)
}
throw error
} finally {
await reader.cancel()
await getMonitoringService().finalizeSession({
monitoringSessionId: monitoringContext.monitoringSessionId,
agentId: id,
sessionKey,
status,
finalAssistantMessage,
error: finalError,
})
}
})
} catch (err) {
await getMonitoringService().finalizeSession({
monitoringSessionId: monitoringContext.monitoringSessionId,
agentId: id,
sessionKey,
status: c.req.raw.signal.aborted ? 'aborted' : 'failed',
error: err instanceof Error ? err.message : String(err),
})
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.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)
}
@@ -352,12 +401,17 @@ export function createOpenClawRoutes() {
}
try {
await getOpenClawService().updateProviderKeys(body)
const result = await getOpenClawService().updateProviderKeys(body)
return c.json({
status: 'restarting',
message: 'Provider updated, restarting gateway',
status: result.restarted ? 'restarting' : 'updated',
message: result.restarted
? 'Provider updated, restarting gateway'
: 'Provider updated without a restart',
})
} catch (err) {
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
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'
@@ -29,6 +30,7 @@ import { createHealthRoute } from './routes/health'
import { createKlavisRoutes } from './routes/klavis'
import { createMcpRoutes } from './routes/mcp'
import { createMemoryRoutes } from './routes/memory'
import { createMonitoringRoutes } from './routes/monitoring'
import { createOAuthRoutes } from './routes/oauth'
import { createOpenClawRoutes } from './routes/openclaw'
import { createProviderRoutes } from './routes/provider'
@@ -44,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'
@@ -113,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,
}),
)
@@ -121,6 +124,10 @@ export async function createHttpServer(config: HttpServerConfig) {
.use('/*', requireTrustedAppOrigin())
.route('/', createAclRoutes({ policyService: aclPolicyService }))
const monitoringRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route('/', createMonitoringRoutes())
const app = new Hono<Env>()
.use('/*', cors(defaultCorsConfig))
.route('/health', createHealthRoute({ browser }))
@@ -143,6 +150,7 @@ export async function createHttpServer(config: HttpServerConfig) {
.route('/soul', createSoulRoutes())
.route('/memory', createMemoryRoutes())
.route('/skills', createSkillsRoutes())
.route('/monitoring', monitoringRoutes)
.route('/acl-rules', aclRoutes)
.route('/test-provider', createProviderRoutes({ browserosId }))
.route('/refine-prompt', createRefinePromptRoutes({ browserosId }))

View File

@@ -20,6 +20,7 @@ import { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
import { logger } from '../../../lib/logger'
import { metrics } from '../../../lib/metrics'
import type { ToolExecutionObserver } from '../../../monitoring/observer'
import { klavisStrataCache } from './strata-cache'
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
@@ -237,6 +238,7 @@ export function buildKlavisToolSet(handle: KlavisProxyHandle): ToolSet {
export function registerKlavisTools(
mcpServer: McpServer,
handle: KlavisProxyHandle,
observer?: ToolExecutionObserver,
): void {
mcpServer.registerTool(
'connector_mcp_servers',
@@ -247,9 +249,16 @@ export function registerKlavisTools(
},
async (args: Record<string, unknown>) => {
const startTime = performance.now()
const toolCallId = crypto.randomUUID()
const server_name = args.server_name as string
try {
await observer?.onToolStart({
toolCallId,
toolName: 'connector_mcp_servers',
source: 'klavis-tool',
args,
})
const klavisClient = new KlavisClient()
const integrations = await klavisClient.getUserIntegrations(
handle.browserosId,
@@ -266,6 +275,14 @@ export function registerKlavisTools(
success: true,
})
await observer?.onToolEnd({
toolCallId,
output: {
connected: true,
server_name,
},
})
return {
content: [
{
@@ -294,6 +311,15 @@ export function registerKlavisTools(
success: true,
})
await observer?.onToolEnd({
toolCallId,
output: {
connected: false,
server_name,
authUrl,
},
})
return {
content: [
{
@@ -320,6 +346,11 @@ export function registerKlavisTools(
error_message: errorText,
})
await observer?.onToolEnd({
toolCallId,
error: errorText,
})
return {
content: [{ type: 'text' as const, text: errorText }],
isError: true,
@@ -339,7 +370,14 @@ export function registerKlavisTools(
},
async (args: Record<string, unknown>) => {
const startTime = performance.now()
const toolCallId = crypto.randomUUID()
try {
await observer?.onToolStart({
toolCallId,
toolName: tool.name,
source: 'klavis-tool',
args,
})
const result = await handle.callTool(tool.name, args)
metrics.log('tool_executed', {
@@ -349,6 +387,12 @@ export function registerKlavisTools(
success: !result.isError,
})
await observer?.onToolEnd({
toolCallId,
output: result,
error: result.isError ? 'Tool returned isError=true' : undefined,
})
return result
} catch (error) {
const errorText =
@@ -362,6 +406,11 @@ export function registerKlavisTools(
error_message: errorText,
})
await observer?.onToolEnd({
toolCallId,
error: errorText,
})
return {
content: [{ type: 'text' as const, text: errorText }],
isError: true,

View File

@@ -8,6 +8,7 @@ import type { AclRule } from '@browseros/shared/types/acl'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import type { Browser } from '../../../browser/browser'
import type { ToolExecutionObserver } from '../../../monitoring/observer'
import type { ToolRegistry } from '../../../tools/tool-registry'
import {
type KlavisProxyRef,
@@ -24,6 +25,7 @@ export interface McpServiceDeps {
resourcesDir: string
aclRules?: AclRule[]
klavisRef?: KlavisProxyRef
observer?: ToolExecutionObserver
}
export function createMcpServer(deps: McpServiceDeps): McpServer {
@@ -48,11 +50,12 @@ export function createMcpServer(deps: McpServiceDeps): McpServer {
resourcesDir: deps.resourcesDir,
},
aclRules: deps.aclRules,
observer: deps.observer,
})
// Register Klavis proxy tools (if connected via background init)
if (deps.klavisRef?.handle) {
registerKlavisTools(server, deps.klavisRef.handle)
registerKlavisTools(server, deps.klavisRef.handle, deps.observer)
}
return server

View File

@@ -1,13 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { logger } from '../../../lib/logger'
import { metrics } from '../../../lib/metrics'
import type { ToolExecutionObserver } from '../../../monitoring/observer'
import { executeTool, type ToolContext } from '../../../tools/framework'
import type { ToolRegistry } from '../../../tools/tool-registry'
export function registerTools(
mcpServer: McpServer,
registry: ToolRegistry,
ctx: ToolContext,
ctx: ToolContext & { observer?: ToolExecutionObserver },
): void {
for (const tool of registry.all()) {
const handler = async (
@@ -15,9 +16,16 @@ export function registerTools(
extra: { signal: AbortSignal },
) => {
const startTime = performance.now()
const toolCallId = crypto.randomUUID()
try {
logger.info(`${tool.name} request: ${JSON.stringify(args, null, ' ')}`)
await ctx.observer?.onToolStart({
toolCallId,
toolName: tool.name,
source: 'browser-tool',
args,
})
const result = await executeTool(tool, args, ctx, extra.signal)
@@ -28,6 +36,12 @@ export function registerTools(
source: 'mcp',
})
await ctx.observer?.onToolEnd({
toolCallId,
output: result.structuredContent ?? result.content,
error: result.isError ? 'Tool returned isError=true' : undefined,
})
return {
content: result.content,
isError: result.isError,
@@ -44,6 +58,11 @@ export function registerTools(
source: 'mcp',
})
await ctx.observer?.onToolEnd({
toolCallId,
error: errorText,
})
return {
content: [{ type: 'text' as const, text: errorText }],
isError: true,

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,191 +2,290 @@
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Compose-level abstraction over PodmanRuntime.
* Manages a single compose project for the OpenClaw gateway container.
*/
import { copyFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
OPENCLAW_COMPOSE_PROJECT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
} from '@browseros/shared/constants/openclaw'
import type { ContainerCli, ContainerSpec } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import type { LogFn, PodmanRuntime } from './podman-runtime'
import {
GUEST_VM_STATE,
hostPathToGuest,
type LogFn,
type VmRuntime,
} from '../../../lib/vm'
const COMPOSE_FILE_NAME = 'docker-compose.yml'
const ENV_FILE_NAME = '.env'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
export type GatewayContainerSpec = {
image: string
hostPort: number
hostHome: string
envFilePath: string
gatewayToken?: string
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 composeUp(onLog?: LogFn): Promise<void> {
const code = await this.compose(['up', '-d'], onLog)
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
async pullImage(image: string, onLog?: LogFn): Promise<void> {
await this.loader.ensureImageLoaded(image, onLog)
}
async composeDown(onLog?: LogFn): Promise<void> {
const code = await this.compose(['down'], onLog)
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
async startGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.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 composeStop(onLog?: LogFn): Promise<void> {
const code = await this.compose(['stop'], onLog)
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
async stopGateway(onLog?: LogFn): Promise<void> {
await this.removeGatewayContainer(onLog)
}
async composeRestart(onLog?: LogFn): Promise<void> {
const code = await this.compose(['restart'], onLog)
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
async restartGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.startGateway(input, onLog)
}
async composePull(onLog?: LogFn): Promise<void> {
const code = await this.compose(['pull', '--quiet'], onLog)
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
}
async composeLogs(tail = 50): Promise<string[]> {
async getGatewayLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
lines.push(line),
await this.vm.runCommand(
['nerdctl', 'logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
{ onOutput: (line) => lines.push(line) },
)
return lines
}
async isHealthy(port: number): Promise<boolean> {
async isHealthy(hostPort: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
return res.ok
} catch {
return false
}
}
async isReady(port: number): Promise<boolean> {
async isReady(hostPort: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
return res.ok
} catch {
return false
}
}
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
logger.info('Waiting for OpenClaw gateway readiness', { port, timeoutMs })
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
logger.info('Waiting for OpenClaw gateway readiness', {
hostPort,
timeoutMs,
})
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.isReady(port)) {
logger.info('OpenClaw gateway became ready', {
port,
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', {
port,
hostPort,
timeoutMs,
})
return false
}
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
}
async writeEnvFile(content: string): Promise<void> {
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
mode: 0o600,
})
}
/**
* Stops the Podman machine only if no non-BrowserOS containers are running.
* Prevents killing the user's own Podman workloads.
*/
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.startsWith(OPENCLAW_COMPOSE_PROJECT_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(
command: string[],
spec: GatewayContainerSpec,
onLog?: LogFn,
): Promise<number> {
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.vm.runCommand(['nerdctl', 'rm', '-f', setupContainerName], {
onOutput: onLog,
})
await this.loader.ensureImageLoaded(spec.image, onLog)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
const createExitCode = await this.vm.runCommand(
[
'nerdctl',
'create',
'--name',
setupContainerName,
...(await this.buildGatewayRunArgs(spec)),
spec.image,
'node',
...setupArgs,
],
{ onOutput: onLog },
)
if (createExitCode !== 0) {
await this.vm.runCommand(['nerdctl', 'rm', '-f', setupContainerName], {
onOutput: onLog,
})
return createExitCode
}
try {
return await this.vm.runCommand(
['nerdctl', 'start', '-a', setupContainerName],
{ onOutput: onLog },
)
} finally {
await this.vm.runCommand(['nerdctl', 'rm', '-f', setupContainerName], {
onOutput: onLog,
})
}
}
tailGatewayLogs(onLine: LogFn): () => void {
return this.podman.tailContainerLogs(
OPENCLAW_GATEWAY_CONTAINER_NAME,
onLine,
)
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
}
private async compose(args: string[], onLog?: LogFn): Promise<number> {
const lines: string[] = []
const command = ['podman', 'compose', ...args].join(' ')
logger.info('Running OpenClaw compose command', {
command,
})
const code = await this.podman.runCommand(['compose', ...args], {
cwd: this.projectDir,
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
onOutput: (line) => {
lines.push(line)
onLog?.(line)
},
})
if (code !== 0) {
logger.error('OpenClaw compose command failed', {
command,
exitCode: code,
output: lines,
})
} else {
logger.info('OpenClaw compose command succeeded', {
command,
})
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
if (onLog) {
await this.shell.removeContainer(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
onLog,
)
return
}
await this.shell.removeContainer(OPENCLAW_GATEWAY_CONTAINER_NAME, {
force: true,
})
}
return code
private async buildGatewayContainerSpec(
input: GatewayContainerSpec,
): 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',
this.translateHostPath(input.envFilePath, input.hostHome),
'-v',
`${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

@@ -1,754 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* WebSocket client for the OpenClaw Gateway protocol.
* Handles handshake (challenge → connect → hello-ok) with Ed25519 device
* identity signing, JSON-RPC over WS, and auto-reconnect.
* Used for agent CRUD and health — chat uses HTTP.
*/
import crypto from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import { logger } from '../../../lib/logger'
const RPC_TIMEOUT_MS = 15_000
const SCOPES = [
'operator.read',
'operator.write',
'operator.admin',
'operator.approvals',
'operator.pairing',
]
interface DeviceIdentity {
deviceId: string
publicKeyPem: string
privateKeyPem: string
}
interface PendingRequest {
resolve: (value: unknown) => void
reject: (reason: Error) => void
timer: ReturnType<typeof setTimeout>
}
interface WsFrame {
type: 'req' | 'res' | 'event'
id?: string
method?: string
params?: Record<string, unknown>
ok?: boolean
payload?: Record<string, unknown>
error?: { message: string; code?: string }
event?: string
}
export type GatewayClientConnectionState =
| 'idle'
| 'connecting'
| 'connected'
| 'closed'
| 'failed'
export interface GatewayHandshakeError {
code?: string
message: string
}
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}
export interface GatewayAgentEntry {
agentId: string
name: string
workspace: string
model?: string
}
// ── Device Identity Helpers ─────────────────────────────────────────
function rawPublicKeyFromPem(pem: string): Buffer {
const der = Buffer.from(
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
'base64',
)
return der.subarray(12)
}
function signChallenge(
device: DeviceIdentity,
nonce: string,
token: string,
): { signature: string; signedAt: number; publicKey: string } {
const signedAt = Date.now()
const payload = `v3|${device.deviceId}|cli|cli|operator|${SCOPES.join(',')}|${signedAt}|${token}|${nonce}|${process.platform}|`
const privateKey = crypto.createPrivateKey(device.privateKeyPem)
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
return {
signature: sig.toString('base64url'),
signedAt,
publicKey: rawPublicKeyFromPem(device.publicKeyPem).toString('base64url'),
}
}
/**
* Generates a client Ed25519 identity and pre-seeds it into the gateway's
* paired devices file so the gateway trusts it on next boot.
* Must be called before compose up (or requires a restart after).
*/
export function ensureClientIdentity(openclawDir: string): DeviceIdentity {
const identityPath = join(openclawDir, 'client-identity.json')
try {
return JSON.parse(readFileSync(identityPath, 'utf-8'))
} catch {
// Generate new identity
}
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
const publicKeyPem = publicKey
.export({ type: 'spki', format: 'pem' })
.toString()
const privateKeyPem = privateKey
.export({ type: 'pkcs8', format: 'pem' })
.toString()
const rawPub = rawPublicKeyFromPem(publicKeyPem)
const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex')
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem }
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
mode: 0o600,
})
seedPairedDevice(openclawDir, identity)
logger.info('Generated client device identity and pre-seeded pairing')
return identity
}
function seedPairedDevice(openclawDir: string, identity: DeviceIdentity): void {
const devicesDir = join(openclawDir, 'devices')
mkdirSync(devicesDir, { recursive: true })
const pairedPath = join(devicesDir, 'paired.json')
let paired: Record<string, unknown> = {}
try {
paired = JSON.parse(readFileSync(pairedPath, 'utf-8'))
} catch {
// First time
}
const rawPub = rawPublicKeyFromPem(identity.publicKeyPem)
paired[identity.deviceId] = {
deviceId: identity.deviceId,
publicKey: rawPub.toString('base64url'),
platform: process.platform,
clientId: 'cli',
clientMode: 'cli',
role: 'operator',
roles: ['operator'],
scopes: SCOPES,
pairedAt: Date.now(),
label: 'browseros-server',
}
writeFileSync(pairedPath, JSON.stringify(paired, null, 2), { mode: 0o600 })
}
// ── Gateway Client ──────────────────────────────────────────────────
export class GatewayClient {
private ws: WebSocket | null = null
private _connected = false
private pendingRequests = new Map<string, PendingRequest>()
private device: DeviceIdentity | null = null
private connectionState: GatewayClientConnectionState = 'idle'
private lastHandshakeError: GatewayHandshakeError | null = null
constructor(
private readonly port: number,
private readonly token: string,
private readonly openclawDir: string,
private readonly version = '1.0.0',
) {
try {
const identityPath = join(this.openclawDir, 'client-identity.json')
this.device = JSON.parse(readFileSync(identityPath, 'utf-8'))
} catch {
logger.warn('Client device identity not found, WS auth may fail')
}
}
get isConnected(): boolean {
return this._connected
}
get state(): GatewayClientConnectionState {
return this.connectionState
}
get lastError(): GatewayHandshakeError | null {
return this.lastHandshakeError
}
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.connectionState = 'connecting'
this.lastHandshakeError = null
logger.info('Connecting to OpenClaw Gateway WS', {
port: this.port,
hasDeviceIdentity: !!this.device,
})
this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`, {
headers: { Origin: `http://127.0.0.1:${this.port}` },
} as unknown as string[])
let handshakeComplete = false
let connectReqId: string | null = null
this.ws.onmessage = (event) => {
const frame = GatewayClient.parseFrame(event.data)
if (!frame) return
if (!handshakeComplete) {
if (frame.type === 'event' && frame.event === 'connect.challenge') {
const nonce = (frame.payload as Record<string, unknown>)
?.nonce as string
logger.info('Received OpenClaw Gateway challenge', {
hasNonce: !!nonce,
hasDeviceIdentity: !!this.device,
})
connectReqId = globalThis.crypto.randomUUID()
const params: Record<string, unknown> = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'cli',
version: this.version,
platform: process.platform,
mode: 'cli',
},
role: 'operator',
scopes: SCOPES,
caps: [],
commands: [],
permissions: {},
auth: { token: this.token },
locale: 'en-US',
userAgent: `browseros-server/${this.version}`,
}
if (this.device && nonce) {
const signed = signChallenge(this.device, nonce, this.token)
params.device = {
id: this.device.deviceId,
publicKey: signed.publicKey,
signature: signed.signature,
signedAt: signed.signedAt,
nonce,
}
}
this.ws?.send(
JSON.stringify({
type: 'req',
id: connectReqId,
method: 'connect',
params,
}),
)
return
}
if (frame.type === 'res' && frame.id === connectReqId) {
if (frame.ok) {
handshakeComplete = true
this._connected = true
this.connectionState = 'connected'
logger.info('Gateway WS connected')
resolve()
} else {
const msg = frame.error?.message ?? 'Handshake failed'
this.connectionState = 'failed'
this.lastHandshakeError = {
message: msg,
code: frame.error?.code,
}
logger.error('Gateway WS handshake rejected', {
error: msg,
code: frame.error?.code,
})
reject(new Error(msg))
}
return
}
return
}
this.resolvePendingRequest(frame)
}
this.ws.onerror = (err) => {
logger.error('Gateway WS socket error', {
error: err instanceof Error ? err.message : 'unknown',
handshakeComplete,
})
if (!handshakeComplete) {
this.connectionState = 'failed'
reject(
new Error(
`WS connection error: ${err instanceof Error ? err.message : 'unknown'}`,
),
)
}
}
this.ws.onclose = () => {
this._connected = false
this.connectionState = 'closed'
this.rejectAllPending('WebSocket closed')
if (handshakeComplete) {
logger.info('Gateway WS disconnected')
}
this.ws = null
}
})
}
disconnect(): void {
this._connected = false
this.connectionState = 'closed'
this.rejectAllPending('Client disconnecting')
if (this.ws) {
this.ws.onclose = null
this.ws.close()
this.ws = null
}
}
// ── RPC ──────────────────────────────────────────────────────────────
async rpc<T = Record<string, unknown>>(
method: string,
params: Record<string, unknown> = {},
): Promise<T> {
if (!this._connected || !this.ws) {
throw new Error('Gateway WS not connected')
}
const id = globalThis.crypto.randomUUID()
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id)
reject(new Error(`RPC timeout: ${method}`))
}, RPC_TIMEOUT_MS)
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timer,
})
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
})
}
// ── Agent Methods ────────────────────────────────────────────────────
async listAgents(): Promise<GatewayAgentEntry[]> {
const result = await this.rpc<{
agents: Array<{
id: string
name?: string
workspace: string
model?: string
}>
}>('agents.list')
return (result.agents ?? []).map((a) => ({
agentId: a.id,
name: a.name ?? a.id,
workspace: a.workspace,
model: a.model,
}))
}
async createAgent(input: {
name: string
workspace: string
model?: string
}): Promise<GatewayAgentEntry> {
const result = await this.rpc<{
agentId?: string
id?: string
name?: string
workspace?: string
model?: string
}>('agents.create', input)
return {
agentId: result.agentId ?? result.id ?? input.name,
name: result.name ?? input.name,
workspace: result.workspace ?? input.workspace,
model: result.model ?? input.model,
}
}
async deleteAgent(agentId: string): Promise<void> {
await this.rpc('agents.delete', { id: agentId })
}
// ── Health ───────────────────────────────────────────────────────────
async getHealth(): Promise<Record<string, unknown>> {
return this.rpc('health')
}
// ── Chat Stream ─────────────────────────────────────────────────────
chatStream(
agentId: string,
sessionKey: string,
message: string,
): ReadableStream<OpenClawStreamEvent> {
if (!this._connected) {
throw new Error('Gateway WS not connected')
}
const fullSessionKey = `agent:${agentId}:browseros-${sessionKey}`
const idempotencyKey = globalThis.crypto.randomUUID()
const streamClient = new GatewayClient(
this.port,
this.token,
this.openclawDir,
this.version,
)
return new ReadableStream<OpenClawStreamEvent>({
start: async (controller) => {
try {
await streamClient.connect()
} catch (error) {
controller.enqueue({
type: 'error',
data: {
message:
error instanceof Error
? error.message
: 'Gateway WS not connected',
},
})
controller.close()
return
}
const ws = streamClient.ws
if (!ws) {
controller.enqueue({
type: 'error',
data: { message: 'Gateway WS not connected' },
})
controller.close()
return
}
const subscribeId = globalThis.crypto.randomUUID()
const agentReqId = globalThis.crypto.randomUUID()
let finished = false
const finish = (event?: OpenClawStreamEvent) => {
if (finished) return
finished = true
if (event) controller.enqueue(event)
controller.close()
streamClient.disconnect()
}
ws.onmessage = (event) => {
const frame = GatewayClient.parseFrame(event.data)
if (!frame) return
if (
this.handleChatStreamControlFrame(
frame,
subscribeId,
agentReqId,
finish,
)
) {
return
}
this.handleChatStreamEventFrame(frame, controller, finish)
}
ws.onclose = () => {
if (finished) return
finish({
type: 'error',
data: { message: 'Gateway WS disconnected' },
})
}
ws.onerror = () => {
if (finished) return
finish({
type: 'error',
data: { message: 'Gateway WS connection error' },
})
}
ws.send(
JSON.stringify({
type: 'req',
id: subscribeId,
method: 'sessions.subscribe',
params: { sessionKey: fullSessionKey },
}),
)
ws.send(
JSON.stringify({
type: 'req',
id: agentReqId,
method: 'agent',
params: {
message,
sessionKey: fullSessionKey,
idempotencyKey,
},
}),
)
},
cancel: () => {
if (streamClient.ws?.readyState === WebSocket.OPEN) {
streamClient.ws.send(
JSON.stringify({
type: 'req',
id: globalThis.crypto.randomUUID(),
method: 'sessions.abort',
params: { sessionKey: fullSessionKey },
}),
)
}
streamClient.disconnect()
},
})
}
// ── Helpers ──────────────────────────────────────────────────────────
static agentWorkspace(name: string): string {
return name === 'main'
? `${OPENCLAW_CONTAINER_HOME}/workspace`
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
}
private static parseFrame(data: unknown): WsFrame | null {
try {
return JSON.parse(
typeof data === 'string'
? data
: new TextDecoder().decode(data as ArrayBuffer),
) as WsFrame
} catch {
return null
}
}
private rejectAllPending(reason: string): void {
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new Error(reason))
this.pendingRequests.delete(id)
}
}
private resolvePendingRequest(frame: WsFrame): void {
if (frame.type !== 'res' || !frame.id) return
const pending = this.pendingRequests.get(frame.id)
if (!pending) return
this.pendingRequests.delete(frame.id)
clearTimeout(pending.timer)
if (frame.ok) {
pending.resolve(frame.payload)
} else {
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
}
}
private handleChatStreamControlFrame(
frame: WsFrame,
subscribeId: string,
agentReqId: string,
finish: (event?: OpenClawStreamEvent) => void,
): boolean {
if (frame.type !== 'res' || !frame.id) return false
if (frame.id !== subscribeId && frame.id !== agentReqId) return false
if (!frame.ok) {
finish({
type: 'error',
data: {
message: frame.error?.message ?? 'RPC error',
code: frame.error?.code,
},
})
}
return true
}
private handleChatStreamEventFrame(
frame: WsFrame,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
finish: (event?: OpenClawStreamEvent) => void,
): void {
if (frame.type !== 'event' || !frame.event || !frame.payload) return
switch (frame.event) {
case 'agent':
this.handleAgentStreamEvent(frame.payload, controller)
return
case 'session.tool':
this.handleSessionToolStreamEvent(frame.payload, controller)
return
case 'session.message':
this.handleSessionMessageStreamEvent(frame.payload, controller)
return
case 'chat':
this.handleChatCompletionEvent(frame.payload, finish)
return
default:
return
}
}
private handleAgentStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const streamType = payload.stream as string | undefined
const data = payload.data as Record<string, unknown> | undefined
if (streamType === 'assistant' && data?.delta) {
controller.enqueue({
type: 'text-delta',
data: { text: data.delta },
})
return
}
if (streamType === 'item' && data) {
const phase = data.phase as string | undefined
if (phase === 'start') {
controller.enqueue({
type: 'tool-start',
data: {
toolCallId: data.toolCallId ?? data.id,
toolName: data.name ?? data.title,
kind: data.kind,
},
})
return
}
if (phase === 'end') {
controller.enqueue({
type: 'tool-end',
data: {
toolCallId: data.toolCallId ?? data.id,
status: data.status,
durationMs: data.durationMs,
},
})
return
}
}
if (streamType === 'lifecycle') {
controller.enqueue({
type: 'lifecycle',
data: { phase: data?.phase ?? payload.phase },
})
}
}
private handleSessionToolStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const toolData = (payload.data as Record<string, unknown>) ?? payload
const phase = (toolData.phase as string) ?? (payload.phase as string)
if (phase !== 'result') return
controller.enqueue({
type: 'tool-output',
data: {
toolCallId: toolData.toolCallId,
isError: toolData.isError ?? false,
meta: toolData.meta,
},
})
}
private handleSessionMessageStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const message = payload.message as Record<string, unknown> | undefined
if (message?.role !== 'assistant') return
const content = message.content as
| Array<Record<string, unknown>>
| undefined
if (!content) return
for (const block of content) {
if (block.type !== 'thinking') continue
const text =
(block.thinking as string) ??
(block.content as string) ??
(block.text as string) ??
''
if (!text) continue
controller.enqueue({
type: 'thinking',
data: { text },
})
}
}
private handleChatCompletionEvent(
payload: Record<string, unknown>,
finish: (event?: OpenClawStreamEvent) => void,
): void {
if ((payload.state as string | undefined) !== 'final') return
finish({
type: 'done',
data: { text: (payload.text as string) ?? '' },
})
}
}

View File

@@ -0,0 +1,407 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
type LogFn = (line: string) => void
interface ContainerExecutor {
execInContainer(command: string[], onLog?: LogFn): Promise<number>
}
export interface OpenClawConfigBatchEntry {
path: string
value: unknown
}
interface RawAgentRecord {
id: string
name?: string
workspace: string
model?: string
}
export interface OpenClawAgentRecord {
agentId: string
name: string
workspace: string
model?: string
}
export class OpenClawCliClient {
constructor(private readonly executor: ContainerExecutor) {}
async runOnboard(
input: {
acceptRisk?: boolean
authChoice?: string
customBaseUrl?: string
customCompatibility?: 'anthropic' | 'openai-completions'
customModelId?: string
customProviderId?: string
gatewayAuth?: 'none' | 'password' | 'token'
gatewayBind?: 'auto' | 'custom' | 'lan' | 'loopback' | 'tailnet'
gatewayPort?: number
gatewayToken?: string
gatewayTokenRefEnv?: string
installDaemon?: boolean
mode?: 'local' | 'remote'
nonInteractive?: boolean
reset?: boolean
resetScope?: 'config' | 'config+creds+sessions' | 'full'
secretInputMode?: 'plain' | 'ref'
skipHealth?: boolean
workspace?: string
} = {},
): Promise<void> {
const args = ['onboard']
if (input.nonInteractive) {
args.push('--non-interactive')
}
if (input.mode) {
args.push('--mode', input.mode)
}
if (input.workspace) {
args.push('--workspace', input.workspace)
}
if (input.reset) {
args.push('--reset')
}
if (input.resetScope) {
args.push('--reset-scope', input.resetScope)
}
if (input.authChoice) {
args.push('--auth-choice', input.authChoice)
}
if (input.secretInputMode) {
args.push('--secret-input-mode', input.secretInputMode)
}
if (input.customBaseUrl) {
args.push('--custom-base-url', input.customBaseUrl)
}
if (input.customModelId) {
args.push('--custom-model-id', input.customModelId)
}
if (input.customProviderId) {
args.push('--custom-provider-id', input.customProviderId)
}
if (input.customCompatibility) {
args.push('--custom-compatibility', input.customCompatibility)
}
if (input.gatewayAuth) {
args.push('--gateway-auth', input.gatewayAuth)
}
if (input.gatewayToken) {
args.push('--gateway-token', input.gatewayToken)
}
if (input.gatewayTokenRefEnv) {
args.push('--gateway-token-ref-env', input.gatewayTokenRefEnv)
}
if (input.gatewayPort) {
args.push('--gateway-port', String(input.gatewayPort))
}
if (input.gatewayBind) {
args.push('--gateway-bind', input.gatewayBind)
}
if (input.installDaemon === true) {
args.push('--install-daemon')
} else if (input.installDaemon === false) {
args.push('--no-install-daemon')
}
if (input.skipHealth) {
args.push('--skip-health')
}
if (input.acceptRisk) {
args.push('--accept-risk')
}
await this.runCommand(args)
}
async setConfig(path: string, value: unknown): Promise<void> {
await this.runCommand(['config', 'set', path, formatConfigValue(value)])
}
async setConfigBatch(entries: OpenClawConfigBatchEntry[]): Promise<void> {
await this.runCommand([
'config',
'set',
'--batch-json',
JSON.stringify(entries),
])
}
async getConfig(path: string): Promise<unknown> {
const output = await this.runCommand(['config', 'get', path])
return parseConfigValue(output)
}
async validateConfig(): Promise<unknown> {
const output = await this.runCommand(['config', 'validate', '--json'])
return parseConfigValue(output)
}
async setDefaultModel(model: string): Promise<void> {
await this.runCommand(['models', 'set', model])
}
async listAgents(): Promise<OpenClawAgentRecord[]> {
const records = await this.runAgentListCommand()
const agents = Array.isArray(records) ? records : (records.agents ?? [])
return agents.map((record) => ({
agentId: record.id,
name: record.name ?? record.id,
workspace: record.workspace,
model: record.model,
}))
}
async createAgent(input: {
name: string
model?: string
}): Promise<OpenClawAgentRecord> {
const workspace = this.agentWorkspace(input.name)
const args = ['agents', 'add', input.name, '--workspace', workspace]
if (input.model) {
args.push('--model', input.model)
}
args.push('--non-interactive', '--json')
await this.runCommand(args)
const agents = await this.listAgents()
const agent = agents.find((entry) => entry.agentId === input.name)
if (!agent) {
throw new Error(`Created agent ${input.name} was not found in agent list`)
}
return agent
}
async deleteAgent(agentId: string): Promise<void> {
await this.runCommand(['agents', 'delete', agentId, '--force', '--json'])
}
async probe(): Promise<void> {
await this.listAgents()
}
private agentWorkspace(name: string): string {
return name === 'main'
? `${OPENCLAW_CONTAINER_HOME}/workspace`
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
}
private async runCommand(args: string[]): Promise<string> {
const output: string[] = []
const command = ['node', 'dist/index.js', ...args]
const exitCode = await this.executor.execInContainer(command, (line) => {
output.push(line)
})
if (exitCode !== 0) {
const detail = output.join('\n').trim()
throw new Error(
detail || `OpenClaw command failed (${args.slice(0, 2).join(' ')})`,
)
}
return output.join('\n').trim()
}
private async runAgentListCommand(): Promise<
RawAgentRecord[] | { agents?: RawAgentRecord[] }
> {
const output = await this.runCommand(['agents', 'list', '--json'])
return parseAgentListOutput(output)
}
}
function formatConfigValue(value: unknown): string {
if (typeof value === 'string') return value
return JSON.stringify(value)
}
function parseConfigValue(output: string): unknown {
const parsed = selectConfigJson<unknown>(output)
return parsed ?? output
}
function parseAgentListOutput(
output: string,
): RawAgentRecord[] | { agents?: RawAgentRecord[] } {
const parsed = parseFirstMatchingJson<
RawAgentRecord[] | { agents?: RawAgentRecord[] }
>(output, isAgentListPayload)
if (parsed !== null) return parsed
throw new Error(
`Failed to parse OpenClaw JSON output: ${output.slice(0, 200)}`,
)
}
function parseFirstMatchingJson<T>(
output: string,
predicate?: (value: unknown) => boolean,
): T | null {
const candidates = collectJsonCandidates(output)
for (const candidate of candidates) {
const parsed = tryParseJson<T>(candidate)
if (parsed === null) continue
if (predicate && !predicate(parsed)) continue
return parsed
}
return null
}
function selectConfigJson<T>(output: string): T | null {
const candidates = collectJsonCandidates(output)
const parsedCandidates: Array<{ text: string; value: T }> = []
for (const candidate of candidates) {
const parsed = tryParseJson<T>(candidate)
if (parsed === null) continue
if (isStructuredLogPayload(parsed)) continue
parsedCandidates.push({ text: candidate, value: parsed })
}
if (parsedCandidates.length === 0) return null
return parsedCandidates.reduce((best, candidate) =>
candidate.text.length > best.text.length ? candidate : best,
).value
}
function collectJsonCandidates(output: string): string[] {
const candidates = [output.trim()]
for (const line of output.split(/\r?\n/)) {
const trimmed = line.trim()
if (trimmed) candidates.push(trimmed)
}
for (let index = 0; index < output.length; index += 1) {
const char = output[index]
if (char !== '[' && char !== '{') continue
const extracted = extractJsonSubstring(output, index)
if (extracted) {
candidates.push(extracted)
}
}
return candidates
}
function extractJsonSubstring(
output: string,
startIndex: number,
): string | null {
const opening = output[startIndex]
const closing = opening === '{' ? '}' : ']'
const stack: string[] = [closing]
let inString = false
let escaped = false
for (let index = startIndex + 1; index < output.length; index += 1) {
const char = output[index]
if (inString) {
if (escaped) {
escaped = false
continue
}
if (char === '\\') {
escaped = true
continue
}
if (char === '"') {
inString = false
}
continue
}
if (char === '"') {
inString = true
continue
}
if (char === '{') {
stack.push('}')
continue
}
if (char === '[') {
stack.push(']')
continue
}
const expectedClosing = stack[stack.length - 1]
if (char === expectedClosing) {
stack.pop()
if (stack.length === 0) {
return output.slice(startIndex, index + 1)
}
}
}
return null
}
function tryParseJson<T>(value: string): T | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
return JSON.parse(trimmed) as T
} catch {
return null
}
}
function isAgentListPayload(
value: unknown,
): value is RawAgentRecord[] | { agents?: RawAgentRecord[] } {
if (Array.isArray(value)) {
return value.every(isRawAgentRecord)
}
if (!isPlainObject(value)) return false
if (!('agents' in value)) return false
const agents = (value as { agents?: unknown }).agents
return (
agents === undefined ||
(Array.isArray(agents) && agents.every(isRawAgentRecord))
)
}
function isRawAgentRecord(value: unknown): value is RawAgentRecord {
return (
isPlainObject(value) &&
typeof value.id === 'string' &&
typeof value.workspace === 'string' &&
(value.name === undefined || typeof value.name === 'string') &&
(value.model === undefined || typeof value.model === 'string')
)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isStructuredLogPayload(value: unknown): boolean {
if (!isPlainObject(value)) return false
return (
typeof value.level === 'string' &&
(typeof value.message === 'string' || typeof value.msg === 'string')
)
}

View File

@@ -1,279 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Pure functions for building OpenClaw bootstrap configuration.
* Config is write-once at setup — agent CRUD uses WS RPC, not config edits.
*/
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_PORT,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
export const PROVIDER_ENV_MAP: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
groq: 'GROQ_API_KEY',
mistral: 'MISTRAL_API_KEY',
}
export interface OpenClawProviderInput {
providerType?: string
providerName?: string
baseUrl?: string
modelId?: string
apiKey?: string
}
export interface BootstrapConfigInput {
gatewayPort: number
gatewayToken: string
browserosServerPort?: number
providerType?: string
providerName?: string
baseUrl?: string
modelId?: string
}
export interface EnvFileInput {
image?: string
port?: number
token: string
configDir: string
timezone?: string
providerKeys?: Record<string, string>
}
export interface ResolvedProviderConfig {
model?: string
providerKeys: Record<string, string>
models?: {
mode: 'merge'
providers: Record<string, Record<string, unknown>>
}
}
function hasBuiltinProvider(providerType?: string): providerType is string {
return !!providerType && providerType in PROVIDER_ENV_MAP
}
/**
* OpenRouter's public slugs use dots for version numbers
* (e.g. `anthropic/claude-haiku-4.5`), but openclaw's model registry expects
* dashes (`claude-haiku-4-5`). Passing the dotted form makes openclaw fail
* the registry lookup silently and the agent turn completes with zero
* payloads. Rewrite dots to dashes for openrouter model ids only.
*/
function normalizeBuiltinModelId(
providerType: string,
modelId: string,
): string {
if (providerType !== 'openrouter') return modelId
return modelId.replace(/\./g, '-')
}
export function deriveOpenClawProviderId(providerInput: {
providerType?: string
providerName?: string
baseUrl?: string
}): string {
const source =
providerInput.providerName?.trim() ||
providerInput.baseUrl?.trim() ||
providerInput.providerType?.trim() ||
'custom-provider'
const candidate = source
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
return candidate || 'custom-provider'
}
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
}
export function resolveProviderConfig(
input: OpenClawProviderInput,
): ResolvedProviderConfig {
if (!input.providerType) {
return { providerKeys: {} }
}
if (hasBuiltinProvider(input.providerType)) {
const providerKeys: Record<string, string> = {}
if (input.apiKey) {
providerKeys[PROVIDER_ENV_MAP[input.providerType]] = input.apiKey
}
const normalizedModelId = input.modelId
? normalizeBuiltinModelId(input.providerType, input.modelId)
: undefined
return {
providerKeys,
model: normalizedModelId
? `${input.providerType}/${normalizedModelId}`
: undefined,
}
}
if (!input.baseUrl) {
return { providerKeys: {} }
}
const providerId = deriveOpenClawProviderId(input)
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
const providerKeys: Record<string, string> = {}
if (input.apiKey) {
providerKeys[apiKeyEnvVar] = input.apiKey
}
const providerConfig: Record<string, unknown> = {
baseUrl: input.baseUrl,
apiKey: `\${${apiKeyEnvVar}}`,
api: 'openai-completions',
}
if (input.modelId) {
providerConfig.models = [{ id: input.modelId, name: input.modelId }]
}
return {
providerKeys,
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
models: {
mode: 'merge',
providers: {
[providerId]: providerConfig,
},
},
}
}
export function buildBootstrapConfig(
input: BootstrapConfigInput,
): Record<string, unknown> {
const serverPort = input.browserosServerPort ?? DEFAULT_PORTS.server
const provider = resolveProviderConfig(input)
const defaults: Record<string, unknown> = {
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
timeoutSeconds: 4200,
thinkingDefault: 'adaptive',
}
if (provider.model) {
defaults.model = { primary: provider.model }
}
const config: Record<string, unknown> = {
gateway: {
mode: 'local',
port: input.gatewayPort,
bind: 'lan',
auth: { mode: 'token', token: input.gatewayToken },
reload: { mode: 'restart' },
controlUi: {
allowInsecureAuth: true,
allowedOrigins: [
`http://127.0.0.1:${input.gatewayPort}`,
`http://localhost:${input.gatewayPort}`,
],
},
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
agents: { defaults },
tools: {
profile: 'full',
web: {
search: { provider: 'duckduckgo', enabled: true },
},
exec: {
host: 'gateway',
security: 'full',
ask: 'off',
},
},
cron: { enabled: true },
hooks: {
internal: {
enabled: true,
entries: {
'boot-md': { enabled: true },
'bootstrap-extra-files': { enabled: true },
'session-memory': { enabled: true },
},
},
},
mcp: {
servers: {
browseros: {
url: `http://host.containers.internal:${serverPort}/mcp`,
transport: 'streamable-http',
},
},
},
approvals: {
exec: { enabled: false },
},
skills: {
install: { nodeManager: 'bun' },
},
}
if (provider.models) {
config.models = provider.models
}
if (process.env.NODE_ENV === 'development') {
config.logging = { level: 'debug', consoleLevel: 'debug' }
}
return config
}
export function buildEnvFile(input: EnvFileInput): string {
const lines: string[] = [
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
`OPENCLAW_GATEWAY_TOKEN=${input.token}`,
`OPENCLAW_CONFIG_DIR=${input.configDir}`,
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
]
if (input.providerKeys) {
for (const [key, value] of Object.entries(input.providerKeys)) {
lines.push(`${key}=${value}`)
}
}
return `${lines.join('\n')}\n`
}
export function resolveProviderKeys(
input: OpenClawProviderInput,
): Record<string, string> {
return resolveProviderConfig(input).providerKeys
}
export function resolveProviderModel(
input: OpenClawProviderInput,
): string | undefined {
return resolveProviderConfig(input).model
}

View File

@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { join } from 'node:path'
const STATE_DIR_NAME = '.openclaw'
export function getOpenClawStateDir(openclawDir: string): string {
return join(openclawDir, STATE_DIR_NAME)
}
export function getOpenClawStateConfigPath(openclawDir: string): string {
return join(getOpenClawStateDir(openclawDir), 'openclaw.json')
}
export function getOpenClawStateEnvPath(openclawDir: string): string {
return join(getOpenClawStateDir(openclawDir), '.env')
}
export function getHostWorkspaceDir(
openclawDir: string,
agentName: string,
): string {
return join(
getOpenClawStateDir(openclawDir),
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
)
}
export function mergeEnvContent(
current: string,
updates: Record<string, string>,
): { changed: boolean; content: string } {
if (Object.keys(updates).length === 0) {
return {
changed: false,
content: normalizeEnvContent(current),
}
}
const lines = current === '' ? [] : current.replace(/\r\n/g, '\n').split('\n')
const nextLines = [...lines]
let changed = false
for (const [key, value] of Object.entries(updates)) {
const replacement = `${key}=${value}`
const index = nextLines.findIndex((line) => line.startsWith(`${key}=`))
if (index === -1) {
nextLines.push(replacement)
changed = true
continue
}
if (nextLines[index] === replacement) {
continue
}
nextLines[index] = replacement
changed = true
}
const content = normalizeEnvContent(nextLines.join('\n'))
return {
changed: changed || content !== normalizeEnvContent(current),
content,
}
}
function normalizeEnvContent(content: string): string {
const trimmed = content.trim()
return trimmed ? `${trimmed}\n` : ''
}

View File

@@ -0,0 +1,467 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createParser, type EventSourceMessage } from 'eventsource-parser'
import { OpenClawSessionNotFoundError } from './errors'
import type { OpenClawStreamEvent } from './openclaw-types'
export interface OpenClawChatHistoryMessage {
role: 'user' | 'assistant'
content: string
}
export interface OpenClawChatRequest {
agentId: string
sessionKey: string
message: string
history?: OpenClawChatHistoryMessage[]
signal?: AbortSignal
}
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>,
) {}
async streamChat(
input: OpenClawChatRequest,
): Promise<ReadableStream<OpenClawStreamEvent>> {
const response = await this.fetchChat(input)
const body = response.body
if (!body) {
throw new Error('OpenClaw chat response had no body')
}
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)
}
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.hostPort}/v1/chat/completions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: resolveAgentModel(input.agentId),
stream: true,
messages: [
...(input.history ?? []),
{ role: 'user', content: input.message },
],
user: `browseros:${input.agentId}:${input.sessionKey}`,
}),
signal: input.signal,
},
)
if (response.ok) {
return response
}
const detail = await response.text()
throw new Error(
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 {
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
}
function createEventStream(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): ReadableStream<OpenClawStreamEvent> {
return new ReadableStream<OpenClawStreamEvent>({
start(controller) {
void pumpChatEvents(body, controller, signal)
},
})
}
async function pumpChatEvents(
body: ReadableStream<Uint8Array>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let text = ''
let done = false
const parser = createParser({
onEvent(message) {
if (done) return
const nextText = updateAccumulatedText(message, text)
done = handleMessage(message, controller, nextText, done)
if (!done) {
text = nextText
}
},
})
try {
while (true) {
if (signal?.aborted) {
await reader.cancel()
controller.close()
return
}
const { done: streamDone, value } = await reader.read()
if (streamDone) break
parser.feed(decoder.decode(value, { stream: true }))
}
} catch (error) {
if (!done) {
controller.enqueue({
type: 'error',
data: {
message: error instanceof Error ? error.message : String(error),
},
})
controller.close()
}
} finally {
if (!done) {
controller.close()
}
reader.releaseLock()
}
}
function handleMessage(
message: EventSourceMessage,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
text: string,
done: boolean,
): boolean {
if (message.data === '[DONE]') {
return finishStream(controller, text, done)
}
const chunk = parseChunk(message.data)
if (!chunk) {
controller.enqueue({
type: 'error',
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
})
controller.close()
return true
}
for (const event of mapChunkToEvents(chunk)) {
controller.enqueue(event)
}
return hasFinishReason(chunk) ? finishStream(controller, text, done) : false
}
function updateAccumulatedText(
message: EventSourceMessage,
text: string,
): string {
const chunk = parseChunk(message.data)
if (!chunk) return text
let next = text
for (const choice of readChoices(chunk)) {
const delta = readDeltaText(choice)
if (delta) {
next += delta
}
}
return next
}
function finishStream(
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
text: string,
done: boolean,
): boolean {
if (!done) {
if (!text.trim()) {
controller.enqueue({
type: 'error',
data: {
message: "Agent couldn't generate a response. Please try again.",
},
})
controller.close()
return true
}
controller.enqueue({
type: 'done',
data: { text },
})
controller.close()
}
return true
}
function mapChunkToEvents(
chunk: Record<string, unknown>,
): OpenClawStreamEvent[] {
const events: OpenClawStreamEvent[] = []
for (const choice of readChoices(chunk)) {
const delta = readDeltaText(choice)
if (delta) {
events.push({
type: 'text-delta',
data: { text: delta },
})
}
}
return events
}
function hasFinishReason(chunk: Record<string, unknown>): boolean {
return readChoices(chunk).some((choice) => !!readFinishReason(choice))
}
function readChoices(
chunk: Record<string, unknown>,
): Array<Record<string, unknown>> {
const choices = chunk.choices
return Array.isArray(choices)
? choices.filter(
(choice): choice is Record<string, unknown> =>
!!choice && typeof choice === 'object',
)
: []
}
function readDeltaText(choice: Record<string, unknown>): string {
const delta = choice.delta
if (!delta || typeof delta !== 'object') return ''
const content = (delta as Record<string, unknown>).content
return typeof content === 'string' ? content : ''
}
function readFinishReason(choice: Record<string, unknown>): string | null {
const reason = choice.finish_reason
return typeof reason === 'string' && reason ? reason : null
}
function parseChunk(data: string): Record<string, unknown> | null {
try {
return JSON.parse(data) as Record<string, unknown>
} catch {
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

@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const SUPPORTED_OPENCLAW_PROVIDERS = [
'openrouter',
'openai',
'anthropic',
'moonshot',
] as const
export type SupportedOpenClawProvider =
(typeof SUPPORTED_OPENCLAW_PROVIDERS)[number]
export interface CustomOpenClawProviderConfig {
providerId: string
apiKeyEnvVar: string
config: Record<string, unknown>
}
export interface ResolvedOpenClawProviderConfig {
envValues: Record<string, string>
model?: string
providerType?: SupportedOpenClawProvider
customProvider?: CustomOpenClawProviderConfig
}
const PROVIDER_ENV_VARS: Record<SupportedOpenClawProvider, string> = {
anthropic: 'ANTHROPIC_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
openai: 'OPENAI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
}
export class UnsupportedOpenClawProviderError extends Error {
constructor(providerType: string) {
super(`Unsupported OpenClaw provider: ${providerType}`)
this.name = 'UnsupportedOpenClawProviderError'
}
}
export function isUnsupportedOpenClawProviderError(
error: unknown,
): error is UnsupportedOpenClawProviderError {
return (
error instanceof UnsupportedOpenClawProviderError ||
(error instanceof Error &&
error.name === 'UnsupportedOpenClawProviderError')
)
}
export function isSupportedOpenClawProvider(
providerType: string,
): providerType is SupportedOpenClawProvider {
return SUPPORTED_OPENCLAW_PROVIDERS.includes(
providerType as SupportedOpenClawProvider,
)
}
export function assertSupportedOpenClawProvider(
providerType?: string,
): SupportedOpenClawProvider | undefined {
if (!providerType) {
return undefined
}
if (!isSupportedOpenClawProvider(providerType)) {
throw new UnsupportedOpenClawProviderError(providerType)
}
return providerType
}
export function buildOpenClawModelRef(
providerType: SupportedOpenClawProvider,
modelId?: string,
): string | undefined {
return modelId ? `${providerType}/${modelId}` : undefined
}
export function deriveOpenClawProviderId(input: {
providerType?: string
providerName?: string
baseUrl?: string
}): string {
const source =
input.providerName?.trim() ||
input.baseUrl?.trim() ||
input.providerType?.trim() ||
'custom-provider'
const candidate = source
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
return candidate || 'custom-provider'
}
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
}
export function getOpenClawProviderEnvVar(
providerType: SupportedOpenClawProvider,
): string {
return PROVIDER_ENV_VARS[providerType]
}
export function resolveSupportedOpenClawProvider(input: {
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}): ResolvedOpenClawProviderConfig {
if (!input.providerType) {
return { envValues: {} }
}
if (isSupportedOpenClawProvider(input.providerType)) {
const providerType = input.providerType
const envVar = getOpenClawProviderEnvVar(providerType)
return {
envValues: input.apiKey ? { [envVar]: input.apiKey } : {},
model: buildOpenClawModelRef(providerType, input.modelId),
providerType,
}
}
if (!input.baseUrl) {
throw new UnsupportedOpenClawProviderError(input.providerType)
}
const providerId = deriveOpenClawProviderId(input)
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
return {
envValues: input.apiKey ? { [apiKeyEnvVar]: input.apiKey } : {},
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
customProvider: {
providerId,
apiKeyEnvVar,
config: {
api: 'openai-completions',
baseUrl: input.baseUrl,
apiKey: `\${${apiKeyEnvVar}}`,
...(input.modelId
? {
models: [{ id: input.modelId, name: input.modelId }],
}
: {}),
},
},
}
}

View File

@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}

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',
'2',
'--memory',
'2048',
'--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

@@ -1,200 +0,0 @@
import {
type BROWSEROS_ROLE_TEMPLATES,
getBrowserOSRoleTemplate,
} from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSAgentRoleSummary,
BrowserOSCustomRoleInput,
BrowserOSRoleTemplate,
} from '@browseros/shared/types/role-aware-agents'
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
interface BootstrapRenderableRole {
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
boundaries: BrowserOSRoleTemplate['boundaries']
bootstrap: BrowserOSRoleTemplate['bootstrap']
}
export interface RoleBootstrapFiles {
'AGENTS.md': string
'SOUL.md': string
'TOOLS.md': string
'.browseros-role.json': string
}
export function resolveRoleTemplate(
roleId: BrowserOSAgentRoleId,
): RoleTemplate {
const role = getBrowserOSRoleTemplate(roleId)
if (!role) {
throw new Error(`Unknown BrowserOS role: ${roleId}`)
}
return role
}
export function buildRoleBootstrapFiles(input: {
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
agentName: string
}): RoleBootstrapFiles {
const normalizedRole = normalizeRoleForBootstrap(input.role)
const roleId = 'id' in input.role ? input.role.id : undefined
return {
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
'SOUL.md': normalizedRole.bootstrap.soulMd,
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
'.browseros-role.json': `${JSON.stringify(
{
version: 1,
roleSource: roleId ? 'builtin' : 'custom',
roleId,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
createdBy: 'browseros',
agentName: input.agentName,
},
null,
2,
)}\n`,
}
}
export function toRoleSummary(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BrowserOSAgentRoleSummary {
const normalizedRole = normalizeRoleForBootstrap(role)
return {
roleSource: 'id' in role ? 'builtin' : 'custom',
roleId: 'id' in role ? role.id : undefined,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
}
}
export function normalizeCustomRole(
role: BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
const recommendedApps = Array.isArray(role.recommendedApps)
? role.recommendedApps.filter(
(app): app is string => typeof app === 'string',
)
: []
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
return {
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps,
boundaries,
bootstrap: {
agentsMd:
role.bootstrap?.agentsMd?.trim() ||
buildAgentsMd({
name: role.name,
longDescription: role.longDescription,
boundaries,
}),
soulMd:
role.bootstrap?.soulMd?.trim() ||
buildSoulMd({
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
}),
toolsMd:
role.bootstrap?.toolsMd?.trim() ||
buildToolsMd({
boundaries,
recommendedApps,
}),
},
}
}
function normalizeRoleForBootstrap(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
return 'id' in role ? role : normalizeCustomRole(role)
}
function buildAgentsMd(input: {
name: string
longDescription: string
boundaries: BrowserOSRoleTemplate['boundaries']
}): string {
const boundaryLines = input.boundaries
.map(
(boundary) =>
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
)
.join('\n')
return `# ${input.name}
You are the ${input.name} specialist for this workspace.
## Core Purpose
${input.longDescription}
## Operating Rules
${boundaryLines}
## Default Output Style
- concise
- action-oriented
- explicit about blockers and approvals
`
}
function buildSoulMd(input: {
name: string
shortDescription: string
longDescription: string
}): string {
return `# Operating Style
You act like a trusted ${input.name}.
## Working Posture
- calm
- structured
- direct
- explicit about tradeoffs
## Role Framing
${input.shortDescription}
${input.longDescription}
`
}
function buildToolsMd(input: {
boundaries: BrowserOSRoleTemplate['boundaries']
recommendedApps: string[]
}): string {
const boundaryLines = input.boundaries
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
.join('\n')
const appsLine =
input.recommendedApps.length > 0
? input.recommendedApps.join(', ')
: 'No specific apps configured yet.'
return `# Tooling Guidelines
- Use BrowserOS MCP for browser and connected SaaS tasks.
- Prefer read, summarize, and draft flows.
- Keep outputs in the workspace when possible so work remains inspectable.
## Recommended Apps
${appsLine}
## Boundary Defaults
${boundaryLines}
`
}

View File

@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Runtime state for the OpenClaw gateway. Today this is just the host port
* we mapped the gateway container to, persisted so that a once-chosen port
* is reused across restarts when it's still free.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
import { getOpenClawStateDir } from './openclaw-env'
const RUNTIME_STATE_FILE = 'runtime-state.json'
interface RuntimeState {
gatewayPort: number
}
function getRuntimeStatePath(openclawDir: string): string {
return join(getOpenClawStateDir(openclawDir), RUNTIME_STATE_FILE)
}
export async function readPersistedGatewayPort(
openclawDir: string,
): Promise<number | null> {
const path = getRuntimeStatePath(openclawDir)
if (!existsSync(path)) return null
try {
const parsed = JSON.parse(
await readFile(path, 'utf-8'),
) as Partial<RuntimeState>
if (
typeof parsed.gatewayPort === 'number' &&
Number.isInteger(parsed.gatewayPort) &&
parsed.gatewayPort > 0 &&
parsed.gatewayPort <= 65535
) {
return parsed.gatewayPort
}
return null
} catch {
return null
}
}
async function writePersistedGatewayPort(
openclawDir: string,
port: number,
): Promise<void> {
await mkdir(getOpenClawStateDir(openclawDir), { recursive: true })
const state: RuntimeState = { gatewayPort: port }
await writeFile(
getRuntimeStatePath(openclawDir),
`${JSON.stringify(state, null, 2)}\n`,
)
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer()
server.once('error', () => resolve(false))
server.once('listening', () => {
server.close(() => resolve(true))
})
server.listen(port, '127.0.0.1')
})
}
async function findAvailablePort(startPort: number): Promise<number> {
let port = startPort
while (!(await isPortAvailable(port))) {
port++
}
return port
}
/**
* Pick a host port for the gateway container and persist it. Prefers the
* previously persisted port when it's still bindable; otherwise scans
* upward from OPENCLAW_GATEWAY_CONTAINER_PORT until a free port is found.
*/
export async function allocateGatewayPort(
openclawDir: string,
): Promise<number> {
const persisted = await readPersistedGatewayPort(openclawDir)
if (persisted !== null && (await isPortAvailable(persisted))) {
return persisted
}
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
await writePersistedGatewayPort(openclawDir, port)
return port
}

View File

@@ -11,7 +11,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,12 +26,17 @@ export interface TerminalSession {
}
export function buildTerminalExecCommand(
podmanPath: string,
limactlPath: string,
vmName: string,
containerName: string,
workingDir: string,
): string[] {
return [
podmanPath,
limactlPath,
'shell',
vmName,
'--',
'nerdctl',
'exec',
'-it',
'-w',
@@ -39,17 +46,23 @@ export function buildTerminalExecCommand(
]
}
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 +71,7 @@ export function createTerminalSession(
if (chunk) deps.onOutput(chunk)
},
},
env: { ...process.env, TERM: TERMINAL_NAME },
env: buildTerminalEnv(deps.limaHome),
},
)
let closed = false

View File

@@ -517,15 +517,45 @@ export class Browser {
return null
}
private async resolveWindowIdForNewPage(opts?: {
hidden?: boolean
windowId?: number
}): Promise<number | undefined> {
if (!opts?.hidden) {
return opts?.windowId
}
if (opts.windowId !== undefined) {
const windows = await this.listWindows()
const targetWindow = windows.find(
(window) => window.windowId === opts.windowId,
)
if (targetWindow && !targetWindow.isVisible) {
return targetWindow.windowId
}
if (targetWindow?.isVisible) {
logger.warn(
'Requested hidden page target window is visible, creating a new hidden window instead',
{
requestedWindowId: opts.windowId,
},
)
}
}
const hiddenWindow = await this.createWindow({ hidden: true })
return hiddenWindow.windowId
}
async newPage(
url: string,
opts?: { hidden?: boolean; background?: boolean; windowId?: number },
): Promise<number> {
const windowId = await this.resolveWindowIdForNewPage(opts)
const createResult = await this.cdp.Browser.createTab({
url,
...(opts?.hidden !== undefined && { hidden: opts.hidden }),
...(opts?.background !== undefined && { background: opts.background }),
...(opts?.windowId !== undefined && { windowId: opts.windowId }),
...(windowId !== undefined && { windowId }),
})
const tabId = (createResult.tab as TabInfo).tabId
@@ -553,7 +583,7 @@ export class Browser {
loadProgress: tabInfo.loadProgress,
isPinned: tabInfo.isPinned,
isHidden: tabInfo.isHidden,
windowId: tabInfo.windowId,
windowId: tabInfo.windowId ?? windowId,
index: tabInfo.index,
groupId: tabInfo.groupId,
})

View File

@@ -7,7 +7,16 @@ import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-confi
import { logger } from './logger'
export function getBrowserosDir(): string {
return join(homedir(), PATHS.BROWSEROS_DIR_NAME)
const dirName =
process.env.NODE_ENV === 'development'
? PATHS.DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return join(homedir(), dirName)
}
export function logDevelopmentBrowserosDir(): void {
if (process.env.NODE_ENV !== 'development') return
logger.info(`Using development BrowserOS directory: ${getBrowserosDir()}`)
}
export function getMemoryDir(): string {
@@ -35,9 +44,49 @@ 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')
}
export function getLazyMonitoringRunsDir(): string {
return join(getLazyMonitoringDir(), 'runs')
}
export function getLazyMonitoringRunDir(runId: string): string {
return join(getLazyMonitoringRunsDir(), runId)
}
export function getServerConfigPath(): string {
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
}
@@ -57,10 +106,13 @@ export function removeServerConfigSync(): void {
}
export async function ensureBrowserosDir(): Promise<void> {
logDevelopmentBrowserosDir()
await mkdir(getMemoryDir(), { recursive: true })
await mkdir(getSkillsDir(), { recursive: true })
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,202 @@
/**
* @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 interface ContainerCliConfig {
limactlPath: string
limaHome: string
vmName: string
sshPath?: string
}
interface CommandResult {
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.run(['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.run(['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.run(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.run(['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,
['nerdctl', 'logs', '-f', '-n', '0', name],
{ onStdout: onLine, onStderr: onLine },
)
let stopped = false
return () => {
if (stopped) return
stopped = true
proc.kill()
}
}
private async runRequired(
args: string[],
onLog?: LogFn,
): Promise<CommandResult> {
const result = await this.run(args, onLog)
if (result.exitCode === 0) return result
throw this.commandError(args, result)
}
private async run(args: string[], onLog?: LogFn): Promise<CommandResult> {
const stdoutLines: string[] = []
const stderrLines: string[] = []
const exitCode = await this.lima.shell(
this.cfg.vmName,
['nerdctl', ...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 commandError(
args: string[],
result: CommandResult,
): 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,54 @@
/**
* @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(
`VM manifest is missing at ${manifestPath}; run bun run cache:sync before starting the server`,
)
}
}

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,265 @@
/**
* @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>
}
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 [stdout, stderr, exitCode] = await Promise.all([
drainToString(proc.stdout),
drainToString(proc.stderr, (line) => {
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
pid: proc.pid,
firstArg: args[0],
line,
})
}),
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,71 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Arch } from './paths'
export interface LimaConfigInput {
arch: Arch
diskPath: string
cpus: number
memory: string
disk: string
vmStateDir: string
imageCacheDir: string
socketHostPath: string
}
export function generateLimaYaml(cfg: LimaConfigInput): string {
const arch = cfg.arch === 'arm64' ? 'aarch64' : 'x86_64'
return [
'vmType: "vz"',
'rosetta:',
' enabled: false',
`arch: "${arch}"`,
`cpus: ${cfg.cpus}`,
`memory: "${cfg.memory}"`,
`disk: "${cfg.disk}"`,
'images:',
` - location: "${cfg.diskPath}"`,
` arch: "${arch}"`,
'mounts:',
` - location: "${cfg.vmStateDir}"`,
' mountPoint: "/mnt/browseros/vm"',
' writable: true',
` - location: "${cfg.imageCacheDir}"`,
' mountPoint: "/mnt/browseros/cache/images"',
' writable: false',
'portForwards:',
' - guestSocket: "/var/run/containerd/containerd.sock"',
` hostSocket: "${cfg.socketHostPath}"`,
'user:',
' name: "browseros"',
'',
].join('\n')
}
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,177 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { homedir, arch as osArch } from 'node:os'
import { 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'
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 'limactl'
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
}
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 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,35 @@
/**
* @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',
upgradeSwap: 'vm.upgrade.swap',
upgradeReplay: 'vm.upgrade.replay',
resetDetected: 'vm.reset.detected',
resetOk: 'vm.reset.ok',
socketWaitStart: 'vm.socket_wait.start',
socketWaitOk: 'vm.socket_wait.ok',
socketWaitPoll: 'vm.socket_wait.poll',
socketWaitTimeout: 'vm.socket_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,351 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mkdir, readFile, stat, 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 {
getContainerdSocketPath,
getImageCacheDir,
getVmStateDir,
VM_NAME,
} from './paths'
import { VM_TELEMETRY_EVENTS } from './telemetry'
export type LogFn = (msg: string) => void
export interface VmRuntimeDeps {
limactlPath: string
limaHome: string
sshPath?: string
templatePath?: string
browserosRoot?: string
socketTimeoutMs?: number
socketPollMs?: number
}
export class VmRuntime {
private readonly cli: LimaCli
private readonly socketTimeoutMs: number
private readonly socketPollMs: 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.socketTimeoutMs = deps.socketTimeoutMs ?? 60_000
this.socketPollMs = deps.socketPollMs ?? 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'
: '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,
})
}
}
await this.waitForSocket(this.socketTimeoutMs)
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 listRunningContainers(): Promise<string[]> {
const lines: string[] = []
await this.runCommand(['nerdctl', 'ps', '--format', '{{.Names}}'], {
onOutput: (line) => lines.push(line),
})
return lines.map((line) => line.trim()).filter(Boolean)
}
tailContainerLogs(containerName: string, onLine: LogFn): () => void {
const proc = this.cli.spawnShell(
VM_NAME,
['nerdctl', 'logs', '-f', '-n', '0', containerName],
{ onStdout: onLine, onStderr: onLine },
)
let stopped = false
return () => {
if (stopped) return
stopped = true
proc.kill()
}
}
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> {
try {
const info = await stat(this.socketPath())
return info.isSocket()
} catch {
return false
}
}
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() === 'runtime:containerd')
}
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 waitForSocket(timeoutMs: number): Promise<void> {
const started = Date.now()
const deadline = started + timeoutMs
const sockPath = this.socketPath()
logger.info(VM_TELEMETRY_EVENTS.socketWaitStart, {
sockPath,
timeoutMs,
pollMs: this.socketPollMs,
})
let pollCount = 0
while (Date.now() < deadline) {
pollCount += 1
if (await this.isReady()) {
logger.info(VM_TELEMETRY_EVENTS.socketWaitOk, {
sockPath,
pollCount,
waitMs: Date.now() - started,
})
return
}
if (pollCount === 1 || pollCount % 10 === 0) {
logger.debug(VM_TELEMETRY_EVENTS.socketWaitPoll, {
sockPath,
pollCount,
elapsedMs: Date.now() - started,
})
}
await Bun.sleep(this.socketPollMs)
}
logger.error(VM_TELEMETRY_EVENTS.socketWaitTimeout, {
sockPath,
timeoutMs,
pollCount,
})
throw new VmNotReadyError(`containerd.sock never appeared at ${sockPath}`)
}
private socketPath(): string {
return getContainerdSocketPath(this.deps.browserosRoot)
}
}
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

@@ -13,8 +13,11 @@ import fs from 'node:fs'
import path from 'node:path'
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
import { createHttpServer } from './api/server'
import { getOpenClawService } from './api/services/openclaw/openclaw-service'
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
import {
configureOpenClawService,
configureVmRuntime,
getOpenClawService,
} from './api/services/openclaw/openclaw-service'
import { CdpBackend } from './browser/backends/cdp'
import { Browser } from './browser/browser'
import type { ServerConfig } from './config'
@@ -56,9 +59,8 @@ export class Application {
resourcesDir: path.resolve(this.config.resourcesDir),
})
configurePodmanRuntime({
resourcesDir: path.resolve(this.config.resourcesDir),
})
const resourcesDir = path.resolve(this.config.resourcesDir)
configureVmRuntime({ resourcesDir })
await this.initCoreServices()
if (!this.config.cdpPort) {
@@ -123,7 +125,10 @@ export class Application {
this.logStartupSummary()
startSkillSync()
getOpenClawService(this.config.serverPort)
configureOpenClawService({
browserosServerPort: this.config.serverPort,
resourcesDir,
})
.tryAutoStart()
.catch((err) =>
logger.warn('OpenClaw auto-start failed', {

View File

@@ -0,0 +1,23 @@
import type {
JudgeAuditEnvelope,
MonitoringFinalization,
MonitoringSessionContext,
MonitoringToolCallRecord,
} from './types'
export function buildJudgeAuditEnvelope(input: {
context: MonitoringSessionContext
toolCalls: MonitoringToolCallRecord[]
finalization: MonitoringFinalization | null
}): JudgeAuditEnvelope {
const envelope: JudgeAuditEnvelope = {
run: input.context,
toolCalls: input.toolCalls,
}
if (input.finalization) {
envelope.finalization = input.finalization
}
return envelope
}

View File

@@ -0,0 +1,18 @@
import { logger } from '../lib/logger'
import type { MonitoringToolEndInput, MonitoringToolStartInput } from './types'
export interface ToolExecutionObserver {
onToolStart(input: MonitoringToolStartInput): Promise<void>
onToolEnd(input: MonitoringToolEndInput): Promise<void>
}
export function swallowMonitoringError(
operation: string,
error: unknown,
metadata: Record<string, unknown>,
): void {
logger.warn(`Lazy monitoring ${operation} failed`, {
...metadata,
error: error instanceof Error ? error.message : String(error),
})
}

View File

@@ -0,0 +1,222 @@
import { buildJudgeAuditEnvelope } from './envelope'
import { swallowMonitoringError, type ToolExecutionObserver } from './observer'
import { MonitoringSessionRegistry } from './session-registry'
import { MonitoringStorage } from './storage'
import type {
JudgeAuditEnvelope,
MonitoringFinalization,
MonitoringFinalizeInput,
MonitoringRunSummary,
MonitoringSessionContext,
MonitoringSessionStartInput,
MonitoringToolCallRecord,
MonitoringToolEndInput,
MonitoringToolStartInput,
} from './types'
type ActiveToolCallState = Omit<
MonitoringToolCallRecord,
'finishedAt' | 'durationMs' | 'error' | 'output'
>
export class MonitoringService {
private readonly storage = new MonitoringStorage()
private readonly registry = new MonitoringSessionRegistry()
async startSession(
input: MonitoringSessionStartInput,
): Promise<MonitoringSessionContext> {
const context: MonitoringSessionContext = {
monitoringSessionId: crypto.randomUUID(),
agentId: input.agentId,
sessionKey: input.sessionKey,
originalPrompt: input.originalPrompt,
chatHistory: input.chatHistory,
startedAt: new Date().toISOString(),
source: input.source ?? 'openclaw-agent-chat',
}
await this.storage.writeContext(context)
this.registry.setActive(context.agentId, context.monitoringSessionId)
return context
}
getActiveSessionId(agentId: string): string | undefined {
return this.registry.getActive(agentId)
}
getSingleActiveSession():
| { agentId: string; monitoringSessionId: string }
| undefined {
return this.registry.getSingleActive()
}
clearActiveSession(agentId: string, monitoringSessionId: string): void {
this.registry.clearIfMatches(agentId, monitoringSessionId)
}
createObserver(
monitoringSessionId: string,
agentId: string,
): ToolExecutionObserver {
const activeToolCalls = new Map<string, ActiveToolCallState>()
return {
onToolStart: async (input: MonitoringToolStartInput) => {
try {
activeToolCalls.set(input.toolCallId, {
monitoringSessionId,
agentId,
toolCallId: input.toolCallId,
toolName: input.toolName,
source: input.source,
args: input.args,
startedAt: new Date().toISOString(),
})
} catch (error) {
swallowMonitoringError('tool start recording', error, {
monitoringSessionId,
agentId,
toolCallId: input.toolCallId,
toolName: input.toolName,
})
}
},
onToolEnd: async (input: MonitoringToolEndInput) => {
try {
const active = activeToolCalls.get(input.toolCallId)
if (!active) return
const finishedAt = new Date().toISOString()
const durationMs = Math.max(
0,
new Date(finishedAt).getTime() -
new Date(active.startedAt).getTime(),
)
const record: MonitoringToolCallRecord = {
...active,
finishedAt,
durationMs,
}
if (input.error) {
record.error = input.error
}
if (input.output !== undefined) {
record.output = input.output
}
await this.storage.appendToolCall(record)
activeToolCalls.delete(input.toolCallId)
} catch (error) {
swallowMonitoringError('tool end recording', error, {
monitoringSessionId,
agentId,
toolCallId: input.toolCallId,
})
}
},
}
}
async finalizeSession(
input: MonitoringFinalizeInput,
): Promise<JudgeAuditEnvelope | null> {
const context = await this.storage.readContext(input.monitoringSessionId)
if (!context) {
return null
}
const finalization: MonitoringFinalization = {
monitoringSessionId: input.monitoringSessionId,
agentId: input.agentId,
sessionKey: input.sessionKey,
status: input.status,
finalizedAt: new Date().toISOString(),
}
if (input.finalAssistantMessage) {
finalization.finalAssistantMessage = input.finalAssistantMessage
}
if (input.error) {
finalization.error = input.error
}
await this.storage.writeFinalization(finalization)
this.registry.clearIfMatches(input.agentId, input.monitoringSessionId)
return this.buildAndPersistEnvelope(input.monitoringSessionId)
}
async getRunEnvelope(runId: string): Promise<JudgeAuditEnvelope | null> {
const context = await this.storage.readContext(runId)
if (!context) return null
const toolCalls = await this.storage.readToolCalls(runId)
const finalization = await this.storage.readFinalization(runId)
return buildJudgeAuditEnvelope({
context,
toolCalls,
finalization,
})
}
async listRuns(limit = 50): Promise<MonitoringRunSummary[]> {
const runIds = (await this.storage.listRunIds()).slice(0, limit)
const summaries = await Promise.all(
runIds.map(async (runId) => {
const context = await this.storage.readContext(runId)
if (!context) return null
const [toolCalls, finalization] = await Promise.all([
this.storage.readToolCalls(runId),
this.storage.readFinalization(runId),
])
const summary: MonitoringRunSummary = {
monitoringSessionId: context.monitoringSessionId,
agentId: context.agentId,
sessionKey: context.sessionKey,
originalPrompt: context.originalPrompt,
startedAt: context.startedAt,
source: context.source,
toolCallCount: toolCalls.length,
}
if (finalization) {
summary.finalization = {
status: finalization.status,
finalizedAt: finalization.finalizedAt,
error: finalization.error,
}
}
return summary
}),
)
return summaries.filter((summary): summary is MonitoringRunSummary =>
Boolean(summary),
)
}
private async buildAndPersistEnvelope(
runId: string,
): Promise<JudgeAuditEnvelope | null> {
const envelope = await this.getRunEnvelope(runId)
if (!envelope) return null
await this.storage.writeAuditEnvelope(runId, envelope)
return envelope
}
}
let monitoringService: MonitoringService | null = null
export function getMonitoringService(): MonitoringService {
if (!monitoringService) {
monitoringService = new MonitoringService()
}
return monitoringService
}

View File

@@ -0,0 +1,34 @@
export class MonitoringSessionRegistry {
private readonly activeSessionsByAgent = new Map<string, string>()
setActive(agentId: string, monitoringSessionId: string): void {
this.activeSessionsByAgent.set(agentId, monitoringSessionId)
}
getActive(agentId: string): string | undefined {
return this.activeSessionsByAgent.get(agentId)
}
getSingleActive():
| { agentId: string; monitoringSessionId: string }
| undefined {
if (this.activeSessionsByAgent.size !== 1) {
return undefined
}
const [agentId, monitoringSessionId] =
this.activeSessionsByAgent.entries().next().value ?? []
if (!agentId || !monitoringSessionId) {
return undefined
}
return { agentId, monitoringSessionId }
}
clearIfMatches(agentId: string, monitoringSessionId: string): void {
if (this.activeSessionsByAgent.get(agentId) !== monitoringSessionId) {
return
}
this.activeSessionsByAgent.delete(agentId)
}
}

View File

@@ -0,0 +1,175 @@
import {
appendFile,
mkdir,
readdir,
readFile,
stat,
writeFile,
} from 'node:fs/promises'
import { join } from 'node:path'
import {
getLazyMonitoringRunDir,
getLazyMonitoringRunsDir,
} from '../lib/browseros-dir'
import type {
MonitoringFinalization,
MonitoringSessionContext,
MonitoringToolCallRecord,
} from './types'
const CONTEXT_FILE_NAME = 'context.json'
const TOOL_CALLS_FILE_NAME = 'tool-calls.jsonl'
const FINALIZATION_FILE_NAME = 'finalization.json'
const AUDIT_ENVELOPE_FILE_NAME = 'audit-envelope.json'
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export class InvalidMonitoringRunIdError extends Error {
constructor(runId: string) {
super(`Invalid monitoring run id: ${runId}`)
this.name = 'InvalidMonitoringRunIdError'
}
}
export function isValidMonitoringRunId(runId: string): boolean {
return UUID_PATTERN.test(runId)
}
function assertValidMonitoringRunId(runId: string): void {
if (!isValidMonitoringRunId(runId)) {
throw new InvalidMonitoringRunIdError(runId)
}
}
export class MonitoringStorage {
async writeContext(context: MonitoringSessionContext): Promise<void> {
await this.ensureRunDir(context.monitoringSessionId)
await writeFile(
this.getContextPath(context.monitoringSessionId),
`${JSON.stringify(context, null, 2)}\n`,
)
}
async appendToolCall(record: MonitoringToolCallRecord): Promise<void> {
await this.ensureRunDir(record.monitoringSessionId)
await appendFile(
this.getToolCallsPath(record.monitoringSessionId),
`${JSON.stringify(record)}\n`,
)
}
async writeFinalization(finalization: MonitoringFinalization): Promise<void> {
await this.ensureRunDir(finalization.monitoringSessionId)
await writeFile(
this.getFinalizationPath(finalization.monitoringSessionId),
`${JSON.stringify(finalization, null, 2)}\n`,
)
}
async writeAuditEnvelope(runId: string, envelope: unknown): Promise<void> {
await this.ensureRunDir(runId)
await writeFile(
this.getAuditEnvelopePath(runId),
`${JSON.stringify(envelope, null, 2)}\n`,
)
}
async readContext(runId: string): Promise<MonitoringSessionContext | null> {
return this.readJsonFile<MonitoringSessionContext>(
this.getContextPath(runId),
)
}
async readFinalization(
runId: string,
): Promise<MonitoringFinalization | null> {
return this.readJsonFile<MonitoringFinalization>(
this.getFinalizationPath(runId),
)
}
async readToolCalls(runId: string): Promise<MonitoringToolCallRecord[]> {
try {
const content = await readFile(this.getToolCallsPath(runId), 'utf8')
return content
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
try {
return [JSON.parse(line) as MonitoringToolCallRecord]
} catch {
return []
}
})
} catch {
return []
}
}
async listRunIds(): Promise<string[]> {
try {
const entries = await readdir(getLazyMonitoringRunsDir(), {
withFileTypes: true,
})
const directories = entries.filter(
(entry) => entry.isDirectory() && isValidMonitoringRunId(entry.name),
)
const runStats = await Promise.all(
directories.map(async (entry) => ({
runId: entry.name,
mtimeMs: await this.getDirectoryMtimeMs(entry.name),
})),
)
return runStats
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map((entry) => entry.runId)
} catch {
return []
}
}
private async ensureRunDir(runId: string): Promise<void> {
assertValidMonitoringRunId(runId)
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunDir(runId), { recursive: true })
}
private async getDirectoryMtimeMs(runId: string): Promise<number> {
try {
const info = await stat(getLazyMonitoringRunDir(runId))
return info.mtimeMs
} catch {
return 0
}
}
private async readJsonFile<T>(path: string): Promise<T | null> {
try {
const content = await readFile(path, 'utf8')
return JSON.parse(content) as T
} catch {
return null
}
}
private getContextPath(runId: string): string {
assertValidMonitoringRunId(runId)
return join(getLazyMonitoringRunDir(runId), CONTEXT_FILE_NAME)
}
private getToolCallsPath(runId: string): string {
assertValidMonitoringRunId(runId)
return join(getLazyMonitoringRunDir(runId), TOOL_CALLS_FILE_NAME)
}
private getFinalizationPath(runId: string): string {
assertValidMonitoringRunId(runId)
return join(getLazyMonitoringRunDir(runId), FINALIZATION_FILE_NAME)
}
private getAuditEnvelopePath(runId: string): string {
assertValidMonitoringRunId(runId)
return join(getLazyMonitoringRunDir(runId), AUDIT_ENVELOPE_FILE_NAME)
}
}

View File

@@ -0,0 +1,92 @@
export type MonitoringChatTurnRole = 'user' | 'assistant'
export interface MonitoringChatTurn {
role: MonitoringChatTurnRole
content: string
}
export interface MonitoringSessionContext {
monitoringSessionId: string
agentId: string
sessionKey: string
originalPrompt: string
chatHistory: MonitoringChatTurn[]
startedAt: string
source: 'openclaw-agent-chat' | 'debug'
}
export type MonitoringToolCallSource = 'browser-tool' | 'klavis-tool'
export interface MonitoringToolCallRecord {
monitoringSessionId: string
agentId: string
toolCallId: string
toolName: string
source: MonitoringToolCallSource
args: unknown
output?: unknown
error?: string
startedAt: string
finishedAt?: string
durationMs?: number
}
export interface MonitoringFinalization {
monitoringSessionId: string
agentId: string
sessionKey: string
status: 'completed' | 'failed' | 'aborted' | 'incomplete'
finalAssistantMessage?: string
error?: string
finalizedAt: string
}
export interface JudgeAuditEnvelope {
run: MonitoringSessionContext
toolCalls: MonitoringToolCallRecord[]
finalization?: MonitoringFinalization
}
export interface MonitoringRunSummary {
monitoringSessionId: string
agentId: string
sessionKey: string
originalPrompt: string
startedAt: string
source: MonitoringSessionContext['source']
toolCallCount: number
finalization?: Pick<
MonitoringFinalization,
'status' | 'finalizedAt' | 'error'
>
}
export interface MonitoringSessionStartInput {
agentId: string
sessionKey: string
originalPrompt: string
chatHistory: MonitoringChatTurn[]
source?: MonitoringSessionContext['source']
}
export interface MonitoringToolStartInput {
toolCallId: string
toolName: string
source: MonitoringToolCallSource
args: unknown
}
export interface MonitoringToolEndInput {
toolCallId: string
output?: unknown
error?: string
}
export interface MonitoringFinalizeInput {
monitoringSessionId: string
agentId: string
sessionKey: string
status: MonitoringFinalization['status']
finalAssistantMessage?: string
error?: string
}

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

@@ -0,0 +1,166 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
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',
'skills',
'tools',
'browser',
'sdk',
]
const ignoredDirectories = new Set(['__fixtures__', '__helpers__'])
const rootGroupExclusions = new Set(['server.integration.test.ts'])
const testFilePattern = /\.(test|spec)\.[cm]?[jt]sx?$/
function compareGroupNames(left: string, right: string): number {
const leftIndex = preferredDirectoryGroups.indexOf(left)
const rightIndex = preferredDirectoryGroups.indexOf(right)
const leftRank =
leftIndex === -1 ? preferredDirectoryGroups.length : leftIndex
const rightRank =
rightIndex === -1 ? preferredDirectoryGroups.length : rightIndex
if (leftRank !== rightRank) {
return leftRank - rightRank
}
return left.localeCompare(right)
}
function listDirectoryGroups(): string[] {
return readdirSync(testsRoot, { withFileTypes: true })
.filter(
(entry) => entry.isDirectory() && !ignoredDirectories.has(entry.name),
)
.map((entry) => entry.name)
.sort(compareGroupNames)
}
function listRootTestTargets(): string[] {
return readdirSync(testsRoot, { withFileTypes: true })
.filter((entry) => entry.isFile() && testFilePattern.test(entry.name))
.filter((entry) => !rootGroupExclusions.has(entry.name))
.map((entry) => `./tests/${entry.name}`)
.sort((left, right) => left.localeCompare(right))
}
function listAllGroups(): string[] {
const groups = [...listDirectoryGroups()]
if (existsSync(resolve(testsRoot, 'server.integration.test.ts'))) {
groups.push('integration')
}
if (listRootTestTargets().length > 0) {
groups.push('root')
}
return groups
}
function listAvailableGroupNames(): string[] {
return ['all', 'core', 'cdp', ...listAllGroups()].sort((left, right) =>
left.localeCompare(right),
)
}
function getCompositeGroupMembers(group: string): string[] | null {
if (group === 'all') {
return listAllGroups()
}
if (group === 'core') {
return ['agent', 'api', 'skills', 'root']
}
return null
}
function getAtomicGroupTargets(group: string): string[] {
if (group === 'cdp') {
return getAtomicGroupTargets('browser')
}
if (group === 'integration') {
return existsSync(resolve(testsRoot, 'server.integration.test.ts'))
? ['./tests/server.integration.test.ts']
: []
}
if (group === 'root') {
return listRootTestTargets()
}
if (existsSync(resolve(testsRoot, group))) {
return [`./tests/${group}`]
}
return []
}
function runCommand(cmd: string[], label: string): number {
console.log(`\n==> ${label}`)
const result = spawnSync(cmd[0], cmd.slice(1), {
cwd: projectRoot,
env: withTestEnv(process.env),
stdio: 'inherit',
})
if (result.error) {
throw result.error
}
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) {
throw new Error(
`Unknown test group "${group}". Available groups: ${listAvailableGroupNames().join(', ')}`,
)
}
runCommand(['bash', cleanupScript], `Cleaning up test resources for ${group}`)
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
const cmd = buildTestCommand(targets, junitPath)
return runCommand(cmd, `Running ${group} tests`)
}
function runGroup(group: string): number {
const compositeMembers = getCompositeGroupMembers(group)
if (compositeMembers) {
let exitCode = 0
for (const member of compositeMembers) {
const status = runGroup(member)
if (status !== 0 && exitCode === 0) {
exitCode = status
}
}
return exitCode
}
return runAtomicGroup(group)
}
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

@@ -1168,8 +1168,9 @@ describe('compaction E2E — pruning and output reduction', () => {
{ role: 'user', content: 'x'.repeat(3000) },
]
const estimated = estimateTokensForThreshold(messages, config)
// 3000 chars / 3 = 1000 tokens, * 1.3 = 1300, + 12000 = 13300
expect(estimated).toBe(Math.ceil(1000 * 1.3) + 12_000)
expect(estimated).toBe(
Math.ceil(1000 * config.safetyMultiplier) + config.fixedOverhead,
)
})
})

View File

@@ -19,7 +19,7 @@ afterEach(() => {
})
describe('createKlavisRoutes', () => {
it('normalizes string integrations into authenticated entries', async () => {
it('normalizes string integrations into unauthenticated entries', async () => {
globalThis.fetch = (async () =>
Response.json({
integrations: ['Google Docs', 'Slack'],
@@ -32,8 +32,8 @@ describe('createKlavisRoutes', () => {
assert.strictEqual(response.status, 200)
assert.deepStrictEqual(body, {
integrations: [
{ name: 'Google Docs', is_authenticated: true },
{ name: 'Slack', is_authenticated: true },
{ name: 'Google Docs', is_authenticated: false },
{ name: 'Slack', is_authenticated: false },
],
count: 2,
})

View File

@@ -0,0 +1,437 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { OpenClawSessionNotFoundError } from '../../../src/api/services/openclaw/errors'
import { UnsupportedOpenClawProviderError } from '../../../src/api/services/openclaw/openclaw-provider-map'
describe('createOpenClawRoutes', () => {
afterEach(() => {
mock.restore()
})
it('preserves BrowserOS SSE framing, session headers, and defaults chat history for chat', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const chatStream = mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'text-delta',
data: { text: 'Hello' },
})
controller.enqueue({
type: 'done',
data: { text: 'Hello' },
})
controller.close()
},
}),
)
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
chatStream,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents/research/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'hi',
sessionKey: 'session-123',
}),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
expect(response.headers.get('X-Session-Key')).toBe('session-123')
expect(chatStream).toHaveBeenCalledWith('research', 'session-123', 'hi', [])
expect(await response.text()).toBe(
'data: {"type":"text-delta","data":{"text":"Hello"}}\n\n' +
'data: {"type":"done","data":{"text":"Hello"}}\n\n' +
'data: [DONE]\n\n',
)
})
it('passes prior chat history through to the OpenClaw chat stream', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const chatStream = mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'done',
data: { text: 'Done' },
})
controller.close()
},
}),
)
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
chatStream,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const history = [
{ role: 'user' as const, content: 'Find my open tasks' },
{ role: 'assistant' as const, content: 'I am checking Linear now.' },
]
const response = await route.request('/agents/research/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Summarize what is blocked',
sessionKey: 'session-456',
history,
}),
})
expect(response.status).toBe(200)
expect(chatStream).toHaveBeenCalledWith(
'research',
'session-456',
'Summarize what is blocked',
history,
)
})
it('rejects concurrent monitored chat requests for the same agent', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const actualMonitoringService = await import(
'../../../src/monitoring/service'
)
const chatStream = mock(async () => new ReadableStream())
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
chatStream,
}) as never,
}))
mock.module('../../../src/monitoring/service', () => ({
...actualMonitoringService,
getMonitoringService: () =>
({
getActiveSessionId: (agentId: string) =>
agentId === 'research' ? 'existing-run' : undefined,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents/research/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'hi',
sessionKey: 'session-789',
}),
})
expect(response.status).toBe(409)
expect(chatStream).not.toHaveBeenCalled()
expect(await response.json()).toEqual({
error:
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
})
})
it('returns 400 for unsupported provider payloads', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const updateProviderKeys = mock(async () => {
throw new UnsupportedOpenClawProviderError('google')
})
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
updateProviderKeys,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/providers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerType: 'google',
apiKey: 'google-key',
}),
})
expect(response.status).toBe(400)
expect(updateProviderKeys).toHaveBeenCalledWith({
providerType: 'google',
apiKey: 'google-key',
})
expect(await response.json()).toEqual({
error: 'Unsupported OpenClaw provider: google',
})
})
it('returns a non-restarting response when only the default model changes', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const updateProviderKeys = mock(async () => ({
restarted: false,
modelUpdated: true,
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
updateProviderKeys,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/providers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerType: 'openai',
apiKey: 'sk-test',
modelId: 'gpt-5.4-mini',
}),
})
expect(response.status).toBe(200)
expect(updateProviderKeys).toHaveBeenCalledWith({
providerType: 'openai',
apiKey: 'sk-test',
modelId: 'gpt-5.4-mini',
})
expect(await response.json()).toEqual({
status: 'updated',
message: 'Provider updated without a restart',
})
})
it('does not expose a roles route', async () => {
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/roles')
expect(response.status).toBe(404)
})
it('ignores role fields when creating agents', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const createAgent = mock(async () => ({
agentId: 'research',
name: 'research',
workspace: '/home/node/.openclaw/workspace-research',
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
createAgent,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'research',
roleId: 'chief-of-staff',
customRole: {
name: 'Ignored',
shortDescription: 'Ignored',
longDescription: 'Ignored',
recommendedApps: [],
boundaries: [],
},
providerType: 'openai',
apiKey: 'sk-test',
modelId: 'gpt-5.4-mini',
}),
})
expect(response.status).toBe(201)
expect(createAgent).toHaveBeenCalledWith({
name: 'research',
providerType: 'openai',
providerName: undefined,
baseUrl: undefined,
apiKey: 'sk-test',
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

@@ -4,11 +4,13 @@
*/
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import {
parseTerminalClientMessage,
serializeTerminalServerMessage,
} from '../../../src/api/services/terminal/terminal-protocol'
import {
buildTerminalEnv,
buildTerminalExecCommand,
TERMINAL_HOME_DIR,
} from '../../../src/api/services/terminal/terminal-session'
@@ -49,21 +51,35 @@ 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',
'browseros-openclaw-openclaw-gateway-1',
'limactl',
'browseros-vm',
OPENCLAW_GATEWAY_CONTAINER_NAME,
TERMINAL_HOME_DIR,
),
).toEqual([
'podman',
'limactl',
'shell',
'browseros-vm',
'--',
'nerdctl',
'exec',
'-it',
'-w',
'/home/node/.openclaw',
'browseros-openclaw-openclaw-gateway-1',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'/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

@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
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'
const PROJECT_DIR = '/tmp/openclaw'
const defaultSpec = {
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: 18789,
hostHome: '/Users/me/.browseros/vm/openclaw',
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
gatewayToken: 'token-123',
timezone: 'America/Los_Angeles',
}
describe('ContainerRuntime', () => {
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(deps.shell.removeContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
)
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('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(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
)
expect(deps.vm.runCommand).toHaveBeenCalledWith(
expect.arrayContaining([
'nerdctl',
'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',
]),
expect.any(Object),
)
expect(deps.vm.runCommand).toHaveBeenCalledWith(
['nerdctl', 'start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
expect.any(Object),
)
expect(deps.vm.runCommand).toHaveBeenCalledWith(
['nerdctl', 'rm', '-f', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
expect.any(Object),
)
})
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,
})
const stop = runtime.tailGatewayLogs(() => {})
const logs = await runtime.getGatewayLogs(10)
stop()
expect(deps.shell.tailLogs).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
expect.any(Function),
)
expect(deps.vm.runCommand).toHaveBeenCalledWith(
['nerdctl', 'logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
expect.any(Object),
)
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),
runCommand: mock(
async (
_args: string[],
opts?: { onOutput?: (line: string) => void },
) => {
opts?.onOutput?.('log line')
return 0
},
),
},
shell: {
createContainer: mock(async () => {}),
startContainer: mock(async () => {}),
stopContainer: mock(async () => {}),
removeContainer: mock(async () => {}),
exec: mock(async () => 0),
tailLogs: mock(() => () => {}),
},
loader: {
ensureImageLoaded: mock(async () => {}),
},
}
}

View File

@@ -0,0 +1,412 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it, mock } from 'bun:test'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import { OpenClawCliClient } from '../../../../src/api/services/openclaw/openclaw-cli-client'
describe('OpenClawCliClient', () => {
it('passes real non-interactive onboarding flags through to the upstream cli', async () => {
const execInContainer = mock(async (command: string[]) => {
expect(command).toEqual([
'node',
'dist/index.js',
'onboard',
'--non-interactive',
'--mode',
'local',
'--auth-choice',
'skip',
'--gateway-auth',
'token',
'--gateway-port',
'18789',
'--gateway-bind',
'lan',
'--no-install-daemon',
'--skip-health',
'--accept-risk',
])
return 0
})
const client = new OpenClawCliClient({ execInContainer })
await client.runOnboard({
nonInteractive: true,
mode: 'local',
authChoice: 'skip',
gatewayAuth: 'token',
gatewayPort: 18789,
gatewayBind: 'lan',
installDaemon: false,
skipHealth: true,
acceptRisk: true,
})
})
it('uses batch mode for grouped config writes', async () => {
const execInContainer = mock(async (command: string[]) => {
expect(command).toEqual([
'node',
'dist/index.js',
'config',
'set',
'--batch-json',
'[{"path":"gateway.mode","value":"local"},{"path":"gateway.http.endpoints.chatCompletions.enabled","value":true}]',
])
return 0
})
const client = new OpenClawCliClient({ execInContainer })
await client.setConfigBatch([
{
path: 'gateway.mode',
value: 'local',
},
{
path: 'gateway.http.endpoints.chatCompletions.enabled',
value: true,
},
])
})
it('runs upstream CLI commands without appending a gateway token flag', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'agents' && command[3] === 'list') {
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-sonnet-4.5',
},
]),
)
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agents = await client.listAgents()
expect(execInContainer.mock.calls[0]?.[0]).toEqual([
'node',
'dist/index.js',
'agents',
'list',
'--json',
])
expect(agents[0]?.model).toBe('openrouter/anthropic/claude-sonnet-4.5')
})
it('derives the workspace when creating an agent', async () => {
let callIndex = 0
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
callIndex += 1
if (callIndex === 1) {
expect(command).toEqual([
'node',
'dist/index.js',
'agents',
'add',
'research',
'--workspace',
`${OPENCLAW_CONTAINER_HOME}/workspace-research`,
'--model',
'openai/gpt-5.4-mini',
'--non-interactive',
'--json',
])
return 0
}
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
},
{
id: 'research',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
model: 'openai/gpt-5.4-mini',
},
]),
)
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agent = await client.createAgent({
name: 'research',
model: 'openai/gpt-5.4-mini',
})
expect(execInContainer).toHaveBeenCalledTimes(2)
expect(agent).toEqual({
agentId: 'research',
name: 'research',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
model: 'openai/gpt-5.4-mini',
})
})
it('parses agent lists from mixed log and JSON output', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.('starting agent listing')
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
},
]),
)
onLog?.('done')
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agents = await client.listAgents()
expect(agents).toEqual([
{
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
},
])
})
it('parses pretty-printed JSON surrounded by logs', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.('starting agent listing')
onLog?.('[')
onLog?.(' {')
onLog?.(' "id": "main",')
onLog?.(` "workspace": "${OPENCLAW_CONTAINER_HOME}/workspace",`)
onLog?.(' "model": "openrouter/anthropic/claude-sonnet-4.5"')
onLog?.(' }')
onLog?.(']')
onLog?.('done')
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agents = await client.listAgents()
expect(agents).toEqual([
{
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-sonnet-4.5',
},
])
})
it('skips structured JSON logs before the real agent list payload', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.(
JSON.stringify({
level: 'info',
message: 'agent list requested',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
}),
)
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-sonnet-4.5',
},
]),
)
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agents = await client.listAgents()
expect(agents).toEqual([
{
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-sonnet-4.5',
},
])
})
it('preserves exit details when the CLI fails', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.('agent already exists')
return 1
},
)
const client = new OpenClawCliClient({ execInContainer })
await expect(client.listAgents()).rejects.toThrow('agent already exists')
})
it('parses config get output from mixed logs and pretty-printed JSON', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'get') {
onLog?.('reading config')
onLog?.('{')
onLog?.(' "gateway": {')
onLog?.(' "mode": "local"')
onLog?.(' }')
onLog?.('}')
onLog?.('done')
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const config = await client.getConfig('gateway')
expect(config).toEqual({
gateway: {
mode: 'local',
},
})
})
it('skips structured JSON log lines before config get payloads', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'get') {
onLog?.(
JSON.stringify({
level: 'info',
message: 'reading config',
}),
)
onLog?.('{')
onLog?.(' "gateway": {')
onLog?.(' "mode": "local"')
onLog?.(' }')
onLog?.('}')
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const config = await client.getConfig('gateway')
expect(config).toEqual({
gateway: {
mode: 'local',
},
})
})
it('skips structured JSON log lines before config validate payloads', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'validate') {
onLog?.(
JSON.stringify({
level: 'info',
message: 'validating config',
}),
)
onLog?.(
JSON.stringify({
ok: true,
warnings: [],
}),
)
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const result = await client.validateConfig()
expect(result).toEqual({
ok: true,
warnings: [],
})
})
it('keeps the config get payload when a structured JSON log follows it', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'get') {
onLog?.('{')
onLog?.(' "gateway": {')
onLog?.(' "mode": "local"')
onLog?.(' }')
onLog?.('}')
onLog?.(
JSON.stringify({
level: 'info',
message: 'config fetched',
}),
)
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const config = await client.getConfig('gateway')
expect(config).toEqual({
gateway: {
mode: 'local',
},
})
})
it('keeps the config validate payload when a structured JSON log follows it', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'validate') {
onLog?.(
JSON.stringify({
ok: true,
warnings: [],
}),
)
onLog?.(
JSON.stringify({
level: 'info',
message: 'config validated',
}),
)
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const result = await client.validateConfig()
expect(result).toEqual({
ok: true,
warnings: [],
})
})
})

View File

@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { mergeEnvContent } from '../../../../src/api/services/openclaw/openclaw-env'
describe('mergeEnvContent', () => {
it('appends new env keys and normalizes trailing newline', () => {
expect(
mergeEnvContent('OPENAI_API_KEY=sk-old', {
ANTHROPIC_API_KEY: 'ant-key',
}),
).toEqual({
changed: true,
content: 'OPENAI_API_KEY=sk-old\nANTHROPIC_API_KEY=ant-key\n',
})
})
it('overwrites existing keys when values change', () => {
expect(
mergeEnvContent('OPENAI_API_KEY=sk-old\n', {
OPENAI_API_KEY: 'sk-new',
}),
).toEqual({
changed: true,
content: 'OPENAI_API_KEY=sk-new\n',
})
})
it('reports unchanged when incoming values match existing content', () => {
expect(
mergeEnvContent('OPENAI_API_KEY=sk-test\n', {
OPENAI_API_KEY: 'sk-test',
}),
).toEqual({
changed: false,
content: 'OPENAI_API_KEY=sk-test\n',
})
})
})

View File

@@ -0,0 +1,480 @@
/**
* @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('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

@@ -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

@@ -0,0 +1,108 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
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'
describe('getBrowserosDir', () => {
const originalNodeEnv = process.env.NODE_ENV
beforeEach(() => {
delete process.env.NODE_ENV
})
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
return
}
process.env.NODE_ENV = originalNodeEnv
})
it('uses a separate home directory in development', () => {
process.env.NODE_ENV = 'development'
expect(getBrowserosDir()).toBe(join(homedir(), '.browseros-dev'))
})
it('uses the standard home directory outside development', () => {
process.env.NODE_ENV = 'test'
expect(getBrowserosDir()).toBe(join(homedir(), PATHS.BROWSEROS_DIR_NAME))
})
it('logs the resolved development directory path', () => {
process.env.NODE_ENV = 'development'
const originalInfo = logger.info
const info = mock(() => {})
logger.info = info
try {
logDevelopmentBrowserosDir()
expect(info).toHaveBeenCalledWith(
`Using development BrowserOS directory: ${join(homedir(), '.browseros-dev')}`,
)
} finally {
logger.info = originalInfo
}
})
it('does not log a development directory outside development', () => {
process.env.NODE_ENV = 'test'
const originalInfo = logger.info
const info = mock(() => {})
logger.info = info
try {
logDevelopmentBrowserosDir()
expect(info).not.toHaveBeenCalled()
} finally {
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,94 @@
/**
* @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('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,
socketTimeoutMs: 5 * 60 * 1000,
socketPollMs: 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 nerdctlInfoExit = await runtime.runCommand(['nerdctl', 'info'], {
onOutput: (line) => nerdctlInfoOutput.push(line),
})
if (nerdctlInfoExit !== 0) {
throw new Error(
`nerdctl info failed with exit ${nerdctlInfoExit}:\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,57 @@
/**
* @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.socketWaitTimeout).toBe('vm.socket_wait.timeout')
expect(VM_TELEMETRY_EVENTS.migrationOpenClawMoved).toBe(
'vm.migration.openclaw_moved',
)
})
})

View File

@@ -0,0 +1,192 @@
/**
* @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 { LimaCommandError, VmNotReadyError } from '../../../src/lib/vm/errors'
import { LimaCli } from '../../../src/lib/vm/lima-cli'
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('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,86 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import {
generateLimaYaml,
renderLimaTemplate,
} from '../../../src/lib/vm/lima-config'
describe('generateLimaYaml', () => {
it('generates the BrowserOS Lima VM config for arm64', () => {
const yaml = generateLimaYaml({
arch: 'arm64',
diskPath: '/cache/browseros-vm.qcow2',
cpus: 2,
memory: '2GiB',
disk: '10GiB',
vmStateDir: '/Users/me/.browseros/vm',
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
socketHostPath:
'/Users/me/.browseros/lima/browseros-vm/sock/containerd.sock',
})
expect(yaml).toContain('vmType: "vz"')
expect(yaml).toContain('arch: "aarch64"')
expect(yaml).toContain('location: "/cache/browseros-vm.qcow2"')
expect(yaml).toContain('mountPoint: "/mnt/browseros/vm"')
expect(yaml).toContain('writable: true')
expect(yaml).toContain('mountPoint: "/mnt/browseros/cache/images"')
expect(yaml).toContain('writable: false')
expect(yaml).toContain('guestSocket: "/var/run/containerd/containerd.sock"')
expect(yaml).toContain(
'hostSocket: "/Users/me/.browseros/lima/browseros-vm/sock/containerd.sock"',
)
expect(yaml).toContain('name: "browseros"')
expect(yaml).not.toContain('mountType: "9p"')
})
it('maps x64 to Lima x86_64', () => {
const yaml = generateLimaYaml({
arch: 'x64',
diskPath: '/cache/browseros-vm.qcow2',
cpus: 4,
memory: '4GiB',
disk: '20GiB',
vmStateDir: '/Users/me/.browseros/vm',
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
socketHostPath:
'/Users/me/.browseros/lima/browseros-vm/sock/containerd.sock',
})
expect(yaml).toContain('arch: "x86_64"')
expect(yaml).toContain('cpus: 4')
expect(yaml).toContain('memory: "4GiB"')
expect(yaml).toContain('disk: "20GiB"')
})
})
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,138 @@
/**
* @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 with a cache-sync hint when cached manifest is absent', async () => {
await expect(readCachedManifest(root)).rejects.toThrow(ManifestMissingError)
await expect(readCachedManifest(root)).rejects.toThrow('bun run cache:sync')
})
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,187 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { 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
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
})
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', () => {
process.env.NODE_ENV = 'development'
expect(resolveBundledLimactl('/tmp/missing-dev-resources')).toBe('limactl')
})
it('uses PATH limactl in test mode', () => {
process.env.NODE_ENV = 'test'
expect(resolveBundledLimactl('/tmp/missing-test-resources')).toBe('limactl')
})
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 })
}
})
})

View File

@@ -0,0 +1,447 @@
/**
* @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 { logger } from '../../../src/lib/logger'
import { VmNotReadyError } from '../../../src/lib/vm/errors'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import {
getCachedManifestPath,
getContainerdSocketPath,
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
let socketServer: ReturnType<typeof Bun.listen> | null
beforeEach(async () => {
root = await mkdtemp('/tmp/vmrt-')
limaHome = join(root, 'lima')
logPath = join(root, 'limactl.log')
templatePath = join(root, 'browseros-vm.yaml')
socketServer = null
await writeCachedManifest(root)
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
})
afterEach(async () => {
socketServer?.stop(true)
await rm(root, { recursive: true, force: true })
})
it('provisions a fresh VM, waits for the socket, and installs the manifest', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const runtime = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: root,
})
socketServer = await createSocket(getContainerdSocketPath(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}`)
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 runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
socketServer = await createSocket(getContainerdSocketPath(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 runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
socketServer = await createSocket(getContainerdSocketPath(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 fakeSsh({ stdout: 'provisioned:old\n' }, logPath)
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(join(limaHome, VM_NAME, 'ssh.config'), '')
setTimeout(() => {
void createSocket(getContainerdSocketPath(root)).then((server) => {
socketServer = server
})
}, 10)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
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 the socket never appears', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const runtime = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: root,
socketTimeoutMs: 10,
socketPollMs: 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 runtime = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: root,
})
socketServer = await createSocket(getContainerdSocketPath(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('preserves a newer installed manifest when cached manifest is older', 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 runtime = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: root,
})
socketServer = await createSocket(getContainerdSocketPath(root))
await runtime.ensureReady()
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-23T00:00:00.000Z')
})
it('does not auto-reset when socket readiness fails', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const runtime = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: root,
socketTimeoutMs: 10,
socketPollMs: 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 and listRunningContainers through ssh', async () => {
const sshPath = await fakeSsh({ stdout: 'gateway\nworker\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.runCommand(['nerdctl', 'version'])).resolves.toBe(0)
await expect(runtime.listRunningContainers()).resolves.toEqual([
'gateway',
'worker',
])
const log = await readFile(logPath, 'utf8')
expect(log).toContain(
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'version'`,
)
expect(log).toContain(
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'ps' '--format' '{{.Names}}'`,
)
})
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)
})
it('returns a stop handle for tailing container logs', async () => {
const sshPath = await fakeSsh({ stdout: 'line\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,
})
const lines: string[] = []
const stop = runtime.tailContainerLogs('gateway', (line) =>
lines.push(line),
)
await Bun.sleep(20)
stop()
expect(lines).toEqual(['line'])
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'logs' '-f' '-n' '0' 'gateway'`,
)
})
})
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 createSocket(
path: string,
): Promise<ReturnType<typeof Bun.listen>> {
await mkdir(dirname(path), { recursive: true })
return Bun.listen({
unix: path,
socket: {
data() {},
},
})
}

View File

@@ -3,7 +3,7 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
const config = {
cdpPort: 9222,
@@ -19,87 +19,87 @@ const config = {
describe('Application.start', () => {
afterEach(() => {
mock.restore()
mock.clearAllMocks()
})
it('starts with the CDP backend only', async () => {
const createHttpServer = mock(async () => ({}))
const apiServer = await import('../src/api/server')
const browserModule = await import('../src/browser/browser')
const cdpModule = await import('../src/browser/backends/cdp')
const browserosDir = await import('../src/lib/browseros-dir')
const dbModule = await import('../src/lib/db')
const identityModule = await import('../src/lib/identity')
const loggerModule = await import('../src/lib/logger')
const metricsModule = await import('../src/lib/metrics')
const sentryModule = await import('../src/lib/sentry')
const soulModule = await import('../src/lib/soul')
const openclawService = await import(
'../src/api/services/openclaw/openclaw-service'
)
const migrateModule = await import('../src/skills/migrate')
const remoteSyncModule = await import('../src/skills/remote-sync')
const createHttpServer = spyOn(apiServer, 'createHttpServer')
createHttpServer.mockImplementation(async () => ({}) as never)
const cdpConnect = mock(async () => {})
const browserCtor = mock(() => {})
const loggerInfo = mock(() => {})
const loggerWarn = mock(() => {})
const loggerDebug = mock(() => {})
const loggerError = mock(() => {})
mock.module('../src/api/server', () => ({
createHttpServer,
}))
mock.module('../src/browser/backends/cdp', () => ({
CdpBackend: class {
async connect(): Promise<void> {
await cdpConnect()
}
},
}))
mock.module('../src/browser/browser', () => ({
Browser: class {
constructor(cdp: unknown) {
browserCtor(cdp)
}
},
}))
mock.module('../src/lib/browseros-dir', () => ({
cleanOldSessions: mock(async () => {}),
ensureBrowserosDir: mock(async () => {}),
removeServerConfigSync: mock(() => {}),
writeServerConfig: mock(async () => {}),
}))
mock.module('../src/lib/db', () => ({
initializeDb: mock(() => ({})),
}))
mock.module('../src/lib/identity', () => ({
identity: {
initialize: mock(() => {}),
getBrowserOSId: mock(() => 'browseros-id'),
},
}))
mock.module('../src/lib/logger', () => ({
logger: {
setLogFile: mock(() => {}),
info: loggerInfo,
warn: loggerWarn,
debug: loggerDebug,
error: loggerError,
},
}))
mock.module('../src/lib/metrics', () => ({
metrics: {
initialize: mock(() => {}),
isEnabled: mock(() => true),
log: mock(() => {}),
},
}))
mock.module('../src/lib/sentry', () => ({
Sentry: {
setContext: mock(() => {}),
setUser: mock(() => {}),
captureException: mock(() => {}),
},
}))
mock.module('../src/lib/soul', () => ({
seedSoulTemplate: mock(async () => {}),
}))
mock.module('../src/skills/migrate', () => ({
migrateBuiltinSkills: mock(async () => {}),
}))
mock.module('../src/skills/remote-sync', () => ({
startSkillSync: mock(() => {}),
stopSkillSync: mock(() => {}),
syncBuiltinSkills: mock(async () => {}),
}))
mock.module('../src/tools/registry', () => ({
registry: {
names: () => ['test_tool'],
},
}))
spyOn(cdpModule.CdpBackend.prototype, 'connect').mockImplementation(
cdpConnect,
)
spyOn(browserosDir, 'cleanOldSessions').mockImplementation(async () => {})
spyOn(browserosDir, 'ensureBrowserosDir').mockImplementation(async () => {})
spyOn(browserosDir, 'writeServerConfig').mockImplementation(async () => {})
spyOn(browserosDir, 'removeServerConfigSync').mockImplementation(() => {})
spyOn(dbModule, 'initializeDb').mockImplementation(() => ({}) as never)
spyOn(identityModule.identity, 'initialize').mockImplementation(() => {})
spyOn(identityModule.identity, 'getBrowserOSId').mockImplementation(
() => 'browseros-id',
)
const loggerInfo = spyOn(loggerModule.logger, 'info').mockImplementation(
() => {},
)
const loggerWarn = spyOn(loggerModule.logger, 'warn').mockImplementation(
() => {},
)
spyOn(loggerModule.logger, 'debug').mockImplementation(() => {})
const loggerError = spyOn(loggerModule.logger, 'error').mockImplementation(
() => {},
)
spyOn(loggerModule.logger, 'setLogFile').mockImplementation(() => {})
spyOn(metricsModule.metrics, 'initialize').mockImplementation(() => {})
spyOn(metricsModule.metrics, 'isEnabled').mockImplementation(() => true)
spyOn(metricsModule.metrics, 'log').mockImplementation(() => {})
spyOn(sentryModule.Sentry, 'setContext').mockImplementation(() => {})
spyOn(sentryModule.Sentry, 'setUser').mockImplementation(() => {})
spyOn(sentryModule.Sentry, 'captureException').mockImplementation(() => {})
spyOn(soulModule, 'seedSoulTemplate').mockImplementation(async () => {})
spyOn(migrateModule, 'migrateBuiltinSkills').mockImplementation(
async () => {},
)
spyOn(remoteSyncModule, 'syncBuiltinSkills').mockImplementation(
async () => {},
)
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
() =>
({
tryAutoStart: async () => {},
}) as never,
)
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
() =>
({
tryAutoStart: async () => {},
}) as never,
)
const { Application } = await import('../src/main')
const app = new Application(config)
@@ -107,9 +107,14 @@ describe('Application.start', () => {
await app.start()
expect(cdpConnect).toHaveBeenCalledTimes(1)
expect(browserCtor).toHaveBeenCalledTimes(1)
expect(createHttpServer).toHaveBeenCalledTimes(1)
expect(createHttpServer.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
browser: expect.any(browserModule.Browser),
}),
)
expect(createHttpServer.mock.calls[0]?.[0]).not.toHaveProperty('controller')
expect(loggerInfo).toHaveBeenCalled()
expect(loggerWarn).not.toHaveBeenCalled()
expect(loggerError).not.toHaveBeenCalled()
})

View File

@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { appendFile, mkdir, rm } from 'node:fs/promises'
import {
getLazyMonitoringRunDir,
getLazyMonitoringRunsDir,
} from '../src/lib/browseros-dir'
import {
InvalidMonitoringRunIdError,
isValidMonitoringRunId,
MonitoringStorage,
} from '../src/monitoring/storage'
const createdRunDirs = new Set<string>()
afterEach(async () => {
await Promise.all(
[...createdRunDirs].map(async (runId) => {
await rm(getLazyMonitoringRunDir(runId), { recursive: true, force: true })
}),
)
createdRunDirs.clear()
})
describe('MonitoringStorage run id validation', () => {
it('accepts UUID monitoring run ids', () => {
expect(isValidMonitoringRunId('123e4567-e89b-12d3-a456-426614174000')).toBe(
true,
)
})
it('rejects path traversal run ids', async () => {
expect(isValidMonitoringRunId('../../secret')).toBe(false)
const storage = new MonitoringStorage()
await expect(storage.readContext('../../secret')).rejects.toBeInstanceOf(
InvalidMonitoringRunIdError,
)
})
it('preserves valid JSONL records when one line is malformed', async () => {
const runId = '123e4567-e89b-12d3-a456-426614174001'
createdRunDirs.add(runId)
const storage = new MonitoringStorage()
await storage.writeContext({
monitoringSessionId: runId,
agentId: 'test-agent',
sessionKey: 'session-1',
originalPrompt: 'Inspect browser state safely',
chatHistory: [{ role: 'user', content: 'Inspect browser state safely' }],
startedAt: new Date().toISOString(),
source: 'debug',
})
await appendFile(
`${getLazyMonitoringRunDir(runId)}/tool-calls.jsonl`,
[
JSON.stringify({
monitoringSessionId: runId,
agentId: 'test-agent',
toolCallId: 'tool-1',
toolName: 'list_windows',
source: 'browser-tool',
args: {},
startedAt: '2026-04-20T15:22:49.817Z',
finishedAt: '2026-04-20T15:22:49.818Z',
durationMs: 1,
}),
'{"broken":',
JSON.stringify({
monitoringSessionId: runId,
agentId: 'test-agent',
toolCallId: 'tool-2',
toolName: 'take_snapshot',
source: 'browser-tool',
args: {},
startedAt: '2026-04-20T15:22:50.817Z',
finishedAt: '2026-04-20T15:22:50.818Z',
durationMs: 1,
}),
'',
].join('\n'),
)
const toolCalls = await storage.readToolCalls(runId)
expect(toolCalls).toHaveLength(2)
expect(toolCalls.map((record) => record.toolCallId)).toEqual([
'tool-1',
'tool-2',
])
})
it('skips non-uuid directories when listing run ids', async () => {
const validRunId = '123e4567-e89b-12d3-a456-426614174002'
createdRunDirs.add(validRunId)
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunDir(validRunId), { recursive: true })
await mkdir(`${getLazyMonitoringRunsDir()}/not-a-uuid`, {
recursive: true,
})
const storage = new MonitoringStorage()
const runIds = await storage.listRunIds()
expect(runIds).toContain(validRunId)
expect(runIds).not.toContain('not-a-uuid')
await rm(`${getLazyMonitoringRunsDir()}/not-a-uuid`, {
recursive: true,
force: true,
})
})
})

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

@@ -1,6 +1,7 @@
import { describe, it } from 'bun:test'
import { afterAll, describe, it } from 'bun:test'
import assert from 'node:assert'
import type { Browser } from '../../src/browser/browser'
import { disposeSemanticPipeline } from '../../src/tools/acl/acl-embeddings'
import { executeTool, type ToolContext } from '../../src/tools/framework'
import {
check,
@@ -16,7 +17,9 @@ import {
} from '../../src/tools/input'
import { close_page, navigate_page, new_page } from '../../src/tools/navigation'
import { evaluate_script, take_snapshot } from '../../src/tools/snapshot'
import { withBrowser } from '../__helpers__/with-browser'
import { cleanupWithBrowser, withBrowser } from '../__helpers__/with-browser'
process.env.ACL_EMBEDDING_DISABLE = 'true'
function textOf(result: {
content: { type: string; text?: string }[]
@@ -52,6 +55,72 @@ function findElementId(snapshotText: string, label: string): number {
return Number.parseInt(match[1], 10)
}
async function pointInsideElement(
ctx: ToolContext,
pageId: number,
elementDomId: string,
): Promise<{ x: number; y: number }> {
const pointResult = await executeTool(
evaluate_script,
{
page: pageId,
expression: `(() => {
const el = document.getElementById(${JSON.stringify(elementDomId)});
if (!el) return null;
const rect = el.getBoundingClientRect();
const insetX = Math.max(1, Math.min(10, Math.floor(rect.width / 4)));
const insetY = Math.max(1, Math.min(10, Math.floor(rect.height / 4)));
const candidates = [
{
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
},
{
x: Math.round(rect.left + insetX),
y: Math.round(rect.top + insetY),
},
{
x: Math.round(rect.right - insetX),
y: Math.round(rect.top + insetY),
},
{
x: Math.round(rect.left + insetX),
y: Math.round(rect.bottom - insetY),
},
{
x: Math.round(rect.right - insetX),
y: Math.round(rect.bottom - insetY),
},
];
for (const candidate of candidates) {
const target = document.elementFromPoint(candidate.x, candidate.y);
if (target && (target === el || el.contains(target))) {
return { ...candidate, matched: true, hitId: target.id || null };
}
}
const fallback = candidates[0];
const fallbackTarget = document.elementFromPoint(fallback.x, fallback.y);
return {
...fallback,
matched: false,
hitId: fallbackTarget instanceof Element ? fallbackTarget.id || null : null,
};
})()`,
},
ctx,
AbortSignal.timeout(30_000),
)
const point = structuredOf<{
value: { x: number; y: number; matched: boolean; hitId: string | null }
} | null>(pointResult)?.value
assert.ok(point, `Expected a point for #${elementDomId}`)
assert.ok(
point.matched,
`Expected coordinates inside #${elementDomId}, got ${point.hitId ?? 'null'}`,
)
return { x: point.x, y: point.y }
}
const FORM_PAGE = `data:text/html,${encodeURIComponent(`<!DOCTYPE html>
<html><body>
<h1>Test Form</h1>
@@ -89,6 +158,11 @@ const FORM_PAGE = `data:text/html,${encodeURIComponent(`<!DOCTYPE html>
</script>
</body></html>`)}`
afterAll(async () => {
await disposeSemanticPipeline()
await cleanupWithBrowser()
})
describe('input tools', () => {
it('fill types text into an input', async () => {
await withBrowser(async ({ execute }) => {
@@ -410,7 +484,7 @@ describe('input tools', () => {
{
id: 'submit-rule',
sitePattern: '*',
description: 'submit',
textMatch: 'Submit',
enabled: true,
},
]
@@ -437,7 +511,7 @@ describe('input tools', () => {
{
id: 'submit-rule',
sitePattern: '*',
description: 'submit',
textMatch: 'Submit',
enabled: true,
},
{
@@ -457,24 +531,7 @@ describe('input tools', () => {
)
const pageId = pageIdOf(newResult)
const buttonCenter = await executeTool(
evaluate_script,
{
page: pageId,
expression: `(() => {
const rect = document.getElementById('submit-btn').getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
})()`,
},
ctx,
AbortSignal.timeout(30_000),
)
const buttonPoint = structuredOf<{ value: { x: number; y: number } }>(
buttonCenter,
).value
const buttonPoint = await pointInsideElement(ctx, pageId, 'submit-btn')
const blockedClick = await executeTool(
click_at,
@@ -492,24 +549,7 @@ describe('input tools', () => {
},
]
const inputCenter = await executeTool(
evaluate_script,
{
page: pageId,
expression: `(() => {
const rect = document.getElementById('name').getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
})()`,
},
ctx,
AbortSignal.timeout(30_000),
)
const inputPoint = structuredOf<{ value: { x: number; y: number } }>(
inputCenter,
).value
const inputPoint = await pointInsideElement(ctx, pageId, 'name')
const blockedType = await executeTool(
type_at,

View File

@@ -156,7 +156,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.85",
"version": "0.0.88",
"bin": {
"browseros-server": "./src/index.ts",
},
@@ -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

@@ -27,10 +27,17 @@
"build:agent": "bun run codegen:agent && bun run --filter @browseros/agent build",
"build:agent-sdk": "bun run --filter @browseros-ai/agent-sdk build",
"codegen:agent": "bun run --filter @browseros/agent codegen",
"test": "bun run test:tools && bun run test:integration",
"test": "bun run test:all",
"test:all": "bun run test:server && bun run test:agent && bun run test:eval && bun run test:agent-sdk && bun run test:build",
"test:server": "bun run --filter @browseros/server test",
"test:tools": "bun run --filter @browseros/server test:tools",
"test:cdp": "bun run --filter @browseros/server test:cdp",
"test:integration": "bun run --filter @browseros/server test:integration",
"test:sdk": "echo 'SDK tests disabled: test environment does not provide the extract/verify LLM service'",
"test:sdk": "bun run --filter @browseros/server test:sdk",
"test:agent": "bun run ./scripts/run-bun-test.ts ./apps/agent",
"test:eval": "bun run ./scripts/run-bun-test.ts ./apps/eval/tests",
"test:agent-sdk": "bun run ./scripts/run-bun-test.ts ./packages/agent-sdk",
"test:build": "bun run ./scripts/run-bun-test.ts ./scripts/build",
"typecheck": "bun run --filter '*' typecheck",
"lint": "bunx biome check",
"lint:fix": "bunx biome check --write --unsafe",

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,79 @@
# @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
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync:dev
```
`cache:sync:dev` hardcodes `arm64` (all devs are on Apple Silicon), skips R2 entirely, and writes an arm64-only manifest + tarball into `~/.browseros-dev/cache/vm/` from `./dist/`. It refuses to run unless `NODE_ENV=development`. Use this when you want to test the server against a local 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",
"cache:sync:dev": "bun run scripts/cache-sync-dev.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,92 @@
#!/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 Bundle,
tarballKey,
} from './common/manifest'
import { sha256File, verifySha256 } from './common/sha256'
const ARM64: Arch = 'arm64'
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'cache:sync:dev refuses to run without NODE_ENV=development — it writes to ~/.browseros-dev/cache/vm/',
)
}
const pkgRoot = path.resolve(import.meta.dir, '..')
const distDir = path.join(pkgRoot, 'dist')
const bundle = JSON.parse(
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
) as Bundle
const cacheRoot = path.join(
homedir(),
PATHS.DEV_BROWSEROS_DIR_NAME,
PATHS.CACHE_DIR_NAME,
)
const imagesDir = path.join(cacheRoot, 'vm', 'images')
const manifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
await mkdir(imagesDir, { recursive: true })
const agents: Record<string, AgentEntry> = {}
for (const agent of bundle.agents) {
const key = tarballKey(agent.name, agent.version, ARM64)
const srcTarball = path.join(distDir, 'images', path.basename(key))
await assertExists(srcTarball)
const sha256 = await sha256File(srcTarball)
const sizeBytes = (await stat(srcTarball)).size
const destTarball = path.join(cacheRoot, key)
if (await matchesExisting(destTarball, sha256)) {
console.log(`cache hit: ${key}`)
} else {
await mkdir(path.dirname(destTarball), { recursive: true })
await copyFile(srcTarball, destTarball)
await verifySha256(destTarball, sha256)
console.log(`seeded ${key}`)
}
agents[agent.name] = {
image: agent.image,
version: agent.version,
tarballs: { arm64: { key, sha256, sizeBytes } } as AgentEntry['tarballs'],
}
}
const manifest: AgentManifest = {
schemaVersion: 2,
updatedAt: new Date().toISOString(),
agents,
}
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`manifest written to ${manifestPath}`)
async function assertExists(filePath: string): Promise<void> {
try {
await stat(filePath)
} catch {
throw new Error(
`missing ${filePath} — run: bun run build:tarball -- --agent <name> --arch arm64`,
)
}
}
async function matchesExisting(
filePath: string,
expectedSha: string,
): Promise<boolean> {
try {
await stat(filePath)
} catch {
return false
}
return (await sha256File(filePath)) === expectedSha
}

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}`,
)
}
}

Some files were not shown because too many files have changed in this diff Show More