Compare commits

...

14 Commits

Author SHA1 Message Date
Nikhil Sonti
1f504702ec feat(build-tools): add cache:sync:dev for local tarball seeding
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:30:04 -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
77 changed files with 6269 additions and 776 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

@@ -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,5 +1,7 @@
import {
AlertCircle,
ChevronDown,
ChevronRight,
Cpu,
Loader2,
MessageSquare,
@@ -37,12 +39,22 @@ import { AgentTerminal } from './AgentTerminal'
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
import {
type AgentEntry,
type GatewayLifecycleAction,
type OpenClawStatus,
useOpenClawAgents,
useOpenClawMutations,
useOpenClawStatus,
usePodmanOverrides,
} from './useOpenClaw'
const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
setup: 'Setting up OpenClaw...',
start: 'Starting gateway...',
stop: 'Stopping gateway...',
restart: 'Restarting gateway...',
reconnect: 'Restoring gateway connection...',
}
const CONTROL_PLANE_COPY: Record<
OpenClawStatus['controlPlaneStatus'],
{
@@ -226,6 +238,122 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
)
}
const PodmanOverridesCard: FC = () => {
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
usePodmanOverrides()
const [value, setValue] = useState('')
const [touched, setTouched] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [localError, setLocalError] = useState<string | null>(null)
useEffect(() => {
if (!touched && overrides) setValue(overrides.podmanPath ?? '')
}, [overrides, touched])
const handleSave = async () => {
const trimmed = value.trim()
if (!trimmed) return
setLocalError(null)
try {
await saveOverrides(trimmed)
setTouched(false)
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err))
}
}
const handleClear = async () => {
setLocalError(null)
try {
await clearOverrides()
setValue('')
setTouched(false)
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err))
}
}
const hasOverride = !!overrides?.podmanPath
const effective = overrides?.effectivePodmanPath ?? null
const inlineErrorMessage = localError ?? error?.message ?? null
const body = (
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor="podman-path" className="font-medium text-sm">
Podman binary path
</label>
<Input
id="podman-path"
value={value}
onChange={(event) => {
setTouched(true)
setValue(event.target.value)
}}
placeholder="/opt/homebrew/bin/podman"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
/>
<p className="text-muted-foreground text-xs">
Install Podman yourself (e.g. <code>brew install podman</code>) and
paste the absolute path to the binary. Restart the gateway after
saving.
</p>
</div>
{effective && (
<p className="text-muted-foreground text-xs">
Currently using: <code className="break-all">{effective}</code>
</p>
)}
{inlineErrorMessage && (
<p className="text-destructive text-xs">{inlineErrorMessage}</p>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={saving || loading || !value.trim()}
>
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
Save
</Button>
<Button
size="sm"
variant="outline"
onClick={handleClear}
disabled={saving || loading || !hasOverride}
>
Clear
</Button>
</div>
</div>
)
return (
<Card>
<CardHeader
className="cursor-pointer py-3"
onClick={() => setCollapsed((prev) => !prev)}
>
<CardTitle className="flex items-center gap-2 text-base">
{collapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
Advanced: Podman binary path
</CardTitle>
</CardHeader>
{!collapsed && <CardContent className="pt-0">{body}</CardContent>}
</Card>
)
}
export const AgentsPage: FC = () => {
const {
status,
@@ -253,6 +381,7 @@ export const AgentsPage: FC = () => {
creating,
deleting,
reconnecting,
pendingGatewayAction,
} = useOpenClawMutations()
const [setupOpen, setSetupOpen] = useState(false)
@@ -289,8 +418,13 @@ export const AgentsPage: FC = () => {
setNewName((current) => current || 'agent')
}, [createOpen])
const inlineError =
error ?? statusError?.message ?? agentsError?.message ?? null
const lifecyclePending = pendingGatewayAction !== null
const inlineError = lifecyclePending
? null
: (error ?? statusError?.message ?? agentsError?.message ?? null)
const lifecycleBanner = pendingGatewayAction
? LIFECYCLE_BANNER_COPY[pendingGatewayAction]
: null
const gatewayUiState = useMemo(() => {
if (!status) {
@@ -319,6 +453,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)
@@ -482,7 +620,7 @@ export const AgentsPage: FC = () => {
</Button>
<Button
onClick={() => setCreateOpen(true)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
New Agent
@@ -493,6 +631,13 @@ export const AgentsPage: FC = () => {
)}
</div>
{lifecycleBanner && (
<Alert>
<Loader2 className="animate-spin" />
<AlertTitle>{lifecycleBanner}</AlertTitle>
</Alert>
)}
{inlineError && (
<Alert variant="destructive">
<AlertCircle />
@@ -512,7 +657,7 @@ export const AgentsPage: FC = () => {
</Alert>
)}
{status && gatewayUiState.controlPlaneDegraded && (
{status && showControlPlaneDegraded && (
<Alert
variant={
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
@@ -633,7 +778,7 @@ export const AgentsPage: FC = () => {
<Button
variant="outline"
onClick={() => setCreateOpen(true)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
@@ -662,7 +807,7 @@ export const AgentsPage: FC = () => {
variant="ghost"
size="sm"
onClick={() => setChatAgent(agent)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
@@ -672,7 +817,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>
@@ -685,6 +830,8 @@ export const AgentsPage: FC = () => {
</div>
)}
<PodmanOverridesCard />
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
<DialogContent>
<DialogHeader>
@@ -754,7 +901,7 @@ export const AgentsPage: FC = () => {
disabled={
!newName.trim() ||
creating ||
!gatewayUiState.canManageAgents ||
!canManageAgents ||
compatibleProviders.length === 0
}
className="w-full"

View File

@@ -3,6 +3,7 @@ import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
const OPENCLAW_SUPPORTED_PROVIDER_TYPES: ProviderType[] = [
'openrouter',
'openai',
'openai-compatible',
'anthropic',
'moonshot',
]

View File

@@ -59,8 +59,21 @@ export function getModelDisplayName(model: unknown): string | undefined {
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
podmanOverrides: 'openclaw-podman-overrides',
} as const
export interface PodmanOverrides {
podmanPath: string | null
effectivePodmanPath: string
}
export type GatewayLifecycleAction =
| 'setup'
| 'start'
| 'stop'
| 'restart'
| 'reconnect'
async function clawFetch<T>(
baseUrl: string,
path: string,
@@ -218,6 +231,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,
@@ -238,6 +258,51 @@ export function useOpenClawMutations() {
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
pendingGatewayAction,
}
}
export function usePodmanOverrides() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const queryClient = useQueryClient()
const query = useQuery<PodmanOverrides, Error>({
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides, baseUrl],
queryFn: () =>
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides'),
enabled: !!baseUrl && !urlLoading,
})
const saveMutation = useMutation({
mutationFn: async (podmanPath: string | null) =>
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath }),
}),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.status],
}),
])
},
})
return {
overrides: query.data ?? null,
loading: query.isLoading || urlLoading,
error: (query.error ?? urlError) as Error | null,
saving: saveMutation.isPending,
saveOverrides: (podmanPath: string) => saveMutation.mutateAsync(podmanPath),
clearOverrides: () => saveMutation.mutateAsync(null),
}
}
@@ -254,17 +319,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

@@ -1,36 +0,0 @@
services:
openclaw-gateway:
# Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:2026.4.12}
ports:
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
env_file:
- ./.openclaw/.env
environment:
- HOME=/home/node
- OPENCLAW_HOME=/home/node
- OPENCLAW_STATE_DIR=/home/node/.openclaw
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-}
- OPENCLAW_NO_RESPAWN=1
- NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache
- NODE_ENV=production
- TZ=${TZ}
volumes:
- ${OPENCLAW_HOST_HOME}:/home/node
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,10 +7,13 @@
* Thin layer delegating to OpenClawService.
*/
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
import path from 'node:path'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { logger } from '../../lib/logger'
import { getMonitoringService } from '../../monitoring/service'
import type { MonitoringChatTurn } from '../../monitoring/types'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
@@ -27,6 +30,27 @@ function getCreateAgentValidationError(body: { name?: string }): string | null {
return null
}
function getPodmanOverrideValidationError(body: {
podmanPath?: string | null
}): string | null {
if (body.podmanPath === null) return null
if (typeof body.podmanPath !== 'string' || !body.podmanPath.trim()) {
return 'podmanPath must be a non-empty absolute path or null'
}
if (!path.isAbsolute(body.podmanPath)) {
return 'podmanPath must be an absolute path'
}
if (!existsSync(body.podmanPath)) {
return `File does not exist: ${body.podmanPath}`
}
try {
accessSync(body.podmanPath, fsConstants.X_OK)
} catch {
return `File is not executable: ${body.podmanPath}`
}
return null
}
export function createOpenClawRoutes() {
return new Hono()
.get('/status', async (c) => {
@@ -58,7 +82,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,
@@ -205,6 +229,7 @@ export function createOpenClawRoutes() {
const body = await c.req.json<{
message: string
sessionKey?: string
history?: MonitoringChatTurn[]
}>()
if (!body.message?.trim()) {
@@ -212,12 +237,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')
@@ -227,20 +277,65 @@ 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)
}
@@ -288,4 +383,37 @@ export function createOpenClawRoutes() {
return c.json({ error: message }, 500)
}
})
.get('/podman-overrides', async (c) => {
try {
const overrides = await getOpenClawService().getPodmanOverrides()
return c.json(overrides)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error('Podman overrides read failed', { error: message })
return c.json({ error: message }, 500)
}
})
.post('/podman-overrides', async (c) => {
const body = await c.req.json<{ podmanPath: string | null }>()
const validationError = getPodmanOverrideValidationError(body)
if (validationError) {
return c.json({ error: validationError }, 400)
}
try {
logger.info('OpenClaw podman override requested', {
podmanPath: body.podmanPath,
})
const result = await getOpenClawService().applyPodmanOverrides({
podmanPath: body.podmanPath,
})
return c.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error('Podman overrides apply failed', { error: message })
return c.json({ error: message }, 500)
}
})
}

View File

@@ -29,6 +29,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'
@@ -121,6 +122,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 +148,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

@@ -3,21 +3,27 @@
* 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.
* OpenClaw container lifecycle abstraction over PodmanRuntime.
*/
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 { logger } from '../../../lib/logger'
import type { LogFn, PodmanRuntime } from './podman-runtime'
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`
export type GatewayContainerSpec = {
image: string
hostPort: number
hostHome: string
envFilePath: string
gatewayToken?: string
timezone: string
}
export class ContainerRuntime {
constructor(
@@ -41,64 +47,102 @@ export class ContainerRuntime {
return this.podman.getMachineStatus()
}
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> {
const code = await this.runPodmanCommand(['pull', image], onLog)
if (code !== 0) throw new Error(`image pull failed with code ${code}`)
}
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.ensureGatewayRemoved(onLog)
const containerPort = String(OPENCLAW_GATEWAY_CONTAINER_PORT)
const code = await this.runPodmanCommand(
[
'run',
'-d',
'--name',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'--restart',
'unless-stopped',
'-p',
`127.0.0.1:${input.hostPort}:${containerPort}`,
...this.buildGatewayContainerRuntimeArgs(input),
'--health-cmd',
`curl -sf http://127.0.0.1:${containerPort}/healthz`,
'--health-interval',
'30s',
'--health-timeout',
'10s',
'--health-retries',
'3',
input.image,
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
containerPort,
'--allow-unconfigured',
],
onLog,
)
if (code !== 0) throw new Error(`gateway start failed with code ${code}`)
}
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> {
const code = await this.removeGatewayContainer(onLog)
if (code !== 0) {
throw new Error(`gateway stop failed with code ${code}`)
}
}
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.runPodmanCommand(
['logs', '--tail', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
(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)) {
if (await this.isReady(hostPort)) {
logger.info('OpenClaw gateway became ready', {
port,
hostPort,
waitMs: Date.now() - start,
})
return true
@@ -106,22 +150,12 @@ export class ContainerRuntime {
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.
@@ -132,8 +166,8 @@ export class ContainerRuntime {
try {
const containers = await this.podman.listRunningContainers()
const allOurs = containers.every((name) =>
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
const allOurs = containers.every(
(name) => name === OPENCLAW_GATEWAY_CONTAINER_NAME,
)
if (containers.length === 0 || allOurs) {
@@ -155,17 +189,25 @@ export class ContainerRuntime {
async runGatewaySetupCommand(
command: string[],
spec: GatewayContainerSpec,
onLog?: LogFn,
): Promise<number> {
return this.compose(
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.runPodmanCommand(
['rm', '-f', '--ignore', setupContainerName],
onLog,
)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
return this.runPodmanCommand(
[
'run',
'--rm',
'--no-deps',
'--entrypoint',
'--name',
setupContainerName,
...this.buildGatewayContainerRuntimeArgs(spec),
spec.image,
'node',
'openclaw-gateway',
...command.slice(1),
...setupArgs,
],
onLog,
)
@@ -178,15 +220,17 @@ export class ContainerRuntime {
)
}
private async compose(args: string[], onLog?: LogFn): Promise<number> {
private async runPodmanCommand(
args: string[],
onLog?: LogFn,
): Promise<number> {
const lines: string[] = []
const command = ['podman', 'compose', ...args].join(' ')
logger.info('Running OpenClaw compose command', {
const command = ['podman', ...args].join(' ')
logger.info('Running OpenClaw podman command', {
command,
})
const code = await this.podman.runCommand(['compose', ...args], {
const code = await this.podman.runCommand(args, {
cwd: this.projectDir,
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
onOutput: (line) => {
lines.push(line)
onLog?.(line)
@@ -194,17 +238,58 @@ export class ContainerRuntime {
})
if (code !== 0) {
logger.error('OpenClaw compose command failed', {
logger.error('OpenClaw podman command failed', {
command,
exitCode: code,
output: lines,
})
} else {
logger.info('OpenClaw compose command succeeded', {
logger.info('OpenClaw podman command succeeded', {
command,
})
}
return code
}
private async ensureGatewayRemoved(onLog?: LogFn): Promise<void> {
await this.removeGatewayContainer(onLog)
}
private async removeGatewayContainer(onLog?: LogFn): Promise<number> {
return this.runPodmanCommand(
['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
onLog,
)
}
private buildGatewayContainerRuntimeArgs(
input: GatewayContainerSpec,
): string[] {
return [
'--env-file',
input.envFilePath,
'-e',
`HOME=${GATEWAY_CONTAINER_HOME}`,
'-e',
`OPENCLAW_HOME=${GATEWAY_CONTAINER_HOME}`,
'-e',
`OPENCLAW_STATE_DIR=${GATEWAY_STATE_DIR}`,
'-e',
'OPENCLAW_NO_RESPAWN=1',
'-e',
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
'-e',
'NODE_ENV=production',
'-e',
`TZ=${input.timezone}`,
'-v',
`${input.hostHome}:${GATEWAY_CONTAINER_HOME}`,
'--add-host',
'host.containers.internal:host-gateway',
...(input.gatewayToken
? ['-e', `OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`]
: []),
]
}
}

View File

@@ -5,10 +5,7 @@
*/
import { join } from 'node:path'
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
// Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:2026.4.12'
const STATE_DIR_NAME = '.openclaw'
export function getOpenClawStateDir(openclawDir: string): string {
@@ -33,26 +30,6 @@ export function getHostWorkspaceDir(
)
}
export function buildComposeEnvFile(input: {
hostHome: string
image?: string
port?: number
timezone?: string
gatewayToken?: string
}): string {
const lines = [
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
`OPENCLAW_HOST_HOME=${input.hostHome}`,
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
]
if (input.gatewayToken) {
lines.push(`OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`)
}
lines.push('')
return lines.join('\n')
}
export function mergeEnvContent(
current: string,
updates: Record<string, string>,

View File

@@ -7,16 +7,22 @@
import { createParser, type EventSourceMessage } from 'eventsource-parser'
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 class OpenClawHttpChatClient {
constructor(
private readonly port: number,
private readonly hostPort: number,
private readonly getToken: () => Promise<string>,
) {}
@@ -36,7 +42,7 @@ export class OpenClawHttpChatClient {
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.port}/v1/chat/completions`,
`http://127.0.0.1:${this.hostPort}/v1/chat/completions`,
{
method: 'POST',
headers: {
@@ -46,7 +52,10 @@ export class OpenClawHttpChatClient {
body: JSON.stringify({
model: resolveAgentModel(input.agentId),
stream: true,
messages: [{ role: 'user', content: input.message }],
messages: [
...(input.history ?? []),
{ role: 'user', content: input.message },
],
user: `browseros:${input.agentId}:${input.sessionKey}`,
}),
signal: input.signal,

View File

@@ -14,6 +14,19 @@ export const SUPPORTED_OPENCLAW_PROVIDERS = [
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',
@@ -65,6 +78,30 @@ export function buildOpenClawModelRef(
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 {
@@ -77,20 +114,44 @@ export function resolveSupportedOpenClawProvider(input: {
baseUrl?: string
apiKey?: string
modelId?: string
}): {
envValues: Record<string, string>
model?: string
providerType?: SupportedOpenClawProvider
} {
const providerType = assertSupportedOpenClawProvider(input.providerType)
if (!providerType) {
}): ResolvedOpenClawProviderConfig {
if (!input.providerType) {
return { envValues: {} }
}
const envVar = getOpenClawProviderEnvVar(providerType)
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 ? { [envVar]: input.apiKey } : {},
model: buildOpenClawModelRef(providerType, input.modelId),
providerType,
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

@@ -10,15 +10,18 @@
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_PORT,
OPENCLAW_GATEWAY_CONTAINER_PORT,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import { ContainerRuntime } from './container-runtime'
import type { MonitoringChatTurn } from '../../../monitoring/types'
import {
ContainerRuntime,
type GatewayContainerSpec,
} from './container-runtime'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
@@ -31,7 +34,6 @@ import {
type OpenClawConfigBatchEntry,
} from './openclaw-cli-client'
import {
buildComposeEnvFile,
getHostWorkspaceDir,
getOpenClawStateConfigPath,
getOpenClawStateDir,
@@ -39,31 +41,18 @@ import {
mergeEnvContent,
} from './openclaw-env'
import { OpenClawHttpChatClient } from './openclaw-http-chat-client'
import { resolveSupportedOpenClawProvider } from './openclaw-provider-map'
import {
type ResolvedOpenClawProviderConfig,
resolveSupportedOpenClawProvider,
} from './openclaw-provider-map'
import type { OpenClawStreamEvent } from './openclaw-types'
import { getPodmanRuntime } from './podman-runtime'
import { loadPodmanOverrides, savePodmanOverrides } from './podman-overrides'
import { configurePodmanRuntime, getPodmanRuntime } from './podman-runtime'
import { allocateGatewayPort, readPersistedGatewayPort } from './runtime-state'
export const SOURCE_COMPOSE_RESOURCE = resolve(
import.meta.dir,
'../../../../resources/openclaw-compose.yml',
)
const READY_TIMEOUT_MS = 30_000
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
export function resolveComposeResourcePath(resourcesDir?: string): string {
if (resourcesDir) {
const bundledComposePath = join(resourcesDir, 'openclaw-compose.yml')
if (existsSync(bundledComposePath)) {
return bundledComposePath
}
logger.warn(
'Bundled openclaw-compose.yml not found in resourcesDir, falling back to source tree',
{ resourcesDir },
)
}
return SOURCE_COMPOSE_RESOURCE
}
export type OpenClawControlPlaneStatus =
| 'disconnected'
| 'connecting'
@@ -121,39 +110,42 @@ export interface OpenClawServiceConfig {
resourcesDir?: string
}
export interface OpenClawPodmanOverridesResponse {
podmanPath: string | null
effectivePodmanPath: string
}
export class OpenClawService {
private runtime: ContainerRuntime
private cliClient: OpenClawCliClient
private bootstrapCliClient: OpenClawCliClient
private chatClient: OpenClawHttpChatClient
private openclawDir: string
private composeResourcePath: string
private port = OPENCLAW_GATEWAY_PORT
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
private token: string
private tokenLoaded = false
private lastError: string | null = null
private browserosServerPort: number
private resourcesDir: string | null
private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected'
private lastGatewayError: string | null = null
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
private stopLogTail: (() => void) | null = null
private lifecycleLock: Promise<void> = Promise.resolve()
constructor(config: OpenClawServiceConfig = {}) {
this.openclawDir = getOpenClawDir()
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
this.token = crypto.randomUUID()
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = new OpenClawCliClient({
execInContainer: (command, onLog) =>
this.runtime.runGatewaySetupCommand(command, onLog),
})
this.bootstrapCliClient = this.buildBootstrapCliClient()
this.chatClient = new OpenClawHttpChatClient(
this.port,
this.hostPort,
async () => this.token,
)
this.composeResourcePath = resolveComposeResourcePath(config.resourcesDir)
this.browserosServerPort =
config.browserosServerPort ?? DEFAULT_PORTS.server
this.resourcesDir = config.resourcesDir ?? null
}
configure(config: OpenClawServiceConfig): void {
@@ -161,217 +153,268 @@ export class OpenClawService {
this.browserosServerPort = config.browserosServerPort
}
if (config.resourcesDir !== undefined) {
this.composeResourcePath = resolveComposeResourcePath(config.resourcesDir)
this.resourcesDir = config.resourcesDir
}
}
getPort(): number {
return this.hostPort
}
// ── Lifecycle ────────────────────────────────────────────────────────
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
const provider = resolveSupportedOpenClawProvider(input)
logger.info('Starting OpenClaw setup', {
port: this.port,
browserosServerPort: this.browserosServerPort,
providerType: input.providerType,
providerName: input.providerName,
hasBaseUrl: !!input.baseUrl,
hasModel: !!input.modelId,
hasApiKey: !!input.apiKey,
})
return this.withLifecycleLock('setup', async () => {
const logProgress = this.createProgressLogger(onLog)
const provider = resolveSupportedOpenClawProvider(input)
logger.info('Starting OpenClaw setup', {
hostPort: this.hostPort,
browserosServerPort: this.browserosServerPort,
providerType: input.providerType,
providerName: input.providerName,
hasBaseUrl: !!input.baseUrl,
hasModel: !!input.modelId,
hasApiKey: !!input.apiKey,
})
logProgress('Checking container runtime...')
const available = await this.runtime.isPodmanAvailable()
if (!available) {
throw new Error(
'Podman is not available. Install Podman to use OpenClaw agents.',
logProgress('Checking container runtime...')
const available = await this.runtime.isPodmanAvailable()
if (!available) {
throw new Error(
'Podman is not available. Install Podman to use OpenClaw agents.',
)
}
await this.runtime.ensureReady(logProgress)
logProgress('Container runtime ready')
await mkdir(this.openclawDir, { recursive: true })
await mkdir(this.getStateDir(), { recursive: true })
await mkdir(this.getHostWorkspaceDir('main'), { recursive: true })
await this.ensureStateEnvFile()
await this.writeStateEnv(provider.envValues)
logger.info('Updated OpenClaw state env', {
providerKeyCount: Object.keys(provider.envValues).length,
})
logProgress('Pulling OpenClaw image...')
await this.runtime.pullImage(this.getGatewayImage(), logProgress)
logProgress('Image ready')
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Bootstrapping OpenClaw config...')
await this.bootstrapCliClient.runOnboard({
acceptRisk: true,
authChoice: 'skip',
gatewayAuth: 'token',
gatewayBind: 'lan',
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
installDaemon: false,
mode: 'local',
nonInteractive: true,
skipHealth: true,
})
await this.applyBrowserosConfig()
await this.mergeProviderConfigIfChanged(provider)
if (provider.model) {
await this.bootstrapCliClient.setDefaultModel(provider.model)
}
logProgress('Validating OpenClaw config...')
await this.assertConfigValid(this.bootstrapCliClient)
this.tokenLoaded = false
await this.loadTokenFromConfig()
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
}
await this.runtime.ensureReady(logProgress)
logProgress('Container runtime ready')
await mkdir(this.openclawDir, { recursive: true })
await mkdir(this.getStateDir(), { recursive: true })
await mkdir(this.getHostWorkspaceDir('main'), { recursive: true })
logProgress('Copying compose file...')
await this.runtime.copyComposeFile(this.composeResourcePath)
await this.writeComposeEnv()
logProgress('Generated .env file')
logger.info('Wrote OpenClaw env file', {
openclawDir: this.openclawDir,
})
await this.ensureStateEnvFile()
await this.writeStateEnv(provider.envValues)
logger.info('Updated OpenClaw state env', {
providerKeyCount: Object.keys(provider.envValues).length,
})
logProgress('Pulling OpenClaw image...')
await this.runtime.composePull(logProgress)
logProgress('Image ready')
logProgress('Bootstrapping OpenClaw config...')
await this.bootstrapCliClient.runOnboard({
acceptRisk: true,
authChoice: 'skip',
gatewayAuth: 'token',
gatewayBind: 'lan',
gatewayPort: this.port,
installDaemon: false,
mode: 'local',
nonInteractive: true,
skipHealth: true,
})
await this.applyBrowserosConfig()
if (provider.model) {
await this.bootstrapCliClient.setDefaultModel(provider.model)
}
logProgress('Validating OpenClaw config...')
await this.assertConfigValid(this.bootstrapCliClient)
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.writeComposeEnv()
logProgress('Starting OpenClaw gateway...')
await this.runtime.composeUp(logProgress)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
if (!ready) {
this.lastError = 'Gateway did not become ready within 30 seconds'
const logs = await this.runtime.composeLogs()
logger.error('Gateway readiness check failed', { logs })
throw new Error(this.lastError)
}
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
const existingAgents = await this.listAgents()
logger.info('Fetched existing OpenClaw agents after setup', {
count: existingAgents.length,
names: existingAgents.map((agent) => agent.name),
})
if (existingAgents.some((agent) => agent.agentId === 'main')) {
logProgress('Main agent detected')
} else {
logProgress('Creating main agent...')
await this.runControlPlaneCall(() =>
this.cliClient.createAgent({
name: 'main',
model: provider.model,
}),
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
}
if (!ready) {
this.lastError = 'Gateway did not become ready within 30 seconds'
const logs = await this.runtime.getGatewayLogs()
logger.error('Gateway readiness check failed', { logs })
throw new Error(this.lastError)
}
this.lastError = null
logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.port}`)
logger.info('OpenClaw setup complete', { port: this.port })
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
const existingAgents = await this.listAgents()
logger.info('Fetched existing OpenClaw agents after setup', {
count: existingAgents.length,
names: existingAgents.map((agent) => agent.name),
})
if (existingAgents.some((agent) => agent.agentId === 'main')) {
logProgress('Main agent detected')
} else {
logProgress('Creating main agent...')
await this.runControlPlaneCall(() =>
this.cliClient.createAgent({
name: 'main',
model: provider.model,
}),
)
}
this.lastError = null
logProgress(
`OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`,
)
logger.info('OpenClaw setup complete', { hostPort: this.hostPort })
})
}
async start(onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
logger.info('Starting OpenClaw service', {
port: this.port,
return this.withLifecycleLock('start', async () => {
const logProgress = this.createProgressLogger(onLog)
logger.info('Starting OpenClaw service', {
hostPort: this.hostPort,
})
await this.runtime.ensureReady(logProgress)
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
if (await this.isGatewayAvailable(this.hostPort)) {
this.startGatewayLogTail()
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
try {
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logger.info('OpenClaw gateway already running', {
hostPort: this.hostPort,
})
return
} catch (error) {
logger.warn('OpenClaw control plane probe failed during start', {
hostPort: this.hostPort,
error: error instanceof Error ? error.message : String(error),
})
}
}
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready after start'
throw new Error(this.lastError)
}
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logger.info('OpenClaw gateway started', { hostPort: this.hostPort })
})
await this.runtime.ensureReady(logProgress)
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.writeComposeEnv()
logProgress('Starting OpenClaw gateway...')
await this.runtime.composeUp(logProgress)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
if (!ready) {
this.lastError = 'Gateway did not become ready after start'
throw new Error(this.lastError)
}
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logger.info('OpenClaw gateway started', { port: this.port })
}
async stop(): Promise<void> {
logger.info('Stopping OpenClaw service', { port: this.port })
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
await this.runtime.composeStop()
logger.info('OpenClaw container stopped')
return this.withLifecycleLock('stop', async () => {
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
await this.runtime.stopGateway()
logger.info('OpenClaw container stopped')
})
}
async restart(onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
logger.info('Restarting OpenClaw service', {
port: this.port,
return this.withLifecycleLock('restart', async () => {
const logProgress = this.createProgressLogger(onLog)
logger.info('Restarting OpenClaw service', {
hostPort: this.hostPort,
})
this.controlPlaneStatus = 'reconnecting'
this.stopGatewayLogTail()
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Restarting OpenClaw gateway...')
await this.runtime.restartGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready after restart'
throw new Error(this.lastError)
}
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logProgress('Gateway restarted successfully')
logger.info('OpenClaw gateway restarted', { hostPort: this.hostPort })
})
this.controlPlaneStatus = 'reconnecting'
this.stopGatewayLogTail()
logProgress('Restarting OpenClaw gateway...')
await this.runtime.composeRestart(logProgress)
this.startGatewayLogTail()
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
if (!ready) {
this.lastError = 'Gateway did not become ready after restart'
throw new Error(this.lastError)
}
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
this.lastError = null
logProgress('Gateway restarted successfully')
logger.info('OpenClaw gateway restarted', { port: this.port })
}
async reconnectControlPlane(onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
logger.info('Reconnecting OpenClaw control plane', { port: this.port })
return this.withLifecycleLock('reconnect', async () => {
const logProgress = this.createProgressLogger(onLog)
logger.info('Reconnecting OpenClaw control plane', {
hostPort: this.hostPort,
})
logProgress('Checking gateway readiness...')
const ready = await this.runtime.isReady(this.port)
if (!ready) {
this.controlPlaneStatus = 'failed'
this.lastGatewayError = 'OpenClaw gateway is not ready'
this.lastRecoveryReason = 'container_not_ready'
throw new Error('OpenClaw gateway is not ready')
}
logProgress('Checking gateway readiness...')
const ready = await this.runtime.isReady(this.hostPort)
if (!ready) {
this.controlPlaneStatus = 'failed'
this.lastGatewayError = 'OpenClaw gateway is not ready'
this.lastRecoveryReason = 'container_not_ready'
throw new Error('OpenClaw gateway is not ready')
}
logProgress('Reloading gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
this.controlPlaneStatus = 'reconnecting'
logProgress('Reconnecting control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
logProgress('Control plane connected')
logProgress('Reloading gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
this.controlPlaneStatus = 'reconnecting'
logProgress('Reconnecting control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
logProgress('Control plane connected')
})
}
async shutdown(): Promise<void> {
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
try {
await this.runtime.composeStop()
await this.runtime.stopGateway()
} catch {
// Best effort during shutdown
}
@@ -415,7 +458,7 @@ export class OpenClawService {
const machineStatus = await this.runtime.getMachineStatus()
const ready = machineStatus.running
? await this.runtime.isReady(this.port)
? await this.runtime.isReady(this.hostPort)
: false
let agentCount = 0
@@ -434,7 +477,7 @@ export class OpenClawService {
status: ready ? 'running' : this.lastError ? 'error' : 'stopped',
podmanAvailable: true,
machineReady: machineStatus.running,
port: this.port,
port: this.hostPort,
agentCount,
error: this.lastError,
controlPlaneStatus: ready ? this.controlPlaneStatus : 'disconnected',
@@ -469,11 +512,13 @@ export class OpenClawService {
await this.assertGatewayReady()
const provider = resolveSupportedOpenClawProvider(input)
const configChanged = await this.mergeProviderConfigIfChanged(provider)
const keysChanged = await this.writeStateEnv(provider.envValues)
if (keysChanged) {
if (configChanged || keysChanged) {
logger.info('OpenClaw provider config changed while creating agent', {
name,
configChanged,
keysChanged,
})
await this.restart()
@@ -534,22 +579,63 @@ export class OpenClawService {
agentId: string,
sessionKey: string,
message: string,
history: MonitoringChatTurn[] = [],
): Promise<ReadableStream<OpenClawStreamEvent>> {
await this.assertGatewayReady()
logger.info('Starting OpenClaw chat stream', {
agentId,
sessionKey,
messageLength: message.length,
historyLength: history.length,
})
return this.runControlPlaneCall(() =>
this.chatClient.streamChat({
agentId,
sessionKey,
message,
history,
}),
)
}
// ── Podman Overrides ─────────────────────────────────────────────────
async applyPodmanOverrides(input: {
podmanPath: string | null
}): Promise<OpenClawPodmanOverridesResponse> {
await savePodmanOverrides(this.openclawDir, {
podmanPath: input.podmanPath,
})
// Intentionally mutates the module-level PodmanRuntime singleton so every
// consumer (including future service instances) sees the new path.
configurePodmanRuntime({
resourcesDir: this.resourcesDir ?? undefined,
podmanPath: input.podmanPath ?? undefined,
})
this.rebuildRuntimeClients()
const effectivePodmanPath = getPodmanRuntime().getPodmanPath()
logger.info('Applied Podman overrides', {
podmanPath: input.podmanPath,
effectivePodmanPath,
})
return {
podmanPath: input.podmanPath,
effectivePodmanPath,
}
}
async getPodmanOverrides(): Promise<OpenClawPodmanOverridesResponse> {
const { podmanPath } = await loadPodmanOverrides(this.openclawDir)
return {
podmanPath,
effectivePodmanPath: getPodmanRuntime().getPodmanPath(),
}
}
// ── Provider Keys ────────────────────────────────────────────────────
async updateProviderKeys(input: {
@@ -560,21 +646,23 @@ export class OpenClawService {
modelId?: string
}): Promise<OpenClawProviderUpdateResult> {
const provider = resolveSupportedOpenClawProvider(input)
const configChanged = await this.mergeProviderConfigIfChanged(provider)
const envChanged = await this.writeStateEnv(provider.envValues)
const restarted = configChanged || envChanged
if (restarted) {
await this.restart()
}
if (provider.model) {
const model = provider.model
await this.applyCliMutation(() => this.cliClient.setDefaultModel(model))
}
const changed = await this.writeStateEnv(provider.envValues)
if (changed) {
await this.restart()
}
logger.info('Provider keys updated', {
providerType: input.providerType,
modelUpdated: !!provider.model,
restarted: changed,
restarted,
})
return {
restarted: changed,
restarted,
modelUpdated: !!provider.model,
}
}
@@ -583,56 +671,121 @@ export class OpenClawService {
async getLogs(tail = 100): Promise<string[]> {
logger.debug('Fetching OpenClaw container logs', { tail })
return this.runtime.composeLogs(tail)
return this.runtime.getGatewayLogs(tail)
}
// ── Auto-start on BrowserOS boot ────────────────────────────────────
async tryAutoStart(): Promise<void> {
const isSetUp = existsSync(this.getStateConfigPath())
if (!isSetUp) return
return this.withLifecycleLock('auto-start', async () => {
const isSetUp = existsSync(this.getStateConfigPath())
if (!isSetUp) return
const available = await this.runtime.isPodmanAvailable()
if (!available) return
logger.info('Attempting OpenClaw auto-start', {
port: this.port,
})
try {
await this.runtime.ensureReady()
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.writeComposeEnv()
if (!(await this.runtime.isReady(this.port))) {
await this.runtime.composeUp()
const ready = await this.runtime.waitForReady(
this.port,
READY_TIMEOUT_MS,
)
if (!ready) {
logger.warn('OpenClaw gateway failed to become ready on auto-start')
return
}
}
await this.runControlPlaneCall(() => this.cliClient.probe())
logger.info('OpenClaw gateway auto-started')
} catch (err) {
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
const available = await this.runtime.isPodmanAvailable()
if (!available) return
logger.info('Attempting OpenClaw auto-start', {
hostPort: this.hostPort,
})
}
try {
await this.runtime.ensureReady()
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
if (persistedPort !== null) {
this.setPort(persistedPort)
}
if (!(await this.runtime.isReady(this.hostPort))) {
await this.ensureGatewayPortAllocated()
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
logger.warn('OpenClaw gateway failed to become ready on auto-start')
return
}
}
await this.runControlPlaneCall(() => this.cliClient.probe())
logger.info('OpenClaw gateway auto-started')
} catch (err) {
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
})
}
})
}
// ── Internal ─────────────────────────────────────────────────────────
private buildBootstrapCliClient(): OpenClawCliClient {
return new OpenClawCliClient({
execInContainer: (command, onLog) =>
this.runtime.runGatewaySetupCommand(
command,
this.buildGatewayRuntimeSpec(),
onLog,
),
})
}
private rebuildRuntimeClients(): void {
this.stopGatewayLogTail()
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
}
private setPort(hostPort: number): void {
if (hostPort === this.hostPort) return
this.hostPort = hostPort
this.chatClient = new OpenClawHttpChatClient(
this.hostPort,
async () => this.token,
)
}
private async ensureGatewayPortAllocated(
logProgress?: (msg: string) => void,
): Promise<void> {
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
if (persistedPort !== null) {
this.setPort(persistedPort)
}
if (await this.isGatewayAvailable(this.hostPort)) {
return
}
const hostPort = await allocateGatewayPort(this.openclawDir)
if (hostPort !== this.hostPort) {
logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`)
logger.info('Allocated OpenClaw gateway host port', { hostPort })
this.setPort(hostPort)
}
}
private async isGatewayAvailable(hostPort: number): Promise<boolean> {
if (await this.runtime.isReady(hostPort)) {
return true
}
const runtime = this.runtime as {
isHealthy?: (port: number) => Promise<boolean>
}
if (runtime.isHealthy) {
return runtime.isHealthy(hostPort)
}
return false
}
private async assertGatewayReady(): Promise<void> {
const portReady = await this.runtime.isReady(this.port)
const portReady = await this.runtime.isReady(this.hostPort)
logger.debug('Checking OpenClaw gateway readiness before use', {
port: this.port,
hostPort: this.hostPort,
portReady,
controlPlaneStatus: this.controlPlaneStatus,
})
@@ -736,8 +889,8 @@ export class OpenClawService {
{
path: 'gateway.controlUi.allowedOrigins',
value: [
`http://127.0.0.1:${this.port}`,
`http://localhost:${this.port}`,
`http://127.0.0.1:${this.hostPort}`,
`http://localhost:${this.hostPort}`,
],
},
{
@@ -847,7 +1000,10 @@ export class OpenClawService {
}
private async waitForGatewayAfterCliMutation(): Promise<void> {
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
const ready = await this.runtime.waitForReady(
this.hostPort,
READY_TIMEOUT_MS,
)
if (!ready) {
this.lastError = 'Gateway did not become ready after applying config'
throw new Error(this.lastError)
@@ -875,13 +1031,20 @@ export class OpenClawService {
await writeFile(envPath, '', { mode: 0o600 })
}
private async writeComposeEnv(): Promise<void> {
const envContent = buildComposeEnvFile({
// Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
private getGatewayImage(): string {
return process.env.OPENCLAW_IMAGE || 'ghcr.io/openclaw/openclaw:2026.4.12'
}
private buildGatewayRuntimeSpec(): GatewayContainerSpec {
return {
image: this.getGatewayImage(),
hostPort: this.hostPort,
hostHome: this.openclawDir,
port: this.port,
envFilePath: this.getStateEnvPath(),
gatewayToken: this.tokenLoaded ? this.token : undefined,
})
await this.runtime.writeEnvFile(envContent)
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
}
private async writeStateEnv(
@@ -908,6 +1071,82 @@ export class OpenClawService {
return true
}
private async mergeProviderConfigIfChanged(
provider: ResolvedOpenClawProviderConfig,
): Promise<boolean> {
if (!provider.customProvider) {
return false
}
const configPath = this.getStateConfigPath()
const content = await readFile(configPath, 'utf-8')
const config = JSON.parse(content) as Record<string, unknown>
const models =
config.models && typeof config.models === 'object'
? (config.models as Record<string, unknown>)
: {}
const providers =
models.providers && typeof models.providers === 'object'
? (models.providers as Record<string, Record<string, unknown>>)
: {}
const existingProvider = providers[provider.customProvider.providerId] ?? {}
const existingModels = Array.isArray(existingProvider.models)
? (existingProvider.models as Array<Record<string, unknown>>)
: []
const desiredModelEntry =
Array.isArray(provider.customProvider.config.models) &&
provider.customProvider.config.models.length > 0
? (provider.customProvider.config.models[0] as Record<string, unknown>)
: null
const hasDesiredModel = desiredModelEntry
? existingModels.some(
(model) =>
model.id === desiredModelEntry.id ||
model.name === desiredModelEntry.name,
)
: true
const mergedModels =
desiredModelEntry && !hasDesiredModel
? [...existingModels, desiredModelEntry]
: existingModels.length > 0
? existingModels
: Array.isArray(provider.customProvider.config.models)
? provider.customProvider.config.models
: undefined
const nextProvider: Record<string, unknown> = {
...existingProvider,
...provider.customProvider.config,
...(mergedModels ? { models: mergedModels } : {}),
}
const nextModels: Record<string, unknown> = {
...models,
mode: 'merge',
providers: {
...providers,
[provider.customProvider.providerId]: nextProvider,
},
}
const nextConfig: Record<string, unknown> = {
...config,
models: nextModels,
}
if (JSON.stringify(config) === JSON.stringify(nextConfig)) {
return false
}
await writeFile(
configPath,
`${JSON.stringify(nextConfig, null, 2)}\n`,
'utf-8',
)
logger.debug('Updated OpenClaw custom provider config', {
providerId: provider.customProvider.providerId,
})
return true
}
private async ensureTokenLoaded(): Promise<void> {
if (this.tokenLoaded) {
return
@@ -951,6 +1190,24 @@ export class OpenClawService {
onLog?.(msg)
}
}
private async withLifecycleLock<T>(
operation: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = this.lifecycleLock
let release!: () => void
this.lifecycleLock = new Promise<void>((resolve) => {
release = resolve
})
await previous.catch(() => undefined)
try {
logger.debug('OpenClaw lifecycle operation started', { operation })
return await fn()
} finally {
release()
}
}
}
let service: OpenClawService | null = null

View File

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

View File

@@ -37,7 +37,6 @@ export function resolveBundledPodmanPath(
export class PodmanRuntime {
private podmanPath: string
private machineReady = false
constructor(config?: { podmanPath?: string }) {
this.podmanPath = config?.podmanPath ?? 'podman'
@@ -138,12 +137,9 @@ export class PodmanRuntime {
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) {
@@ -155,8 +151,6 @@ export class PodmanRuntime {
onLog?.('Starting Podman machine...')
await this.startMachine(onLog)
}
this.machineReady = true
}
async runCommand(

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

@@ -6,12 +6,10 @@ import { PATHS } from '@browseros/shared/constants/paths'
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
import { logger } from './logger'
const DEV_BROWSEROS_DIR_NAME = '.browseros-dev'
export function getBrowserosDir(): string {
const dirName =
process.env.NODE_ENV === 'development'
? DEV_BROWSEROS_DIR_NAME
? PATHS.DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return join(homedir(), dirName)
}
@@ -49,6 +47,30 @@ export function getOpenClawDir(): 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 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)
}
@@ -73,6 +95,8 @@ export async function ensureBrowserosDir(): Promise<void> {
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

@@ -17,6 +17,7 @@ import {
configureOpenClawService,
getOpenClawService,
} from './api/services/openclaw/openclaw-service'
import { loadPodmanOverrides } from './api/services/openclaw/podman-overrides'
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
import { CdpBackend } from './browser/backends/cdp'
import { Browser } from './browser/browser'
@@ -25,6 +26,7 @@ import { INLINED_ENV } from './env'
import {
cleanOldSessions,
ensureBrowserosDir,
getOpenClawDir,
removeServerConfigSync,
writeServerConfig,
} from './lib/browseros-dir'
@@ -59,9 +61,17 @@ export class Application {
resourcesDir: path.resolve(this.config.resourcesDir),
})
const resourcesDir = path.resolve(this.config.resourcesDir)
const podmanOverrides = await loadPodmanOverrides(getOpenClawDir())
configurePodmanRuntime({
resourcesDir: path.resolve(this.config.resourcesDir),
resourcesDir,
podmanPath: podmanOverrides.podmanPath ?? undefined,
})
if (podmanOverrides.podmanPath) {
logger.info('Using user-overridden Podman binary', {
podmanPath: podmanOverrides.podmanPath,
})
}
await this.initCoreServices()
if (!this.config.cdpPort) {
@@ -128,7 +138,7 @@ export class Application {
configureOpenClawService({
browserosServerPort: this.config.serverPort,
resourcesDir: path.resolve(this.config.resourcesDir),
resourcesDir,
})
.tryAutoStart()
.catch((err) =>

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

@@ -4,6 +4,9 @@
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { UnsupportedOpenClawProviderError } from '../../../src/api/services/openclaw/openclaw-provider-map'
describe('createOpenClawRoutes', () => {
@@ -11,7 +14,7 @@ describe('createOpenClawRoutes', () => {
mock.restore()
})
it('preserves BrowserOS SSE framing and session headers for chat', async () => {
it('preserves BrowserOS SSE framing, session headers, and defaults chat history for chat', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
@@ -57,7 +60,7 @@ describe('createOpenClawRoutes', () => {
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(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' +
@@ -65,6 +68,107 @@ describe('createOpenClawRoutes', () => {
)
})
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'
@@ -160,6 +264,124 @@ describe('createOpenClawRoutes', () => {
expect(response.status).toBe(404)
})
it('returns the current podman overrides on GET', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const getPodmanOverrides = mock(async () => ({
podmanPath: '/opt/homebrew/bin/podman',
effectivePodmanPath: '/opt/homebrew/bin/podman',
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ getPodmanOverrides }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
podmanPath: '/opt/homebrew/bin/podman',
effectivePodmanPath: '/opt/homebrew/bin/podman',
})
})
it('rejects a relative podman path on POST', async () => {
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath: 'podman' }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'podmanPath must be an absolute path',
})
})
it('rejects a nonexistent podman path on POST', async () => {
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath: '/does/not/exist/podman' }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'File does not exist: /does/not/exist/podman',
})
})
it('rejects a non-executable podman path on POST', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaw-route-'))
const nonExec = join(tempDir, 'podman')
writeFileSync(nonExec, 'not a binary')
chmodSync(nonExec, 0o644)
try {
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath: nonExec }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: `File is not executable: ${nonExec}`,
})
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
it('applies and echoes when POST clears the override', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const applyPodmanOverrides = mock(async () => ({
podmanPath: null,
effectivePodmanPath: 'podman',
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ applyPodmanOverrides }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/podman-overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ podmanPath: null }),
})
expect(response.status).toBe(200)
expect(applyPodmanOverrides).toHaveBeenCalledWith({ podmanPath: null })
expect(await response.json()).toEqual({
podmanPath: null,
effectivePodmanPath: 'podman',
})
})
it('ignores role fields when creating agents', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'

View File

@@ -4,6 +4,7 @@
*/
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import {
parseTerminalClientMessage,
serializeTerminalServerMessage,
@@ -53,7 +54,7 @@ describe('terminal protocol', () => {
expect(
buildTerminalExecCommand(
'podman',
'browseros-openclaw-openclaw-gateway-1',
OPENCLAW_GATEWAY_CONTAINER_NAME,
TERMINAL_HOME_DIR,
),
).toEqual([
@@ -62,7 +63,7 @@ describe('terminal protocol', () => {
'-it',
'-w',
'/home/node/.openclaw',
'browseros-openclaw-openclaw-gateway-1',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'/bin/sh',
])
})

View File

@@ -0,0 +1,326 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } 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: '/tmp/openclaw',
envFilePath: '/tmp/openclaw/.openclaw/.env',
gatewayToken: 'token-123',
timezone: 'America/Los_Angeles',
}
function createRuntime(
runCommand: (
args: string[],
options?: { cwd?: string; onOutput?: (line: string) => void },
) => Promise<number>,
listRunningContainers: () => Promise<string[]> = async () => [],
stopMachine: () => Promise<void> = async () => {},
): ContainerRuntime {
return new ContainerRuntime(
{
ensureReady: async () => {},
isPodmanAvailable: async () => true,
getMachineStatus: async () => ({ initialized: true, running: true }),
runCommand,
tailContainerLogs: () => () => {},
listRunningContainers,
stopMachine,
} as never,
PROJECT_DIR,
)
}
function expectedGatewayRuntimeArgs(spec: typeof defaultSpec): string[] {
return [
'--env-file',
spec.envFilePath,
'-e',
'HOME=/home/node',
'-e',
'OPENCLAW_HOME=/home/node',
'-e',
'OPENCLAW_STATE_DIR=/home/node/.openclaw',
'-e',
'OPENCLAW_NO_RESPAWN=1',
'-e',
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
'-e',
'NODE_ENV=production',
'-e',
`TZ=${spec.timezone}`,
'-v',
`${spec.hostHome}:/home/node`,
'--add-host',
'host.containers.internal:host-gateway',
'-e',
`OPENCLAW_GATEWAY_TOKEN=${spec.gatewayToken}`,
]
}
function expectedStartGatewayRunArgs(spec: typeof defaultSpec): string[] {
return [
'run',
'-d',
'--name',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'--restart',
'unless-stopped',
'-p',
`127.0.0.1:${spec.hostPort}:18789`,
...expectedGatewayRuntimeArgs(spec),
'--health-cmd',
'curl -sf http://127.0.0.1:18789/healthz',
'--health-interval',
'30s',
'--health-timeout',
'10s',
'--health-retries',
'3',
spec.image,
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
'18789',
'--allow-unconfigured',
]
}
describe('ContainerRuntime', () => {
it('pullImage runs podman pull for the requested image', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12')
expect(calls).toEqual([
{
args: ['pull', 'ghcr.io/openclaw/openclaw:2026.4.12'],
cwd: PROJECT_DIR,
},
])
})
it('startGateway removes any existing gateway and runs a fresh container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.startGateway(defaultSpec)
expect(calls).toHaveLength(2)
expect(calls[0]).toEqual({
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
})
expect(calls[1]).toEqual({
cwd: PROJECT_DIR,
args: expectedStartGatewayRunArgs(defaultSpec),
})
})
it('runGatewaySetupCommand in direct mode builds a one-off podman run command', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.runGatewaySetupCommand(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
)
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: [
'rm',
'-f',
'--ignore',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
],
},
{
cwd: PROJECT_DIR,
args: [
'run',
'--rm',
'--name',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
...expectedGatewayRuntimeArgs(defaultSpec),
defaultSpec.image,
'node',
'dist/index.js',
'agents',
'list',
'--json',
],
},
])
})
it('stopGateway removes the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.stopGateway()
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('stopGateway is idempotent when the managed container is already absent', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
options?.onOutput?.(
`Error: no container with name "${OPENCLAW_GATEWAY_CONTAINER_NAME}" found`,
)
return 0
})
await expect(runtime.stopGateway()).resolves.toBeUndefined()
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('getGatewayLogs tails logs from the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
options?.onOutput?.('first')
options?.onOutput?.('second')
return 0
})
const logs = await runtime.getGatewayLogs(25)
expect(logs).toEqual(['first', 'second'])
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['logs', '--tail', '25', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
])
})
it('restartGateway recreates and launches the direct runtime container', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.restartGateway(defaultSpec)
expect(calls).toEqual([
{
cwd: PROJECT_DIR,
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
},
{
cwd: PROJECT_DIR,
args: expectedStartGatewayRunArgs(defaultSpec),
},
])
})
it('stopMachineIfSafe allows the managed gateway container', async () => {
let stopCalls = 0
const runtime = createRuntime(
async () => 0,
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME],
async () => {
stopCalls += 1
},
)
await runtime.stopMachineIfSafe()
expect(stopCalls).toBe(1)
})
it('stopMachineIfSafe does not stop machine if non-BrowserOS containers are running', async () => {
let stopCalls = 0
const runtime = createRuntime(
async () => 0,
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME, 'postgres-dev'],
async () => {
stopCalls += 1
},
)
await runtime.stopMachineIfSafe()
expect(stopCalls).toBe(0)
})
it('execInContainer targets the shared gateway container name', async () => {
const calls: Array<{ args: string[]; cwd?: string }> = []
const runtime = createRuntime(async (args, options) => {
calls.push({ args, cwd: options?.cwd })
return 0
})
await runtime.execInContainer(['node', '--version'])
expect(calls).toEqual([
{
cwd: undefined,
args: ['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, 'node', '--version'],
},
])
})
it('tailGatewayLogs targets the shared gateway container name', () => {
const names: string[] = []
const runtime = new ContainerRuntime(
{
ensureReady: async () => {},
isPodmanAvailable: async () => true,
getMachineStatus: async () => ({ initialized: true, running: true }),
runCommand: async () => 0,
tailContainerLogs: (containerName: string) => {
names.push(containerName)
return () => {}
},
listRunningContainers: async () => [],
stopMachine: async () => {},
} as never,
PROJECT_DIR,
)
const stop = runtime.tailGatewayLogs(() => {})
stop()
expect(names).toEqual([OPENCLAW_GATEWAY_CONTAINER_NAME])
})
})

View File

@@ -1,32 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
resolveComposeResourcePath,
SOURCE_COMPOSE_RESOURCE,
} from '../../../../src/api/services/openclaw/openclaw-service'
describe('resolveComposeResourcePath', () => {
let tempDir: string | null = null
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = null
}
})
it('prefers the packaged resourcesDir copy when present', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-compose-resource-'))
const resourcesDir = join(tempDir, 'resources')
const composePath = join(resourcesDir, 'openclaw-compose.yml')
await Bun.write(composePath, 'services:\n')
expect(resolveComposeResourcePath(resourcesDir)).toBe(composePath)
})
it('falls back to the source tree when no packaged copy exists', () => {
expect(resolveComposeResourcePath(undefined)).toBe(SOURCE_COMPOSE_RESOURCE)
})
})

View File

@@ -4,25 +4,39 @@
*/
import { describe, expect, it } from 'bun:test'
import { buildComposeEnvFile } from '../../../../src/api/services/openclaw/openclaw-env'
import { mergeEnvContent } from '../../../../src/api/services/openclaw/openclaw-env'
describe('buildComposeEnvFile', () => {
it('pins the default OpenClaw image to 2026.4.12', () => {
describe('mergeEnvContent', () => {
it('appends new env keys and normalizes trailing newline', () => {
expect(
buildComposeEnvFile({
hostHome: '/tmp/openclaw-home',
timezone: 'UTC',
mergeEnvContent('OPENAI_API_KEY=sk-old', {
ANTHROPIC_API_KEY: 'ant-key',
}),
).toContain('OPENCLAW_IMAGE=ghcr.io/openclaw/openclaw:2026.4.12')
).toEqual({
changed: true,
content: 'OPENAI_API_KEY=sk-old\nANTHROPIC_API_KEY=ant-key\n',
})
})
it('respects an explicit image override', () => {
it('overwrites existing keys when values change', () => {
expect(
buildComposeEnvFile({
hostHome: '/tmp/openclaw-home',
timezone: 'UTC',
image: 'ghcr.io/openclaw/openclaw:custom',
mergeEnvContent('OPENAI_API_KEY=sk-old\n', {
OPENAI_API_KEY: 'sk-new',
}),
).toContain('OPENCLAW_IMAGE=ghcr.io/openclaw/openclaw:custom')
).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

@@ -56,6 +56,7 @@ describe('OpenClawHttpChatClient', () => {
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
history: [{ role: 'assistant', content: 'Earlier reply' }],
})
const events = await readEvents(stream)
@@ -72,7 +73,10 @@ describe('OpenClawHttpChatClient', () => {
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
model: 'openclaw/research',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
messages: [
{ role: 'assistant', content: 'Earlier reply' },
{ role: 'user', content: 'hi' },
],
user: 'browseros:research:session-123',
})
expect(events).toEqual([

View File

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

View File

@@ -11,9 +11,43 @@ import path from 'node:path'
import {
configurePodmanRuntime,
getPodmanRuntime,
PodmanRuntime,
resolveBundledPodmanPath,
} from '../../../../src/api/services/openclaw/podman-runtime'
class FakePodmanRuntime extends PodmanRuntime {
machineStatuses: Array<{ initialized: boolean; running: boolean }>
initCalls = 0
startCalls = 0
statusCalls = 0
constructor(statuses: Array<{ initialized: boolean; running: boolean }>) {
super({ podmanPath: 'podman' })
this.machineStatuses = [...statuses]
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
this.statusCalls += 1
return (
this.machineStatuses.shift() ?? {
initialized: true,
running: true,
}
)
}
async initMachine(): Promise<void> {
this.initCalls += 1
}
async startMachine(): Promise<void> {
this.startCalls += 1
}
}
describe('podman runtime', () => {
let tempDir: string
@@ -80,4 +114,56 @@ describe('podman runtime', () => {
expect(runtime.getPodmanPath()).toBe('podman')
})
it('ensureReady re-checks machine status on every call', async () => {
const runtime = new FakePodmanRuntime([
{ initialized: true, running: true },
{ initialized: true, running: true },
{ initialized: true, running: true },
])
await runtime.ensureReady()
await runtime.ensureReady()
await runtime.ensureReady()
expect(runtime.statusCalls).toBe(3)
expect(runtime.initCalls).toBe(0)
expect(runtime.startCalls).toBe(0)
})
it('ensureReady initializes when machine is not present', async () => {
const runtime = new FakePodmanRuntime([
{ initialized: false, running: false },
])
await runtime.ensureReady()
expect(runtime.statusCalls).toBe(1)
expect(runtime.initCalls).toBe(1)
expect(runtime.startCalls).toBe(1)
})
it('ensureReady starts when machine is initialized but stopped', async () => {
const runtime = new FakePodmanRuntime([
{ initialized: true, running: false },
])
await runtime.ensureReady()
expect(runtime.initCalls).toBe(0)
expect(runtime.startCalls).toBe(1)
})
it('ensureReady detects an externally stopped machine on the next call', async () => {
const runtime = new FakePodmanRuntime([
{ initialized: true, running: true },
{ initialized: true, running: false },
])
await runtime.ensureReady()
await runtime.ensureReady()
expect(runtime.statusCalls).toBe(2)
expect(runtime.startCalls).toBe(1)
})
})

View File

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

View File

@@ -0,0 +1,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

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

@@ -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 podman info
SOCK="$(limactl list browseros-vm-dev --format '{{.Dir}}')/sock/podman.sock"
curl --unix-socket "$SOCK" http://d/v5.0.0/libpod/_ping
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
limactl shell browseros-vm-dev podman load -i "$(ls dist/images/openclaw-*-arm64.tar.gz | head -1)"
limactl delete --force browseros-vm-dev
```
## Build an agent tarball
Requires `podman`.
```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}`,
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
# BrowserOS VM -- consumed directly by limactl, no build step.
# Based on Lima's built-in podman.yaml + _images/debian-12 templates.
# https://github.com/lima-vm/lima/tree/master/templates
minimumLimaVersion: 2.0.0
vmType: vz
cpus: 2
memory: 2GiB
disk: 10GiB
# Pinned Debian 12 genericcloud -- matches the deprecated disk pipeline pin.
# Bump in lockstep with upstream when provisioning changes.
images:
- location: "https://cloud.debian.org/images/cloud/bookworm/20260413-2447/debian-12-genericcloud-arm64-20260413-2447.qcow2"
arch: aarch64
digest: "sha512:15ad6c52e255c84eb0e91001c5907b27199d8a7164d8ac172cfe9c92850dfaf606a6c3161d6af7f0fd5a5fef2aa8dcd9a23c2eb0fedbfcddb38e2bc306cba98f"
- location: "https://cloud.debian.org/images/cloud/bookworm/20260413-2447/debian-12-genericcloud-amd64-20260413-2447.qcow2"
arch: x86_64
digest: "sha512:db11b13c4efcc37828ffadae521d101e85079d349e1418074087bb7d306f11caccdc2b0b539d6fd50d623d40a898f83c6137268a048d7700397dc35b7dcbc927"
# Fallbacks for when Debian rotates the dated snapshot.
- location: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2
arch: aarch64
- location: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
arch: x86_64
# Host-state isolation -- matches spec D5 / D7 defaults.
mounts: []
# We run podman, not containerd.
containerd:
system: false
user: false
provision:
- mode: system
script: |
#!/bin/bash
set -eux -o pipefail
if [ -e /etc/browseros-vm-provisioned ]; then exit 0; fi
DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
podman crun fuse-overlayfs slirp4netns ca-certificates
systemctl enable --now podman.socket
# Keep Docker config out of the image pull path (spec D7).
mkdir -p /etc/containers
containers_auth=/etc/containers/auth
printf '{}\n' > "${containers_auth}.json"
# Single-tenant appliance user (spec D7).
id browseros >/dev/null 2>&1 || useradd --create-home --uid 1000 --shell /bin/bash browseros
usermod -aG sudo browseros
# Version marker consumed by the runtime (WS4).
printf 'provisioned:%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /etc/browseros-vm-version
apt-get clean
rm -rf /var/lib/apt/lists/*
touch /etc/browseros-vm-provisioned
# Block `limactl start` until podman is ready.
probes:
- script: |
#!/bin/bash
set -eux -o pipefail
if ! timeout 60s bash -c 'until systemctl is-active podman.socket >/dev/null; do sleep 2; done'; then
echo >&2 "podman.socket not active after 60s"
exit 1
fi
hint: See /var/log/cloud-init-output.log inside the guest
# Expose guest podman socket to host for the TypeScript runtime to dial.
portForwards:
- guestSocket: "/run/podman/podman.sock"
hostSocket: "{{.Dir}}/sock/podman.sock"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
export const OPENCLAW_GATEWAY_PORT = 18789
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'
export const OPENCLAW_GATEWAY_CONTAINER_NAME = `${OPENCLAW_COMPOSE_PROJECT_NAME}-openclaw-gateway-1`

View File

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

View File

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

View File

@@ -1,19 +1,8 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { loadManifest } from './manifest'
import { stageCompiledArtifact } from './stage'
import type { BuildTarget } from './types'
const TARGET: BuildTarget = {
id: 'darwin-arm64',
name: 'macOS arm64',
os: 'macos',
arch: 'arm64',
bunTarget: 'bun-darwin-arm64-modern',
serverBinaryName: 'browseros-server-darwin-arm64',
}
describe('server artifact staging', () => {
let tempDir: string | null = null
@@ -25,75 +14,13 @@ describe('server artifact staging', () => {
}
})
it('loads local resource rules from the manifest', async () => {
it('loads empty local-resource rules from the manifest', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
const manifestPath = join(tempDir, 'manifest.json')
await writeFile(
manifestPath,
JSON.stringify({
resources: [
{
name: 'OpenClaw compose file',
source: {
type: 'local',
path: 'apps/server/resources/openclaw-compose.yml',
},
destination: 'resources/openclaw-compose.yml',
},
],
}),
)
await writeFile(manifestPath, JSON.stringify({ resources: [] }))
expect(loadManifest(manifestPath)).toEqual({
resources: [
{
name: 'OpenClaw compose file',
source: {
type: 'local',
path: 'apps/server/resources/openclaw-compose.yml',
},
destination: 'resources/openclaw-compose.yml',
executable: false,
},
],
resources: [],
})
})
it('copies local resource files into the packaged artifact', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
const distRoot = join(tempDir, 'dist')
const compiledBinaryPath = join(tempDir, 'browseros-server')
const sourceRoot = join(tempDir, 'repo')
const composeSourcePath = join(
sourceRoot,
'apps/server/resources/openclaw-compose.yml',
)
await writeFile(compiledBinaryPath, '#!/bin/sh\n')
await Bun.write(composeSourcePath, 'services:\n')
const staged = await stageCompiledArtifact(
distRoot,
compiledBinaryPath,
TARGET,
'1.2.3',
[
{
name: 'OpenClaw compose file',
source: {
type: 'local',
path: 'apps/server/resources/openclaw-compose.yml',
},
destination: 'resources/openclaw-compose.yml',
},
],
sourceRoot,
)
expect(
await readFile(
join(staged.resourcesDir, 'openclaw-compose.yml'),
'utf-8',
),
).toBe('services:\n')
})
})

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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