Compare commits

..

30 Commits

Author SHA1 Message Date
Nikhil Sonti
6b3b22ce17 fix: align agent container artifact paths 2026-04-22 13:08:52 -07:00
Nikhil Sonti
19fff97a9c fix: emit clean matrix JSON in CI 2026-04-22 12:57:54 -07:00
Nikhil Sonti
25c027863c fix: address review feedback for PR #782 2026-04-22 12:50:57 -07:00
Nikhil Sonti
8440ae09ce refactor: simplify agent container pipeline 2026-04-22 12:41:10 -07:00
Nikhil Sonti
95f34da014 docs: add agent-container env sample 2026-04-22 12:22:31 -07:00
Nikhil Sonti
fe9913a4fe feat: add agent container tarball pipeline 2026-04-22 12:13:59 -07:00
Nikhil
ecba7de221 fix: address PR review comments for 0422-ws3_lima_resources (#780)
- Guard uploaded_keys append with !dry_run so the rollback list
  never contains keys for objects that were never written.
- Prefer GITHUB_ACTOR over local OS username for manifest.uploaded_by;
  manifest.json is CDN-fronted so leaking a developer's login is
  unnecessary (falls back to 'local').
- Extend test_windows_has_no_stale_third_party to cover bun.exe/rg.exe
  too, matching the macOS forbidden-set pattern.
2026-04-22 10:47:25 -07:00
Nikhil
123a13fe62 feat(build): swap podman server resources for Lima (WS3) (#778)
* feat(build): swap podman server resources for Lima (WS3)

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

* fix: address review comments for 0422-ws3_lima_resources

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

* chore: drop bun + rg from Windows sign list

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

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

* fix: address review comments for openclaw-lifecycle-progress
2026-04-21 07:59:33 -07:00
Nikhil
0650f21c80 fix(openclaw): allocate gateway host port dynamically + name the two ports distinctly (#771)
* feat(openclaw): dynamically allocate and persist gateway host port

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

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

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

* feat: wire lazy monitoring to openclaw chat handoff

* test: cover openclaw chat history handoff

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

* fix: validate monitoring run ids

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

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

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

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

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

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

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

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

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

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

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

* feat: run OpenClaw containers with direct podman commands

* test: assert exact podman run args

* fix: stage direct runtime container migration safely

* refactor: switch OpenClaw service to direct podman runtime

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

* fix: handle legacy openclaw gateway container during runtime cutover

* chore: remove OpenClaw compose resources from server build

* refactor: drop obsolete setup-command overload

* fix: remove dead OpenClaw runtime env file flow

* fix: restore scoped OpenClaw gateway container name

* test: assert scoped OpenClaw terminal container name

* fix: make OpenClaw gateway removal idempotent

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

* fix: stabilize server test reporting in CI

* fix: address PR review feedback

* refactor: extract server core test runner

* refactor: group server tests by filesystem

* fix: align CI suites with server test groups

* fix: provision server env for all CI suites

* fix: stabilize ci checks

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

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

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

* fix(openclaw): swap service to cli client

* fix(openclaw): restore mixed json parsing

* fix(openclaw): validate agent list payloads

* fix(openclaw): simplify cli client boundary

* fix(openclaw): simplify cli client boundary

* fix(openclaw): prefer outer config json payloads

* fix(openclaw): ignore trailing config log payloads

* refactor(openclaw): bootstrap config through cli

* fix(openclaw): narrow bootstrap ownership

* fix(openclaw): avoid noop key restarts

* fix(openclaw): enforce supported provider sync

* refactor(openclaw): remove agent role contract

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

* fix(openclaw): migrate legacy agent state

* fix(openclaw): harden state updates

* refactor: stabilize local OpenClaw bootstrap and chat auth

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

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

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

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

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

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

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

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

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

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

* chore: bump server
2026-04-16 12:59:49 -07:00
Nikhil
d653883e99 fix(ci): add PR comment with test summary (#724)
* fix(ci): add PR comment with test summary and block on failure

Add a `comment` job to the test workflow that parses JUnit XML artifacts
and posts a sticky PR comment showing pass/fail counts per suite, with
failed test names listed in a collapsible section and a link to the run.

Guards against fork PRs (read-only token) and stale overlapping runs
(skips comment if PR head has moved past our SHA).

* fix(ci): use payload SHA for staleness check, handle missing artifacts

- Replace context.sha (merge commit SHA) with
  context.payload.pull_request.head.sha so the staleness guard
  compares the correct values and the comment actually gets posted
- Add continue-on-error to download-artifact so cancelled runs
  gracefully fall through to the "no test results" message

* fix(ci): show warning icon for zero-test suites instead of failure
2026-04-15 21:35:58 -07:00
141 changed files with 11870 additions and 3664 deletions

View File

@@ -0,0 +1,170 @@
name: Build Agent Container
on:
workflow_dispatch:
inputs:
agent:
description: Optional agent filter from recipe/agents.json
required: false
type: string
version:
description: Optional dry-run version override for the selected agent
required: false
type: string
publish:
description: Publish artifacts to R2
required: false
default: false
type: boolean
pull_request:
paths:
- .github/workflows/build-agent-container.yml
- packages/browseros-agent/packages/agent-container/**
schedule:
- cron: '0 7 * * 1'
permissions:
contents: read
jobs:
list-matrix:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: packages/browseros-agent
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun ci
- name: Typecheck package
run: bun run --filter @browseros/agent-container typecheck
- name: Run package tests
run: bun run --filter @browseros/agent-container test
- name: Compute matrix
id: matrix
env:
INPUT_AGENT: ${{ inputs.agent }}
run: |
set -euo pipefail
args=()
if [ -n "$INPUT_AGENT" ]; then
args+=(--agent "$INPUT_AGENT")
fi
matrix="$(bun run packages/agent-container/scripts/list-matrix.ts "${args[@]}")"
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
build:
needs: list-matrix
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 60
defaults:
run:
working-directory: packages/browseros-agent
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.list-matrix.outputs.matrix) }}
env:
ARTIFACT_ROOT: ${{ github.workspace }}/packages/browseros-agent/packages/agent-container/dist/agent-container
OUTPUT_DIR: ${{ github.workspace }}/packages/browseros-agent/packages/agent-container/dist/agent-container/${{ matrix.agent }}/${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun ci
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- name: Build tarball
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
cmd=(
bun run --filter @browseros/agent-container build --
--agent "${{ matrix.agent }}"
--arch "${{ matrix.arch }}"
--output-dir "$OUTPUT_DIR"
)
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "$INPUT_VERSION" ]; then
cmd+=(--version "$INPUT_VERSION")
fi
"${cmd[@]}"
- name: Smoke test archive
run: |
set -euo pipefail
result_json="$OUTPUT_DIR/build-result.json"
tarball="$(bun -e "const result = await Bun.file(process.argv[1]).json(); console.log(result.tarballPath)" "$result_json")"
expected_image="$(bun -e 'const result = await Bun.file(process.argv[1]).json(); console.log(result.image + ":" + result.version)' "$result_json")"
expected_fingerprint="$(bun -e "const result = await Bun.file(process.argv[1]).json(); console.log(result.smokeFingerprint)" "$result_json")"
bun run --filter @browseros/agent-container smoke -- \
--tarball "$tarball" \
--expected-image "$expected_image" \
--expected-fingerprint "$expected_fingerprint"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: agent-container-${{ matrix.agent }}-${{ matrix.arch }}
path: ${{ env.ARTIFACT_ROOT }}
publish:
needs: build
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
runs-on: ubuntu-24.04
defaults:
run:
working-directory: packages/browseros-agent
env:
ARTIFACT_ROOT: ${{ github.workspace }}/packages/browseros-agent/packages/agent-container/dist/agent-container
steps:
- name: Guard recipe source of truth
if: ${{ inputs.version != '' }}
run: |
echo "Refusing to publish a workflow_dispatch version override. Update recipe/agents.json instead." >&2
exit 1
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun ci
- name: Download matrix artifacts
uses: actions/download-artifact@v4
with:
pattern: agent-container-*
merge-multiple: true
path: ${{ env.ARTIFACT_ROOT }}
- name: Publish to R2
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
bun run --filter @browseros/agent-container upload -- \
--artifact-dir "$ARTIFACT_ROOT" \
--update-aggregate

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,3 @@
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
@@ -11,27 +7,6 @@ export interface AgentEntry {
name: string
workspace: string
model?: unknown
role?: {
roleSource: 'builtin' | 'custom'
roleId?: BrowserOSAgentRoleId
roleName: string
shortDescription: string
}
}
export interface RoleTemplateSummary {
id: BrowserOSAgentRoleId
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
defaultAgentName: string
boundaries: Array<{
key: string
label: string
description: string
defaultMode: 'allow' | 'ask' | 'block'
}>
}
export interface OpenClawStatus {
@@ -61,8 +36,6 @@ export interface OpenClawStatus {
export interface OpenClawAgentMutationInput {
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
@@ -86,9 +59,21 @@ export function getModelDisplayName(model: unknown): string | undefined {
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
roles: 'openclaw-roles',
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,
@@ -117,16 +102,6 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
return data.agents ?? []
}
async function fetchOpenClawRoles(
baseUrl: string,
): Promise<RoleTemplateSummary[]> {
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
baseUrl,
'/roles',
)
return data.roles ?? []
}
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
@@ -179,28 +154,6 @@ export function useOpenClawAgents(enabled = true) {
}
}
export function useOpenClawRoles() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<RoleTemplateSummary[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
queryFn: () => fetchOpenClawRoles(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
staleTime: 60_000,
})
return {
roles: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
@@ -278,6 +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,
@@ -298,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),
}
}
@@ -314,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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,38 +7,48 @@
* Thin layer delegating to OpenClawService.
*/
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { 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,
OpenClawInvalidAgentNameError,
OpenClawProtectedAgentError,
} from '../services/openclaw/errors'
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
import { getOpenClawService } from '../services/openclaw/openclaw-service'
function isValidBoundaryMode(
value: unknown,
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
return value === 'allow' || value === 'ask' || value === 'block'
function getCreateAgentValidationError(body: { name?: string }): string | null {
if (!body.name?.trim()) {
return 'Name is required'
}
return null
}
function isValidCustomRoleBoundary(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const boundary = value as Record<string, unknown>
return (
typeof boundary.key === 'string' &&
typeof boundary.label === 'string' &&
typeof boundary.description === 'string' &&
isValidBoundaryMode(boundary.defaultMode)
)
function 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() {
@@ -72,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,
@@ -89,6 +99,9 @@ export function createOpenClawRoutes() {
providerType: body.providerType,
providerName: body.providerName,
})
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
if (message.includes('Podman is not available')) {
return c.json({ error: message }, 503)
}
@@ -154,97 +167,23 @@ export function createOpenClawRoutes() {
}
})
.get('/roles', async (c) => {
return c.json({
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
id: role.id,
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps: role.recommendedApps,
boundaries: role.boundaries,
defaultAgentName: role.defaultAgentName,
})),
})
})
.post('/agents', async (c) => {
const body = await c.req.json<{
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}>()
const name = body.name?.trim()
if (!name) {
return c.json({ error: 'Name is required' }, 400)
}
if (body.roleId && body.customRole) {
return c.json(
{ error: 'Provide either roleId or customRole, not both' },
400,
)
}
if (
body.customRole &&
(!body.customRole.name?.trim() ||
!body.customRole.shortDescription?.trim() ||
!body.customRole.longDescription?.trim())
) {
return c.json(
{
error:
'Custom roles require name, shortDescription, and longDescription',
},
400,
)
}
if (
body.customRole &&
(!Array.isArray(body.customRole.recommendedApps) ||
!Array.isArray(body.customRole.boundaries))
) {
return c.json(
{
error: 'Custom roles require recommendedApps and boundaries arrays',
},
400,
)
}
if (
body.customRole &&
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
) {
return c.json(
{
error: 'Custom role recommendedApps must be an array of strings',
},
400,
)
}
if (
body.customRole &&
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
) {
return c.json(
{
error:
'Custom role boundaries must include key, label, description, and a valid defaultMode',
},
400,
)
const validationError = getCreateAgentValidationError(body)
if (validationError) {
return c.json({ error: validationError }, 400)
}
try {
const agent = await getOpenClawService().createAgent({
name,
roleId: body.roleId,
customRole: body.customRole,
name: body.name.trim(),
providerType: body.providerType,
providerName: body.providerName,
baseUrl: body.baseUrl,
@@ -259,6 +198,9 @@ export function createOpenClawRoutes() {
if (err instanceof OpenClawInvalidAgentNameError) {
return c.json({ error: err.message }, 400)
}
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
@@ -287,6 +229,7 @@ export function createOpenClawRoutes() {
const body = await c.req.json<{
message: string
sessionKey?: string
history?: MonitoringChatTurn[]
}>()
if (!body.message?.trim()) {
@@ -294,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')
@@ -309,20 +277,68 @@ export function createOpenClawRoutes() {
return stream(c, async (s) => {
const reader = eventStream.getReader()
const encoder = new TextEncoder()
let finalAssistantMessage: string | undefined
let status: 'completed' | 'failed' | 'aborted' | 'incomplete' =
'incomplete'
let finalError: string | undefined
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (
value.type === 'done' &&
typeof value.data.text === 'string' &&
value.data.text.trim()
) {
finalAssistantMessage = value.data.text
status = 'completed'
}
if (value.type === 'error') {
finalError =
(typeof value.data.message === 'string'
? value.data.message
: typeof value.data.error === 'string'
? value.data.error
: undefined) ?? 'Unknown chat stream error'
status = 'failed'
}
await s.write(
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
)
}
await s.write(encoder.encode('data: [DONE]\n\n'))
} catch (error) {
if (c.req.raw.signal.aborted) {
status = 'aborted'
} else {
status = 'failed'
finalError =
error instanceof Error ? error.message : String(error)
}
throw error
} finally {
await reader.cancel()
await getMonitoringService().finalizeSession({
monitoringSessionId: monitoringContext.monitoringSessionId,
agentId: id,
sessionKey,
status,
finalAssistantMessage,
error: finalError,
})
}
})
} catch (err) {
await getMonitoringService().finalizeSession({
monitoringSessionId: monitoringContext.monitoringSessionId,
agentId: id,
sessionKey,
status: c.req.raw.signal.aborted ? 'aborted' : 'failed',
error: err instanceof Error ? err.message : String(err),
})
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
@@ -352,14 +368,52 @@ export function createOpenClawRoutes() {
}
try {
await getOpenClawService().updateProviderKeys(body)
const result = await getOpenClawService().updateProviderKeys(body)
return c.json({
status: 'restarting',
message: 'Provider updated, restarting gateway',
status: result.restarted ? 'restarting' : 'updated',
message: result.restarted
? 'Provider updated, restarting gateway'
: 'Provider updated without a restart',
})
} catch (err) {
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.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) {
@@ -153,6 +187,32 @@ export class ContainerRuntime {
)
}
async runGatewaySetupCommand(
command: string[],
spec: GatewayContainerSpec,
onLog?: LogFn,
): Promise<number> {
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',
'--name',
setupContainerName,
...this.buildGatewayContainerRuntimeArgs(spec),
spec.image,
'node',
...setupArgs,
],
onLog,
)
}
tailGatewayLogs(onLine: LogFn): () => void {
return this.podman.tailContainerLogs(
OPENCLAW_GATEWAY_CONTAINER_NAME,
@@ -160,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)
@@ -176,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
@@ -99,9 +98,9 @@ export class PodmanRuntime {
'machine',
'init',
'--cpus',
'2',
'8',
'--memory',
'2048',
'8096',
'--disk-size',
'10',
],
@@ -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

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

View File

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

View File

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

View File

@@ -6,8 +6,19 @@ 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 {
return join(homedir(), PATHS.BROWSEROS_DIR_NAME)
const dirName =
process.env.NODE_ENV === 'development'
? DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return join(homedir(), dirName)
}
export function logDevelopmentBrowserosDir(): void {
if (process.env.NODE_ENV !== 'development') return
logger.info(`Using development BrowserOS directory: ${getBrowserosDir()}`)
}
export function getMemoryDir(): string {
@@ -38,6 +49,18 @@ export function getOpenClawDir(): string {
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
}
export function getLazyMonitoringDir(): string {
return join(getBrowserosDir(), 'lazy-monitoring')
}
export function getLazyMonitoringRunsDir(): string {
return join(getLazyMonitoringDir(), 'runs')
}
export function getLazyMonitoringRunDir(runId: string): string {
return join(getLazyMonitoringRunsDir(), runId)
}
export function getServerConfigPath(): string {
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
}
@@ -57,10 +80,12 @@ export function removeServerConfigSync(): void {
}
export async function ensureBrowserosDir(): Promise<void> {
logDevelopmentBrowserosDir()
await mkdir(getMemoryDir(), { recursive: true })
await mkdir(getSkillsDir(), { recursive: true })
await mkdir(getBuiltinSkillsDir(), { recursive: true })
await mkdir(getSessionsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
}
export async function cleanOldSessions(): Promise<void> {

View File

@@ -13,7 +13,11 @@ import fs from 'node:fs'
import path from 'node:path'
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
import { createHttpServer } from './api/server'
import { getOpenClawService } from './api/services/openclaw/openclaw-service'
import {
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'
@@ -22,6 +26,7 @@ import { INLINED_ENV } from './env'
import {
cleanOldSessions,
ensureBrowserosDir,
getOpenClawDir,
removeServerConfigSync,
writeServerConfig,
} from './lib/browseros-dir'
@@ -56,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) {
@@ -123,7 +136,10 @@ export class Application {
this.logStartupSummary()
startSkillSync()
getOpenClawService(this.config.serverPort)
configureOpenClawService({
browserosServerPort: this.config.serverPort,
resourcesDir,
})
.tryAutoStart()
.catch((err) =>
logger.warn('OpenClaw auto-start failed', {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import {
getBrowserosDir,
logDevelopmentBrowserosDir,
} from '../src/lib/browseros-dir'
import { logger } from '../src/lib/logger'
describe('getBrowserosDir', () => {
const originalNodeEnv = process.env.NODE_ENV
beforeEach(() => {
delete process.env.NODE_ENV
})
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
return
}
process.env.NODE_ENV = originalNodeEnv
})
it('uses a separate home directory in development', () => {
process.env.NODE_ENV = 'development'
expect(getBrowserosDir()).toBe(join(homedir(), '.browseros-dev'))
})
it('uses the standard home directory outside development', () => {
process.env.NODE_ENV = 'test'
expect(getBrowserosDir()).toBe(join(homedir(), PATHS.BROWSEROS_DIR_NAME))
})
it('logs the resolved development directory path', () => {
process.env.NODE_ENV = 'development'
const originalInfo = logger.info
const info = mock(() => {})
logger.info = info
try {
logDevelopmentBrowserosDir()
expect(info).toHaveBeenCalledWith(
`Using development BrowserOS directory: ${join(homedir(), '.browseros-dev')}`,
)
} finally {
logger.info = originalInfo
}
})
it('does not log a development directory outside development', () => {
process.env.NODE_ENV = 'test'
const originalInfo = logger.info
const info = mock(() => {})
logger.info = info
try {
logDevelopmentBrowserosDir()
expect(info).not.toHaveBeenCalled()
} finally {
logger.info = originalInfo
}
})
})

View File

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

View File

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

View File

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

View File

@@ -156,7 +156,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.85",
"version": "0.0.88",
"bin": {
"browseros-server": "./src/index.ts",
},
@@ -216,6 +216,17 @@
"chrome-devtools-mcp": "latest",
},
},
"packages/agent-container": {
"name": "@browseros/agent-container",
"version": "0.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"zod": "^3.24.2",
},
"devDependencies": {
"@types/node": "^24.3.3",
},
},
"packages/agent-sdk": {
"name": "@browseros-ai/agent-sdk",
"version": "0.0.7",
@@ -463,6 +474,8 @@
"@browseros/agent": ["@browseros/agent@workspace:apps/agent"],
"@browseros/agent-container": ["@browseros/agent-container@workspace:packages/agent-container"],
"@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/agent-container/@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=="],
@@ -5211,6 +5226,100 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@browseros/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
"@browseros/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"@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=="],
@@ -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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],

View File

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

View File

@@ -0,0 +1,14 @@
# Required for `bun run --filter @browseros/agent-container upload`
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=
# Optional overrides
R2_PUBLIC_BASE_URL=https://cdn.browseros.com
PODMAN_BIN=podman
# Optional recipe-driven registry auth
# If an agent entry in recipe/agents.json sets `requires_auth.secret`,
# define that env var before running `build`.
# EXAMPLE_REGISTRY_TOKEN=

View File

@@ -0,0 +1,52 @@
# @browseros/agent-container
OCI tarball producer for BrowserOS-bundled agent containers.
This package owns the WS2 pipeline:
- Read the active agent set from `recipe/agents.json`
- Pull the upstream image with `podman`
- Save it as an OCI archive and gzip it
- Smoke-test the archive with `podman load`
- Publish tarballs, checksum sidecars, and manifests to R2
Package env requirements are documented in [.env.sample](./.env.sample).
## Local usage
```bash
cd packages/browseros-agent
# Print the GitHub Actions matrix JSON
bun run --filter @browseros/agent-container list-matrix
# Build one artifact locally
bun run --filter @browseros/agent-container build -- \
--agent openclaw \
--arch arm64 \
--output-dir packages/agent-container/dist/agent-container/openclaw/arm64
# Smoke-test a built tarball
bun run --filter @browseros/agent-container smoke -- \
--tarball packages/agent-container/dist/agent-container/openclaw/arm64/openclaw-2026.4.12-arm64.tar.gz \
--expected-image ghcr.io/openclaw/openclaw:2026.4.12 \
--expected-fingerprint ...
# Upload pre-built artifacts
# Fill these from packages/agent-container/.env.sample
R2_ACCOUNT_ID=... \
R2_ACCESS_KEY_ID=... \
R2_SECRET_ACCESS_KEY=... \
R2_BUCKET=... \
bun run --filter @browseros/agent-container upload -- \
--artifact-dir packages/agent-container/dist/agent-container \
--update-aggregate
```
## Notes
- `recipe/agents.json` is the source of truth for the active set.
- `workflow_dispatch` version overrides are intended for dry runs. Publishing still needs the recipe to be authoritative.
- `src/load.ts` is intentionally stubbed. WS6 fills in the runtime consumer path.
- Private registry auth is recipe-driven: if `requires_auth.secret` is set for an agent, export that env var before running `build`.
- When invoking package scripts with `bun run --filter @browseros/agent-container`, pass artifact paths that are explicit from `packages/browseros-agent`. Filtered scripts run inside the package directory, so bare `dist/...` paths are ambiguous.

View File

@@ -0,0 +1,32 @@
{
"name": "@browseros/agent-container",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "BrowserOS agent container OCI tarball producer",
"exports": {
"./schema": {
"types": "./src/schema/index.ts",
"default": "./src/schema/index.ts"
},
"./load": {
"types": "./src/load.ts",
"default": "./src/load.ts"
}
},
"scripts": {
"build": "bun run scripts/build.ts",
"upload": "bun run scripts/upload.ts",
"smoke": "bun run scripts/smoke.ts",
"list-matrix": "bun run scripts/list-matrix.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^24.3.3"
}
}

View File

@@ -0,0 +1,12 @@
{
"schema": "v1",
"agents": [
{
"name": "openclaw",
"image": "ghcr.io/openclaw/openclaw",
"version": "2026.4.12",
"arches": ["amd64", "arm64"],
"publishAs": "openclaw"
}
]
}

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bun
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { buildTarball } from '../src/build'
import { readAgentsConfig } from '../src/catalog'
import { parseArch } from '../src/schema/arch'
const packageRoot = resolve(import.meta.dir, '..')
const recipePath = resolve(packageRoot, 'recipe', 'agents.json')
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
agent: { type: 'string' },
version: { type: 'string' },
arch: { type: 'string' },
'output-dir': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help) {
console.log(
'Usage: bun run build -- --agent <name> --arch <amd64|arm64> --output-dir <path> [--version <override>]',
)
process.exit(0)
}
if (!values.agent || !values.arch || !values['output-dir']) {
throw new Error('--agent, --arch, and --output-dir are required')
}
const config = await readAgentsConfig(recipePath)
const selected = config.agents.find((agent) => agent.name === values.agent)
if (!selected) {
throw new Error(`unknown agent: ${values.agent}`)
}
const result = await buildTarball({
agent: {
...selected,
version: values.version ?? selected.version,
},
arch: parseArch(values.arch),
outputDir: values['output-dir'],
recipePath,
})
console.log(JSON.stringify(result, null, 2))

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bun
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { expandMatrix, readAgentsConfig } from '../src/catalog'
const packageRoot = resolve(import.meta.dir, '..')
const recipePath = resolve(packageRoot, 'recipe', 'agents.json')
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
agent: { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help) {
console.log('Usage: bun run list-matrix [--agent <name>]')
process.exit(0)
}
const config = await readAgentsConfig(recipePath)
const include = expandMatrix(config, { agent: values.agent })
if (include.length === 0) {
throw new Error(
values.agent
? `no agents matched filter: ${values.agent}`
: 'recipe/agents.json produced an empty matrix',
)
}
console.log(JSON.stringify({ include }))

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bun
import { parseArgs } from 'node:util'
import { roundTripPodmanLoad } from '../src/smoke'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
tarball: { type: 'string' },
'expected-image': { type: 'string' },
'expected-image-id': { type: 'string' },
'expected-fingerprint': { type: 'string' },
'expected-digest': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help) {
console.log(
'Usage: bun run smoke -- --tarball <path> --expected-image <ref> [--expected-fingerprint <sha256-hex>] [--expected-image-id <sha256:...> | --expected-digest <sha256:...>]',
)
process.exit(0)
}
const expectedImageId = values['expected-image-id'] ?? values['expected-digest']
if (
!values.tarball ||
!values['expected-image'] ||
(!expectedImageId && !values['expected-fingerprint'])
) {
throw new Error(
'--tarball, --expected-image, and one verification flag are required',
)
}
await roundTripPodmanLoad({
tarballPath: values.tarball,
expectedImage: values['expected-image'],
expectedImageId,
expectedSmokeFingerprint: values['expected-fingerprint'],
})

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bun
import { readdir } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { loadBuildResult } from '../src/build'
import { publishAgents } from '../src/publish'
async function findBuildResultPaths(root: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true })
const paths: string[] = []
for (const entry of entries) {
const path = join(root, entry.name)
if (entry.isDirectory()) {
paths.push(...(await findBuildResultPaths(path)))
continue
}
if (entry.isFile() && entry.name === 'build-result.json') {
paths.push(path)
}
}
return paths.sort()
}
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
'artifact-dir': { type: 'string' },
'update-aggregate': { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help) {
console.log(
'Usage: bun run upload -- --artifact-dir <path> [--update-aggregate]',
)
process.exit(0)
}
if (!values['artifact-dir']) {
throw new Error('--artifact-dir is required')
}
const artifactDir = resolve(values['artifact-dir'])
const buildResultPaths = await findBuildResultPaths(artifactDir)
if (buildResultPaths.length === 0) {
throw new Error(`no build-result.json files found under ${artifactDir}`)
}
const buildResults = await Promise.all(
buildResultPaths.map((path) => loadBuildResult(path)),
)
await publishAgents({
buildResults,
updateAggregate: Boolean(values['update-aggregate']),
})

View File

@@ -0,0 +1,470 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { access, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { type AgentEntry, publishNameForAgent } from './catalog'
import type { ContainerArch } from './schema/arch'
const PODMAN_BIN = process.env.PODMAN_BIN ?? 'podman'
interface PodmanCommandResult {
stdout: string
stderr: string
}
interface PodmanInspectShape {
Id?: string
Digest?: string
RepoDigests?: string[]
Architecture?: string
Os?: string
Config?: unknown
RootFS?: unknown
}
interface PodmanImageMetadata {
imageId: string
sourceOciDigest: string
smokeFingerprint: string
}
interface RepoDigestCount {
digest: string
count: number
}
export interface BuildOptions {
agent: AgentEntry
arch: ContainerArch
outputDir: string
recipePath?: string
builtBy?: string
}
export interface BuildResult {
name: string
publishAs: string
image: string
version: string
arch: ContainerArch
sourceOciDigest: string
imageId: string
smokeFingerprint: string
filename: string
tarballPath: string
tarballShaPath: string
compressedSha256: string
compressedSizeBytes: number
uncompressedSha256: string
uncompressedSizeBytes: number
podmanVersion: string
builtAt: string
builtBy: string
gitSha: string
gitDirty: boolean
configSha256: string
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableJson(entry)).join(',')}]`
}
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>).sort(
([left], [right]) => left.localeCompare(right),
)
return `{${entries
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
.join(',')}}`
}
return JSON.stringify(value)
}
function smokeFingerprintForInspect(inspected: PodmanInspectShape): string {
const payload = stableJson({
Architecture: inspected.Architecture ?? '',
Os: inspected.Os ?? '',
Config: inspected.Config ?? null,
RootFS: inspected.RootFS ?? null,
})
return createHash('sha256').update(payload).digest('hex')
}
function normalizeSha256Like(value: string): string {
const trimmed = value.trim()
if (/^sha256:[a-f0-9]{64}$/.test(trimmed)) {
return trimmed
}
if (/^[a-f0-9]{64}$/.test(trimmed)) {
return `sha256:${trimmed}`
}
throw new Error(`unexpected sha256-like value: ${value}`)
}
function selectSourceOciDigest(
platformDigest: string,
repoDigests: string[],
): string {
const counts = new Map<string, number>()
for (const digest of repoDigests) {
counts.set(digest, (counts.get(digest) ?? 0) + 1)
}
const candidates: RepoDigestCount[] = [...counts.entries()]
.filter(([digest]) => digest !== platformDigest)
.map(([digest, count]) => ({ digest, count }))
.sort(
(left, right) =>
right.count - left.count || left.digest.localeCompare(right.digest),
)
const [firstCandidate, secondCandidate] = candidates
if (!firstCandidate) {
return platformDigest
}
if (!secondCandidate || firstCandidate.count > secondCandidate.count) {
return firstCandidate.digest
}
throw new Error(
`ambiguous source OCI digest for ${platformDigest}: ${candidates
.map((candidate) => `${candidate.digest} (${candidate.count})`)
.join(', ')}`,
)
}
async function runPodman(
args: string[],
options: { stdin?: string } = {},
): Promise<PodmanCommandResult> {
const proc = Bun.spawn([PODMAN_BIN, ...args], {
stdin: options.stdin ? Buffer.from(`${options.stdin}\n`) : undefined,
stdout: 'pipe',
stderr: 'pipe',
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
const exitCode = await proc.exited
if (exitCode !== 0) {
throw new Error(
`podman ${args.join(' ')} exited ${exitCode}\n${stderr.trim() || stdout.trim()}`,
)
}
return { stdout, stderr }
}
async function runCommand(command: string[]): Promise<string> {
const proc = Bun.spawn(command, {
stdout: 'pipe',
stderr: 'pipe',
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
const exitCode = await proc.exited
if (exitCode !== 0) {
throw new Error(
`${command.join(' ')} exited ${exitCode}\n${stderr.trim() || stdout.trim()}`,
)
}
return stdout.trim()
}
async function sha256OfFile(path: string): Promise<string> {
const hash = createHash('sha256')
const stream = createReadStream(path)
for await (const chunk of stream) {
hash.update(chunk)
}
return hash.digest('hex')
}
async function gzipArchive(tarPath: string): Promise<void> {
const proc = Bun.spawn(['gzip', '-9', '-f', '-k', tarPath], {
stdout: 'pipe',
stderr: 'pipe',
})
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode !== 0) {
throw new Error(`gzip exited ${exitCode}\n${stderr.trim()}`)
}
}
async function gitSha(): Promise<string> {
return runCommand(['git', 'rev-parse', 'HEAD'])
}
async function gitDirty(): Promise<boolean> {
const stdout = await runCommand(['git', 'status', '--short'])
return stdout.length > 0
}
function recipePathForPackage(): string {
return resolve(import.meta.dir, '..', 'recipe', 'agents.json')
}
function imageRefForBuild(options: BuildOptions): string {
return `${options.agent.image}:${options.agent.version}`
}
function builtByForBuild(explicitBuiltBy?: string): string {
if (explicitBuiltBy) {
return explicitBuiltBy
}
const workflowRef = process.env.GITHUB_WORKFLOW_REF?.trim()
if (workflowRef) {
return workflowRef
}
const workflow = process.env.GITHUB_WORKFLOW?.trim()
const ref = process.env.GITHUB_REF?.trim()
if (workflow && ref) {
return `${workflow}@${ref}`
}
const user = process.env.USER ?? process.env.LOGNAME ?? 'unknown'
return `local:${user}`
}
export function registryForImage(image: string): string {
const firstSegment = image.split('/')[0]
if (
!firstSegment ||
(!firstSegment.includes('.') &&
!firstSegment.includes(':') &&
firstSegment !== 'localhost')
) {
return 'docker.io'
}
return firstSegment
}
async function podmanVersion(): Promise<string> {
const { stdout } = await runPodman(['--version'])
return stdout.trim()
}
async function podmanLogin(options: {
registry: string
username: string
password: string
}): Promise<void> {
await runPodman(
[
'login',
'--username',
options.username,
'--password-stdin',
options.registry,
],
{ stdin: options.password },
)
}
async function podmanPull(
imageRef: string,
arch: ContainerArch,
): Promise<void> {
await runPodman([
'pull',
'--quiet',
'--os',
'linux',
'--arch',
arch,
imageRef,
])
}
export async function podmanInspectImage(
imageRef: string,
): Promise<PodmanImageMetadata> {
const { stdout } = await runPodman([
'inspect',
'--type',
'image',
'--format',
'{{json .}}',
imageRef,
])
const inspected = JSON.parse(stdout.trim()) as PodmanInspectShape
const imageId = normalizeSha256Like(inspected.Id ?? '')
const platformDigest = normalizeSha256Like(inspected.Digest ?? imageId)
const repoDigests = (inspected.RepoDigests ?? [])
.map((entry) => entry.split('@')[1] ?? '')
.filter(Boolean)
.map((entry) => normalizeSha256Like(entry))
const sourceOciDigest = selectSourceOciDigest(platformDigest, repoDigests)
return {
imageId,
sourceOciDigest,
smokeFingerprint: smokeFingerprintForInspect(inspected),
}
}
async function podmanSaveOci(options: {
imageRef: string
outPath: string
}): Promise<void> {
await runPodman([
'save',
'--format',
'oci-archive',
'--output',
options.outPath,
options.imageRef,
])
}
export async function podmanLoadArchive(tarballPath: string): Promise<void> {
await runPodman(['load', '--input', tarballPath])
}
export async function podmanRemoveImage(imageRef: string): Promise<void> {
await runPodman(['rmi', '-f', imageRef])
}
async function maybeLoginForAgent(options: BuildOptions): Promise<void> {
const auth = options.agent.requires_auth
if (!auth) {
return
}
const password = process.env[auth.secret]?.trim()
if (!password) {
throw new Error(`missing registry credential env var: ${auth.secret}`)
}
await podmanLogin({
registry: registryForImage(options.agent.image),
username: auth.username ?? 'oauth2accesstoken',
password,
})
}
export async function buildTarball(
options: BuildOptions,
): Promise<BuildResult> {
const imageRef = imageRefForBuild(options)
const publishAs = publishNameForAgent(options.agent)
const outputDir = resolve(options.outputDir)
const recipePath = resolve(options.recipePath ?? recipePathForPackage())
const baseName = `${publishAs}-${options.agent.version}-${options.arch}.tar`
const tarPath = join(outputDir, baseName)
const tarballPath = `${tarPath}.gz`
const tarballShaPath = `${tarballPath}.sha256`
const buildResultPath = join(outputDir, 'build-result.json')
await mkdir(outputDir, { recursive: true })
await Promise.all([
rm(tarPath, { force: true }),
rm(tarballPath, { force: true }),
rm(tarballShaPath, { force: true }),
rm(buildResultPath, { force: true }),
])
const [gitShaValue, gitDirtyValue, configSha256, podmanVersionValue] =
await Promise.all([
gitSha(),
gitDirty(),
sha256OfFile(recipePath),
podmanVersion(),
])
const builtAt = new Date().toISOString()
const builtBy = builtByForBuild(options.builtBy)
await maybeLoginForAgent(options)
await podmanPull(imageRef, options.arch)
const inspection = await podmanInspectImage(imageRef)
await podmanSaveOci({ imageRef, outPath: tarPath })
await gzipArchive(tarPath)
const [
compressedSha256,
uncompressedSha256,
compressedStats,
uncompressedStats,
] = await Promise.all([
sha256OfFile(tarballPath),
sha256OfFile(tarPath),
stat(tarballPath),
stat(tarPath),
])
const filename = basename(tarballPath)
await writeFile(tarballShaPath, `${compressedSha256} ${filename}\n`, 'utf8')
await rm(tarPath, { force: true })
const result: BuildResult = {
name: options.agent.name,
publishAs,
image: options.agent.image,
version: options.agent.version,
arch: options.arch,
sourceOciDigest: inspection.sourceOciDigest,
imageId: inspection.imageId,
smokeFingerprint: inspection.smokeFingerprint,
filename,
tarballPath,
tarballShaPath,
compressedSha256,
compressedSizeBytes: compressedStats.size,
uncompressedSha256,
uncompressedSizeBytes: uncompressedStats.size,
podmanVersion: podmanVersionValue,
builtAt,
builtBy,
gitSha: gitShaValue,
gitDirty: gitDirtyValue,
configSha256,
}
await writeFile(
buildResultPath,
`${JSON.stringify(result, null, 2)}\n`,
'utf8',
)
return result
}
export async function loadBuildResult(path: string): Promise<BuildResult> {
const raw = await readFile(path, 'utf8')
const result = JSON.parse(raw) as BuildResult
const resultDir = dirname(path)
const tarballPath = (await pathExists(result.tarballPath))
? result.tarballPath
: join(resultDir, result.filename)
const tarballShaPath = (await pathExists(result.tarballShaPath))
? result.tarballShaPath
: `${tarballPath}.sha256`
return {
...result,
tarballPath,
tarballShaPath,
}
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,94 @@
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import { ARCHES, type ContainerArch } from './schema/arch'
export const agentEntrySchema = z.object({
name: z.string().regex(/^[a-z0-9-]+$/),
image: z.string().min(1),
version: z.string().min(1),
arches: z.array(z.enum(ARCHES)).min(1),
publishAs: z
.string()
.regex(/^[a-z0-9-]+$/)
.optional(),
requires_auth: z
.object({
secret: z.string().min(1),
username: z.string().min(1).optional(),
})
.optional(),
})
export const agentsConfigSchema = z
.object({
schema: z.literal('v1'),
agents: z.array(agentEntrySchema).min(1),
})
.superRefine((config, ctx) => {
const seen = new Set<string>()
for (const [index, agent] of config.agents.entries()) {
if (seen.has(agent.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['agents', index, 'name'],
message: `duplicate agent name: ${agent.name}`,
})
}
seen.add(agent.name)
}
})
export type AgentEntry = z.infer<typeof agentEntrySchema>
export type AgentsConfig = z.infer<typeof agentsConfigSchema>
export interface MatrixEntry {
agent: string
image: string
version: string
arch: ContainerArch
publishAs: string
}
export function publishNameForAgent(agent: AgentEntry): string {
return agent.publishAs ?? agent.name
}
export async function readAgentsConfig(path: string): Promise<AgentsConfig> {
const raw = await readFile(path, 'utf8')
const parsed = JSON.parse(raw) as unknown
return agentsConfigSchema.parse(parsed)
}
export function expandMatrix(
config: AgentsConfig,
filter: { agent?: string } = {},
): MatrixEntry[] {
const entries: MatrixEntry[] = []
for (const agent of config.agents) {
if (filter.agent && agent.name !== filter.agent) {
continue
}
for (const arch of agent.arches) {
entries.push({
agent: agent.name,
image: agent.image,
version: agent.version,
arch,
publishAs: publishNameForAgent(agent),
})
}
}
return entries.sort((left, right) => {
const byAgent = left.agent.localeCompare(right.agent)
if (byAgent !== 0) {
return byAgent
}
return left.arch.localeCompare(right.arch)
})
}

View File

@@ -0,0 +1,39 @@
import type {
AgentArtifact,
AgentManifest,
AggregateManifest,
ContainerArch,
} from './schema'
export async function fetchAggregateManifest(): Promise<AggregateManifest> {
throw new Error('fetchAggregateManifest: implemented in WS6')
}
export async function fetchAgentManifest(
_agent: string,
_version: string,
): Promise<AgentManifest> {
throw new Error('fetchAgentManifest: implemented in WS6')
}
export async function verifySha256(
_path: string,
_expectedSha256: string,
): Promise<void> {
throw new Error('verifySha256: implemented in WS6')
}
export async function findStagedTarball(
_name: string,
_version: string,
_arch: ContainerArch,
): Promise<string> {
throw new Error('findStagedTarball: implemented in WS6')
}
export async function loadTarball(
_artifact: AgentArtifact,
_destinationPath: string,
): Promise<void> {
throw new Error('loadTarball: implemented in WS6')
}

View File

@@ -0,0 +1,439 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
type S3ClientConfig,
} from '@aws-sdk/client-s3'
import type { BuildResult } from './build'
import { ARCHES } from './schema/arch'
import {
type AgentManifest,
type AggregateEntry,
type AggregateManifest,
agentManifestSchema,
aggregateManifestSchema,
} from './schema/manifest'
import {
keyForAggregateManifest,
keyForSha,
keyForTarball,
keyForVersionManifest,
} from './schema/r2-keys'
const CDN_BASE_URL =
process.env.R2_PUBLIC_BASE_URL ?? 'https://cdn.browseros.com'
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8'
const SHA_CONTENT_TYPE = 'text/plain; charset=utf-8'
export interface PublishOptions {
buildResults: BuildResult[]
updateAggregate: boolean
bucket?: string
cdnBaseURL?: string
client?: S3Client
now?: () => Date
}
interface ResultGroup {
name: string
publishAs: string
image: string
version: string
sourceOciDigest: string
podmanVersions: string[]
gitSha: string
gitDirty: boolean
configSha256: string
builtBy: string
results: BuildResult[]
}
function requiredEnv(name: string): string {
const value = process.env[name]?.trim()
if (!value) {
throw new Error(`missing required env var: ${name}`)
}
return value
}
function createR2Client(): S3Client {
const config: S3ClientConfig = {
region: 'auto',
endpoint: `https://${requiredEnv('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: requiredEnv('R2_ACCESS_KEY_ID'),
secretAccessKey: requiredEnv('R2_SECRET_ACCESS_KEY'),
},
}
return new S3Client(config)
}
function getBucket(): string {
return requiredEnv('R2_BUCKET')
}
async function uploadFile(
client: S3Client,
bucket: string,
key: string,
path: string,
contentType = 'application/gzip',
): Promise<void> {
const { size } = await stat(path)
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: createReadStream(path),
ContentLength: size,
ContentType: contentType,
}),
)
}
async function uploadBody(
client: S3Client,
bucket: string,
key: string,
body: string | Uint8Array,
contentType = JSON_CONTENT_TYPE,
): Promise<void> {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
}),
)
}
async function deleteObject(
client: S3Client,
bucket: string,
key: string,
): Promise<void> {
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
}),
)
}
function keyForGroup(name: string, version: string): string {
return `${name}:${version}`
}
function compareByArch(left: BuildResult, right: BuildResult): number {
return ARCHES.indexOf(left.arch) - ARCHES.indexOf(right.arch)
}
function cdnUrl(baseUrl: string, key: string): string {
return `${baseUrl.replace(/\/+$/, '')}/${key}`
}
function createManifestForGroup(
group: ResultGroup,
builtAt: string,
cdnBaseURL: string,
): AgentManifest {
return agentManifestSchema.parse({
name: group.name,
schema: 'v1',
build: {
git_sha: group.gitSha,
git_dirty: group.gitDirty,
built_at: builtAt,
built_by: group.builtBy,
config_sha256: group.configSha256,
podman_versions: group.podmanVersions,
},
source: {
image: group.image,
version: group.version,
oci_digest: group.sourceOciDigest,
},
artifacts: [...group.results].sort(compareByArch).map((result) => {
const key = keyForTarball(
result.name,
result.version,
result.arch,
result.publishAs,
)
return {
arch: result.arch,
filename: result.filename,
format: 'oci-archive+gzip',
compressed_sha256: result.compressedSha256,
compressed_size_bytes: result.compressedSizeBytes,
uncompressed_sha256: result.uncompressedSha256,
uncompressed_size_bytes: result.uncompressedSizeBytes,
url: cdnUrl(cdnBaseURL, key),
}
}),
})
}
function mergeAggregateEntries(
existing: AggregateEntry[],
nextEntries: AggregateEntry[],
builtAt: string,
builtBy: string,
): AggregateManifest {
const merged = new Map<string, AggregateEntry>()
for (const entry of existing) {
merged.set(entry.name, entry)
}
for (const entry of nextEntries) {
merged.set(entry.name, entry)
}
return aggregateManifestSchema.parse({
schema: 'v1',
built_at: builtAt,
built_by: builtBy,
agents: [...merged.values()].sort((left, right) =>
left.name.localeCompare(right.name),
),
})
}
function buildAggregateEntries(
groups: ResultGroup[],
cdnBaseURL: string,
): AggregateEntry[] {
return groups
.map((group) => ({
name: group.name,
version: group.version,
oci_digest: group.sourceOciDigest,
manifest_url: cdnUrl(
cdnBaseURL,
keyForVersionManifest(group.name, group.version),
),
}))
.sort((left, right) => left.name.localeCompare(right.name))
}
function aggregateBuiltBy(groups: ResultGroup[]): string {
const builtByValues = [
...new Set(groups.map((group) => group.builtBy)),
].sort()
return builtByValues.join(', ')
}
function buildGroup(results: BuildResult[]): ResultGroup {
const [firstResult, ...rest] = results
if (!firstResult) {
throw new Error('cannot publish an empty build result group')
}
for (const result of rest) {
if (result.name !== firstResult.name) {
throw new Error('mixed agent names in publish group')
}
if (result.publishAs !== firstResult.publishAs) {
throw new Error('mixed publishAs values in publish group')
}
if (result.image !== firstResult.image) {
throw new Error('mixed source images in publish group')
}
if (result.version !== firstResult.version) {
throw new Error('mixed versions in publish group')
}
if (result.sourceOciDigest !== firstResult.sourceOciDigest) {
throw new Error('mixed source OCI digests in publish group')
}
if (
result.gitSha !== firstResult.gitSha ||
result.gitDirty !== firstResult.gitDirty
) {
throw new Error('mixed git metadata in publish group')
}
if (result.configSha256 !== firstResult.configSha256) {
throw new Error('mixed recipe config hashes in publish group')
}
if (result.builtBy !== firstResult.builtBy) {
throw new Error('mixed build provenance in publish group')
}
}
const podmanVersions = [
...new Set(results.map((result) => result.podmanVersion)),
].sort()
return {
name: firstResult.name,
publishAs: firstResult.publishAs,
image: firstResult.image,
version: firstResult.version,
sourceOciDigest: firstResult.sourceOciDigest,
podmanVersions,
gitSha: firstResult.gitSha,
gitDirty: firstResult.gitDirty,
configSha256: firstResult.configSha256,
builtBy: firstResult.builtBy,
results: [...results].sort(compareByArch),
}
}
function groupByAgentVersion(buildResults: BuildResult[]): ResultGroup[] {
const grouped = new Map<string, BuildResult[]>()
for (const result of buildResults) {
const key = keyForGroup(result.name, result.version)
const existing = grouped.get(key)
if (existing) {
existing.push(result)
continue
}
grouped.set(key, [result])
}
return [...grouped.values()]
.map((results) => buildGroup(results))
.sort((left, right) => left.name.localeCompare(right.name))
}
async function readBodyAsString(body: unknown): Promise<string> {
const withTransform = body as {
transformToByteArray?: () => Promise<Uint8Array>
}
if (!withTransform?.transformToByteArray) {
throw new Error('R2 response body is not readable')
}
const bytes = await withTransform.transformToByteArray()
return new TextDecoder().decode(bytes)
}
async function readExistingAggregateEntries(
client: S3Client,
bucket: string,
): Promise<AggregateEntry[]> {
try {
const response = await client.send(
new GetObjectCommand({
Bucket: bucket,
Key: keyForAggregateManifest(),
}),
)
const body = await readBodyAsString(response.Body)
const parsed = aggregateManifestSchema.parse(JSON.parse(body))
return parsed.agents
} catch (error) {
const maybeError = error as {
name?: string
$metadata?: { httpStatusCode?: number }
}
if (
maybeError?.name === 'NoSuchKey' ||
maybeError?.$metadata?.httpStatusCode === 404
) {
return []
}
throw error
}
}
async function rollbackKeys(
client: S3Client,
bucket: string,
uploadedKeys: string[],
): Promise<void> {
await Promise.allSettled(
[...uploadedKeys].reverse().map((key) => deleteObject(client, bucket, key)),
)
}
export async function publishAgents(options: PublishOptions): Promise<void> {
if (options.buildResults.length === 0) {
throw new Error('buildResults must not be empty')
}
const client = options.client ?? createR2Client()
const bucket = options.bucket ?? getBucket()
const cdnBaseURL = options.cdnBaseURL ?? CDN_BASE_URL
const now = options.now ?? (() => new Date())
const uploadedKeys: string[] = []
try {
const groups = groupByAgentVersion(options.buildResults)
const builtAt = now().toISOString()
for (const group of groups) {
for (const result of group.results) {
const tarKey = keyForTarball(
result.name,
result.version,
result.arch,
result.publishAs,
)
const shaKey = keyForSha(
result.name,
result.version,
result.arch,
result.publishAs,
)
await uploadFile(client, bucket, tarKey, result.tarballPath)
uploadedKeys.push(tarKey)
await uploadFile(
client,
bucket,
shaKey,
result.tarballShaPath,
SHA_CONTENT_TYPE,
)
uploadedKeys.push(shaKey)
}
const manifest = createManifestForGroup(group, builtAt, cdnBaseURL)
const manifestKey = keyForVersionManifest(group.name, group.version)
await uploadBody(
client,
bucket,
manifestKey,
`${JSON.stringify(manifest, null, 2)}\n`,
JSON_CONTENT_TYPE,
)
uploadedKeys.push(manifestKey)
}
if (options.updateAggregate) {
const existingEntries = await readExistingAggregateEntries(client, bucket)
const aggregate = mergeAggregateEntries(
existingEntries,
buildAggregateEntries(groups, cdnBaseURL),
builtAt,
aggregateBuiltBy(groups),
)
const aggregateKey = keyForAggregateManifest()
await uploadBody(
client,
bucket,
aggregateKey,
`${JSON.stringify(aggregate, null, 2)}\n`,
JSON_CONTENT_TYPE,
)
uploadedKeys.push(aggregateKey)
}
} catch (error) {
await rollbackKeys(client, bucket, uploadedKeys)
throw error
} finally {
if (!options.client) {
client.destroy()
}
}
}

View File

@@ -0,0 +1,11 @@
export const ARCHES = ['amd64', 'arm64'] as const
export type ContainerArch = (typeof ARCHES)[number]
export function parseArch(value: string): ContainerArch {
if (value === 'amd64' || value === 'arm64') {
return value
}
throw new Error(`invalid container arch: ${value}`)
}

View File

@@ -0,0 +1,26 @@
export type { ContainerArch } from './arch'
export { ARCHES, parseArch } from './arch'
export type {
AgentArtifact,
AgentManifest,
AggregateEntry,
AggregateManifest,
} from './manifest'
export {
agentArtifactSchema,
agentManifestSchema,
aggregateEntrySchema,
aggregateManifestSchema,
MANIFEST_SCHEMA_VERSION,
ociDigestSchema,
parseAgentManifest,
parseAggregateManifest,
sha256HexSchema,
} from './manifest'
export {
keyForAggregateManifest,
keyForSha,
keyForTarball,
keyForVersionManifest,
R2_AGENTS_PREFIX,
} from './r2-keys'

View File

@@ -0,0 +1,65 @@
import { z } from 'zod'
import { ARCHES } from './arch'
export const MANIFEST_SCHEMA_VERSION = 'v1' as const
export const sha256HexSchema = z.string().regex(/^[a-f0-9]{64}$/)
export const ociDigestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/)
export const agentArtifactSchema = z.object({
arch: z.enum(ARCHES),
filename: z.string().min(1),
format: z.literal('oci-archive+gzip'),
compressed_sha256: sha256HexSchema,
compressed_size_bytes: z.number().int().positive(),
uncompressed_sha256: sha256HexSchema,
uncompressed_size_bytes: z.number().int().positive(),
url: z.string().url(),
})
export const agentManifestSchema = z.object({
name: z.string().min(1),
schema: z.literal(MANIFEST_SCHEMA_VERSION),
build: z.object({
git_sha: z.string().min(1),
git_dirty: z.boolean(),
built_at: z.string().datetime(),
built_by: z.string().min(1),
config_sha256: sha256HexSchema,
podman_versions: z.array(z.string().min(1)).min(1),
}),
source: z.object({
image: z.string().min(1),
version: z.string().min(1),
oci_digest: ociDigestSchema,
}),
artifacts: z.array(agentArtifactSchema).min(1),
})
export const aggregateEntrySchema = z.object({
name: z.string().min(1),
version: z.string().min(1),
oci_digest: ociDigestSchema,
manifest_url: z.string().url(),
})
export const aggregateManifestSchema = z.object({
schema: z.literal(MANIFEST_SCHEMA_VERSION),
built_at: z.string().datetime(),
built_by: z.string().min(1),
agents: z.array(aggregateEntrySchema).min(1),
})
export type AgentArtifact = z.infer<typeof agentArtifactSchema>
export type AgentManifest = z.infer<typeof agentManifestSchema>
export type AggregateEntry = z.infer<typeof aggregateEntrySchema>
export type AggregateManifest = z.infer<typeof aggregateManifestSchema>
export function parseAgentManifest(raw: unknown): AgentManifest {
return agentManifestSchema.parse(raw)
}
export function parseAggregateManifest(raw: unknown): AggregateManifest {
return aggregateManifestSchema.parse(raw)
}

View File

@@ -0,0 +1,29 @@
import type { ContainerArch } from './arch'
export const R2_AGENTS_PREFIX = 'agents'
export function keyForTarball(
agent: string,
version: string,
arch: ContainerArch,
publishAs = agent,
): string {
return `${R2_AGENTS_PREFIX}/${agent}/${version}/${publishAs}-${version}-${arch}.tar.gz`
}
export function keyForSha(
agent: string,
version: string,
arch: ContainerArch,
publishAs = agent,
): string {
return `${keyForTarball(agent, version, arch, publishAs)}.sha256`
}
export function keyForVersionManifest(agent: string, version: string): string {
return `${R2_AGENTS_PREFIX}/${agent}/${version}/manifest.json`
}
export function keyForAggregateManifest(): string {
return `${R2_AGENTS_PREFIX}/manifest.json`
}

View File

@@ -0,0 +1,78 @@
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { createGunzip } from 'node:zlib'
import {
podmanInspectImage,
podmanLoadArchive,
podmanRemoveImage,
} from './build'
export interface RoundTripPodmanLoadOptions {
tarballPath: string
expectedImage: string
expectedImageId?: string
expectedSmokeFingerprint?: string
}
async function maybeDecompressTarball(tarballPath: string): Promise<{
tarPath: string
cleanupDir?: string
}> {
if (!tarballPath.endsWith('.gz')) {
return { tarPath: tarballPath }
}
const tempDir = await mkdtemp(join(tmpdir(), 'agent-container-smoke-'))
const tarPath = join(tempDir, 'image.tar')
await pipeline(
createReadStream(tarballPath),
createGunzip(),
createWriteStream(tarPath),
)
return { tarPath, cleanupDir: tempDir }
}
export async function roundTripPodmanLoad(
options: RoundTripPodmanLoadOptions,
): Promise<void> {
if (!options.expectedImageId && !options.expectedSmokeFingerprint) {
throw new Error(
'expectedImageId or expectedSmokeFingerprint is required for smoke verification',
)
}
const decompressed = await maybeDecompressTarball(options.tarballPath)
try {
await podmanRemoveImage(options.expectedImage).catch(() => {})
await podmanLoadArchive(decompressed.tarPath)
const inspection = await podmanInspectImage(options.expectedImage)
if (
options.expectedSmokeFingerprint &&
inspection.smokeFingerprint !== options.expectedSmokeFingerprint
) {
throw new Error(
`loaded image fingerprint mismatch: expected ${options.expectedSmokeFingerprint}, got ${inspection.smokeFingerprint}`,
)
}
if (
options.expectedImageId &&
inspection.imageId !== options.expectedImageId
) {
throw new Error(
`loaded image ID mismatch: expected ${options.expectedImageId}, got ${inspection.imageId}`,
)
}
} finally {
await podmanRemoveImage(options.expectedImage).catch(() => {})
if (decompressed.cleanupDir) {
await rm(decompressed.cleanupDir, { recursive: true, force: true })
}
}
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'bun:test'
import { ARCHES, parseArch } from '../src/schema/arch'
describe('schema/arch', () => {
it('exports the supported arches', () => {
expect(ARCHES).toEqual(['amd64', 'arm64'])
})
it('parses valid arches', () => {
expect(parseArch('amd64')).toBe('amd64')
expect(parseArch('arm64')).toBe('arm64')
})
it('rejects invalid arches', () => {
expect(() => parseArch('x64')).toThrow('invalid container arch')
})
})

View File

@@ -0,0 +1,112 @@
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 { expandMatrix, readAgentsConfig } from '../src/catalog'
const tempPaths: string[] = []
async function writeTempConfig(contents: unknown): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'agent-container-catalog-'))
const filePath = join(dir, 'agents.json')
tempPaths.push(dir)
await writeFile(filePath, `${JSON.stringify(contents, null, 2)}\n`, 'utf8')
return filePath
}
afterEach(async () => {
await Promise.all(
tempPaths
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
)
})
describe('catalog', () => {
it('reads and expands the agent matrix', async () => {
const path = await writeTempConfig({
schema: 'v1',
agents: [
{
name: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
arches: ['amd64', 'arm64'],
},
],
})
const config = await readAgentsConfig(path)
expect(expandMatrix(config)).toEqual([
{
agent: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
arch: 'amd64',
publishAs: 'openclaw',
},
{
agent: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
arch: 'arm64',
publishAs: 'openclaw',
},
])
})
it('filters the matrix by agent name', async () => {
const path = await writeTempConfig({
schema: 'v1',
agents: [
{
name: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
arches: ['amd64'],
},
{
name: 'claude-code',
image: 'ghcr.io/example/claude-code',
version: '1.2.3',
arches: ['arm64'],
publishAs: 'claude',
},
],
})
const config = await readAgentsConfig(path)
expect(expandMatrix(config, { agent: 'claude-code' })).toEqual([
{
agent: 'claude-code',
image: 'ghcr.io/example/claude-code',
version: '1.2.3',
arch: 'arm64',
publishAs: 'claude',
},
])
})
it('rejects duplicate agent names', async () => {
const path = await writeTempConfig({
schema: 'v1',
agents: [
{
name: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
arches: ['amd64'],
},
{
name: 'openclaw',
image: 'ghcr.io/example/openclaw',
version: '2026.4.13',
arches: ['arm64'],
},
],
})
await expect(readAgentsConfig(path)).rejects.toThrow('duplicate agent name')
})
})

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'bun:test'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { readAgentsConfig } from '../src/catalog'
const packageRoot = resolve(import.meta.dir, '..')
const recipePath = resolve(packageRoot, 'recipe', 'agents.json')
const runtimePath = resolve(
import.meta.dir,
'..',
'..',
'..',
'apps',
'server',
'src',
'api',
'services',
'openclaw',
'openclaw-service.ts',
)
describe('OpenClaw drift guard', () => {
it('keeps recipe/agents.json in sync with the runtime image pin', async () => {
const [config, runtimeSource] = await Promise.all([
readAgentsConfig(recipePath),
readFile(runtimePath, 'utf8'),
])
const openclaw = config.agents.find((agent) => agent.name === 'openclaw')
expect(openclaw).toBeDefined()
const match = runtimeSource.match(
/return process\.env\.OPENCLAW_IMAGE \|\| ['"]([^'"]+)['"]/,
)
if (!match?.[1]) {
throw new Error(
`failed to extract OpenClaw image fallback from ${runtimePath}`,
)
}
const recipeImage = `${openclaw?.image}:${openclaw?.version}`
expect(recipeImage).toBe(match[1], {
message: `OpenClaw image drifted between ${recipePath} and ${runtimePath}`,
})
})
})

View File

@@ -0,0 +1,223 @@
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
import { existsSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
buildTarball,
podmanInspectImage,
registryForImage,
} from '../src/build'
const tempDirs: string[] = []
function processResult(
stdout: string,
stderr = '',
exitCode = 0,
): Bun.Subprocess {
return {
stdout: new Response(stdout).body,
stderr: new Response(stderr).body,
exited: Promise.resolve(exitCode),
} as Bun.Subprocess
}
async function createTempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'agent-container-build-'))
tempDirs.push(dir)
return dir
}
afterEach(async () => {
mock.restore()
await Promise.all(
tempDirs
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
)
})
describe('build', () => {
it('resolves registry hosts correctly', () => {
expect(registryForImage('ghcr.io/openclaw/openclaw')).toBe('ghcr.io')
expect(registryForImage('localhost:5000/example/image')).toBe(
'localhost:5000',
)
expect(registryForImage('busybox')).toBe('docker.io')
})
it('builds a tarball result and writes the sidecar files', async () => {
const dir = await createTempDir()
const outputDir = join(dir, 'dist')
const recipePath = join(dir, 'agents.json')
await writeFile(
recipePath,
JSON.stringify({
schema: 'v1',
agents: [
{
name: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
arches: ['arm64'],
},
],
}),
'utf8',
)
const originalSpawn = Bun.spawn
const podmanCommands: string[][] = []
spyOn(Bun, 'spawn').mockImplementation((command, options) => {
if (Array.isArray(command) && command[0] === 'podman') {
podmanCommands.push(command)
if (command[1] === '--version') {
return processResult('podman version 5.8.1\n')
}
if (command[1] === 'pull') {
return processResult('')
}
if (command[1] === 'inspect') {
return processResult(
JSON.stringify({
Id: 'f'.repeat(64),
Digest: `sha256:${'1'.repeat(64)}`,
RepoDigests: [
`ghcr.io/openclaw/openclaw@sha256:${'2'.repeat(64)}`,
`ghcr.io/openclaw/openclaw@sha256:${'1'.repeat(64)}`,
],
Architecture: 'arm64',
Os: 'linux',
Config: {
Entrypoint: ['/entrypoint.sh'],
Env: ['NODE_ENV=production'],
},
RootFS: {
Type: 'layers',
Layers: ['sha256:abc'],
},
}),
)
}
if (command[1] === 'save') {
const outPath = String(command[5])
void writeFile(outPath, 'oci archive payload', 'utf8')
return processResult('')
}
}
return originalSpawn(
command as string[],
options as SpawnOptions.OptionsObject<string[]>,
)
})
const result = await buildTarball({
agent: {
name: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
arches: ['arm64'],
},
arch: 'arm64',
outputDir,
recipePath,
builtBy: 'test-run',
})
expect(result.filename).toBe('openclaw-2026.4.12-arm64.tar.gz')
expect(result.sourceOciDigest).toBe(`sha256:${'2'.repeat(64)}`)
expect(result.imageId).toBe(`sha256:${'f'.repeat(64)}`)
expect(result.smokeFingerprint).toHaveLength(64)
expect(existsSync(result.tarballPath)).toBe(true)
expect(existsSync(result.tarballShaPath)).toBe(true)
expect(existsSync(join(outputDir, 'openclaw-2026.4.12-arm64.tar'))).toBe(
false,
)
expect(existsSync(join(outputDir, 'build-result.json'))).toBe(true)
expect(
podmanCommands.some(
(command) =>
command[1] === 'pull' &&
command.includes('--arch') &&
command.includes('arm64'),
),
).toBe(true)
})
it('prefers the repeated non-platform repo digest as the source OCI digest', async () => {
const originalSpawn = Bun.spawn
spyOn(Bun, 'spawn').mockImplementation((command, options) => {
if (
Array.isArray(command) &&
command[0] === 'podman' &&
command[1] === 'inspect'
) {
return processResult(
JSON.stringify({
Id: 'f'.repeat(64),
Digest: `sha256:${'1'.repeat(64)}`,
RepoDigests: [
`ghcr.io/openclaw/openclaw@sha256:${'2'.repeat(64)}`,
`mirror.example/openclaw/openclaw@sha256:${'2'.repeat(64)}`,
`docker.io/openclaw/openclaw@sha256:${'1'.repeat(64)}`,
],
Architecture: 'arm64',
Os: 'linux',
Config: {},
RootFS: {},
}),
)
}
return originalSpawn(
command as string[],
options as SpawnOptions.OptionsObject<string[]>,
)
})
const inspection = await podmanInspectImage(
'ghcr.io/openclaw/openclaw:2026.4.12',
)
expect(inspection.sourceOciDigest).toBe(`sha256:${'2'.repeat(64)}`)
})
it('fails when repo digests disagree without a clear winner', async () => {
const originalSpawn = Bun.spawn
spyOn(Bun, 'spawn').mockImplementation((command, options) => {
if (
Array.isArray(command) &&
command[0] === 'podman' &&
command[1] === 'inspect'
) {
return processResult(
JSON.stringify({
Id: 'f'.repeat(64),
Digest: `sha256:${'1'.repeat(64)}`,
RepoDigests: [
`ghcr.io/openclaw/openclaw@sha256:${'2'.repeat(64)}`,
`mirror.example/openclaw/openclaw@sha256:${'3'.repeat(64)}`,
`docker.io/openclaw/openclaw@sha256:${'1'.repeat(64)}`,
],
Architecture: 'arm64',
Os: 'linux',
Config: {},
RootFS: {},
}),
)
}
return originalSpawn(
command as string[],
options as SpawnOptions.OptionsObject<string[]>,
)
})
await expect(
podmanInspectImage('ghcr.io/openclaw/openclaw:2026.4.12'),
).rejects.toThrow('ambiguous source OCI digest')
})
})

View File

@@ -0,0 +1,365 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
type S3Client,
} from '@aws-sdk/client-s3'
import type { BuildResult } from '../src/build'
import { publishAgents } from '../src/publish'
const tempDirs: string[] = []
function sha(char: string): string {
return char.repeat(64)
}
async function createBuildResult(
root: string,
arch: 'amd64' | 'arm64',
overrides: Partial<BuildResult> = {},
): Promise<BuildResult> {
const dir = join(root, arch)
await mkdir(dir, { recursive: true })
const tarballPath = join(dir, `openclaw-2026.4.12-${arch}.tar.gz`)
const tarballShaPath = `${tarballPath}.sha256`
await writeFile(tarballPath, `${arch}-tarball`, 'utf8')
await writeFile(
tarballShaPath,
`${sha(arch === 'amd64' ? 'a' : 'b')} file\n`,
'utf8',
)
return {
name: 'openclaw',
publishAs: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
arch,
sourceOciDigest: `sha256:${sha('c')}`,
imageId: `sha256:${sha(arch === 'amd64' ? 'd' : 'e')}`,
smokeFingerprint: sha(arch === 'amd64' ? '6' : '7'),
filename: `openclaw-2026.4.12-${arch}.tar.gz`,
tarballPath,
tarballShaPath,
compressedSha256: sha(arch === 'amd64' ? '1' : '2'),
compressedSizeBytes: 100,
uncompressedSha256: sha(arch === 'amd64' ? '3' : '4'),
uncompressedSizeBytes: 200,
podmanVersion: 'podman version 5.8.1',
builtAt: '2026-04-22T17:30:00.000Z',
builtBy: 'workflow@refs/heads/dev',
gitSha: 'abc123',
gitDirty: false,
configSha256: sha('5'),
...overrides,
}
}
async function createTempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'agent-container-publish-'))
tempDirs.push(dir)
return dir
}
afterEach(async () => {
await Promise.all(
tempDirs
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
)
})
describe('publish', () => {
it('uploads version manifests and updates aggregate last', async () => {
const root = await createTempDir()
const buildResults = await Promise.all([
createBuildResult(root, 'amd64'),
createBuildResult(root, 'arm64'),
])
const puts: Array<{ key: string; body: unknown }> = []
const client = {
send: async (command: unknown) => {
if (command instanceof GetObjectCommand) {
throw { name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } }
}
if (command instanceof PutObjectCommand) {
puts.push({
key: String(command.input.Key),
body: command.input.Body,
})
return {}
}
if (command instanceof DeleteObjectCommand) {
return {}
}
throw new Error('unexpected command')
},
destroy: () => {},
} as unknown as S3Client
await publishAgents({
buildResults,
updateAggregate: true,
bucket: 'test-bucket',
cdnBaseURL: 'https://cdn.example.com',
client,
now: () => new Date('2026-04-22T18:00:00.000Z'),
})
expect(puts.map((entry) => entry.key)).toEqual([
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz.sha256',
'agents/openclaw/2026.4.12/openclaw-2026.4.12-arm64.tar.gz',
'agents/openclaw/2026.4.12/openclaw-2026.4.12-arm64.tar.gz.sha256',
'agents/openclaw/2026.4.12/manifest.json',
'agents/manifest.json',
])
const versionManifest = JSON.parse(
String(puts.find((entry) => entry.key.endsWith('/manifest.json'))?.body),
)
expect(versionManifest.source.oci_digest).toBe(`sha256:${sha('c')}`)
expect(versionManifest.artifacts[0].url).toBe(
'https://cdn.example.com/agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
)
const aggregateManifest = JSON.parse(String(puts.at(-1)?.body))
expect(aggregateManifest.agents).toEqual([
{
name: 'openclaw',
version: '2026.4.12',
oci_digest: `sha256:${sha('c')}`,
manifest_url:
'https://cdn.example.com/agents/openclaw/2026.4.12/manifest.json',
},
])
})
it('rolls back uploaded keys when a later upload fails', async () => {
const root = await createTempDir()
const buildResults = [await createBuildResult(root, 'amd64')]
const deleted: string[] = []
const client = {
send: async (command: unknown) => {
if (command instanceof GetObjectCommand) {
throw { name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } }
}
if (command instanceof PutObjectCommand) {
if (String(command.input.Key).endsWith('/manifest.json')) {
throw new Error('manifest upload failed')
}
return {}
}
if (command instanceof DeleteObjectCommand) {
deleted.push(String(command.input.Key))
return {}
}
throw new Error('unexpected command')
},
destroy: () => {},
} as unknown as S3Client
await expect(
publishAgents({
buildResults,
updateAggregate: true,
bucket: 'test-bucket',
client,
}),
).rejects.toThrow('manifest upload failed')
expect(deleted).toEqual([
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz.sha256',
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
])
})
it('merges new entries into an existing aggregate manifest', async () => {
const root = await createTempDir()
const buildResults = [await createBuildResult(root, 'amd64')]
const puts: Array<{ key: string; body: unknown }> = []
const client = {
send: async (command: unknown) => {
if (command instanceof GetObjectCommand) {
return {
Body: {
transformToByteArray: async () =>
new TextEncoder().encode(
JSON.stringify({
schema: 'v1',
built_at: '2026-04-21T00:00:00.000Z',
built_by: 'previous',
agents: [
{
name: 'claude-code',
version: '1.0.0',
oci_digest: `sha256:${sha('9')}`,
manifest_url:
'https://cdn.example.com/agents/claude-code/1.0.0/manifest.json',
},
{
name: 'openclaw',
version: '2026.4.11',
oci_digest: `sha256:${sha('8')}`,
manifest_url:
'https://cdn.example.com/agents/openclaw/2026.4.11/manifest.json',
},
],
}),
),
},
}
}
if (command instanceof PutObjectCommand) {
puts.push({
key: String(command.input.Key),
body: command.input.Body,
})
return {}
}
if (command instanceof DeleteObjectCommand) {
return {}
}
throw new Error('unexpected command')
},
destroy: () => {},
} as unknown as S3Client
await publishAgents({
buildResults,
updateAggregate: true,
bucket: 'test-bucket',
cdnBaseURL: 'https://cdn.example.com',
client,
now: () => new Date('2026-04-22T18:00:00.000Z'),
})
const aggregateManifest = JSON.parse(String(puts.at(-1)?.body))
expect(aggregateManifest.agents).toEqual([
{
name: 'claude-code',
version: '1.0.0',
oci_digest: `sha256:${sha('9')}`,
manifest_url:
'https://cdn.example.com/agents/claude-code/1.0.0/manifest.json',
},
{
name: 'openclaw',
version: '2026.4.12',
oci_digest: `sha256:${sha('c')}`,
manifest_url:
'https://cdn.example.com/agents/openclaw/2026.4.12/manifest.json',
},
])
})
it('records distinct podman versions across arches', async () => {
const root = await createTempDir()
const buildResults = await Promise.all([
createBuildResult(root, 'amd64', {
podmanVersion: 'podman version 5.8.1',
}),
createBuildResult(root, 'arm64', {
podmanVersion: 'podman version 5.9.0',
}),
])
const puts: Array<{ key: string; body: unknown }> = []
const client = {
send: async (command: unknown) => {
if (command instanceof GetObjectCommand) {
throw { name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } }
}
if (command instanceof PutObjectCommand) {
puts.push({
key: String(command.input.Key),
body: command.input.Body,
})
return {}
}
if (command instanceof DeleteObjectCommand) {
return {}
}
throw new Error('unexpected command')
},
destroy: () => {},
} as unknown as S3Client
await publishAgents({
buildResults,
updateAggregate: false,
bucket: 'test-bucket',
client,
})
const versionManifest = JSON.parse(
String(puts.find((entry) => entry.key.endsWith('/manifest.json'))?.body),
)
expect(versionManifest.build.podman_versions).toEqual([
'podman version 5.8.1',
'podman version 5.9.0',
])
})
it('records all build provenance values in the aggregate manifest', async () => {
const root = await createTempDir()
const buildResults = [
await createBuildResult(root, 'amd64', {
name: 'claude-code',
publishAs: 'claude-code',
image: 'ghcr.io/example/claude-code',
version: '1.0.0',
builtBy: 'workflow-a@refs/heads/dev',
}),
await createBuildResult(root, 'arm64', {
name: 'openclaw',
publishAs: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
builtBy: 'workflow-b@refs/heads/dev',
}),
]
const puts: Array<{ key: string; body: unknown }> = []
const client = {
send: async (command: unknown) => {
if (command instanceof GetObjectCommand) {
throw { name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } }
}
if (command instanceof PutObjectCommand) {
puts.push({
key: String(command.input.Key),
body: command.input.Body,
})
return {}
}
if (command instanceof DeleteObjectCommand) {
return {}
}
throw new Error('unexpected command')
},
destroy: () => {},
} as unknown as S3Client
await publishAgents({
buildResults,
updateAggregate: true,
bucket: 'test-bucket',
client,
now: () => new Date('2026-04-22T18:00:00.000Z'),
})
const aggregateManifest = JSON.parse(String(puts.at(-1)?.body))
expect(aggregateManifest.built_by).toBe(
'workflow-a@refs/heads/dev, workflow-b@refs/heads/dev',
)
})
})

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'bun:test'
import {
keyForAggregateManifest,
keyForSha,
keyForTarball,
keyForVersionManifest,
} from '../src/schema/r2-keys'
describe('schema/r2-keys', () => {
it('builds tarball keys', () => {
expect(keyForTarball('openclaw', '2026.4.12', 'amd64')).toBe(
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
)
})
it('supports a custom publishAs filename prefix', () => {
expect(keyForTarball('claude-code', '1.2.3', 'arm64', 'claude')).toBe(
'agents/claude-code/1.2.3/claude-1.2.3-arm64.tar.gz',
)
expect(keyForSha('claude-code', '1.2.3', 'arm64', 'claude')).toBe(
'agents/claude-code/1.2.3/claude-1.2.3-arm64.tar.gz.sha256',
)
})
it('builds manifest keys', () => {
expect(keyForVersionManifest('openclaw', '2026.4.12')).toBe(
'agents/openclaw/2026.4.12/manifest.json',
)
expect(keyForAggregateManifest()).toBe('agents/manifest.json')
})
})

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'bun:test'
import {
parseAgentManifest,
parseAggregateManifest,
} from '../src/schema/manifest'
function hex(char: string): string {
return char.repeat(64)
}
describe('schema/manifest', () => {
it('parses a valid agent manifest', () => {
const manifest = parseAgentManifest({
name: 'openclaw',
schema: 'v1',
build: {
git_sha: 'abc123',
git_dirty: false,
built_at: '2026-04-22T17:30:00.000Z',
built_by: 'workflow@refs/heads/dev',
config_sha256: hex('0'),
podman_versions: ['podman version 5.8.1'],
},
source: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
oci_digest: `sha256:${hex('1')}`,
},
artifacts: [
{
arch: 'amd64',
filename: 'openclaw-2026.4.12-amd64.tar.gz',
format: 'oci-archive+gzip',
compressed_sha256: hex('2'),
compressed_size_bytes: 123,
uncompressed_sha256: hex('3'),
uncompressed_size_bytes: 456,
url: 'https://cdn.browseros.com/agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
},
],
})
expect(manifest.source.version).toBe('2026.4.12')
expect(manifest.artifacts).toHaveLength(1)
})
it('rejects invalid artifact hashes', () => {
expect(() =>
parseAgentManifest({
name: 'openclaw',
schema: 'v1',
build: {
git_sha: 'abc123',
git_dirty: false,
built_at: '2026-04-22T17:30:00.000Z',
built_by: 'workflow@refs/heads/dev',
config_sha256: hex('0'),
podman_versions: ['podman version 5.8.1'],
},
source: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
oci_digest: `sha256:${hex('1')}`,
},
artifacts: [
{
arch: 'amd64',
filename: 'openclaw-2026.4.12-amd64.tar.gz',
format: 'oci-archive+gzip',
compressed_sha256: 'bad',
compressed_size_bytes: 123,
uncompressed_sha256: hex('3'),
uncompressed_size_bytes: 456,
url: 'https://cdn.browseros.com/agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
},
],
}),
).toThrow()
})
it('parses a valid aggregate manifest', () => {
const manifest = parseAggregateManifest({
schema: 'v1',
built_at: '2026-04-22T17:30:00.000Z',
built_by: 'workflow@refs/heads/dev',
agents: [
{
name: 'openclaw',
version: '2026.4.12',
oci_digest: `sha256:${hex('4')}`,
manifest_url:
'https://cdn.browseros.com/agents/openclaw/2026.4.12/manifest.json',
},
],
})
expect(manifest.agents[0]?.name).toBe('openclaw')
})
})

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src"
},
"include": ["src/**/*"]
}

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

@@ -1,242 +1,26 @@
{
"resources": [
{
"name": "Bun Runtime - macOS ARM64",
"name": "Lima limactl - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/bun/bun-darwin-arm64"
"key": "third_party/lima/limactl-darwin-arm64"
},
"destination": "resources/bin/third_party/bun",
"destination": "resources/bin/third_party/lima/limactl",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Bun Runtime - macOS x64",
"name": "Lima limactl - macOS x64",
"source": {
"type": "r2",
"key": "third_party/bun/bun-darwin-x64"
"key": "third_party/lima/limactl-darwin-x64"
},
"destination": "resources/bin/third_party/bun",
"destination": "resources/bin/third_party/lima/limactl",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Bun Runtime - Linux ARM64",
"source": {
"type": "r2",
"key": "third_party/bun/bun-linux-arm64"
},
"destination": "resources/bin/third_party/bun",
"os": ["linux"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Bun Runtime - Linux x64",
"source": {
"type": "r2",
"key": "third_party/bun/bun-linux-x64"
},
"destination": "resources/bin/third_party/bun",
"os": ["linux"],
"arch": ["x64"],
"executable": true
},
{
"name": "Bun Runtime - Windows x64",
"source": {
"type": "r2",
"key": "third_party/bun/bun-windows-x64.exe"
},
"destination": "resources/bin/third_party/bun.exe",
"os": ["windows"],
"arch": ["x64"]
},
{
"name": "Podman CLI - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/podman/podman-darwin-arm64"
},
"destination": "resources/bin/third_party/podman/podman",
"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"]
},
{
"name": "ripgrep - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/ripgrep/rg-darwin-arm64"
},
"destination": "resources/bin/third_party/rg",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "ripgrep - macOS x64",
"source": {
"type": "r2",
"key": "third_party/ripgrep/rg-darwin-x64"
},
"destination": "resources/bin/third_party/rg",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "ripgrep - Linux ARM64",
"source": {
"type": "r2",
"key": "third_party/ripgrep/rg-linux-arm64"
},
"destination": "resources/bin/third_party/rg",
"os": ["linux"],
"arch": ["arm64"],
"executable": true
},
{
"name": "ripgrep - Linux x64",
"source": {
"type": "r2",
"key": "third_party/ripgrep/rg-linux-x64"
},
"destination": "resources/bin/third_party/rg",
"os": ["linux"],
"arch": ["x64"],
"executable": true
},
{
"name": "ripgrep - Windows x64",
"source": {
"type": "r2",
"key": "third_party/ripgrep/rg-windows-x64.exe"
},
"destination": "resources/bin/third_party/rg.exe",
"os": ["windows"],
"arch": ["x64"]
}
]
}

View File

@@ -12,9 +12,12 @@ function validateRule(rule: ResourceRule): void {
if (!rule.name || rule.name.trim().length === 0) {
throw new Error('Manifest rule is missing name')
}
if (!rule.source.key || !rule.destination) {
const hasSourcePath =
(rule.source.type === 'r2' && rule.source.key) ||
(rule.source.type === 'local' && rule.source.path)
if (!hasSourcePath || !rule.destination) {
throw new Error(
`Manifest rule ${rule.name} is missing source key or destination`,
`Manifest rule ${rule.name} is missing source path or destination`,
)
}
}
@@ -24,16 +27,21 @@ function parseSource(raw: unknown): ResourceRule['source'] {
throw new Error('Manifest source must be an object')
}
const source = raw as Record<string, unknown>
if (source.type !== 'r2') {
throw new Error(
`Unsupported source type in manifest: ${String(source.type)}`,
)
if (source.type === 'r2') {
const key = source.key
if (typeof key !== 'string' || key.length === 0) {
throw new Error('Manifest source key is required')
}
return { type: 'r2', key }
}
const key = source.key
if (typeof key !== 'string' || key.length === 0) {
throw new Error('Manifest source key is required')
if (source.type === 'local') {
const path = source.path
if (typeof path !== 'string' || path.length === 0) {
throw new Error('Manifest source path is required')
}
return { type: 'local', path }
}
return { type: 'r2', key }
throw new Error(`Unsupported source type in manifest: ${String(source.type)}`)
}
function parseRule(raw: unknown): ResourceRule {

View File

@@ -34,17 +34,28 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
{ ci: args.ci },
)
const manifestPath = resolve(rootDir, args.manifestPath)
if (!existsSync(manifestPath)) {
throw new Error(`Manifest not found: ${manifestPath}`)
}
const manifest = loadManifest(manifestPath)
if (args.ci) {
const distRoot = getDistProdRoot()
const localArtifacts = []
for (const binary of compiled) {
log.step(`Packaging ${binary.target.name}`)
const rules = getTargetRules(manifest, binary.target).filter(
(rule) => rule.source.type === 'local',
)
const staged = await stageCompiledArtifact(
distRoot,
binary.binaryPath,
binary.target,
buildConfig.version,
rules,
rootDir,
)
localArtifacts.push(staged)
log.success(`Packaged ${binary.target.id}`)
@@ -58,12 +69,6 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
return
}
const manifestPath = resolve(rootDir, args.manifestPath)
if (!existsSync(manifestPath)) {
throw new Error(`Manifest not found: ${manifestPath}`)
}
const manifest = loadManifest(manifestPath)
const distRoot = getDistProdRoot()
const r2 = buildConfig.r2
if (!r2) {
@@ -76,13 +81,14 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
for (const binary of compiled) {
const rules = getTargetRules(manifest, binary.target)
log.step(
`Staging ${binary.target.name} (${rules.length} download rule(s))`,
`Staging ${binary.target.name} (${rules.length} resource rule(s))`,
)
const staged = await stageTargetArtifact(
distRoot,
binary.binaryPath,
binary.target,
rules,
rootDir,
client,
r2,
buildConfig.version,

View File

@@ -0,0 +1,26 @@
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 { loadManifest } from './manifest'
describe('server artifact staging', () => {
let tempDir: string | null = null
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = null
}
})
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: [] }))
expect(loadManifest(manifestPath)).toEqual({
resources: [],
})
})
})

View File

@@ -1,5 +1,5 @@
import { chmod, cp, mkdir, rm } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative } from 'node:path'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { S3Client } from '@aws-sdk/client-s3'
@@ -75,13 +75,40 @@ function resolveDestination(rootDir: string, destination: string): string {
async function stageRule(
rootDir: string,
sourceRoot: string,
rule: ResourceRule,
target: BuildTarget,
client: S3Client,
r2: R2Config,
): Promise<void> {
const destinationPath = resolveDestination(rootDir, rule.destination)
await downloadObjectToFile(client, r2, rule.source.key, destinationPath)
await mkdir(dirname(destinationPath), { recursive: true })
if (rule.source.type === 'local') {
await stageLocalRule(destinationPath, sourceRoot, rule, target)
} else {
await downloadObjectToFile(client, r2, rule.source.key, destinationPath)
if (rule.executable && target.os !== 'windows') {
await chmod(destinationPath, 0o755)
}
}
}
async function stageLocalRule(
destinationPath: string,
sourceRoot: string,
rule: ResourceRule,
target: BuildTarget,
): Promise<void> {
if (rule.source.type !== 'local') {
throw new Error(`Expected local source rule, got ${rule.source.type}`)
}
await mkdir(dirname(destinationPath), { recursive: true })
const sourcePath = isAbsolute(rule.source.path)
? rule.source.path
: resolve(sourceRoot, rule.source.path)
await cp(sourcePath, destinationPath)
if (rule.executable && target.os !== 'windows') {
await chmod(destinationPath, 0o755)
@@ -93,6 +120,7 @@ export async function stageTargetArtifact(
compiledBinaryPath: string,
target: BuildTarget,
rules: ResourceRule[],
sourceRoot: string,
client: S3Client,
r2: R2Config,
version: string,
@@ -100,7 +128,7 @@ export async function stageTargetArtifact(
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
for (const rule of rules) {
await stageRule(rootDir, rule, target, client, r2)
await stageRule(rootDir, sourceRoot, rule, target, client, r2)
}
return finalizeArtifact(rootDir, target, version)
@@ -111,7 +139,22 @@ export async function stageCompiledArtifact(
compiledBinaryPath: string,
target: BuildTarget,
version: string,
rules: ResourceRule[] = [],
sourceRoot = process.cwd(),
): Promise<StagedArtifact> {
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
for (const rule of rules) {
if (rule.source.type !== 'local') {
continue
}
await stageLocalRule(
resolveDestination(rootDir, rule.destination),
sourceRoot,
rule,
target,
)
}
return finalizeArtifact(rootDir, target, version)
}

View File

@@ -40,11 +40,18 @@ export interface BuildConfig {
r2?: R2Config
}
export interface ResourceSource {
export interface R2ResourceSource {
type: 'r2'
key: string
}
export interface LocalResourceSource {
type: 'local'
path: string
}
export type ResourceSource = R2ResourceSource | LocalResourceSource
export interface ResourceRule {
name: string
source: ResourceSource

View File

@@ -0,0 +1,29 @@
import { spawnSync } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
const projectRoot = resolve(import.meta.dir, '..')
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
const testArgs = process.argv.slice(2)
const cmd = [process.execPath, 'test']
if (junitPath) {
const outputPath = resolve(projectRoot, junitPath)
mkdirSync(dirname(outputPath), { recursive: true })
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
}
cmd.push(...testArgs)
const result = spawnSync(cmd[0], cmd.slice(1), {
cwd: projectRoot,
env: process.env,
stdio: 'inherit',
})
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)

View File

@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"time"
"browseros-dev/proc"
@@ -33,7 +34,9 @@ func runCleanup(cmd *cobra.Command, args []string) error {
if doPorts {
ports := proc.DefaultLocalPorts()
proc.LogMsgf(proc.TagInfo, "Killing processes on ports %d, %d, %d...", ports.CDP, ports.Server, ports.Extension)
proc.KillPorts(ports)
if err := proc.KillPortsAndWait(ports, 3*time.Second); err != nil {
return err
}
proc.LogMsg(proc.TagInfo, "Ports cleared")
}

View File

@@ -8,6 +8,7 @@ import (
"path/filepath"
"sync"
"syscall"
"time"
"browseros-dev/browser"
"browseros-dev/proc"
@@ -62,7 +63,9 @@ func runWatch(cmd *cobra.Command, args []string) error {
return fmt.Errorf("creating user-data dir: %w", err)
}
proc.LogMsg(proc.TagInfo, "Killing processes on preferred ports...")
proc.KillPorts(defaultPorts)
if err := proc.KillPortsAndWait(defaultPorts, 3*time.Second); err != nil {
return err
}
proc.LogMsg(proc.TagInfo, "Ports cleared")
p, reservations, err = proc.ResolveWatchPorts(false)
@@ -159,6 +162,9 @@ func runWatch(cmd *cobra.Command, args []string) error {
Env: env,
Restart: true,
Cmd: []string{"bun", "--watch", "--env-file=.env.development", "src/index.ts"},
BeforeStart: func() error {
return proc.KillPortAndWait(p.Server, 3*time.Second)
},
}))
<-sigCh

View File

@@ -11,11 +11,12 @@ import (
)
type ProcConfig struct {
Tag Tag
Dir string
Env []string
Restart bool
Cmd []string
Tag Tag
Dir string
Env []string
Restart bool
Cmd []string
BeforeStart func() error
}
type ManagedProc struct {
@@ -49,6 +50,17 @@ func (mp *ManagedProc) run(ctx context.Context) {
return
}
if mp.Cfg.BeforeStart != nil {
if err := mp.Cfg.BeforeStart(); err != nil {
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Pre-start failed: %v", err))
if !mp.Cfg.Restart || ctx.Err() != nil {
return
}
time.Sleep(time.Second)
continue
}
}
LogMsgf(mp.Cfg.Tag, "Starting: %s", DimColor.Sprint(strings.Join(mp.Cfg.Cmd, " ")))
cmd := exec.Command(mp.Cfg.Cmd[0], mp.Cfg.Cmd[1:]...)

View File

@@ -0,0 +1,60 @@
package proc
import (
"context"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestStartManagedRunsBeforeStartOnEachRetry(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2200*time.Millisecond)
defer cancel()
var count atomic.Int32
var wg sync.WaitGroup
StartManaged(ctx, &wg, ProcConfig{
Tag: TagInfo,
Dir: t.TempDir(),
Restart: true,
Cmd: []string{"sh", "-c", "exit 1"},
BeforeStart: func() error {
count.Add(1)
return nil
},
})
wg.Wait()
if count.Load() < 2 {
t.Fatalf("expected BeforeStart to run on retries, got %d calls", count.Load())
}
}
func TestStartManagedSkipsLaunchWhenBeforeStartFails(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sentinel := filepath.Join(t.TempDir(), "started")
var wg sync.WaitGroup
StartManaged(ctx, &wg, ProcConfig{
Tag: TagInfo,
Dir: t.TempDir(),
Restart: false,
Cmd: []string{"sh", "-c", "touch " + sentinel},
BeforeStart: func() error {
return context.DeadlineExceeded
},
})
wg.Wait()
if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
t.Fatalf("expected process launch to be skipped, stat err=%v", err)
}
}

View File

@@ -133,6 +133,29 @@ func KillPort(port int) {
exec.Command("sh", "-c", fmt.Sprintf("lsof -ti:%d | xargs kill -9 2>/dev/null || true", port)).Run()
}
func KillPortAndWait(port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
KillPort(port)
if IsPortAvailable(port) {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("port %d is still in use after kill -9 cleanup", port)
}
time.Sleep(100 * time.Millisecond)
}
}
func KillPortsAndWait(p Ports, timeout time.Duration) error {
for _, port := range []int{p.CDP, p.Server, p.Extension} {
if err := KillPortAndWait(port, timeout); err != nil {
return err
}
}
return nil
}
func BuildEnv(p Ports, nodeEnv string) []string {
env := os.Environ()
env = append(env,

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()

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