Compare commits

...

15 Commits

Author SHA1 Message Date
Nikhil Sonti
acde1edacc fix: address review feedback for PR #901 2026-04-30 17:34:27 -07:00
Nikhil Sonti
3260b72418 fix(ci): handle clean internal-docs sync PRs 2026-04-30 17:22:07 -07:00
Nikhil
ab354d7dd7 fix(ci): restore PAT on actions/checkout for submodule fetch (#898)
Without a token on actions/checkout, the action falls back to
GITHUB_TOKEN, which has no access to the private internal-docs
repo. Submodule clone fails with "repository not found".

PAT is back on checkout. PR ops still use GITHUB_TOKEN via the
GH_TOKEN env var on the run step. The bot-branch git push uses
the credential helper set up by checkout (the PAT, which has
Contents: Read and write).
2026-04-30 16:23:58 -07:00
Nikhil
0e779fa344 fix(ci): switch internal-docs sync to PR + auto-merge (#897)
Direct push to dev fails the dev ruleset's "Require pull request"
rule. Open a tiny PR from a bot branch and enable auto-merge
(squash, 0 approvals required) instead. No bypass actor needed —
the rule stays strict for everyone, including the bot.

PR ops use GITHUB_TOKEN with explicit pull-requests: write
permission. The cross-repo PAT is only used to rewrite the SSH
submodule URL so internal-docs can be cloned over HTTPS.
2026-04-30 16:17:15 -07:00
Nikhil
dfbce48994 feat: remove CLI auto init discovery (#896)
* feat: remove CLI auto init discovery

* fix: address review feedback for PR #896
2026-04-30 16:03:47 -07:00
Nikhil
7c942e91ce chore: add internal-docs submodule (#895)
Mounts browseros-ai/internal-docs at .internal-docs/, tracking main.

This activates the /document-internal and /ask-internal skills (which
early-exit if the submodule is missing) and lets the sync-internal-docs
workflow start bumping the pointer on its 4-hourly schedule.

Team members: after this lands, run once from a fresh dev pull:
    git submodule update --init .internal-docs
2026-04-30 15:13:41 -07:00
Nikhil
1ff92c44b3 feat(internal-docs): scaffold private docs submodule, skills, sync action (#894)
* feat(internal-docs): scaffold private docs submodule, skills, sync action

Adds the OSS-side scaffolding for the internal-docs system:

- /document-internal skill — drafts a 1-page feature/architecture/design
  doc from the current branch's diff, asks four sharp questions, enforces
  voice rules (no em dashes, banned filler words, 60-line cap on feature
  notes), then opens a PR to browseros-ai/internal-docs via a tmp clone.
- /ask-internal skill — answers team-internal questions by greping
  internal-docs and the codebase, synthesizing with file:line citations,
  optionally executing surfaced commands with per-command confirmation,
  and drafting a new doc + PR if grep returns nothing useful.
- .github/workflows/sync-internal-docs.yml — every 4 hours, bumps the
  submodule pointer on dev directly (no PR; relies on dev branch
  protection blocking force-push). Skips silently until the submodule
  is configured. Uses url.insteadOf to rewrite the SSH submodule URL
  to HTTPS-with-token for the bot, while keeping SSH the local default.
- .claude/skills/document-internal/seeds/ — root README and three
  templates (feature-note, architecture-note, design-spec) ready to
  copy into the new internal-docs repo on rollout.

Design spec: .llm/superpowers/specs/2026-04-30-internal-docs-submodule-design.md

Manual prereqs (NOT in this PR — handled out-of-band):
1. Create private repo browseros-ai/internal-docs with branch protection on main.
2. Seed it with the contents of .claude/skills/document-internal/seeds/.
3. Create a bot account, mark as bypass actor on dev branch protection.
4. Add INTERNAL_DOCS_SYNC_TOKEN secret with repo + read access to internal-docs.
5. Once internal-docs exists, on a follow-up branch:
     git submodule add -b main git@github.com:browseros-ai/internal-docs.git .internal-docs
6. Send the team the one-time init snippet for their existing checkouts:
     git submodule update --init .internal-docs

* fix(internal-docs): address Greptile review feedback

- Workflow: rebase onto dev before push to handle non-fast-forward race;
  bump fetch-depth 1->50 so rebase has merge-base history.
- Workflow: move INTERNAL_DOCS_SYNC_TOKEN into step env: per Actions
  credential-injection pattern, instead of inlining in the script body.
- Skill (BASE bug): suppress git rev-parse stdout so SHA does not get
  captured into BASE alongside the literal 'dev'. Was breaking every
  downstream git log/diff call.
- Skill (tmp clone): trap 'rm -rf "$TMP" EXIT after mktemp so cleanup
  always runs, even if any subsequent step fails.
2026-04-30 15:04:08 -07:00
shivammittal274
c81906ecbf feat(eval): add claude code eval agent (#885) 2026-05-01 02:25:08 +05:30
Nikhil
ffc0f09c86 feat(dev): add target-aware reset cleanup (#893)
* feat(dev): add target-aware reset cleanup

* fix(dev): address cleanup reset review comments
2026-04-30 13:34:52 -07:00
Nikhil
7fb53c9921 feat(dev): bootstrap setup from dev watch (#891)
* feat(dev): bootstrap setup from dev watch

* fix: address review feedback for PR #891
2026-04-30 13:00:46 -07:00
Nikhil
d38b01a8c7 feat(dev): add guided cleanup and reset commands (#890)
* feat(dev): add guided cleanup and reset commands

* fix: address cleanup reset review feedback
2026-04-30 12:27:15 -07:00
Nikhil
ff36c8412b fix(dev): use run lock for watch cleanup (#889)
* fix(dev): use run lock for watch cleanup

* fix(dev): address watch lock review comments
2026-04-30 11:46:17 -07:00
Nikhil
fd5aba249b fix: stabilize OpenClaw gateway startup (#888)
* feat(server): add shared process lock helper

* feat(container): add container name reconciliation helpers

* feat(openclaw): serialize lifecycle across processes

* fix(openclaw): reconcile fixed gateway container startup

* test(openclaw): cover lifecycle race recovery

* fix(server): satisfy process lock error override

* fix(openclaw): address review feedback

* test(openclaw): align serialization mock with image check
2026-04-30 11:31:40 -07:00
Nikhil
492f3fcdf2 feat(openclaw): prewarm ghcr image in vm (#887)
* feat(openclaw): add gateway image inspection

* feat(openclaw): pull gateway image from registry

* refactor(vm): decouple readiness from image cache

* refactor(openclaw): remove vm cache from runtime factory

* feat(openclaw): detect current gateway image

* feat(openclaw): prewarm vm runtime and reuse current gateway

* feat(openclaw): prewarm runtime on server startup

* refactor(vm): remove browseros image cache runtime

* refactor(build-tools): remove openclaw tarball pipeline

* chore: self-review fixes

* fix(openclaw): suppress prewarm pull progress logs

* fix(openclaw): address review feedback

* fix(openclaw): resolve review findings

* fix(dev): stop stale watch supervisors
2026-04-30 11:18:11 -07:00
Nikhil
cb0c0dd0c1 chore: simplify root test scripts (#886)
* chore: simplify root test scripts

* fix: avoid chained root test scripts

* fix: update test workflow commands

* fix: move app test commands into packages
2026-04-30 10:58:08 -07:00
136 changed files with 6041 additions and 3836 deletions

View File

@@ -0,0 +1,152 @@
---
name: ask-internal
description: Answer questions about BrowserOS internal stuff (setup, features, architecture, design decisions) by reading the private internal-docs submodule and the codebase. Use for "how do I X", "where is Y", "what is the deal with Z", or any question that mixes ops/setup knowledge with code knowledge. Can execute steps with per-command confirmation.
allowed-tools: Bash, Read, Grep, Glob, Edit, Write
---
# Ask Internal
Answer team-internal questions by reading `.internal-docs/` and the codebase, synthesizing a direct answer with file:line citations, and optionally running surfaced commands with confirmation.
**Announce at start:** "I'm using the ask-internal skill to answer this from internal-docs and the codebase."
## When to use
- "How do I reset my dogfood profile?"
- "What's the deal with the OpenClaw VM startup?"
- "Where do we configure release signing?"
- Any question whose answer lives in setup runbooks, feature notes, architecture docs, or the code that produced them.
## Hard rules — never do these
- NEVER execute a state-mutating command without per-command `y` confirmation from the user.
- NEVER edit BrowserOS code in response to an ask-internal question. The skill answers; it does not modify code. Use `/document-internal` for writes.
- NEVER guess. If grep finds nothing useful in docs or code, say so plainly.
- NEVER run this skill if `.internal-docs/` is missing. Stop with the init command.
- NEVER cite a file or line number you have not actually read.
## Voice rules
Apply the same voice rules as `document-internal` to the synthesized answer:
- Lead with the point.
- Concrete nouns. Name files, functions, commands.
- Short sentences. Active voice. No em dashes.
- Banned words: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, leverage, utilize.
- No filler intros.
## Workflow
### Step 0: Pre-flight
```bash
if git submodule status .internal-docs 2>/dev/null | grep -q '^-'; then
echo "internal-docs submodule not initialized. Run: git submodule update --init .internal-docs"
exit 0
fi
[ -d .internal-docs ] && [ -n "$(ls -A .internal-docs 2>/dev/null)" ] || {
echo ".internal-docs/ missing or empty. Submodule not configured?"
exit 0
}
```
### Step 1: Parse the question
Pull the keywords from the user's question. Drop stop words. Identify intent:
- **Setup-question** ("how do I", "how to", "where do I configure"): bias the search toward `setup/`.
- **Feature-question** ("what is X", "why does X work this way"): bias toward `features/` and `architecture/`.
- **Free-form** ("anything about Y"): search all categories.
### Step 2: Multi-source search
Run grep in parallel across two sources.
**Internal docs:**
```bash
grep -rni --include='*.md' '<keyword>' .internal-docs/
```
Search each keyword separately. Collect top hits by relevance (more keyword matches = higher).
**Codebase (skip vendored Chromium and `node_modules`):**
```bash
grep -rni --include='*.ts' --include='*.tsx' --include='*.js' --include='*.json' --include='*.sh' \
--exclude-dir=node_modules --exclude-dir=chromium --exclude-dir=.grove \
'<keyword>' packages/ scripts/ .config/ .github/
```
Read the top 3-5 doc hits and top 3-5 code hits. Do not skim — read the relevant section fully so citations are accurate.
### Step 3: Synthesize answer
Structure the response:
1. **Direct answer.** First sentence answers the question. No preamble.
2. **Steps if applicable.** Numbered list with exact commands.
3. **Citations.** Every factual claim references `path/to/file.md:42` or `path/to/code.ts:117`. Run the voice self-check before printing.
If multiple docs cover the topic at different layers (e.g., a setup runbook and a feature note both mention dogfood profiles), reconcile them in the answer rather than dumping both.
### Step 4: Offer execution (only if commands surfaced)
If Step 3 produced executable commands the user could run, ask:
> Run these for you? (y / n / dry-run)
- **y:** Execute one at a time. For any command that mutates state (writes a file, modifies config, kills a process, deletes anything), ask "run this? <command>" before each. Read-only commands (`ls`, `cat`, `git status`) run without per-command confirmation but still print before running.
- **n:** Skip. Done.
- **dry-run:** Print the full sequence as a `bash` block. Do not execute.
### Step 5: Doc-not-found path
If Step 2 returned nothing useful (no doc hits AND no clear code answer):
1. Tell the user: "No doc covers this. Tangentially relevant files: <list>."
2. Ask: "Draft a new doc and open a PR to internal-docs?"
3. On yes: invoke the full `/document-internal` flow (four sharp questions, draft, voice check, PR), forced to `setup/` doc type, with the code-grep findings handed in as initial context.
### Step 6: Completion status
Report one of:
- **DONE** — answer delivered, citations verified.
- **DONE_WITH_CONCERNS** — answered, but flag uncertainty (e.g., docs and code disagreed; user should reconcile).
- **BLOCKED** — submodule missing or other pre-flight failure.
- **NEEDS_CONTEXT** — question too vague to search effectively. Ask one clarifying question.
## Citation discipline
Every "X is at Y" claim in the answer must point to a file:line that the skill actually read. Do not approximate. If you didn't read it, don't cite it.
If a doc says one thing and the code says another, surface the conflict explicitly:
> The setup runbook (`setup/dogfood-profile.md:23`) says to delete `~/.cache/browseros/dogfood`, but the actual code path in `packages/cli/src/cleanup.ts:47` removes `~/.local/share/browseros/dogfood`. The doc looks stale. Recommend updating it.
## Common Mistakes
**Skimming and then citing**
- **Problem:** Citation points to a line that doesn't actually contain the claim.
- **Fix:** Read the section fully before citing. If you didn't read line 117, don't cite line 117.
**Executing without per-command confirmation for mutations**
- **Problem:** User says "y" to "run all", skill blasts through `rm -rf`-style commands.
- **Fix:** "y" means "run this sequence with per-mutation confirmations". Per-command y is required for writes.
**Searching only docs, not code**
- **Problem:** Doc says X but code does Y; answer is wrong.
- **Fix:** Always grep both sources in Step 2.
## Red Flags
**Never:**
- Cite a file:line you haven't read.
- Run mutations without per-command confirmation.
- Modify BrowserOS code from this skill (use `/document-internal` for writes).
**Always:**
- Pre-flight check before any search.
- Reconcile doc vs code conflicts in the answer, don't hide them.
- Plain "no doc covers this" when grep is empty — never invent.

View File

@@ -0,0 +1,208 @@
---
name: document-internal
description: Draft a 1-page internal doc (feature, architecture, or design) for the private browseros-ai/internal-docs repo. Use when wrapping up a feature on a branch, after the PR is open or about to be opened. Skill drafts from the diff, asks four sharp questions, enforces voice rules, and opens a PR to internal-docs.
allowed-tools: Bash, Read, Write, Edit, Grep, Glob
---
# Document Internal
Draft a 1-page internal doc (feature note, architecture note, or design spec) from the current branch's diff and open a PR to `browseros-ai/internal-docs`.
**Announce at start:** "I'm using the document-internal skill to draft a doc for internal-docs."
## When to use
After finishing implementation on a feature branch, when the work is doc-worthy (a major feature, a new subsystem, a setup runbook for something internal, or a design decision that future engineers need to know).
## Hard rules — never do these
- NEVER `git add -A` or `git add .` inside the tmp clone of internal-docs. Always specific paths.
- NEVER write outside the tmp clone (no spillover into the OSS repo's working tree).
- NEVER fabricate filler content for empty template sections. Empty stays empty.
- NEVER touch the OSS repo's `.gitmodules` or submodule pointer — the sync workflow handles that.
- NEVER run this skill if `.internal-docs/` is missing. Stop with the init command.
- NEVER push to `internal-docs/main` directly. Always a feature branch + PR.
## Voice rules — enforced by Step 4
The skill MUST follow these and refuse to draft otherwise. After generation, scan for violations and regenerate offending sentences (max 3 attempts).
- Lead with the point. First sentence answers "what is this?"
- Concrete nouns. Name files, functions, commands. Not "the system" or "the component".
- Short sentences. Average <20 words. No deeply nested clauses.
- Active voice. "X does Y" not "Y is done by X".
- No em dashes. Use commas, periods, or rephrase.
- Banned words: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, leverage, utilize.
- "110 IQ" target. Write for a smart engineer who has not seen this code yet.
- No filler intros ("This document describes..."). Start with the substance.
- Empty sections stay empty. Do not write "N/A" or fabricate content.
## Workflow
### Step 0: Pre-flight
Bail with a clear message on any failure.
```bash
# Submodule must be initialized
if git submodule status .internal-docs 2>/dev/null | grep -q '^-'; then
echo "internal-docs submodule not initialized. Run: git submodule update --init .internal-docs"
exit 0
fi
[ -d .internal-docs ] || { echo ".internal-docs/ missing. Submodule not configured?"; exit 0; }
# Must be on a feature branch
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "dev" ]; then
echo "On $BRANCH. Run from a feature branch."
exit 0
fi
# Determine base branch (default: dev for this repo, fall back to main).
# Suppress rev-parse's SHA output on stdout so it doesn't get captured into BASE.
BASE=$(git rev-parse --verify origin/dev >/dev/null 2>&1 && echo dev || echo main)
# Gather context
git log "$BASE..HEAD" --oneline
git diff "$BASE...HEAD" --stat
gh pr view --json body -q .body 2>/dev/null # may be empty if no PR yet
```
### Step 1: Identify the doc
Ask the user for three things in one prompt:
1. **Doc type:** `feature` (default for `feat/*` branches), `architecture`, or `design`
2. **Slug:** kebab-case, short (e.g., `cowork-mcp`, `auto-skill-suggest`)
3. **Owner:** GitHub handle (default = `git config user.name` or current `gh api user --jq .login`)
### Step 2: Decision brief — four sharp questions
Ask one question at a time. Each answer constrains the next. These force compression before drafting.
1. "In one sentence: what can someone now DO that they could not before?"
2. "What is the one design decision a future engineer needs to know?"
3. "Which 3-5 files are the heart of this change?" (suggest candidates from the diff)
4. "Any sharp edges or gotchas? (or 'none')"
Skip any question that is N/A for the doc type. Architecture notes don't need question 1; design specs don't need question 4.
### Step 3: Draft from the template
Read the matching template from `.internal-docs/_templates/`:
- `feature` `feature-note.md`
- `architecture` `architecture-note.md`
- `design` `design-spec.md`
If `.internal-docs/_templates/` does not exist (first run, before seeding), fall back to the seeds bundled with this skill at `.claude/skills/document-internal/seeds/_templates/`.
Generate the 1-pager from the template, the four answers, and the diff context.
### Step 4: Voice self-check
Scan the draft for violations:
- Em dash present (`—`).
- Any banned word from the list.
- Average sentence length > 20 words.
- Body line count > 60 (feature notes only — architecture/design have no cap).
If any violation found, regenerate the offending sentences in place. Max 3 attempts. If still failing after 3 attempts, stop and report which rules are violated.
If the body is over 60 lines for a feature note, ask: "This is N lines, target is 60. Trim, or promote to `architecture/` (no length cap)?"
### Step 5: Show + iterate
Print the full draft. Ask:
> Edit needed? Paste any changes, or say "looks good".
Apply user edits with the Edit tool. Re-run Step 4. Loop until the user approves.
### Step 6: Open PR to internal-docs
Use a tmp clone. Never the user's `.internal-docs` checkout — keeps the user's submodule clean.
```bash
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT # cleans up even if any step below fails
git clone -b main git@github.com:browseros-ai/internal-docs.git "$TMP"
cd "$TMP"
git checkout -b "docs/<slug>"
# Write the doc
mkdir -p "<type>" # features, architecture, designs, or setup
cat > "<type>/$(date -u +%Y-%m)-<slug>.md" <<'DOC'
<draft content>
DOC
# Update the root README index — insert one line under the matching section
# Use Edit tool to add: "- [<title>](<type>/YYYY-MM-<slug>.md) — <one-line description>"
git add "<type>/$(date -u +%Y-%m)-<slug>.md" README.md
git commit -m "docs(<type>): <slug>"
git push -u origin "docs/<slug>"
PR_URL=$(gh pr create -R browseros-ai/internal-docs --base main \
--head "docs/<slug>" \
--title "docs(<type>): <slug>" \
--body "$(cat <<'BODY'
## Summary
<one-line of what this doc covers>
## Source
- BrowserOS branch: <branch>
- Related PR: <#NNN if any>
BODY
)")
cd -
echo "PR opened: $PR_URL"
# trap above cleans up $TMP on EXIT
```
If the slug contains characters that won't shell-escape cleanly, sanitize before substitution.
### Step 7: Completion status
Report one of:
- **DONE** — file written, branch pushed, PR opened. Print PR URL.
- **DONE_WITH_CONCERNS** — same as DONE but list concerns (e.g., voice check needed multiple regens, user skipped a question).
- **BLOCKED** — submodule missing, auth fail, or template missing. State exactly what's needed.
## Doc type defaults
| Branch pattern | Default doc type | Default location |
|----------------|------------------|------------------|
| `feat/*` | feature | `features/` |
| `arch/*` or refactor branches with >10 files in `packages/` | architecture | `architecture/` |
| `rfc/*` or `design/*` | design | `designs/` |
| Otherwise | ask | ask |
## Common Mistakes
**Drafting before asking the four questions**
- **Problem:** Output is generic filler that says nothing concrete.
- **Fix:** Always ask Step 2 first, even if the diff "looks obvious".
**Touching `.internal-docs/` directly**
- **Problem:** User's submodule HEAD moves, parent repo shows dirty state.
- **Fix:** Always use the tmp clone in Step 6.
**Skipping voice check on user edits**
- **Problem:** User pastes prose with em dashes or filler; ships as-is.
- **Fix:** Re-run Step 4 after every user edit.
## Red Flags
**Never:**
- Push to `internal-docs/main`. Always branch + PR.
- Modify the OSS repo's `.gitmodules` or submodule pointer.
- Fabricate content for empty template sections.
**Always:**
- Pre-flight check before doing any work.
- One-pager rule for feature notes (60-line body cap).
- File:line citations when referencing code.

View File

@@ -0,0 +1,51 @@
# BrowserOS Internal Docs
Private team docs for `browseros-ai`. Mounted as a submodule into the public OSS repo at `.internal-docs/`.
If you are reading this from a public clone of BrowserOS without team access — this submodule is for the BrowserOS internal team. Nothing here is required to build or use BrowserOS.
## How to find what you need
- Setup task ("how do I X locally") → look in [`setup/`](setup/)
- Recently shipped feature → look in [`features/`](features/)
- Cross-cutting subsystem → look in [`architecture/`](architecture/)
- A design decision or RFC → look in [`designs/`](designs/)
Or run `/ask-internal "<your question>"` from any BrowserOS checkout. The skill greps these docs and the codebase, then synthesizes an answer with citations.
## How to add a doc
Run `/document-internal` from a feature branch. The skill drafts a 1-pager from your branch's diff, asks four sharp questions, enforces voice rules, and opens a PR back to this repo.
## Index
### Setup
<!-- one line per setup runbook: -->
<!-- - [Dev environment](setup/dev-environment.md): first-time machine setup -->
### Features
<!-- one line per shipped feature, newest first: -->
<!-- - [Cowork MCP](features/2026-04-cowork-mcp.md): bring outside MCPs into the BrowserOS agent -->
### Architecture
<!-- one line per cross-cutting subsystem: -->
<!-- - [Chrome fork overview](architecture/chrome-fork-overview.md): what we patched and why -->
### Designs
<!-- one line per design spec, newest first: -->
<!-- - [Internal docs submodule](designs/2026-04-30-internal-docs-submodule.md): this system -->
## Templates
When `/document-internal` runs, it reads from [`_templates/`](_templates/). Edit the templates here when the team's preferred shape changes.
## Voice
Docs in this repo follow these rules. The `/document-internal` skill enforces them; humans editing by hand should match.
- Lead with the point.
- Concrete nouns. Name files, functions, commands.
- Short sentences, active voice, no em dashes.
- No filler words: delve, crucial, robust, comprehensive, nuanced, multifaceted, leverage, utilize, etc.
- Empty sections stay empty. Do not write "N/A" or fake content.
- Feature notes target one screen, body 60 lines max.

View File

@@ -0,0 +1,31 @@
---
title: <subsystem name>
owner: <github handle>
status: current | deprecated
date: YYYY-MM-DD
related-features: [feature-slug-1, feature-slug-2]
---
# <subsystem name>
## What this subsystem does
<1-2 paragraphs. The top-level responsibility. Boundaries.>
## Architecture
<Diagram (ASCII or mermaid) plus prose. Components and how they talk.>
## Constraints
<Hard rules the design enforces. "X must never call Y" type statements.>
## Decisions made
<Numbered list of non-obvious decisions and the reason for each.>
## Key files
- `path/to/file.ts` — role
- `path/to/dir/` — what lives here
## How to evolve this
<Where to add things. Which tests to expect to update. What NOT to touch.>
## Open questions
<What is still being figured out. Empty if none.>

View File

@@ -0,0 +1,34 @@
---
title: <design name>
owner: <github handle>
status: proposed | accepted | rejected | superseded
date: YYYY-MM-DD
supersedes: <design-slug or none>
---
# <design name>
## Goal
<2-4 sentences. What this design is trying to accomplish.>
## Context
<1-2 paragraphs. The current state, what is failing, why this needs to change.>
## Selected Approach
<The chosen design at a high level. Architecture, components, data flow.>
## Alternatives Considered
### 1. <name>
<2-3 sentences on what this would look like, then pro/con and why rejected (or deferred).>
### 2. <name>
<Same shape.>
## Out of Scope
<What this design does NOT cover. Defer references.>
## Rollout
<Numbered steps from "nothing exists" to "fully shipped".>
## Open Questions
<Resolved during design? Empty. Unresolved? List with owner.>

View File

@@ -0,0 +1,29 @@
---
title: <feature name>
owner: <github handle>
status: shipped | wip | deprecated
date: YYYY-MM-DD
prs: ["#NNN"]
tags: [agent, browser, mcp]
---
# <feature name>
## What it does
<2-3 sentences. What can someone now do that they could not before. Lead with user-facing impact, not implementation.>
## Why we built it
<1-2 sentences. Motivation. What pain it removed or what unlocked.>
## How it works
<3-6 sentences. The flow at a high level. Name the key files.>
## Key files
- `path/to/file.ts` — what it does
- `path/to/other.ts` — what it does
## How to run / test it locally
<bullet list of commands. Empty section if N/A do not fake.>
## Gotchas
<known sharp edges. "If you see X, that's why." Empty if N/A.>

View File

@@ -1,176 +0,0 @@
name: Publish VM Agent Cache
on:
workflow_dispatch:
inputs:
agent:
description: "Agent name from bundle.json"
required: true
type: string
default: openclaw
publish:
description: "Upload to R2 and merge manifest slice"
required: false
default: false
type: boolean
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- ".github/workflows/publish-vm-agent-cache.yml"
env:
BUN_VERSION: "1.3.6"
PKG_DIR: packages/browseros-agent/packages/build-tools
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- working-directory: packages/browseros-agent
run: bun run --filter @browseros/build-tools typecheck
- working-directory: packages/browseros-agent
run: bun run --filter @browseros/build-tools test
build:
needs: check
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: ubuntu-24.04-arm
- arch: x64
runner: ubuntu-24.04
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Build tarball
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
OUT: ${{ github.workspace }}/dist/images
run: bun run build:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --output-dir "$OUT"
- uses: actions/upload-artifact@v7
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
path: dist/images/
retention-days: 7
smoke:
needs: build
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: ubuntu-24.04-arm
- arch: x64
runner: ubuntu-24.04
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v8
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
path: dist/images
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Smoke test tarball
timeout-minutes: 10
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
run: |
set -euo pipefail
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-${{ matrix.arch }}.tar.gz" -print -quit)"
if [ -z "$tarball" ]; then
echo "missing ${{ matrix.arch }} tarball artifact for ${AGENT}" >&2
exit 1
fi
checksum="${tarball}.sha256"
if [ ! -f "$checksum" ]; then
echo "missing checksum sidecar: $checksum" >&2
exit 1
fi
echo "smoke-testing $tarball"
ls -lh "$tarball" "$checksum"
(cd "$(dirname "$tarball")" && sha256sum -c "$(basename "$checksum")")
timeout --verbose --kill-after=30s 8m bun run smoke:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --tarball "$tarball"
publish:
needs: [build, smoke]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
runs-on: ubuntu-24.04
environment: release
concurrency:
group: r2-manifest-publish
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v8
with:
pattern: tarball-*
path: dist/images
merge-multiple: true
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Upload tarballs to R2
working-directory: ${{ env.PKG_DIR }}
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
base="$(basename "$file")"
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
done
- name: Merge agent slice into manifest
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
mkdir -p dist/images
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
bun run emit-manifest -- \
--slice "agents:${AGENT}" \
--dist-dir dist \
--merge-from dist/baseline-manifest.json \
--out dist/manifest.json
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"

129
.github/workflows/sync-internal-docs.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: Sync internal-docs submodule
on:
schedule:
- cron: '0 */4 * * *'
workflow_dispatch:
concurrency:
group: sync-internal-docs
cancel-in-progress: false
jobs:
sync:
name: Bump internal-docs submodule pointer on dev
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Rewrite SSH submodule URL to HTTPS-with-token
env:
TOKEN: ${{ secrets.INTERNAL_DOCS_SYNC_TOKEN }}
run: |
git config --global "url.https://x-access-token:${TOKEN}@github.com/.insteadOf" "git@github.com:"
- uses: actions/checkout@v4
with:
token: ${{ secrets.INTERNAL_DOCS_SYNC_TOKEN }}
submodules: true
ref: dev
fetch-depth: 50
- name: Open auto-merge PR if internal-docs has new commits
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
# Merges immediately when GitHub reports the PR as clean; otherwise enables auto-merge for pending branch requirements.
merge_or_enable_auto_merge() {
local pr_url="$1"
local head_sha="$2"
local merge_state=""
for attempt in 1 2 3 4 5; do
merge_state=$(gh pr view "$pr_url" --json mergeStateStatus --jq '.mergeStateStatus')
if [ "$merge_state" != "UNKNOWN" ]; then
break
fi
if [ "$attempt" -lt 5 ]; then
echo "PR merge state is UNKNOWN; waiting for GitHub to compute it (attempt ${attempt}/5)."
sleep 3
else
echo "PR merge state is still UNKNOWN after 5 attempts; falling back to auto-merge."
fi
done
echo "PR merge state: ${merge_state}"
if [ "$merge_state" = "CLEAN" ]; then
gh pr merge "$pr_url" --squash --delete-branch --match-head-commit "$head_sha"
else
gh pr merge "$pr_url" --auto --squash --delete-branch --match-head-commit "$head_sha"
fi
}
# Skip if submodule not yet configured (handoff window before someone adds it)
if ! git config --file .gitmodules --get-regexp '^submodule\..internal-docs\.path$' >/dev/null 2>&1; then
echo "internal-docs submodule not yet configured in .gitmodules. Skipping."
exit 0
fi
git submodule update --remote --merge .internal-docs
if git diff --quiet .internal-docs; then
echo "No internal-docs changes to sync."
exit 0
fi
# Reuse an open sync PR so a failed merge attempt does not strand duplicate bot branches.
EXISTING_SYNC_PR=$(gh pr list \
--base dev \
--state open \
--limit 100 \
--json headRefName,url \
--jq 'map(select(.headRefName | startswith("bot/sync-internal-docs"))) | first // empty | [.url, .headRefName] | @tsv')
if [ -n "$EXISTING_SYNC_PR" ]; then
PR_URL=$(printf '%s\n' "$EXISTING_SYNC_PR" | cut -f1)
BRANCH=$(printf '%s\n' "$EXISTING_SYNC_PR" | cut -f2)
echo "Updating existing sync PR: ${PR_URL}"
else
PR_URL=""
BRANCH="bot/sync-internal-docs"
fi
git config user.name "browseros-bot"
git config user.email "bot@browseros.ai"
git checkout -B "$BRANCH"
git add .internal-docs
git commit -m "chore: sync internal-docs submodule"
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
# Auto-merge is tied to the current PR head, so clear it before force-pushing a reused sync branch.
if [ -n "$PR_URL" ]; then
AUTO_MERGE=$(gh pr view "$PR_URL" --json autoMergeRequest --jq '.autoMergeRequest // empty')
if [ -n "$AUTO_MERGE" ]; then
echo "Disabling existing auto-merge before updating reused sync PR."
gh pr merge "$PR_URL" --disable-auto
fi
fi
git fetch --depth=1 origin "+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}"
REMOTE_SHA=$(git rev-parse "refs/remotes/origin/${BRANCH}")
git push -u origin "$BRANCH" --force-with-lease="refs/heads/${BRANCH}:${REMOTE_SHA}"
else
git push -u origin "$BRANCH"
fi
if [ -z "$PR_URL" ]; then
PR_URL=$(gh pr create \
--base dev \
--head "$BRANCH" \
--title "chore: sync internal-docs submodule" \
--body "Automated bump of the \`.internal-docs\` submodule pointer. Auto-merging.")
fi
merge_or_enable_auto_merge "$PR_URL" "$(git rev-parse HEAD)"

View File

@@ -63,15 +63,15 @@ jobs:
junit_path: test-results/server-root.xml
needs_browser: false
- suite: agent
command: bun run test:agent
command: (cd apps/agent && bun run test)
junit_path: test-results/agent.xml
needs_browser: false
- suite: eval
command: bun run test:eval
command: (cd apps/eval && bun run test)
junit_path: test-results/eval.xml
needs_browser: false
- suite: build
command: bun run test:build
command: bun run ./scripts/run-bun-test.ts ./scripts/build
junit_path: test-results/build.xml
needs_browser: false

4
.gitmodules vendored
View File

@@ -0,0 +1,4 @@
[submodule ".internal-docs"]
path = .internal-docs
url = git@github.com:browseros-ai/internal-docs.git
branch = main

1
.internal-docs Submodule

Submodule .internal-docs added at 01085a4ef5

View File

@@ -79,14 +79,15 @@ cp apps/server/.env.example apps/server/.env.development
cp apps/agent/.env.example apps/agent/.env.development
cp apps/server/.env.production.example apps/server/.env.production
# Install deps, generate agent code, and sync the VM cache
# Install deps and generate agent code
bun run dev:setup
# Start the full dev environment
bun run dev:watch
```
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
`dev:watch` starts the server immediately. OpenClaw VM/image prewarm runs from
the server startup path and pulls the configured GHCR image on demand.
### Environment Variables
@@ -156,9 +157,14 @@ bun run build:server # Build production server resource artifacts and u
bun run build:agent # Build agent extension
# Test
bun run test # Run standard tests
bun run test:cdp # Run CDP-based tests
bun run test:integration # Run integration tests
bun run test # Run all tests
bun run test:all # Run all tests
bun run test:main # Run key server tools and integration tests
# App-specific test groups (from packages/browseros-agent)
cd apps/server && bun run test:tools
cd apps/server && bun run test:cdp
cd apps/server && bun run test:integration
# Quality
bun run lint # Check with Biome

View File

@@ -9,6 +9,7 @@
"build": "bun run codegen && wxt build",
"build:dev": "bun --env-file=.env.development wxt build --mode development",
"zip": "wxt zip",
"test": "bun run ../../scripts/run-bun-test.ts ./apps/agent",
"compile": "bun --env-file=.env.development wxt prepare && tsgo --noEmit",
"lint": "bunx biome check",
"typecheck": "bun --env-file=.env.development wxt prepare && tsgo --noEmit",

View File

@@ -38,8 +38,8 @@ browseros-cli install # downloads BrowserOS for your platform
# If BrowserOS is installed but not running
browseros-cli launch # opens BrowserOS, waits for server
# Configure the CLI (auto-discovers running BrowserOS)
browseros-cli init --auto # detects server URL and saves config
# Configure the CLI with the Server URL from BrowserOS settings
browseros-cli init http://127.0.0.1:9000/mcp
# Verify connection
browseros-cli health
@@ -52,7 +52,7 @@ browseros-cli init <url> # non-interactive — pass URL directly
browseros-cli init # interactive — prompts for URL
```
Config is saved to `~/.config/browseros-cli/config.yaml`. The CLI also auto-discovers the server from `~/.browseros/server.json` (written by BrowserOS on startup).
Config is saved to `~/.config/browseros-cli/config.yaml`. If `browseros-cli health` cannot connect, copy the current Server URL from BrowserOS Settings > BrowserOS MCP and run `browseros-cli init <Server URL>` again.
### CLI updates
@@ -126,9 +126,9 @@ To connect Claude Code, Gemini CLI, or any MCP client, see the [MCP setup guide]
| `--debug` | `BOS_DEBUG=1` | Debug output |
| `--timeout, -t` | | Request timeout (default: 2m) |
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > `~/.browseros/server.json` > config file
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > config file
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init`.
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init <Server URL>`.
## Testing
@@ -179,7 +179,7 @@ apps/cli/
│ └── config.go # Config file (~/.config/browseros-cli/config.yaml)
├── cmd/
│ ├── root.go # Root command, global flags
│ ├── init.go # Server URL configuration (URL arg, --auto, interactive)
│ ├── init.go # Server URL configuration (URL arg or interactive)
│ ├── install.go # install (download BrowserOS for current platform)
│ ├── launch.go # launch (find and start BrowserOS, wait for server)
│ ├── open.go # open (new_page / new_hidden_page)

View File

@@ -17,8 +17,6 @@ import (
)
func init() {
var autoDiscover bool
cmd := &cobra.Command{
Use: "init [url]",
Short: "Configure the BrowserOS server connection",
@@ -34,9 +32,8 @@ You can provide the full URL or just the port number:
browseros-cli init http://127.0.0.1:9000/mcp
browseros-cli init 9000
Three modes:
Modes:
browseros-cli init <url> Non-interactive (full URL or port number)
browseros-cli init --auto Auto-discover from ~/.browseros/server.json
browseros-cli init Interactive prompt`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.MaximumNArgs(1),
@@ -49,22 +46,9 @@ Three modes:
switch {
case len(args) == 1:
// Non-interactive: URL provided as argument
input = args[0]
case autoDiscover:
// Auto-discover: server.json → config → probe common ports
discovered := probeRunningServer()
if discovered == "" {
output.Error("auto-discovery failed: no running BrowserOS found.\n\n"+
" If not running: browseros-cli launch\n"+
" If not installed: browseros-cli install", 1)
}
input = discovered
fmt.Printf("Auto-discovered server at %s\n", input)
default:
// Interactive prompt (original behavior)
fmt.Println()
bold.Println("BrowserOS CLI Setup")
fmt.Println()
@@ -95,12 +79,14 @@ Three modes:
output.Errorf(1, "invalid URL: %s", input)
}
// Verify connectivity
fmt.Printf("Checking connection to %s ...\n", baseURL)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(baseURL + "/health")
if err != nil {
output.Errorf(1, "cannot connect to %s: %v\nIs BrowserOS running?", baseURL, err)
output.Errorf(1, "cannot connect to %s: %v\n\n"+
"Open BrowserOS Settings > BrowserOS MCP and copy the Server URL.\n"+
"Then run: browseros-cli init <Server URL>\n"+
"Example: browseros-cli init http://127.0.0.1:9000/mcp", baseURL, err)
}
resp.Body.Close()
@@ -121,6 +107,5 @@ Three modes:
},
}
cmd.Flags().BoolVar(&autoDiscover, "auto", false, "Auto-discover server URL from ~/.browseros/server.json")
rootCmd.AddCommand(cmd)
}

View File

@@ -28,7 +28,7 @@ Linux: Downloads AppImage (or .deb with --deb flag)
After installation:
browseros-cli launch # start BrowserOS
browseros-cli init --auto # configure the CLI`,
browseros-cli init <url> # configure the CLI with the Server URL`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
@@ -81,7 +81,7 @@ After installation:
fmt.Println()
bold.Println("Next steps:")
dim.Println(" browseros-cli launch # start BrowserOS")
dim.Println(" browseros-cli init --auto # configure the CLI")
dim.Println(" browseros-cli init <url> # use the Server URL from BrowserOS settings")
},
}

View File

@@ -1,6 +1,7 @@
package cmd
import (
"encoding/json"
"fmt"
"net/http"
"os"
@@ -38,6 +39,7 @@ If BrowserOS is already running, reports the server URL.`,
if url := probeRunningServer(); url != "" {
green.Printf("BrowserOS is already running at %s\n", url)
dim.Printf("Next: browseros-cli init %s\n", mcpEndpointURL(url))
return
}
@@ -63,7 +65,7 @@ If BrowserOS is already running, reports the server URL.`,
green.Printf("BrowserOS is ready at %s\n", url)
fmt.Println()
dim.Println("Next: browseros-cli init --auto")
dim.Printf("Next: browseros-cli init %s\n", mcpEndpointURL(url))
},
}
@@ -75,39 +77,77 @@ If BrowserOS is already running, reports the server URL.`,
// Server probing
// ---------------------------------------------------------------------------
// probeRunningServer checks server.json, config, and common ports for a running server.
var commonBrowserOSPorts = []int{9100, 9200, 9300}
// probeRunningServer checks launch discovery, explicit config, and common ports for a running server.
func probeRunningServer() string {
check := func(baseURL string) bool {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(baseURL + "/health")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
client := &http.Client{Timeout: 2 * time.Second}
// 1. server.json — written by BrowserOS on startup with the actual port
if url := loadBrowserosServerURL(); url != "" && check(url) {
if url := loadBrowserosServerURL(); url != "" && checkServerHealth(client, url) {
return url
}
// 2. Saved config / env var
if url := defaultServerURL(); url != "" && check(url) {
if url := defaultServerURL(); url != "" && checkServerHealth(client, url) {
return url
}
// 3. Probe common BrowserOS ports as last resort
for _, port := range []int{9100, 9200, 9300} {
return probeCommonServerPorts(client)
}
func checkServerHealth(client *http.Client, baseURL string) bool {
resp, err := client.Get(baseURL + "/health")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
func probeCommonServerPorts(client *http.Client) string {
for _, port := range commonBrowserOSPorts {
url := fmt.Sprintf("http://127.0.0.1:%d", port)
if check(url) {
if checkServerHealth(client, url) {
return url
}
}
return ""
}
type serverDiscoveryConfig struct {
ServerPort int `json:"server_port"`
URL string `json:"url"`
ServerVersion string `json:"server_version"`
BrowserOSVersion string `json:"browseros_version,omitempty"`
ChromiumVersion string `json:"chromium_version,omitempty"`
}
// loadBrowserosServerURL reads BrowserOS's runtime discovery file for launch readiness only.
//
// Normal command resolution must not call this because it can override a URL the
// user explicitly saved with `browseros-cli init <Server URL>`.
func loadBrowserosServerURL() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc serverDiscoveryConfig
if err := json.Unmarshal(data, &sc); err != nil {
return ""
}
return normalizeServerURL(sc.URL)
}
func mcpEndpointURL(baseURL string) string {
return strings.TrimSuffix(baseURL, "/") + "/mcp"
}
// ---------------------------------------------------------------------------
// Platform-native installation detection
// ---------------------------------------------------------------------------
@@ -117,7 +157,8 @@ func probeRunningServer() string {
// macOS: `open -Ra "BrowserOS"` — queries Launch Services (finds apps anywhere)
// Linux: checks /usr/bin/browseros (.deb), browseros.desktop, or AppImage files
// Windows: checks executable at %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe
// and registry uninstall key (per-user Chromium install pattern)
//
// and registry uninstall key (per-user Chromium install pattern)
func isBrowserOSInstalled() bool {
switch runtime.GOOS {
case "darwin":
@@ -271,14 +312,11 @@ func waitForServer(maxWait time.Duration) (string, bool) {
for time.Now().Before(deadline) {
// server.json is written by BrowserOS on startup with the actual port
if url := loadBrowserosServerURL(); url != "" {
resp, err := client.Get(url + "/health")
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return url, true
}
}
if url := loadBrowserosServerURL(); url != "" && checkServerHealth(client, url) {
return url, true
}
if url := probeCommonServerPorts(client); url != "" {
return url, true
}
fmt.Print(".")
time.Sleep(1 * time.Second)

View File

@@ -0,0 +1,99 @@
package cmd
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"browseros-cli/config"
)
func TestProbeRunningServerUsesDiscoveryBeforeConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BROWSEROS_URL", "")
discoveredServer := newHealthyServer(t)
configServer := newHealthyServer(t)
serverDir := filepath.Join(home, ".browseros")
if err := os.MkdirAll(serverDir, 0755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
data := []byte(fmt.Sprintf(`{"url":%q}`, discoveredServer.URL))
if err := os.WriteFile(filepath.Join(serverDir, "server.json"), data, 0644); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
if err := config.Save(&config.Config{ServerURL: configServer.URL}); err != nil {
t.Fatalf("config.Save() error = %v", err)
}
got := probeRunningServer()
if got != normalizeServerURL(discoveredServer.URL) {
t.Fatalf("probeRunningServer() = %q, want %q", got, normalizeServerURL(discoveredServer.URL))
}
}
func TestWaitForServerUsesCommonPortFallback(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
server := newHealthyServer(t)
port := serverPort(t, server.URL)
originalPorts := commonBrowserOSPorts
commonBrowserOSPorts = []int{port}
t.Cleanup(func() {
commonBrowserOSPorts = originalPorts
})
got, ok := waitForServer(100 * time.Millisecond)
if !ok {
t.Fatal("waitForServer() ok = false, want true")
}
if got != normalizeServerURL(server.URL) {
t.Fatalf("waitForServer() = %q, want %q", got, normalizeServerURL(server.URL))
}
}
func newHealthyServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/health" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
return server
}
func serverPort(t *testing.T, rawURL string) int {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse() error = %v", err)
}
_, portText, err := net.SplitHostPort(parsed.Host)
if err != nil {
t.Fatalf("net.SplitHostPort() error = %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("strconv.Atoi() error = %v", err)
}
return port
}

View File

@@ -2,10 +2,8 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -289,18 +287,15 @@ func drainAutomaticUpdateCheckWithTimeout(done <-chan struct{}, timeout time.Dur
}
}
// defaultServerURL returns the implicit target from user-controlled settings only.
//
// BrowserOS writes a discovery file at runtime, but normal commands intentionally
// ignore it so a saved URL is not silently overridden by another running server.
func defaultServerURL() string {
// 1. Explicit env var always wins
if env := normalizeServerURL(os.Getenv("BROWSEROS_URL")); env != "" {
return env
}
// 2. Live discovery file from running BrowserOS (most current)
if url := loadBrowserosServerURL(); url != "" {
return url
}
// 3. Saved config (may be stale if port changed)
cfg, err := config.Load()
if err == nil {
if url := normalizeServerURL(cfg.ServerURL); url != "" {
@@ -311,33 +306,6 @@ func defaultServerURL() string {
return ""
}
type serverDiscoveryConfig struct {
ServerPort int `json:"server_port"`
URL string `json:"url"`
ServerVersion string `json:"server_version"`
BrowserOSVersion string `json:"browseros_version,omitempty"`
ChromiumVersion string `json:"chromium_version,omitempty"`
}
func loadBrowserosServerURL() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc serverDiscoveryConfig
if err := json.Unmarshal(data, &sc); err != nil {
return ""
}
return normalizeServerURL(sc.URL)
}
func normalizeServerURL(raw string) string {
normalized := strings.TrimSpace(raw)
@@ -369,8 +337,10 @@ func validateServerURL(raw string) (string, error) {
return "", fmt.Errorf(
"BrowserOS server URL is not configured.\n\n" +
" If BrowserOS is running: browseros-cli init --auto\n" +
" If BrowserOS is closed: browseros-cli launch\n" +
" If not installed: browseros-cli install",
" Open BrowserOS Settings > BrowserOS MCP and copy the Server URL.\n" +
" Save it with: browseros-cli init <Server URL>\n" +
" Example: browseros-cli init http://127.0.0.1:9000/mcp\n" +
" If BrowserOS is closed: browseros-cli launch\n" +
" If not installed: browseros-cli install",
)
}

View File

@@ -1,8 +1,13 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"browseros-cli/config"
)
func TestSetVersionUpdatesRootCommand(t *testing.T) {
@@ -100,6 +105,76 @@ func TestShouldSkipAutomaticUpdates(t *testing.T) {
}
}
func TestDefaultServerURLUsesEnvBeforeConfig(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BROWSEROS_URL", "http://127.0.0.1:9115/mcp")
if err := config.Save(&config.Config{ServerURL: "http://127.0.0.1:9000/mcp"}); err != nil {
t.Fatalf("config.Save() error = %v", err)
}
got := defaultServerURL()
if got != "http://127.0.0.1:9115" {
t.Fatalf("defaultServerURL() = %q, want %q", got, "http://127.0.0.1:9115")
}
}
func TestDefaultServerURLUsesSavedConfig(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BROWSEROS_URL", "")
if err := config.Save(&config.Config{ServerURL: "http://127.0.0.1:9115/mcp"}); err != nil {
t.Fatalf("config.Save() error = %v", err)
}
got := defaultServerURL()
if got != "http://127.0.0.1:9115" {
t.Fatalf("defaultServerURL() = %q, want %q", got, "http://127.0.0.1:9115")
}
}
func TestDefaultServerURLIgnoresBrowserOSServerJSON(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("BROWSEROS_URL", "")
serverDir := filepath.Join(home, ".browseros")
if err := os.MkdirAll(serverDir, 0755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
data := []byte(`{"url":"http://127.0.0.1:9999"}`)
if err := os.WriteFile(filepath.Join(serverDir, "server.json"), data, 0644); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
if got := defaultServerURL(); got != "" {
t.Fatalf("defaultServerURL() = %q, want empty", got)
}
}
func TestNormalizeServerURLAcceptsMCPEndpoint(t *testing.T) {
got := normalizeServerURL(" http://127.0.0.1:9115/mcp ")
if got != "http://127.0.0.1:9115" {
t.Fatalf("normalizeServerURL() = %q, want %q", got, "http://127.0.0.1:9115")
}
}
func TestValidateServerURLExplainsManualInit(t *testing.T) {
_, err := validateServerURL("")
if err == nil {
t.Fatal("validateServerURL() error = nil, want setup instructions")
}
msg := err.Error()
if !strings.Contains(msg, "browseros-cli init <Server URL>") {
t.Fatalf("validateServerURL() error = %q, want manual init instructions", msg)
}
if strings.Contains(msg, "init --auto") {
t.Fatalf("validateServerURL() error = %q, should not mention init --auto", msg)
}
}
func TestDrainAutomaticUpdateCheckWithTimeoutWaitsForCompletion(t *testing.T) {
done := make(chan struct{})
returned := make(chan struct{})

View File

@@ -44,10 +44,7 @@ func (c *Client) connect(ctx context.Context) (*sdkmcp.ClientSession, error) {
session, err := sdkClient.Connect(ctx, transport, nil)
if err != nil {
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+
" If BrowserOS is running on a different port: browseros-cli init --auto\n"+
" If BrowserOS is not running: browseros-cli launch\n"+
" If not installed: browseros-cli install", c.BaseURL, err)
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w%s", c.BaseURL, err, connectionSetupInstructions())
}
return session, nil
}
@@ -187,10 +184,7 @@ func (c *Client) Status() (map[string]any, error) {
func (c *Client) restGET(path string) (map[string]any, error) {
resp, err := c.HTTPClient.Get(c.BaseURL + path)
if err != nil {
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+
" If BrowserOS is running on a different port: browseros-cli init --auto\n"+
" If BrowserOS is not running: browseros-cli launch\n"+
" If not installed: browseros-cli install", c.BaseURL, err)
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w%s", c.BaseURL, err, connectionSetupInstructions())
}
defer resp.Body.Close()
@@ -205,3 +199,14 @@ func (c *Client) restGET(path string) (map[string]any, error) {
}
return data, nil
}
// connectionSetupInstructions explains how to recover from a stale or missing server URL.
func connectionSetupInstructions() string {
return "\n\n" +
" Open BrowserOS Settings > BrowserOS MCP and copy the Server URL.\n" +
" Save it with: browseros-cli init <Server URL>\n" +
" Example: browseros-cli init http://127.0.0.1:9000/mcp\n" +
" Run once with: browseros-cli --server <Server URL> health\n" +
" If BrowserOS is closed: browseros-cli launch\n" +
" If not installed: browseros-cli install"
}

View File

@@ -31,8 +31,8 @@ browseros-cli install
# Start BrowserOS
browseros-cli launch
# Auto-configure MCP settings for your AI tools
browseros-cli init --auto
# Configure MCP settings with the Server URL from BrowserOS settings
browseros-cli init http://127.0.0.1:9000/mcp
# Verify everything is working
browseros-cli health

View File

@@ -9,6 +9,7 @@ Evaluation framework for BrowserOS browser automation agents. Runs tasks from st
- **BrowserOS binary** at `/Applications/BrowserOS.app` (macOS) or `BROWSEROS_BINARY` pointing at it
- **Bun** runtime
- **API keys** for your LLM provider (and `CLAUDE_CODE_OAUTH_TOKEN` if you use `performance_grader`)
- **Python 3.10+ with `agisdk`** for AGI SDK / REAL Bench grading. Set `BROWSEROS_EVAL_PYTHON` if your default `python3` is older.
## Quick Start
@@ -67,7 +68,7 @@ This lets us run the same suite against multiple model setups without copying th
```txt
agisdk-daily-10 + kimi-fireworks
agisdk-daily-10 + claude-sonnet
agisdk-daily-10 + claude-opus
agisdk-daily-10 + clado-action-000159
```
@@ -79,6 +80,7 @@ For `orchestrator-executor` suites, there can also be an executor model/backend.
|------|-------------|
| `single` | Single LLM agent driven by the BrowserOS tool loop (CDP) |
| `orchestrator-executor` | High-level orchestrator + per-step executor (LLM or Clado visual model) |
| `claude-code` | External Claude Code CLI driven through BrowserOS MCP |
### Single agent
@@ -119,6 +121,24 @@ The orchestrator works with any LLM provider. The executor can be another LLM, o
}
```
### Claude Code
Claude Code runs as an external `claude -p` subprocess. The eval runner passes a task-scoped MCP config that points Claude Code at the active worker's BrowserOS MCP endpoint, while the eval capture layer still saves messages, screenshots, trajectory metadata, and grader outputs.
```json
{
"agent": {
"type": "claude-code",
"model": "opus"
}
}
```
```bash
BROWSEROS_EVAL_PYTHON=/path/to/python3 bun run eval run --config configs/legacy/claude-code-agisdk-real.json
bun run eval suite --config configs/legacy/claude-code-agisdk-real.json --publish r2
```
## Graders
| Name | Description |
@@ -151,6 +171,7 @@ The `apiKey` field supports two formats:
| `CLADO_ACTION_MODEL`, `CLADO_ACTION_API_KEY`, `CLADO_ACTION_BASE_URL` | Clado executor defaults |
| `BROWSEROS_BINARY` | BrowserOS binary path in CI/local smoke runs |
| `BROWSEROS_SERVER_URL` | Optional grader MCP URL override |
| `BROWSEROS_EVAL_PYTHON` | Optional Python interpreter for JSON graders such as `agisdk_state_diff` |
| `WEBARENA_INFINITY_DIR` | Local WebArena-Infinity checkout for Infinity tasks |
| `NOPECHA_API_KEY` | CAPTCHA solver extension |
| `EVAL_R2_ACCOUNT_ID`, `EVAL_R2_ACCESS_KEY_ID`, `EVAL_R2_SECRET_ACCESS_KEY`, `EVAL_R2_BUCKET`, `EVAL_R2_CDN_BASE_URL` | R2 upload and viewer URL |
@@ -194,7 +215,7 @@ Published runs are available at `EVAL_R2_CDN_BASE_URL/viewer.html?run=<run-id>`.
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": true
"headless": false
}
```

View File

@@ -7,7 +7,7 @@
"baseUrl": "https://openrouter.ai/api/v1",
"supportsImages": true
},
"dataset": "../../data/webbench-2of4-50.jsonl",
"dataset": "../../data/agisdk-real.jsonl",
"num_workers": 10,
"restart_server_per_task": true,
"browseros": {
@@ -21,6 +21,6 @@
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["performance_grader"],
"graders": ["agisdk_state_diff"],
"timeout_ms": 1800000
}

View File

@@ -23,7 +23,7 @@
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": true
"headless": false
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"

View File

@@ -0,0 +1,22 @@
{
"agent": {
"type": "claude-code",
"model": "opus"
},
"dataset": "../../data/agisdk-real.jsonl",
"num_workers": 1,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["agisdk_state_diff"],
"timeout_ms": 1800000
}

View File

@@ -14,7 +14,7 @@
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": true
"headless": false
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"eval": "bun --env-file=.env.development run src/index.ts",
"test": "bun run ../../scripts/run-bun-test.ts ./apps/eval/tests",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -0,0 +1,238 @@
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { DEFAULT_TIMEOUT_MS } from '../../constants'
import type { ClaudeCodeAgentConfig, UIMessageStreamEvent } from '../../types'
import { withEvalTimeout } from '../../utils/with-eval-timeout'
import type { AgentContext, AgentEvaluator, AgentResult } from '../types'
import {
type ClaudeCodeProcessRunner,
createClaudeCodeProcessRunner,
} from './process-runner'
import {
ClaudeCodeStreamParser,
shouldCaptureScreenshotForTool,
} from './stream-parser'
export interface ClaudeCodeEvaluatorDeps {
processRunner?: ClaudeCodeProcessRunner
}
export class ClaudeCodeEvaluator implements AgentEvaluator {
private processRunner: ClaudeCodeProcessRunner
constructor(
private ctx: AgentContext,
deps: ClaudeCodeEvaluatorDeps = {},
) {
this.processRunner = deps.processRunner ?? createClaudeCodeProcessRunner()
}
async execute(): Promise<AgentResult> {
const { config, task, capture, taskOutputDir } = this.ctx
const startTime = Date.now()
const timeoutMs = config.timeout_ms ?? DEFAULT_TIMEOUT_MS
await capture.messageLogger.logUser(task.query)
if (config.agent.type !== 'claude-code') {
throw new Error('ClaudeCodeEvaluator only supports claude-code config')
}
const agentConfig = config.agent
const mcpConfigPath = join(taskOutputDir, 'claude-code-mcp.json')
await writeFile(
mcpConfigPath,
JSON.stringify(
buildClaudeCodeMcpConfig(config.browseros.server_url),
null,
2,
),
)
const parser = new ClaudeCodeStreamParser()
const toolNamesById = new Map<string, string>()
const prompt = buildClaudeCodePrompt(task.query)
const args = buildClaudeCodeArgs({
prompt,
mcpConfigPath,
config: agentConfig,
})
const { terminationReason } = await withEvalTimeout(
timeoutMs,
capture,
async (signal) => {
const runResult = await this.processRunner.run({
executable: agentConfig.claudePath,
args,
cwd: taskOutputDir,
signal,
onStdoutLine: async (line) => {
const events = parser.pushLine(line)
for (const event of events) {
await this.handleStreamEvent(event, toolNamesById)
}
},
})
if (runResult.exitCode !== 0) {
const message =
runResult.stderr.trim() ||
`Claude Code exited with status ${runResult.exitCode}`
capture.addError('agent_execution', message, {
exitCode: runResult.exitCode,
})
if (!parser.getLastText()) {
throw new Error(message)
}
}
for (const error of runResult.streamErrors ?? []) {
capture.addWarning(
'message_logging',
`Claude Code stream event processing failed: ${error}`,
)
}
return runResult
},
)
const endTime = Date.now()
const finalAnswer = parser.getLastText() ?? capture.getLastAssistantText()
const metadata = {
query_id: task.query_id,
dataset: task.dataset,
query: task.query,
started_at: new Date(startTime).toISOString(),
completed_at: new Date(endTime).toISOString(),
total_duration_ms: endTime - startTime,
total_steps: parser.getToolCallCount() || capture.getScreenshotCount(),
termination_reason: terminationReason,
final_answer: finalAnswer,
errors: capture.getErrors(),
warnings: capture.getWarnings(),
device_pixel_ratio: capture.screenshot.getDevicePixelRatio(),
agent_config: {
type: 'claude-code' as const,
model: agentConfig.model,
},
grader_results: {},
}
await capture.trajectorySaver.saveMetadata(metadata)
return {
metadata,
messages: capture.getMessages(),
finalAnswer,
}
}
private async handleStreamEvent(
event: UIMessageStreamEvent,
toolNamesById: Map<string, string>,
): Promise<void> {
const { capture, task } = this.ctx
let screenshot: number | undefined
if (event.type === 'tool-input-available') {
toolNamesById.set(event.toolCallId, event.toolName)
if (isPageInput(event.input)) {
capture.setActivePageId(event.input.page)
}
}
if (
event.type === 'tool-output-available' ||
event.type === 'tool-output-error'
) {
const toolName = toolNamesById.get(event.toolCallId)
if (toolName && shouldCaptureScreenshotForTool(toolName)) {
screenshot = await this.captureScreenshot()
}
}
await capture.messageLogger.logStreamEvent(event, screenshot)
capture.emitEvent(task.query_id, {
...event,
...(screenshot !== undefined && { screenshot }),
})
}
private async captureScreenshot(): Promise<number | undefined> {
const { capture, task } = this.ctx
try {
const screenshot = await capture.screenshot.capture(
capture.getActivePageId(),
)
capture.emitEvent(task.query_id, {
type: 'screenshot-captured',
screenshot,
})
return screenshot
} catch {
return undefined
}
}
}
function isPageInput(input: unknown): input is { page: number } {
return (
typeof input === 'object' &&
input !== null &&
'page' in input &&
typeof input.page === 'number'
)
}
function buildClaudeCodePrompt(taskQuery: string): string {
return [
'You are running inside BrowserOS eval.',
'Use the BrowserOS MCP tools to interact with the already-open browser and complete the user task.',
'When the task is complete, respond with the final answer only.',
'If blocked, explain the blocker clearly.',
'',
`Task: ${taskQuery}`,
].join('\n')
}
function buildClaudeCodeArgs({
prompt,
mcpConfigPath,
config,
}: {
prompt: string
mcpConfigPath: string
config: ClaudeCodeAgentConfig
}): string[] {
const args = [
'-p',
prompt,
'--mcp-config',
mcpConfigPath,
'--strict-mcp-config',
'--output-format',
'stream-json',
'--verbose',
]
if (config.model) args.push('--model', config.model)
args.push(...config.extraArgs)
return args
}
function buildClaudeCodeMcpConfig(serverUrl: string) {
const trimmed = serverUrl.replace(/\/$/, '')
const url = trimmed.endsWith('/mcp') ? trimmed : `${trimmed}/mcp`
return {
mcpServers: {
browseros: {
type: 'http',
url,
headers: { 'X-BrowserOS-Source': 'sdk-internal' },
},
},
}
}

View File

@@ -0,0 +1,114 @@
export interface ClaudeCodeRunOptions {
executable: string
args: string[]
cwd: string
signal?: AbortSignal
onStdoutLine: (line: string) => Promise<void>
}
export interface ClaudeCodeRunResult {
exitCode: number
stderr: string
streamErrors?: string[]
}
export interface ClaudeCodeProcessRunner {
run(options: ClaudeCodeRunOptions): Promise<ClaudeCodeRunResult>
}
export interface SpawnOptions {
cwd: string
signal?: AbortSignal
onStdoutLine: (line: string) => Promise<void>
}
export interface CreateClaudeCodeProcessRunnerDeps {
spawn?: (cmd: string[], options: SpawnOptions) => Promise<ClaudeCodeRunResult>
}
export function createClaudeCodeProcessRunner(
deps: CreateClaudeCodeProcessRunnerDeps = {},
): ClaudeCodeProcessRunner {
const spawn = deps.spawn ?? spawnClaudeCode
return {
run: async ({ executable, args, cwd, signal, onStdoutLine }) =>
spawn([executable, ...args], { cwd, signal, onStdoutLine }),
}
}
async function spawnClaudeCode(
cmd: string[],
options: SpawnOptions,
): Promise<ClaudeCodeRunResult> {
const proc = Bun.spawn({
cmd,
cwd: options.cwd,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
})
const abort = () => {
try {
proc.kill('SIGTERM')
} catch {
// Process may already have exited.
}
}
options.signal?.addEventListener('abort', abort, { once: true })
try {
const streamErrors: string[] = []
const stdoutPromise = readLines(
proc.stdout,
options.onStdoutLine,
streamErrors,
)
const stderrPromise = new Response(proc.stderr).text()
const exitCode = await proc.exited
await stdoutPromise
const stderr = await stderrPromise
return { exitCode, stderr, streamErrors }
} finally {
options.signal?.removeEventListener('abort', abort)
}
}
async function readLines(
stream: ReadableStream<Uint8Array>,
onLine: (line: string) => Promise<void>,
streamErrors: string[],
): Promise<void> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
await emitLine(line, onLine, streamErrors)
}
}
buffer += decoder.decode()
if (buffer.length > 0) {
await emitLine(buffer, onLine, streamErrors)
}
}
async function emitLine(
line: string,
onLine: (line: string) => Promise<void>,
streamErrors: string[],
): Promise<void> {
try {
await onLine(line)
} catch (error) {
streamErrors.push(error instanceof Error ? error.message : String(error))
}
}

View File

@@ -0,0 +1,142 @@
import { randomUUID } from 'node:crypto'
import type { UIMessageStreamEvent } from '../../types'
type JsonObject = Record<string, unknown>
export class ClaudeCodeStreamParser {
private lastText: string | null = null
private toolCallCount = 0
pushLine(line: string): UIMessageStreamEvent[] {
const trimmed = line.trim()
if (!trimmed) return []
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
return []
}
if (!isObject(parsed)) return []
if (parsed.type === 'assistant') {
return this.parseAssistantMessage(parsed)
}
if (parsed.type === 'user') {
return this.parseUserMessage(parsed)
}
if (parsed.type === 'result' && typeof parsed.result === 'string') {
this.lastText = parsed.result
}
return []
}
getLastText(): string | null {
return this.lastText
}
getToolCallCount(): number {
return this.toolCallCount
}
private parseAssistantMessage(message: JsonObject): UIMessageStreamEvent[] {
const content = contentBlocks(message)
const events: UIMessageStreamEvent[] = []
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
const id = randomUUID()
this.lastText = block.text
events.push(
{ type: 'text-start', id },
{ type: 'text-delta', id, delta: block.text },
{ type: 'text-end', id },
)
} else if (
block.type === 'tool_use' &&
typeof block.id === 'string' &&
typeof block.name === 'string'
) {
this.toolCallCount++
events.push({
type: 'tool-input-available',
toolCallId: block.id,
toolName: block.name,
input: block.input,
})
}
}
return events
}
private parseUserMessage(message: JsonObject): UIMessageStreamEvent[] {
const content = contentBlocks(message)
const events: UIMessageStreamEvent[] = []
for (const block of content) {
if (
block.type !== 'tool_result' ||
typeof block.tool_use_id !== 'string'
) {
continue
}
if (block.is_error === true) {
events.push({
type: 'tool-output-error',
toolCallId: block.tool_use_id,
errorText: stringifyToolContent(block.content),
})
} else {
events.push({
type: 'tool-output-available',
toolCallId: block.tool_use_id,
output: normalizeToolContent(block.content),
})
}
}
return events
}
}
export function shouldCaptureScreenshotForTool(toolName: string): boolean {
if (!toolName.startsWith('mcp__browseros__')) return false
return !toolName.endsWith('__take_screenshot')
}
function contentBlocks(message: JsonObject): JsonObject[] {
const inner = isObject(message.message) ? message.message : message
return Array.isArray(inner.content) ? inner.content.filter(isObject) : []
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null
}
function normalizeToolContent(content: unknown): unknown {
if (!Array.isArray(content)) return content
return content.map((item) => {
if (
isObject(item) &&
item.type === 'text' &&
typeof item.text === 'string'
) {
return item.text
}
return item
})
}
function stringifyToolContent(content: unknown): string {
const normalized = normalizeToolContent(content)
if (typeof normalized === 'string') return normalized
try {
return JSON.stringify(normalized)
} catch {
return String(normalized)
}
}

View File

@@ -1,3 +1,4 @@
import { ClaudeCodeEvaluator } from './claude-code'
import { OrchestratorExecutorEvaluator } from './orchestrator-executor'
import { SingleAgentEvaluator } from './single-agent'
import type { AgentContext, AgentEvaluator } from './types'
@@ -8,6 +9,8 @@ export function createAgent(context: AgentContext): AgentEvaluator {
return new SingleAgentEvaluator(context)
case 'orchestrator-executor':
return new OrchestratorExecutorEvaluator(context)
case 'claude-code':
return new ClaudeCodeEvaluator(context)
}
}

View File

@@ -105,7 +105,10 @@ export class TrajectorySaver {
errors: [],
warnings: [],
agent_config: {
type: agentConfig.type as 'single' | 'orchestrator-executor',
type: agentConfig.type as
| 'single'
| 'orchestrator-executor'
| 'claude-code',
model: agentConfig.model,
},
grader_results: {},

View File

@@ -82,6 +82,16 @@ function suiteToEvalConfig(
})
}
if (suite.agent.type === 'claude-code') {
return EvalConfigSchema.parse({
...base,
agent: {
type: 'claude-code',
...(variant.agent.model && { model: variant.agent.model }),
},
})
}
const executorBackend = suite.agent.executorBackend ?? 'tool-loop'
const executor =
executorBackend === 'clado'
@@ -135,7 +145,10 @@ export async function resolveSuiteCommand(
const loaded = await loadSuite(options.suitePath)
const variant = resolveVariant({
variantId: options.variantId,
provider: options.provider,
provider:
loaded.suite.agent.type === 'claude-code'
? 'claude-code'
: options.provider,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,

View File

@@ -2,6 +2,7 @@ export interface PythonEvaluatorOptions {
scriptPath: string
input: unknown
timeoutMs: number
pythonPath?: string
}
export interface PythonEvaluatorResult<T> {
@@ -15,7 +16,9 @@ export interface PythonEvaluatorResult<T> {
export async function runPythonJsonEvaluator<T>(
options: PythonEvaluatorOptions,
): Promise<PythonEvaluatorResult<T>> {
const proc = Bun.spawn(['python3', options.scriptPath], {
const pythonPath =
options.pythonPath || process.env.BROWSEROS_EVAL_PYTHON || 'python3'
const proc = Bun.spawn([pythonPath, options.scriptPath], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',

View File

@@ -33,6 +33,13 @@ function variantSource(config: EvalConfig): {
baseUrl?: string
supportsImages?: boolean
} {
if (config.agent.type === 'claude-code') {
return {
provider: 'claude-code',
model: config.agent.model ?? 'default',
}
}
const agent =
config.agent.type === 'single' ? config.agent : config.agent.orchestrator
if (!agent.model) {
@@ -76,10 +83,7 @@ export async function adaptEvalConfigFile(
suite: {
id,
dataset: evalConfig.dataset,
agent:
evalConfig.agent.type === 'single'
? { type: 'tool-loop' }
: { type: 'orchestrated', executorBackend: backend ?? 'tool-loop' },
agent: suiteAgent(evalConfig, backend),
graders: evalConfig.graders ?? [],
workers: evalConfig.num_workers,
restartBrowserPerTask: evalConfig.restart_server_per_task,
@@ -99,3 +103,17 @@ export async function adaptEvalConfigFile(
}),
}
}
function suiteAgent(
config: EvalConfig,
backend: ReturnType<typeof executorBackend>,
): EvalSuite['agent'] {
switch (config.agent.type) {
case 'single':
return { type: 'tool-loop' }
case 'orchestrator-executor':
return { type: 'orchestrated', executorBackend: backend ?? 'tool-loop' }
case 'claude-code':
return { type: 'claude-code' }
}
}

View File

@@ -57,10 +57,30 @@ export function resolveVariant(
options: ResolveVariantOptions = {},
): EvalVariant {
const env = options.env ?? process.env
const id = options.variantId ?? env.EVAL_VARIANT ?? 'default'
const provider =
options.provider ?? env.EVAL_AGENT_PROVIDER ?? 'openai-compatible'
const model = options.model ?? env.EVAL_AGENT_MODEL
if (provider === 'claude-code') {
const id = options.variantId ?? env.EVAL_VARIANT ?? 'claude-code'
return {
id,
agent: {
provider,
model: model ?? '',
},
publicMetadata: {
id,
agent: {
provider,
model: model || 'default',
apiKeyConfigured: false,
},
},
}
}
const id = options.variantId ?? env.EVAL_VARIANT ?? 'default'
const apiKey = options.apiKey ?? env.EVAL_AGENT_API_KEY
const apiKeyEnv =
options.apiKeyEnv ?? (options.apiKey ? undefined : 'EVAL_AGENT_API_KEY')

View File

@@ -8,6 +8,7 @@ export const SuiteAgentSchema = z
'single',
'orchestrated',
'orchestrator-executor',
'claude-code',
]),
executorBackend: z.enum(['tool-loop', 'clado']).optional(),
})

View File

@@ -19,9 +19,19 @@ export const OrchestratorExecutorConfigSchema = z.object({
}),
})
export const ClaudeCodeAgentConfigSchema = z
.object({
type: z.literal('claude-code'),
model: z.string().min(1).optional(),
claudePath: z.string().min(1).default('claude'),
extraArgs: z.array(z.string()).default([]),
})
.strict()
export const AgentConfigSchema = z.discriminatedUnion('type', [
SingleAgentConfigSchema,
OrchestratorExecutorConfigSchema,
ClaudeCodeAgentConfigSchema,
])
export const EvalConfigSchema = z.object({
@@ -53,5 +63,6 @@ export type SingleAgentConfig = z.infer<typeof SingleAgentConfigSchema>
export type OrchestratorExecutorConfig = z.infer<
typeof OrchestratorExecutorConfigSchema
>
export type ClaudeCodeAgentConfig = z.infer<typeof ClaudeCodeAgentConfigSchema>
export type AgentConfig = z.infer<typeof AgentConfigSchema>
export type EvalConfig = z.infer<typeof EvalConfigSchema>

View File

@@ -2,6 +2,8 @@
export {
type AgentConfig,
AgentConfigSchema,
type ClaudeCodeAgentConfig,
ClaudeCodeAgentConfigSchema,
type EvalConfig,
EvalConfigSchema,
type OrchestratorExecutorConfig,

View File

@@ -13,7 +13,7 @@ export const GraderResultSchema = z.object({
// Agent config in metadata
const AgentConfigMetaSchema = z
.object({
type: z.enum(['single', 'orchestrator-executor']),
type: z.enum(['single', 'orchestrator-executor', 'claude-code']),
model: z.string().optional(),
})
.passthrough()

View File

@@ -59,7 +59,7 @@ export async function validateConfig(
) {
envVarsToCheck.push(config.agent.apiKey)
}
} else {
} else if (config.agent.type === 'orchestrator-executor') {
const { orchestrator, executor } = config.agent
if (orchestrator.apiKey && isEnvVarName(orchestrator.apiKey)) {
envVarsToCheck.push(orchestrator.apiKey)

View File

@@ -0,0 +1,268 @@
import { describe, expect, it } from 'bun:test'
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createAgent } from '../../src/agents'
import { ClaudeCodeEvaluator } from '../../src/agents/claude-code'
import { CaptureContext } from '../../src/capture/context'
import {
AgentConfigSchema,
type EvalConfig,
EvalConfigSchema,
type Task,
TaskMetadataSchema,
} from '../../src/types'
function config(): EvalConfig {
return {
agent: {
type: 'claude-code',
model: 'opus',
claudePath: 'claude',
extraArgs: [],
},
dataset: 'data/test.jsonl',
num_workers: 1,
restart_server_per_task: false,
browseros: {
server_url: 'http://127.0.0.1:9110',
base_cdp_port: 9010,
base_server_port: 9110,
base_extension_port: 9310,
load_extensions: false,
headless: false,
},
graders: [],
}
}
const task: Task = {
query_id: 'task-1',
dataset: 'test',
query: 'Find the title',
graders: [],
metadata: {
original_task_id: 'task-1',
},
}
describe('ClaudeCodeEvaluator', () => {
it('accepts claude-code config defaults without permission mode', () => {
const agent = AgentConfigSchema.parse({ type: 'claude-code' })
expect(agent).toEqual({
type: 'claude-code',
claudePath: 'claude',
extraArgs: [],
})
})
it('accepts claude-code as a runnable eval agent', () => {
const parsed = EvalConfigSchema.parse({
agent: {
type: 'claude-code',
model: 'opus',
},
dataset: 'data/test-set.jsonl',
browseros: {
server_url: 'http://127.0.0.1:9110',
},
})
expect(parsed.agent.type).toBe('claude-code')
expect(parsed.agent.model).toBe('opus')
})
it('rejects unsupported claude-code settings instead of silently ignoring them', () => {
expect(
AgentConfigSchema.safeParse({
type: 'claude-code',
permissionMode: 'bypassPermissions',
}).success,
).toBe(false)
expect(
AgentConfigSchema.safeParse({
type: 'claude-code',
maxTurns: 3,
}).success,
).toBe(false)
})
it('allows claude-code in task metadata', () => {
const metadata = TaskMetadataSchema.parse({
query_id: 'task-1',
dataset: 'test',
query: 'Do the thing',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
total_duration_ms: 100,
total_steps: 1,
termination_reason: 'completed',
final_answer: 'done',
errors: [],
warnings: [],
agent_config: {
type: 'claude-code',
model: 'opus',
},
grader_results: {},
})
expect(metadata.agent_config.type).toBe('claude-code')
})
it('is created by the agent factory', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'claude-code-eval-'))
const { capture, taskOutputDir } = await CaptureContext.create({
serverUrl: 'http://127.0.0.1:9110',
outputDir,
taskId: task.query_id,
initialPageId: 1,
})
const agent = createAgent({
config: config(),
task,
workerIndex: 0,
initialPageId: 1,
outputDir,
taskOutputDir,
capture,
})
expect(agent).toBeInstanceOf(ClaudeCodeEvaluator)
})
it('runs claude code, logs messages, writes MCP config, and saves metadata', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'claude-code-eval-'))
const { capture, taskOutputDir } = await CaptureContext.create({
serverUrl: 'http://127.0.0.1:9110',
outputDir,
taskId: task.query_id,
initialPageId: 1,
})
const calls: Array<{ executable: string; args: string[]; cwd: string }> = []
const evaluator = new ClaudeCodeEvaluator(
{
config: config(),
task,
workerIndex: 0,
initialPageId: 1,
outputDir,
taskOutputDir,
capture,
},
{
processRunner: {
async run(options) {
calls.push(options)
await options.onStdoutLine(
JSON.stringify({
type: 'assistant',
message: {
content: [{ type: 'text', text: 'The title is Example' }],
},
}),
)
await options.onStdoutLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'The title is Example',
}),
)
return { exitCode: 0, stderr: '' }
},
},
},
)
const result = await evaluator.execute()
expect(result.finalAnswer).toBe('The title is Example')
expect(result.metadata.agent_config).toMatchObject({
type: 'claude-code',
model: 'opus',
})
expect(result.messages.some((msg) => msg.type === 'user')).toBe(true)
expect(result.messages.some((msg) => msg.type === 'text-delta')).toBe(true)
const mcpConfig = JSON.parse(
await readFile(join(taskOutputDir, 'claude-code-mcp.json'), 'utf-8'),
)
expect(mcpConfig.mcpServers.browseros).toMatchObject({
type: 'http',
url: 'http://127.0.0.1:9110/mcp',
headers: {
'X-BrowserOS-Source': 'sdk-internal',
},
})
expect(calls).toEqual([
expect.objectContaining({
executable: 'claude',
cwd: taskOutputDir,
args: [
'-p',
expect.stringContaining('Task: Find the title'),
'--mcp-config',
join(taskOutputDir, 'claude-code-mcp.json'),
'--strict-mcp-config',
'--output-format',
'stream-json',
'--verbose',
'--model',
'opus',
],
}),
])
expect(calls[0].args).not.toContain('--permission-mode')
})
it('records non-fatal stream processing errors as warnings', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'claude-code-eval-'))
const { capture, taskOutputDir } = await CaptureContext.create({
serverUrl: 'http://127.0.0.1:9110',
outputDir,
taskId: task.query_id,
initialPageId: 1,
})
const evaluator = new ClaudeCodeEvaluator(
{
config: config(),
task,
workerIndex: 0,
initialPageId: 1,
outputDir,
taskOutputDir,
capture,
},
{
processRunner: {
async run(options) {
await options.onStdoutLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'done',
}),
)
return {
exitCode: 0,
stderr: '',
streamErrors: ['bad stream line'],
}
},
},
},
)
const result = await evaluator.execute()
expect(result.finalAnswer).toBe('done')
expect(result.metadata.warnings).toEqual([
expect.objectContaining({
source: 'message_logging',
message: 'Claude Code stream event processing failed: bad stream line',
}),
])
})
})

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'bun:test'
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createClaudeCodeProcessRunner } from '../../src/agents/claude-code/process-runner'
async function writeStdoutScript(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'claude-code-runner-'))
const script = join(dir, 'stdout-lines')
await writeFile(script, '#!/bin/sh\nprintf "first\\nbad\\nlast\\n"\n')
await chmod(script, 0o755)
return script
}
describe('createClaudeCodeProcessRunner', () => {
it('passes executable and args to the spawn dependency', async () => {
const calls: unknown[] = []
const runner = createClaudeCodeProcessRunner({
spawn: async (cmd, options) => {
calls.push({ cmd, options })
await options.onStdoutLine('{"type":"result","result":"done"}')
return { exitCode: 0, stderr: '' }
},
})
const result = await runner.run({
executable: 'claude',
args: ['-p', 'hello'],
cwd: '/tmp',
signal: new AbortController().signal,
onStdoutLine: async () => {},
})
expect(result.exitCode).toBe(0)
expect(calls).toEqual([
{
cmd: ['claude', '-p', 'hello'],
options: expect.objectContaining({ cwd: '/tmp' }),
},
])
})
it('returns stderr and non-zero exit codes', async () => {
const runner = createClaudeCodeProcessRunner({
spawn: async () => ({ exitCode: 2, stderr: 'bad auth' }),
})
const result = await runner.run({
executable: 'claude',
args: [],
cwd: '/tmp',
signal: new AbortController().signal,
onStdoutLine: async () => {},
})
expect(result).toEqual({ exitCode: 2, stderr: 'bad auth' })
})
it('continues reading stdout after a line handler error', async () => {
const script = await writeStdoutScript()
const lines: string[] = []
const runner = createClaudeCodeProcessRunner()
const result = await runner.run({
executable: script,
args: [],
cwd: '/tmp',
onStdoutLine: async (line) => {
lines.push(line)
if (line === 'bad') throw new Error('bad line')
},
})
expect(result.exitCode).toBe(0)
expect(result.streamErrors).toEqual(['bad line'])
expect(lines).toEqual(['first', 'bad', 'last'])
})
})

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'bun:test'
import {
ClaudeCodeStreamParser,
shouldCaptureScreenshotForTool,
} from '../../src/agents/claude-code/stream-parser'
describe('ClaudeCodeStreamParser', () => {
it('maps assistant text and MCP tool use into eval stream events', () => {
const parser = new ClaudeCodeStreamParser()
const events = parser.pushLine(
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'I will navigate.' },
{
type: 'tool_use',
id: 'toolu_1',
name: 'mcp__browseros__navigate_page',
input: { page: 2, url: 'https://example.com' },
},
],
},
}),
)
expect(events).toEqual([
{ type: 'text-start', id: expect.any(String) },
{
type: 'text-delta',
id: expect.any(String),
delta: 'I will navigate.',
},
{ type: 'text-end', id: expect.any(String) },
{
type: 'tool-input-available',
toolCallId: 'toolu_1',
toolName: 'mcp__browseros__navigate_page',
input: { page: 2, url: 'https://example.com' },
},
])
expect(parser.getLastText()).toBe('I will navigate.')
expect(parser.getToolCallCount()).toBe(1)
})
it('maps Claude Code tool results into eval output events', () => {
const parser = new ClaudeCodeStreamParser()
const events = parser.pushLine(
JSON.stringify({
type: 'user',
message: {
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
content: 'Navigated successfully',
},
],
},
}),
)
expect(events).toEqual([
{
type: 'tool-output-available',
toolCallId: 'toolu_1',
output: 'Navigated successfully',
},
])
})
it('uses result messages as the authoritative final text', () => {
const parser = new ClaudeCodeStreamParser()
parser.pushLine(
JSON.stringify({
type: 'assistant',
message: {
content: [{ type: 'text', text: 'I will complete the task.' }],
},
}),
)
parser.pushLine(
JSON.stringify({
type: 'result',
subtype: 'success',
result: 'Final answer',
}),
)
expect(parser.getLastText()).toBe('Final answer')
})
it('identifies BrowserOS MCP tools that should trigger screenshots', () => {
expect(
shouldCaptureScreenshotForTool('mcp__browseros__navigate_page'),
).toBe(true)
expect(
shouldCaptureScreenshotForTool('mcp__browseros__take_screenshot'),
).toBe(false)
expect(shouldCaptureScreenshotForTool('Read')).toBe(false)
})
})

View File

@@ -7,8 +7,11 @@ import {
runSuiteCommand,
} from '../../src/cli/commands/suite'
import type { RunEvalOptions } from '../../src/runner/types'
import type { EvalSuite } from '../../src/suites/schema'
async function writeTempSuite(): Promise<{ dir: string; suitePath: string }> {
async function writeTempSuite(
overrides: Partial<EvalSuite> = {},
): Promise<{ dir: string; suitePath: string }> {
const dir = await mkdtemp(join(tmpdir(), 'eval-suite-cli-'))
const suitePath = join(dir, 'agisdk-daily-10.json')
await writeFile(
@@ -23,8 +26,9 @@ async function writeTempSuite(): Promise<{ dir: string; suitePath: string }> {
restartBrowserPerTask: true,
browseros: {
server_url: 'http://127.0.0.1:9110',
headless: true,
headless: false,
},
...overrides,
},
null,
2,
@@ -43,9 +47,7 @@ describe('suite command', () => {
expect(resolved.kind).toBe('config')
expect(resolved.suite.id).toBe('browseros-agent-weekly')
expect(resolved.evalConfig.dataset).toBe(
'../../data/webbench-2of4-50.jsonl',
)
expect(resolved.evalConfig.dataset).toBe('../../data/agisdk-real.jsonl')
expect(resolved.variant.publicMetadata.agent.apiKeyConfigured).toBe(true)
})
@@ -75,6 +77,25 @@ describe('suite command', () => {
expect(resolved.evalConfig.num_workers).toBe(2)
})
it('resolves claude-code suites without provider API credentials', async () => {
const { dir, suitePath } = await writeTempSuite({
agent: { type: 'claude-code' },
})
const resolved = await resolveSuiteCommand({
suitePath,
model: 'opus',
env: {},
})
expect(resolved.kind).toBe('suite')
expect(resolved.evalConfig.agent).toMatchObject({
type: 'claude-code',
model: 'opus',
})
expect(resolved.datasetPath).toBe(join(dir, 'tasks.jsonl'))
})
it('runs config and suite commands through the runner dependency', async () => {
const calls: RunEvalOptions[] = []
await runSuiteCommand(

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runPythonJsonEvaluator } from '../../src/grading/python-evaluator'
@@ -11,6 +11,17 @@ async function writeScript(source: string): Promise<string> {
return script
}
async function writePythonWrapper(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'eval-python-wrapper-'))
const wrapper = join(dir, 'python-wrapper')
await writeFile(
wrapper,
'#!/bin/sh\necho custom-python >&2\nexec python3 "$@"\n',
)
await chmod(wrapper, 0o755)
return wrapper
}
describe('runPythonJsonEvaluator', () => {
it('sends JSON on stdin, captures stderr, and parses stdout JSON', async () => {
const script = await writeScript(`
@@ -49,6 +60,34 @@ sys.exit(3)
).rejects.toThrow('bad verifier')
})
it('uses BROWSEROS_EVAL_PYTHON when provided', async () => {
const script = await writeScript(`
import json, sys
data = json.loads(sys.stdin.read())
print(json.dumps({"ok": data["ok"]}))
`)
const wrapper = await writePythonWrapper()
const previousPythonPath = process.env.BROWSEROS_EVAL_PYTHON
process.env.BROWSEROS_EVAL_PYTHON = wrapper
try {
const result = await runPythonJsonEvaluator<{ ok: boolean }>({
scriptPath: script,
input: { ok: true },
timeoutMs: 5_000,
})
expect(result.output).toEqual({ ok: true })
expect(result.stderr).toContain('custom-python')
} finally {
if (previousPythonPath === undefined) {
delete process.env.BROWSEROS_EVAL_PYTHON
} else {
process.env.BROWSEROS_EVAL_PYTHON = previousPythonPath
}
}
})
it('enforces timeouts', async () => {
const script = await writeScript(`
import time

View File

@@ -1,15 +1,18 @@
import { describe, expect, it } from 'bun:test'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { adaptEvalConfigFile } from '../../src/suites/config-adapter'
describe('adaptEvalConfigFile', () => {
it('preserves browseros-agent-weekly config semantics', async () => {
it('preserves browseros-agent-weekly AGI SDK config semantics', async () => {
const adapted = await adaptEvalConfigFile(
'apps/eval/configs/legacy/browseros-agent-weekly.json',
)
expect(adapted.suite.id).toBe('browseros-agent-weekly')
expect(adapted.suite.dataset).toBe('../../data/webbench-2of4-50.jsonl')
expect(adapted.suite.graders).toEqual(['performance_grader'])
expect(adapted.suite.dataset).toBe('../../data/agisdk-real.jsonl')
expect(adapted.suite.graders).toEqual(['agisdk_state_diff'])
expect(adapted.suite.workers).toBe(10)
expect(adapted.suite.restartBrowserPerTask).toBe(true)
expect(adapted.suite.timeoutMs).toBe(1_800_000)
@@ -34,4 +37,33 @@ describe('adaptEvalConfigFile', () => {
'secret-openrouter-value',
)
})
it('adapts claude-code configs without provider credentials', async () => {
const dir = await mkdtemp(join(tmpdir(), 'claude-code-config-'))
const configPath = join(dir, 'claude-code-agisdk.json')
await writeFile(
configPath,
JSON.stringify({
agent: {
type: 'claude-code',
model: 'opus',
},
dataset: 'tasks.jsonl',
num_workers: 1,
restart_server_per_task: false,
browseros: {
server_url: 'http://127.0.0.1:9110',
headless: false,
},
}),
)
const adapted = await adaptEvalConfigFile(configPath, { env: {} })
expect(adapted.suite.agent).toEqual({ type: 'claude-code' })
expect(adapted.variant.agent).toMatchObject({
provider: 'claude-code',
model: 'opus',
})
})
})

View File

@@ -35,6 +35,16 @@ describe('EvalSuiteSchema', () => {
expect(parsed.success).toBe(false)
})
it('validates claude-code suites', () => {
const suite = EvalSuiteSchema.parse({
id: 'claude-code-agisdk',
dataset: 'data/agisdk-real.jsonl',
agent: { type: 'claude-code' },
})
expect(suite.agent.type).toBe('claude-code')
})
it('validates the daily AGISDK 10-task suite', async () => {
const loaded = await loadSuite(
'apps/eval/configs/suites/agisdk-daily-10.json',
@@ -89,4 +99,40 @@ describe('resolveVariant', () => {
}),
).toThrow('EVAL_AGENT_API_KEY')
})
it('resolves claude-code variants without model or API key requirements', () => {
const variant = resolveVariant({
variantId: 'claude-opus',
provider: 'claude-code',
model: 'opus',
env: {},
})
expect(variant.id).toBe('claude-opus')
expect(variant.agent).toEqual({
provider: 'claude-code',
model: 'opus',
})
expect(variant.publicMetadata.agent).toEqual({
provider: 'claude-code',
model: 'opus',
apiKeyConfigured: false,
})
const defaultVariant = resolveVariant({
provider: 'claude-code',
env: {},
})
expect(defaultVariant.id).toBe('claude-code')
expect(defaultVariant.agent).toEqual({
provider: 'claude-code',
model: '',
})
expect(defaultVariant.publicMetadata.agent).toEqual({
provider: 'claude-code',
model: 'default',
apiKeyConfigured: false,
})
})
})

View File

@@ -7,11 +7,6 @@ BROWSEROS_EXTENSION_PORT=9300
# BROWSEROS_RESOURCES_DIR=./resources
# BROWSEROS_EXECUTION_DIR=./out
# VM cache (optional - runtime downloads published agent cache in background)
# Set prefetch=false to skip startup warmup; VM/OpenClaw startup still syncs on demand.
BROWSEROS_VM_CACHE_PREFETCH=true
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
# BrowserOS config
BROWSEROS_CONFIG_URL=https://llm.browseros.com/api/browseros-server/config
BROWSEROS_VERSION=

View File

@@ -5,9 +5,6 @@ CODEGEN_SERVICE_URL=
POSTHOG_API_KEY=
SENTRY_DSN=
BROWSEROS_VM_CACHE_PREFETCH=true
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=

View File

@@ -108,6 +108,7 @@
"klavis": "^2.15.0",
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
"proper-lockfile": "^4.1.2",
"puppeteer-core": "24.23.0",
"ws": "^8.18.0",
"zod": "^3.24.2",
@@ -117,6 +118,7 @@
"@types/bun": "1.3.5",
"@types/debug": "^4.1.12",
"@types/node": "^24.3.3",
"@types/proper-lockfile": "^4.1.4",
"@types/sinon": "^21.0.0",
"@types/ws": "^8.5.13",
"async-mutex": "^0.5.0",

View File

@@ -10,19 +10,12 @@ import { getBrowserosDir } from '../../../lib/browseros-dir'
import { ContainerCli, ImageLoader } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
detectArch,
getLimaHomeDir,
resolveBundledLimactl,
resolveBundledLimaTemplate,
VM_NAME,
VmRuntime,
} from '../../../lib/vm'
import {
ensureVmCacheAvailable,
ensureVmCacheSynced,
type VmCacheSyncOptions,
} from '../../../lib/vm/cache-sync'
import { readCachedManifest } from '../../../lib/vm/manifest'
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
import { ContainerRuntime } from './container-runtime'
@@ -34,13 +27,6 @@ export interface ContainerRuntimeFactoryInput {
projectDir: string
browserosRoot?: string
platform?: NodeJS.Platform
vmCache?: VmCacheRuntimeConfig
}
export interface VmCacheRuntimeConfig
extends Pick<VmCacheSyncOptions, 'manifestUrl'> {
ensureAvailable?: () => Promise<void>
ensureSynced?: () => Promise<unknown>
}
export function buildContainerRuntime(
@@ -77,16 +63,9 @@ export function buildContainerRuntime(
? resolveBundledLimaTemplate(input.resourcesDir)
: undefined,
browserosRoot,
ensureCacheAvailable:
input.vmCache?.ensureAvailable ??
(() =>
ensureVmCacheAvailable({
browserosRoot,
manifestUrl: input.vmCache?.manifestUrl,
})),
})
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
const loader = new DeferredImageLoader(shell, browserosRoot, input.vmCache)
const loader = new ImageLoader(shell)
return new ContainerRuntime({
vm,
@@ -122,49 +101,6 @@ function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
})
}
class DeferredImageLoader {
constructor(
private readonly shell: ContainerCli,
private readonly browserosRoot: string,
private readonly vmCache?: VmCacheRuntimeConfig,
) {}
async ensureImageLoaded(ref: string, onLog?: (msg: string) => void) {
const loader = await this.buildLoader()
await loader.ensureImageLoaded(ref, onLog)
}
async ensureAgentImageLoaded(
name: string,
onLog?: (msg: string) => void,
): Promise<string> {
const loader = await this.buildLoader()
return loader.ensureAgentImageLoaded(name, onLog)
}
private async buildLoader(): Promise<ImageLoader> {
await this.ensureCacheSynced()
const manifest = await readCachedManifest(this.browserosRoot)
return new ImageLoader(
this.shell,
manifest,
detectArch(),
this.browserosRoot,
)
}
private async ensureCacheSynced(): Promise<void> {
if (this.vmCache?.ensureSynced) {
await this.vmCache.ensureSynced()
return
}
await ensureVmCacheSynced({
browserosRoot: this.browserosRoot,
manifestUrl: this.vmCache?.manifestUrl,
})
}
}
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
constructor(projectDir: string) {
super({
@@ -197,6 +133,14 @@ class UnsupportedPlatformTestRuntime extends ContainerRuntime {
throw unsupportedPlatformError()
}
override async prewarmGatewayImage(): Promise<void> {
throw unsupportedPlatformError()
}
override async isGatewayCurrent(): Promise<boolean> {
return false
}
override async startGateway(): Promise<void> {
throw unsupportedPlatformError()
}

View File

@@ -8,24 +8,33 @@ import {
OPENCLAW_AGENT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import type {
ContainerCli,
ContainerCommandResult,
ContainerSpec,
LogFn,
WaitForContainerNameReleaseOptions,
} from '../../../lib/container'
import { isContainerNameInUse } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
GUEST_VM_STATE,
hostPathToGuest,
type VmRuntime,
} from '../../../lib/vm'
import { ContainerNameInUseError } from '../../../lib/vm/errors'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
const CREATE_CONTAINER_MAX_ATTEMPTS = 3
const OPENCLAW_NAME_RELEASE_WAIT: WaitForContainerNameReleaseOptions = {
timeoutMs: 10_000,
intervalMs: 100,
}
// Prepend user-installed bin so tools like `claude` / `gemini` CLI that
// are installed via npm into the mounted home are discoverable by
// OpenClaw's child-process spawns (no login shell is involved).
@@ -95,14 +104,34 @@ export class ContainerRuntime {
await this.loader.ensureImageLoaded(image, onLog)
}
/** Warm the gateway image in containerd without creating or starting containers. */
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
await this.ensureGatewayImageLoaded(onLog)
}
/** Report whether the existing gateway container was created from the target image. */
async isGatewayCurrent(): Promise<boolean> {
const image = await this.shell.containerImageRef(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
const expected = this.expectedGatewayImageRef()
const current = imageMatchesExpectedRef(image, expected)
if (!current) {
logger.info('OpenClaw gateway image is not current', {
actualImageRef: image,
expectedImageRef: expected,
})
}
return current
}
async startGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.removeGatewayContainer(onLog)
const image = await this.ensureGatewayImageLoaded(onLog)
const container = await this.buildGatewayContainerSpec(input, image)
await this.shell.createContainer(container, onLog)
await this.createContainerWithNameReconcile(container, onLog)
await this.shell.startContainer(container.name)
}
@@ -186,10 +215,11 @@ export class ContainerRuntime {
onLog?: LogFn,
): Promise<number> {
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
await this.removeContainerAndWait(setupContainerName, onLog)
const image = await this.ensureGatewayImageLoaded(onLog)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
const createResult = await this.shell.runCommand(
const createResult = await this.runSetupCreateWithNameReconcile(
setupContainerName,
[
'create',
'--name',
@@ -230,10 +260,74 @@ export class ContainerRuntime {
}
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
await this.shell.removeContainer(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
onLog,
await this.removeContainerAndWait(OPENCLAW_GATEWAY_CONTAINER_NAME, onLog)
}
/** Create the fixed-name gateway after reconciling stale nerdctl name ownership. */
private async createContainerWithNameReconcile(
container: ContainerSpec,
onLog?: LogFn,
): Promise<void> {
let attempt = 1
while (true) {
await this.removeContainerAndWait(container.name, onLog)
try {
await this.shell.createContainer(container, onLog)
return
} catch (err) {
if (
!(err instanceof ContainerNameInUseError) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
throw err
}
logger.warn('OpenClaw container name still in use; retrying create', {
containerName: container.name,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
})
attempt++
}
}
}
private async runSetupCreateWithNameReconcile(
setupContainerName: string,
createArgs: string[],
onLog?: LogFn,
): Promise<ContainerCommandResult> {
let attempt = 1
while (true) {
const result = await this.shell.runCommand(createArgs, onLog)
if (
result.exitCode === 0 ||
!isContainerNameInUse(result.stderr) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
return result
}
logger.warn(
'OpenClaw setup container name still in use; retrying create',
{
containerName: setupContainerName,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
},
)
await this.removeContainerAndWait(setupContainerName, onLog)
attempt++
}
}
private async removeContainerAndWait(
containerName: string,
onLog?: LogFn,
): Promise<void> {
await this.shell.removeContainer(containerName, { force: true }, onLog)
await this.shell.waitForContainerNameRelease(
containerName,
OPENCLAW_NAME_RELEASE_WAIT,
)
}
@@ -296,7 +390,7 @@ export class ContainerRuntime {
}
private async ensureGatewayImageLoaded(onLog?: LogFn): Promise<string> {
// Local image testing can bypass the synced VM manifest with OPENCLAW_IMAGE.
// Local image testing can override the pinned GHCR image with OPENCLAW_IMAGE.
const override = process.env.OPENCLAW_IMAGE?.trim()
if (override) {
await this.loader.ensureImageLoaded(override, onLog)
@@ -305,6 +399,10 @@ export class ContainerRuntime {
return this.loader.ensureAgentImageLoaded(OPENCLAW_AGENT_NAME, onLog)
}
private expectedGatewayImageRef(): string {
return process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
}
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,
@@ -330,3 +428,12 @@ export class ContainerRuntime {
return hostPathToGuest(path)
}
}
function imageMatchesExpectedRef(
actual: string | null,
expected: string,
): boolean {
return (
actual === expected || actual?.startsWith(`${expected}@sha256:`) === true
)
}

View File

@@ -10,13 +10,16 @@
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import { withProcessLock } from '../../../lib/process-lock'
import {
type AgentLiveStatus,
type AgentSessionState,
@@ -26,10 +29,7 @@ import type {
ContainerRuntime,
GatewayContainerSpec,
} from './container-runtime'
import {
buildContainerRuntime,
type VmCacheRuntimeConfig,
} from './container-runtime-factory'
import { buildContainerRuntime } from './container-runtime-factory'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
@@ -135,7 +135,6 @@ export interface OpenClawServiceConfig {
browserosServerPort?: number
resourcesDir?: string
browserosDir?: string
vmCache?: VmCacheRuntimeConfig
}
export type OpenClawSessionSource =
@@ -267,7 +266,6 @@ export class OpenClawService {
private browserosServerPort: number
private resourcesDir: string | null
private browserosDir: string | undefined
private vmCache: VmCacheRuntimeConfig | undefined
private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected'
private lastGatewayError: string | null = null
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
@@ -282,7 +280,6 @@ export class OpenClawService {
resourcesDir: config.resourcesDir,
projectDir: this.openclawDir,
browserosRoot: config.browserosDir,
vmCache: config.vmCache,
})
this.token = crypto.randomUUID()
this.cliClient = new OpenClawCliClient(this.runtime)
@@ -295,7 +292,6 @@ export class OpenClawService {
config.browserosServerPort ?? DEFAULT_PORTS.server
this.resourcesDir = config.resourcesDir ?? null
this.browserosDir = config.browserosDir
this.vmCache = config.vmCache
}
configure(config: OpenClawServiceConfig): void {
@@ -318,13 +314,6 @@ export class OpenClawService {
this.browserosDir = config.browserosDir
runtimeChanged = true
}
if (
config.vmCache !== undefined &&
!sameVmCacheRuntimeConfig(config.vmCache, this.vmCache)
) {
this.vmCache = config.vmCache
runtimeChanged = true
}
if (runtimeChanged) {
this.rebuildRuntimeClients()
}
@@ -361,6 +350,23 @@ export class OpenClawService {
// ── Lifecycle ────────────────────────────────────────────────────────
/** Warm the VM and gateway image so later setup/start avoids registry work. */
async prewarm(onLog?: (msg: string) => void): Promise<void> {
return this.withLifecycleLock('prewarm', async () => {
const imageRef = process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
const logProgress = (message: string) => {
// Startup prewarm runs outside a user request, so keep phase logs visible without streaming command progress.
logger.info(message)
onLog?.(message)
}
logProgress('OpenClaw prewarm: ensuring BrowserOS VM is ready')
await this.runtime.ensureReady()
logProgress(`OpenClaw prewarm: ensuring image ${imageRef} is available`)
await this.runtime.prewarmGatewayImage()
logProgress('OpenClaw prewarm: ready')
})
}
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
return this.withLifecycleLock('setup', async () => {
const logProgress = this.createProgressLogger(onLog)
@@ -478,7 +484,7 @@ export class OpenClawService {
await this.ensureGatewayPortAllocated(logProgress)
if (await this.isGatewayAvailable(this.hostPort)) {
if (await this.isCurrentGatewayAvailable(this.hostPort)) {
this.startGatewayLogTail()
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
@@ -873,7 +879,7 @@ export class OpenClawService {
this.setPort(persistedPort)
}
if (!(await this.isGatewayAvailable(this.hostPort))) {
if (!(await this.isCurrentGatewayAvailable(this.hostPort))) {
await this.ensureGatewayPortAllocated()
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
const ready = await this.runtime.waitForReady(
@@ -987,7 +993,6 @@ export class OpenClawService {
resourcesDir: this.resourcesDir ?? undefined,
projectDir: this.openclawDir,
browserosRoot: this.browserosDir,
vmCache: this.vmCache,
})
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
@@ -1009,10 +1014,16 @@ export class OpenClawService {
if (persistedPort !== null) {
this.setPort(persistedPort)
}
if (await this.isGatewayAvailable(this.hostPort)) {
const currentPortReady = await this.isGatewayPortReady(this.hostPort)
if (
currentPortReady &&
(await this.isGatewayAuthenticated(this.hostPort))
) {
return
}
const hostPort = await allocateGatewayPort(this.openclawDir)
const hostPort = await allocateGatewayPort(this.openclawDir, {
excludePort: currentPortReady ? this.hostPort : undefined,
})
if (hostPort !== this.hostPort) {
logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`)
logger.info('Allocated OpenClaw gateway host port', { hostPort })
@@ -1022,7 +1033,10 @@ export class OpenClawService {
private async isGatewayAvailable(hostPort: number): Promise<boolean> {
if (!(await this.isGatewayPortReady(hostPort))) return false
return this.isGatewayAuthenticated(hostPort)
}
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
if (!this.tokenLoaded) {
logger.debug(
'OpenClaw gateway port is ready before auth token is loaded',
@@ -1046,6 +1060,11 @@ export class OpenClawService {
return authenticated
}
private async isCurrentGatewayAvailable(hostPort: number): Promise<boolean> {
if (!(await this.isGatewayAvailable(hostPort))) return false
return this.runtime.isGatewayCurrent()
}
private async isGatewayPortReady(hostPort: number): Promise<boolean> {
if (await this.runtime.isReady(hostPort)) return true
@@ -1504,8 +1523,14 @@ export class OpenClawService {
})
await previous.catch(() => undefined)
try {
logger.debug('OpenClaw lifecycle operation started', { operation })
return await fn()
return await withProcessLock(
'openclaw-lifecycle',
{ lockDir: join(this.openclawDir, '.locks') },
async () => {
logger.debug('OpenClaw lifecycle operation started', { operation })
return await fn()
},
)
} finally {
release()
}
@@ -1529,7 +1554,6 @@ export function configureOpenClawService(
export function configureVmRuntime(config: {
resourcesDir?: string
browserosDir?: string
vmCache?: VmCacheRuntimeConfig
}): OpenClawService {
return configureOpenClawService(config)
}
@@ -1538,14 +1562,3 @@ export function getOpenClawService(): OpenClawService {
if (!service) service = new OpenClawService()
return service
}
function sameVmCacheRuntimeConfig(
left: VmCacheRuntimeConfig | undefined,
right: VmCacheRuntimeConfig | undefined,
): boolean {
return (
left?.manifestUrl === right?.manifestUrl &&
left?.ensureAvailable === right?.ensureAvailable &&
left?.ensureSynced === right?.ensureSynced
)
}

View File

@@ -16,6 +16,7 @@ import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/ope
import { getOpenClawStateDir } from './openclaw-env'
const RUNTIME_STATE_FILE = 'runtime-state.json'
const MAX_TCP_PORT = 65_535
interface RuntimeState {
gatewayPort: number
@@ -26,7 +27,7 @@ function readForcedGatewayPort(): number | null {
if (!raw) return null
const parsed = Number.parseInt(raw, 10)
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_TCP_PORT) {
return null
}
return parsed
@@ -49,7 +50,7 @@ export async function readPersistedGatewayPort(
typeof parsed.gatewayPort === 'number' &&
Number.isInteger(parsed.gatewayPort) &&
parsed.gatewayPort > 0 &&
parsed.gatewayPort <= 65535
parsed.gatewayPort <= MAX_TCP_PORT
) {
return parsed.gatewayPort
}
@@ -82,14 +83,26 @@ function isPortAvailable(port: number): Promise<boolean> {
})
}
async function findAvailablePort(startPort: number): Promise<number> {
async function findAvailablePort(
startPort: number,
excludePort?: number,
): Promise<number> {
let port = startPort
while (!(await isPortAvailable(port))) {
while (port === excludePort || !(await isPortAvailable(port))) {
port++
if (port > MAX_TCP_PORT) {
throw new Error(
`No available OpenClaw gateway port found from ${startPort}`,
)
}
}
return port
}
export interface AllocateGatewayPortOptions {
excludePort?: number
}
/**
* Pick a host port for the gateway container and persist it. Prefers the
* previously persisted port when it's still bindable; otherwise scans
@@ -97,6 +110,7 @@ async function findAvailablePort(startPort: number): Promise<number> {
*/
export async function allocateGatewayPort(
openclawDir: string,
opts: AllocateGatewayPortOptions = {},
): Promise<number> {
const forcedPort = readForcedGatewayPort()
if (forcedPort !== null) {
@@ -105,10 +119,17 @@ export async function allocateGatewayPort(
}
const persisted = await readPersistedGatewayPort(openclawDir)
if (persisted !== null && (await isPortAvailable(persisted))) {
if (
persisted !== null &&
persisted !== opts.excludePort &&
(await isPortAvailable(persisted))
) {
return persisted
}
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
const port = await findAvailablePort(
OPENCLAW_GATEWAY_CONTAINER_PORT,
opts.excludePort,
)
await writePersistedGatewayPort(openclawDir, port)
return port
}

View File

@@ -8,7 +8,6 @@
import fs from 'node:fs'
import path from 'node:path'
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { Command, InvalidArgumentError } from 'commander'
import { z } from 'zod'
@@ -31,8 +30,6 @@ export const ServerConfigSchema = z.object({
instanceBrowserosVersion: z.string().optional(),
instanceChromiumVersion: z.string().optional(),
aiSdkDevtoolsEnabled: z.boolean(),
vmCachePrefetch: z.boolean(),
vmCacheManifestUrl: z.string().url(),
})
export type ServerConfig = z.infer<typeof ServerConfigSchema>
@@ -229,11 +226,6 @@ function parseConfigFile(filePath?: string): ConfigResult<PartialConfig> {
cfg.flags?.allow_remote_in_mcp === true ? true : undefined,
aiSdkDevtoolsEnabled:
cfg.flags?.ai_sdk_devtools === true ? true : undefined,
vmCachePrefetch:
typeof cfg.vm_cache?.prefetch === 'boolean'
? cfg.vm_cache.prefetch
: undefined,
vmCacheManifestUrl: parseTrimmedString(cfg.vm_cache?.manifest_url),
instanceClientId:
typeof cfg.instance?.client_id === 'string'
? cfg.instance.client_id
@@ -280,10 +272,6 @@ function parseRuntimeEnv(): PartialConfig {
instanceClientId: process.env.BROWSEROS_CLIENT_ID,
aiSdkDevtoolsEnabled:
process.env.BROWSEROS_AI_SDK_DEVTOOLS === 'true' ? true : undefined,
vmCachePrefetch: parseBooleanEnv(process.env.BROWSEROS_VM_CACHE_PREFETCH),
vmCacheManifestUrl: parseTrimmedString(
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
),
})
}
@@ -317,8 +305,6 @@ function getDefaults(cwd: string): PartialConfig {
executionDir: cwd,
mcpAllowRemote: false,
aiSdkDevtoolsEnabled: false,
vmCachePrefetch: true,
vmCacheManifestUrl: EXTERNAL_URLS.VM_CACHE_MANIFEST,
}
}
@@ -339,18 +325,6 @@ function safeParseInt(value: string): number | undefined {
return Number.isNaN(num) ? undefined : num
}
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === 'true') return true
if (value === 'false') return false
return undefined
}
function parseTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
function omitUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_, v]) => v !== undefined),

View File

@@ -19,8 +19,6 @@ export const INLINED_ENV = {
CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL,
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL,
BROWSEROS_VM_CACHE_PREFETCH: process.env.BROWSEROS_VM_CACHE_PREFETCH,
BROWSEROS_VM_CACHE_MANIFEST_URL: process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
SKILLS_CATALOG_URL: process.env.SKILLS_CATALOG_URL,
} as const
@@ -29,6 +27,4 @@ export const REQUIRED_FOR_PRODUCTION = [
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'BROWSEROS_CONFIG_URL',
'BROWSEROS_VM_CACHE_PREFETCH',
'BROWSEROS_VM_CACHE_MANIFEST_URL',
] as const satisfies readonly (keyof typeof INLINED_ENV)[]

View File

@@ -75,10 +75,6 @@ export function getVmDisksDir(): string {
return getVmCacheDir()
}
export function getAgentCacheDir(): string {
return join(getVmCacheDir(), 'images')
}
export function getLazyMonitoringDir(): string {
return join(getBrowserosDir(), 'lazy-monitoring')
}
@@ -116,7 +112,7 @@ export async function ensureBrowserosDir(): Promise<void> {
await mkdir(getBuiltinSkillsDir(), { recursive: true })
await mkdir(getSessionsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
await mkdir(getAgentCacheDir(), { recursive: true })
await mkdir(getVmDisksDir(), { recursive: true })
}
export async function cleanOldSessions(): Promise<void> {

View File

@@ -4,9 +4,20 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { ContainerCliError } from '../vm/errors'
import {
ContainerCliError,
ContainerNameInUseError,
ContainerNameReleaseTimeoutError,
} from '../vm/errors'
import { LimaCli } from '../vm/lima-cli'
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
import type {
ContainerInfo,
ContainerSpec,
LogFn,
MountSpec,
PortMapping,
WaitForContainerNameReleaseOptions,
} from './types'
export function buildNerdctlCommand(args: string[]): string[] {
return ['nerdctl', ...args]
@@ -41,17 +52,35 @@ export class ContainerCli {
return result.exitCode === 0
}
/** Return the image ref used to create a container, or null when absent. */
async containerImageRef(name: string): Promise<string | null> {
const args = ['inspect', '--format', '{{.Config.Image}}', name]
const result = await this.runCommand(args)
if (result.exitCode === 0) {
const image = result.stdout.trim()
return image || null
}
if (isNoSuchContainer(result.stderr)) return null
throw this.commandError(args, result)
}
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
await this.runRequired(['pull', ref], onLog)
}
async loadImage(tarballPath: string, onLog?: LogFn): Promise<string[]> {
const result = await this.runRequired(['load', '-i', tarballPath], onLog)
return parseLoadedImageRefs(result.stdout)
}
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
await this.runRequired(buildCreateArgs(spec), onLog)
const args = buildCreateArgs(spec)
const result = await this.runCommand(args, onLog)
if (result.exitCode === 0) return
if (isContainerNameInUse(result.stderr)) {
throw new ContainerNameInUseError(
spec.name,
`nerdctl ${args.join(' ')}`,
result.exitCode,
result.stderr.trim(),
)
}
throw this.commandError(args, result)
}
async startContainer(name: string, onLog?: LogFn): Promise<void> {
@@ -77,6 +106,36 @@ export class ContainerCli {
throw this.commandError(args, result)
}
/** Inspect a named container without treating absence as a command failure. */
async inspectContainer(name: string): Promise<ContainerInfo | null> {
const args = ['container', 'inspect', '--format', '{{json .}}', name]
const result = await this.runCommand(args)
if (result.exitCode === 0) {
return parseContainerInfo(result.stdout, name)
}
if (isNoSuchContainer(result.stderr)) return null
throw this.commandError(args, result)
}
/** Wait for containerd/nerdctl to stop resolving a container name after rm. */
async waitForContainerNameRelease(
name: string,
opts: WaitForContainerNameReleaseOptions = {},
): Promise<void> {
const timeoutMs = opts.timeoutMs ?? 5_000
const intervalMs = opts.intervalMs ?? 100
const startedAt = Date.now()
while (Date.now() - startedAt <= timeoutMs) {
if (!(await this.inspectContainer(name))) return
const remainingMs = timeoutMs - (Date.now() - startedAt)
if (remainingMs <= 0) break
await Bun.sleep(Math.min(intervalMs, remainingMs))
}
throw new ContainerNameReleaseTimeoutError(name, timeoutMs)
}
async exec(name: string, cmd: string[], onLog?: LogFn): Promise<number> {
const result = await this.runCommand(['exec', name, ...cmd], onLog)
return result.exitCode
@@ -191,19 +250,65 @@ function mountArg(mount: MountSpec): string {
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
}
function parseLoadedImageRefs(stdout: string): string[] {
return stdout
function parseContainerInfo(
stdout: string,
fallbackName: string,
): ContainerInfo {
const line = stdout
.trim()
.split('\n')
.map((line) => line.match(/^Loaded image(?:\(s\))?:\s*(.+)$/i)?.[1]?.trim())
.filter((ref): ref is string => !!ref)
.map((entry) => entry.trim())
.find(Boolean)
if (!line) {
throw new Error(`nerdctl container inspect returned empty output`)
}
const parsed = JSON.parse(line) as unknown
const container = Array.isArray(parsed) ? parsed[0] : parsed
const object = isRecord(container) ? container : {}
const config = isRecord(object.Config) ? object.Config : {}
const state = isRecord(object.State) ? object.State : {}
const name = stringValue(object.Name)?.replace(/^\/+/, '') ?? fallbackName
const status = stringValue(state.Status) ?? stringValue(object.Status)
const running =
typeof state.Running === 'boolean'
? state.Running
: status
? status.toLowerCase() === 'running'
: null
return {
id: stringValue(object.ID) ?? stringValue(object.Id),
name,
image: stringValue(config.Image) ?? stringValue(object.Image),
status,
running,
}
}
function isNoSuchContainer(stderr: string): boolean {
const lower = stderr.toLowerCase()
return lower.includes('no such container') || lower.includes('not found')
return (
lower.includes('no such container') || lower.includes('container not found')
)
}
export function isContainerNameInUse(stderr: string): boolean {
const lower = stderr.toLowerCase()
return (
(lower.includes('name-store error') && lower.includes('already used')) ||
lower.includes('name is already in use')
)
}
function linesToOutput(lines: string[]): string {
if (lines.length === 0) return ''
return `${lines.join('\n')}\n`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function stringValue(value: unknown): string | null {
return typeof value === 'string' && value ? value : null
}

View File

@@ -4,87 +4,41 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { basename, join } from 'node:path'
import {
OPENCLAW_AGENT_NAME,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { ContainerCliError, ImageLoadError } from '../vm/errors'
import type { VmAgentTarball, VmManifest } from '../vm/manifest'
import type { Arch } from '../vm/paths'
import { getImageCacheDir, hostPathToGuest } from '../vm/paths'
import type { ContainerCli } from './container-cli'
import type { LogFn } from './types'
export class ImageLoader {
constructor(
private readonly cli: ContainerCli,
private readonly manifest: VmManifest,
private readonly arch: Arch,
private readonly browserosRoot?: string,
) {}
constructor(private readonly cli: ContainerCli) {}
/** Ensure an image ref exists in the VM's persistent containerd store. */
async ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> {
if (await this.cli.imageExists(ref)) return
const tarball = this.resolveTarball(ref)
await this.loadResolvedTarball(ref, tarball, onLog)
}
/** Load an agent tarball from the VM cache and return its local image ref. */
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
const agent = this.resolveAgent(name)
const ref = `${agent.image}:${agent.version}`
if (await this.cli.imageExists(ref)) return ref
const tarball = agent.tarballs[this.arch]
if (!tarball) {
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
}
await this.loadResolvedTarball(ref, tarball, onLog)
return ref
}
private async loadResolvedTarball(
ref: string,
tarball: VmAgentTarball,
onLog?: LogFn,
): Promise<void> {
const hostPath = join(
getImageCacheDir(this.browserosRoot),
basename(tarball.key),
)
const guestPath = hostPathToGuest(hostPath, this.browserosRoot)
try {
await this.cli.loadImage(guestPath, onLog)
await this.cli.pullImage(ref, onLog)
} catch (error) {
if (error instanceof ContainerCliError) {
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
throw new ImageLoadError(ref, `pull failed: ${error.stderr}`, error)
}
throw error
}
if (!(await this.cli.imageExists(ref))) {
throw new ImageLoadError(
ref,
`image not present after successful load of ${guestPath}`,
)
throw new ImageLoadError(ref, 'image not present after successful pull')
}
}
private resolveTarball(ref: string): VmAgentTarball {
for (const agent of Object.values(this.manifest.agents)) {
if (`${agent.image}:${agent.version}` !== ref) continue
const tarball = agent.tarballs[this.arch]
if (!tarball) {
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
}
return tarball
/** Resolve BrowserOS agent names to image refs and ensure the image exists. */
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
if (name !== OPENCLAW_AGENT_NAME) {
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
}
throw new ImageLoadError(ref, `no agent in manifest matches ${ref}`)
}
private resolveAgent(name: string): VmManifest['agents'][string] {
const agent = this.manifest.agents[name]
if (!agent) throw new ImageLoadError(name, `no agent in manifest: ${name}`)
return agent
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
return OPENCLAW_IMAGE
}
}

View File

@@ -38,6 +38,19 @@ export interface ContainerSpec {
command?: string[]
}
export interface ContainerInfo {
id: string | null
name: string
image: string | null
status: string | null
running: boolean | null
}
export interface WaitForContainerNameReleaseOptions {
timeoutMs?: number
intervalMs?: number
}
export interface LogLine {
stream: 'stdout' | 'stderr'
line: string

View File

@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import lockfile from 'proper-lockfile'
const DEFAULT_STALE_MS = 60_000
const DEFAULT_UPDATE_MS = 15_000
const DEFAULT_TIMEOUT_MS = 120_000
const DEFAULT_RETRY_MIN_TIMEOUT_MS = 100
const DEFAULT_RETRY_MAX_TIMEOUT_MS = 1_000
export interface ProcessLockOptions {
lockDir: string
staleMs?: number
updateMs?: number
timeoutMs?: number
retryMinTimeoutMs?: number
retryMaxTimeoutMs?: number
randomize?: boolean
}
export class ProcessLockTimeoutError extends Error {
constructor(
public readonly lockName: string,
public readonly lockPath: string,
public readonly timeoutMs: number,
public override readonly cause?: unknown,
) {
super(
`Timed out acquiring process lock "${lockName}" at ${lockPath} after ${timeoutMs}ms`,
)
this.name = 'ProcessLockTimeoutError'
}
}
/** Run a critical section while holding a named lock shared across processes. */
export async function withProcessLock<T>(
name: string,
options: ProcessLockOptions,
fn: () => Promise<T>,
): Promise<T> {
const release = await acquireProcessLock(name, options)
try {
return await fn()
} finally {
await release()
}
}
export function resolveProcessLockPath(lockDir: string, name: string): string {
return join(lockDir, `${sanitizeLockName(name)}.lock`)
}
async function acquireProcessLock(
name: string,
options: ProcessLockOptions,
): Promise<() => Promise<void>> {
await mkdir(options.lockDir, { recursive: true })
const lockPath = resolveProcessLockPath(options.lockDir, name)
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryMinTimeoutMs =
options.retryMinTimeoutMs ?? DEFAULT_RETRY_MIN_TIMEOUT_MS
const retryMaxTimeoutMs =
options.retryMaxTimeoutMs ?? DEFAULT_RETRY_MAX_TIMEOUT_MS
const startedAt = Date.now()
let lastError: unknown
while (Date.now() - startedAt <= timeoutMs) {
try {
return await lockfile.lock(lockPath, {
lockfilePath: lockPath,
realpath: false,
stale: options.staleMs ?? DEFAULT_STALE_MS,
update: options.updateMs ?? DEFAULT_UPDATE_MS,
// The wrapper owns retry/backoff so acquisition respects timeoutMs.
retries: 0,
})
} catch (err) {
if (!isLockedError(err)) throw err
lastError = err
}
const remainingMs = timeoutMs - (Date.now() - startedAt)
if (remainingMs <= 0) break
await Bun.sleep(
Math.min(
remainingMs,
nextRetryDelay(retryMinTimeoutMs, retryMaxTimeoutMs, options.randomize),
),
)
}
throw new ProcessLockTimeoutError(name, lockPath, timeoutMs, lastError)
}
function sanitizeLockName(name: string): string {
const safeName = name
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^[.-]+|[.-]+$/g, '')
if (!safeName) throw new Error('Process lock name must not be empty')
return safeName
}
function isLockedError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'ELOCKED'
)
}
function nextRetryDelay(
minTimeoutMs: number,
maxTimeoutMs: number,
randomize = true,
): number {
if (maxTimeoutMs <= minTimeoutMs) return minTimeoutMs
if (!randomize) return minTimeoutMs
return (
minTimeoutMs + Math.floor(Math.random() * (maxTimeoutMs - minTimeoutMs))
)
}

View File

@@ -1,322 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createHash } from 'node:crypto'
import { createReadStream, existsSync } from 'node:fs'
import { mkdir, readFile, rename, rm } from 'node:fs/promises'
import { arch as hostArch } from 'node:os'
import { dirname, join } from 'node:path'
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import type { VmArtifact, VmManifest } from './manifest'
import type { Arch } from './paths'
import { getCachedManifestPath } from './paths'
const DEFAULT_TIMEOUT_MS = 30_000
const ARCHES: Arch[] = ['arm64', 'x64']
const CANONICAL_MANIFEST_SUFFIX = '/vm/manifest.json'
export interface VmCacheSyncOptions {
browserosRoot?: string
manifestUrl?: string
allArches?: boolean
fetchImpl?: typeof fetch
rawHostArch?: NodeJS.Architecture
timeoutMs?: number
}
export interface VmCacheSyncResult {
downloaded: string[]
manifestPath: string
skipped: boolean
}
const inFlight = new Map<string, Promise<VmCacheSyncResult>>()
export function prefetchVmCache(
options: VmCacheSyncOptions = {},
): Promise<VmCacheSyncResult> {
return startOrReuseSync(options)
}
export function ensureVmCacheSynced(
options: VmCacheSyncOptions = {},
): Promise<VmCacheSyncResult> {
return startOrReuseSync(options)
}
export async function ensureVmCacheAvailable(
options: VmCacheSyncOptions = {},
): Promise<void> {
const cfg = resolveSyncConfig(options)
const pending = inFlight.get(syncKey(cfg))
if (pending) {
await pending.catch(() => {})
}
if (existsSync(getCachedManifestPath(cfg.browserosRoot))) return
await startOrReuseSyncWithConfig(cfg)
}
function startOrReuseSync(
options: VmCacheSyncOptions,
): Promise<VmCacheSyncResult> {
try {
return startOrReuseSyncWithConfig(resolveSyncConfig(options))
} catch (error) {
return Promise.reject(error)
}
}
function startOrReuseSyncWithConfig(
cfg: SyncConfig,
): Promise<VmCacheSyncResult> {
const key = syncKey(cfg)
const existing = inFlight.get(key)
if (existing) return existing
const current = syncVmCache(cfg).finally(() => {
if (inFlight.get(key) === current) inFlight.delete(key)
})
inFlight.set(key, current)
return current
}
async function syncVmCache(cfg: SyncConfig): Promise<VmCacheSyncResult> {
const remote = await fetchManifest(cfg)
const manifestPath = getCachedManifestPath(cfg.browserosRoot)
const local = await readLocalManifest(manifestPath)
const plan = await planDownloads({
remote,
local,
cacheRoot: cacheRootForManifest(manifestPath),
arches: cfg.arches,
})
for (const item of plan) {
await downloadArtifact(
cfg.fetchImpl,
artifactUrlForKey(cfg.manifestUrl, item.key),
item.destPath,
item.sha256,
cfg.timeoutMs,
)
}
await mkdir(dirname(manifestPath), { recursive: true })
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
await Bun.write(tempPath, `${JSON.stringify(remote, null, 2)}\n`)
await rename(tempPath, manifestPath)
return {
downloaded: plan.map((item) => item.key),
manifestPath,
skipped: plan.length === 0,
}
}
interface SyncConfig {
browserosRoot?: string
manifestUrl: string
fetchImpl: typeof fetch
arches: Arch[]
timeoutMs: number
}
function resolveSyncConfig(options: VmCacheSyncOptions): SyncConfig {
return {
browserosRoot: options.browserosRoot,
manifestUrl:
trimNonEmpty(options.manifestUrl) ??
trimNonEmpty(process.env.BROWSEROS_VM_CACHE_MANIFEST_URL) ??
EXTERNAL_URLS.VM_CACHE_MANIFEST,
fetchImpl: options.fetchImpl ?? fetch,
arches: selectSyncArches(options),
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
}
}
async function fetchManifest(cfg: SyncConfig): Promise<VmManifest> {
const response = await fetchWithTimeout(
cfg.fetchImpl,
cfg.manifestUrl,
cfg.timeoutMs,
)
if (!response.ok) {
throw new Error(
`manifest fetch failed: ${cfg.manifestUrl} (${response.status})`,
)
}
return (await response.json()) as VmManifest
}
interface DownloadPlanItem {
key: string
destPath: string
sha256: string
}
async function planDownloads(opts: {
remote: VmManifest
local: VmManifest | null
cacheRoot: string
arches: Arch[]
}): Promise<DownloadPlanItem[]> {
const out: DownloadPlanItem[] = []
for (const arch of opts.arches) {
for (const [name, agent] of Object.entries(opts.remote.agents)) {
const remote = agent.tarballs[arch]
if (!remote) continue
const destPath = join(opts.cacheRoot, remote.key)
if (
!(await needsDownload(
remote,
opts.local?.agents[name]?.tarballs[arch],
destPath,
))
) {
continue
}
out.push({ key: remote.key, destPath, sha256: remote.sha256 })
}
}
return out
}
async function needsDownload(
remote: VmArtifact,
local: VmArtifact | undefined,
destPath: string,
): Promise<boolean> {
if (!existsSync(destPath)) return true
if (local?.sha256 === remote.sha256) return false
try {
return (await sha256File(destPath)) !== remote.sha256
} catch {
return true
}
}
async function downloadArtifact(
fetchImpl: typeof fetch,
url: string,
destPath: string,
sha256: string,
timeoutMs: number,
): Promise<void> {
const partialPath = `${destPath}.partial`
await mkdir(dirname(destPath), { recursive: true })
await rm(partialPath, { force: true })
try {
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs)
if (!response.ok || !response.body) {
throw new Error(`download failed: ${url} (${response.status})`)
}
const sink = Bun.file(partialPath).writer()
const reader = response.body.getReader()
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
sink.write(value)
}
} finally {
await sink.end()
}
await verifySha256(partialPath, sha256)
await rename(partialPath, destPath)
} catch (error) {
await rm(partialPath, { force: true })
throw error
}
}
async function fetchWithTimeout(
fetchImpl: typeof fetch,
url: string,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetchImpl(url, { signal: controller.signal })
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
}
throw error
} finally {
clearTimeout(timer)
}
}
async function verifySha256(path: string, expected: string): Promise<void> {
const actual = await sha256File(path)
if (actual !== expected) {
throw new Error(
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
)
}
}
async function sha256File(path: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) {
hash.update(chunk)
}
return hash.digest('hex')
}
async function readLocalManifest(path: string): Promise<VmManifest | null> {
try {
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
function selectSyncArches(options: VmCacheSyncOptions): Arch[] {
if (options.allArches) return [...ARCHES]
const rawArch = options.rawHostArch ?? hostArch()
if (rawArch === 'arm64') return ['arm64']
if (rawArch === 'x64' || rawArch === 'ia32') return ['x64']
throw new Error(`unsupported host arch: ${rawArch}`)
}
function cacheRootForManifest(manifestPath: string): string {
return dirname(dirname(manifestPath))
}
function syncKey(cfg: SyncConfig): string {
return [
getCachedManifestPath(cfg.browserosRoot),
cfg.manifestUrl,
cfg.arches.join(','),
String(cfg.timeoutMs),
].join('\0')
}
function artifactUrlForKey(manifestUrl: string, key: string): string {
const artifactKey = key.replace(/^\/+/, '')
const url = new URL(manifestUrl)
const normalizedPath = url.pathname.replace(/\/+$/, '')
const prefix = normalizedPath.endsWith(CANONICAL_MANIFEST_SUFFIX)
? normalizedPath.slice(0, -CANONICAL_MANIFEST_SUFFIX.length)
: normalizedPath.slice(0, Math.max(0, normalizedPath.lastIndexOf('/')))
url.pathname = `${prefix.replace(/\/+$/, '')}/${artifactKey}`
url.search = ''
url.hash = ''
return url.toString()
}
function trimNonEmpty(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}

View File

@@ -30,8 +30,36 @@ export class ContainerCliError extends VmError {
command: string,
public readonly exitCode: number,
public readonly stderr: string,
message = `${command} failed with exit code ${exitCode}: ${stderr}`,
) {
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
super(message)
}
}
export class ContainerNameInUseError extends ContainerCliError {
constructor(
public readonly containerName: string,
command: string,
exitCode: number,
stderr: string,
) {
super(
command,
exitCode,
stderr,
`${command} failed because container name "${containerName}" is already in use: ${stderr}`,
)
}
}
export class ContainerNameReleaseTimeoutError extends VmError {
constructor(
public readonly containerName: string,
public readonly timeoutMs: number,
) {
super(
`Timed out waiting ${timeoutMs}ms for container name "${containerName}" to be released`,
)
}
}
@@ -44,17 +72,3 @@ export class ImageLoadError extends VmError {
super(`failed to load image ${imageRef}: ${message}`)
}
}
export class ManifestMissingError extends VmError {
constructor(public readonly manifestPath: string) {
super(manifestMissingMessage(manifestPath))
}
}
function manifestMissingMessage(manifestPath: string): string {
const message = `VM manifest is missing at ${manifestPath}`
if (process.env.NODE_ENV === 'development') {
return `${message}; run bun run dev:setup before starting the server`
}
return message
}

View File

@@ -7,7 +7,6 @@
export * from './errors'
export * from './lima-cli'
export * from './lima-config'
export * from './manifest'
export * from './paths'
export * from './telemetry'
export * from './vm-runtime'

View File

@@ -8,7 +8,6 @@ export function renderLimaTemplate(
template: string,
cfg: {
vmStateDir: string
imageCacheDir: string
},
): string {
const mounts = [
@@ -16,9 +15,6 @@ export function renderLimaTemplate(
`- location: "${cfg.vmStateDir}"`,
' mountPoint: "/mnt/browseros/vm"',
' writable: true',
`- location: "${cfg.imageCacheDir}"`,
' mountPoint: "/mnt/browseros/cache/images"',
' writable: false',
].join('\n')
if (!template.includes('mounts: []')) {

View File

@@ -1,103 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { ManifestMissingError } from './errors'
import type { Arch } from './paths'
import { getCachedManifestPath, getInstalledManifestPath } from './paths'
export interface VmArtifact {
key: string
sha256: string
sizeBytes: number
}
export interface VmAgentEntry {
image: string
version: string
tarballs: Record<Arch, VmArtifact>
}
export interface VmManifest {
schemaVersion: number
updatedAt: string
agents: Record<string, VmAgentEntry>
}
export type VmAgentTarball = VmArtifact
export type VersionComparison = 'same' | 'upgrade' | 'downgrade' | 'fresh'
export async function readCachedManifest(
browserosRoot?: string,
): Promise<VmManifest> {
const manifestPath = getCachedManifestPath(browserosRoot)
if (!existsSync(manifestPath)) throw new ManifestMissingError(manifestPath)
return readManifest(manifestPath)
}
export async function readInstalledManifest(
browserosRoot?: string,
): Promise<VmManifest | null> {
const manifestPath = getInstalledManifestPath(browserosRoot)
if (!existsSync(manifestPath)) return null
return readManifest(manifestPath)
}
export async function writeInstalledManifest(
manifest: VmManifest,
browserosRoot?: string,
): Promise<void> {
const manifestPath = getInstalledManifestPath(browserosRoot)
await mkdir(dirname(manifestPath), { recursive: true })
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`)
await rename(tempPath, manifestPath)
}
export function compareVersions(
installed: VmManifest | null,
cached: VmManifest,
): VersionComparison {
if (!installed) return 'fresh'
const comparison = compareVersionStrings(
installed.updatedAt,
cached.updatedAt,
)
if (comparison === 0) return 'same'
return comparison < 0 ? 'upgrade' : 'downgrade'
}
export function agentForArch(
manifest: VmManifest,
name: string,
arch: Arch,
): {
image: string
version: string
tarball: VmAgentTarball
} {
const agent = manifest.agents[name]
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)
const tarball = agent.tarballs[arch]
if (!tarball) throw new Error(`missing ${arch} tarball for agent ${name}`)
return {
image: agent.image,
version: agent.version,
tarball,
}
}
async function readManifest(path: string): Promise<VmManifest> {
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
}
function compareVersionStrings(left: string, right: string): number {
if (left < right) return -1
if (left > right) return 1
return 0
}

View File

@@ -19,7 +19,6 @@ import { PATHS } from '@browseros/shared/constants/paths'
export const VM_NAME = 'browseros-vm'
export const GUEST_VM_STATE = '/mnt/browseros/vm'
export const GUEST_IMAGE_CACHE = '/mnt/browseros/cache/images'
const HOST_LIMACTL_BINARY = 'limactl'
export type Arch = 'arm64' | 'x64'
@@ -54,18 +53,6 @@ export function getVmCacheDir(browserosRoot = rootDir()): string {
return join(browserosRoot, PATHS.CACHE_DIR_NAME, 'vm')
}
export function getImageCacheDir(browserosRoot = rootDir()): string {
return join(getVmCacheDir(browserosRoot), 'images')
}
export function getCachedManifestPath(browserosRoot = rootDir()): string {
return join(getVmCacheDir(browserosRoot), 'manifest.json')
}
export function getInstalledManifestPath(browserosRoot = rootDir()): string {
return join(getVmStateDir(browserosRoot), 'manifest.json')
}
export function getContainerdSocketPath(browserosRoot = rootDir()): string {
return join(getLimaHomeDir(browserosRoot), VM_NAME, 'sock', 'containerd.sock')
}
@@ -110,7 +97,7 @@ export function resolveBundledLimactl(
const candidate = join(limaRoot, 'bin', 'limactl')
if (!existsSync(candidate)) {
throw new Error(
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
`bundled limactl not found at ${candidate}; refresh server resources from the build-tools README`,
)
}
assertBundledLimaGuestAgent(limaRoot, hostArch)
@@ -158,7 +145,7 @@ export function resolveBundledLimaTemplate(resourcesDir: string): string {
const candidate = join(resourcesDir, 'vm', 'browseros-vm.yaml')
if (!existsSync(candidate)) {
throw new Error(
`bundled Lima template not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
`bundled Lima template not found at ${candidate}; refresh server resources from the build-tools README`,
)
}
return candidate
@@ -215,16 +202,10 @@ export function hostPathToGuest(
browserosRoot = rootDir(),
): string {
const vmState = getVmStateDir(browserosRoot)
const imageCache = getImageCacheDir(browserosRoot)
const vmStateRelative = mountedRelativePath(vmState, hostPath)
if (vmStateRelative !== null)
return guestPath(GUEST_VM_STATE, vmStateRelative)
const imageCacheRelative = mountedRelativePath(imageCache, hostPath)
if (imageCacheRelative !== null) {
return guestPath(GUEST_IMAGE_CACHE, imageCacheRelative)
}
throw new Error(`host path ${hostPath} is not under any known guest mount`)
}

View File

@@ -11,19 +11,12 @@ export const VM_TELEMETRY_EVENTS = {
create: 'vm.create',
start: 'vm.start',
stop: 'vm.stop',
upgradeDetected: 'vm.upgrade.detected',
downgradeDetected: 'vm.downgrade.detected',
upgradeSwap: 'vm.upgrade.swap',
upgradeReplay: 'vm.upgrade.replay',
resetDetected: 'vm.reset.detected',
resetOk: 'vm.reset.ok',
nerdctlWaitStart: 'vm.nerdctl_wait.start',
nerdctlWaitOk: 'vm.nerdctl_wait.ok',
nerdctlWaitPoll: 'vm.nerdctl_wait.poll',
nerdctlWaitTimeout: 'vm.nerdctl_wait.timeout',
manifestMissing: 'vm.manifest.missing',
manifestCompared: 'vm.manifest.compared',
manifestWritten: 'vm.manifest.written',
migrationOpenClawMoved: 'vm.migration.openclaw_moved',
limaSpawn: 'vm.lima.spawn',
limaExit: 'vm.lima.exit',

View File

@@ -7,17 +7,10 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { logger } from '../logger'
import { ensureVmCacheAvailable } from './cache-sync'
import { LimaCommandError, VmError, VmNotReadyError } from './errors'
import { LimaCli } from './lima-cli'
import { renderLimaTemplate } from './lima-config'
import {
compareVersions,
readCachedManifest,
readInstalledManifest,
writeInstalledManifest,
} from './manifest'
import { getImageCacheDir, getVmStateDir, VM_NAME } from './paths'
import { getVmStateDir, VM_NAME } from './paths'
import { VM_TELEMETRY_EVENTS } from './telemetry'
export type LogFn = (msg: string) => void
@@ -31,7 +24,6 @@ export interface VmRuntimeDeps {
browserosRoot?: string
readinessTimeoutMs?: number
readinessPollMs?: number
ensureCacheAvailable?: () => Promise<void>
}
export class VmRuntime {
@@ -59,34 +51,17 @@ export class VmRuntime {
limactlPath: this.deps.limactlPath,
})
await this.ensureCacheAvailable()
const cached = await readCachedManifest(this.deps.browserosRoot)
const installed = await readInstalledManifest(this.deps.browserosRoot)
const versionComparison = compareVersions(installed, cached)
logger.debug(VM_TELEMETRY_EVENTS.manifestCompared, {
versionComparison,
installedUpdatedAt: installed?.updatedAt ?? null,
cachedUpdatedAt: cached.updatedAt,
})
const vms = await this.cli.list()
const existing = vms.find((vm) => vm.name === VM_NAME)
let shouldWriteInstalledManifest =
!existing || versionComparison === 'fresh' || versionComparison === 'same'
let branch = !existing
? 'provision-fresh'
: existing.status !== 'Running'
? 'start-existing'
: versionComparison === 'upgrade'
? 'running-upgrade-warn'
: versionComparison === 'downgrade'
? 'running-downgrade-warn'
: 'running-same'
: 'running'
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
branch,
existingStatus: existing?.status ?? null,
versionComparison,
})
if (!existing) {
@@ -101,28 +76,11 @@ export class VmRuntime {
(await this.needsContainerdReprovision())
) {
branch = 'recreate-legacy-runtime'
shouldWriteInstalledManifest = true
await this.recreateForContainerd(onLog)
} else if (versionComparison === 'upgrade') {
logger.warn(VM_TELEMETRY_EVENTS.upgradeDetected, {
from: installed?.updatedAt ?? null,
to: cached.updatedAt,
})
} else if (versionComparison === 'downgrade') {
logger.warn(VM_TELEMETRY_EVENTS.downgradeDetected, {
from: installed?.updatedAt ?? null,
to: cached.updatedAt,
})
}
}
await this.waitForRootlessNerdctl(this.readinessTimeoutMs)
if (shouldWriteInstalledManifest) {
await writeInstalledManifest(cached, this.deps.browserosRoot)
logger.debug(VM_TELEMETRY_EVENTS.manifestWritten, {
updatedAt: cached.updatedAt,
})
}
logger.info(VM_TELEMETRY_EVENTS.ensureReadyOk, {
durationMs: Date.now() - started,
@@ -220,14 +178,6 @@ export class VmRuntime {
})
}
private async ensureCacheAvailable(): Promise<void> {
if (this.deps.ensureCacheAvailable) {
await this.deps.ensureCacheAvailable()
return
}
await ensureVmCacheAvailable({ browserosRoot: this.deps.browserosRoot })
}
private async recreateForContainerd(onLog?: LogFn): Promise<void> {
onLog?.('Recreating BrowserOS VM for containerd runtime...')
try {
@@ -271,7 +221,6 @@ export class VmRuntime {
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
vmStateDir: getVmStateDir(this.deps.browserosRoot),
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
})
}

View File

@@ -35,7 +35,6 @@ import { metrics } from './lib/metrics'
import { isPortInUseError } from './lib/port-binding'
import { Sentry } from './lib/sentry'
import { seedSoulTemplate } from './lib/soul'
import { prefetchVmCache } from './lib/vm/cache-sync'
import { migrateBuiltinSkills } from './skills/migrate'
import {
startSkillSync,
@@ -61,7 +60,7 @@ export class Application {
})
const resourcesDir = path.resolve(this.config.resourcesDir)
configureVmRuntime({ resourcesDir, vmCache: this.vmCacheConfig() })
configureVmRuntime({ resourcesDir })
await this.initCoreServices()
if (!this.config.cdpPort) {
@@ -132,17 +131,20 @@ export class Application {
// handles async throws inside auto-start. Wrap both in try/catch so the
// process keeps running even when OpenClaw can't initialize at all.
try {
configureOpenClawService({
const openClawService = configureOpenClawService({
browserosServerPort: this.config.serverPort,
resourcesDir,
vmCache: this.vmCacheConfig(),
})
.tryAutoStart()
.catch((err) =>
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
void openClawService.prewarm().catch((err) =>
logger.warn('OpenClaw prewarm failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
void openClawService.tryAutoStart().catch((err) =>
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
} catch (err) {
logger.warn('OpenClaw configuration failed, continuing without it', {
error: err instanceof Error ? err.message : String(err),
@@ -174,7 +176,6 @@ export class Application {
private async initCoreServices(): Promise<void> {
this.configureLogDirectory()
await ensureBrowserosDir()
this.startVmCachePrefetch()
await cleanOldSessions()
await seedSoulTemplate()
await migrateBuiltinSkills()
@@ -223,25 +224,6 @@ export class Application {
})
}
private startVmCachePrefetch(): void {
if (!this.config.vmCachePrefetch) return
void prefetchVmCache({
manifestUrl: this.config.vmCacheManifestUrl,
}).catch((error) => {
logger.warn('BrowserOS VM cache prefetch failed', {
error: error instanceof Error ? error.message : String(error),
})
})
}
private vmCacheConfig(): {
manifestUrl: string
} {
return {
manifestUrl: this.config.vmCacheManifestUrl,
}
}
private configureLogDirectory(): void {
const logDir = this.config.executionDir
const resolvedDir = path.isAbsolute(logDir)

View File

@@ -3,7 +3,7 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
@@ -83,6 +83,10 @@ describe('container-runtime factory', () => {
running: false,
})
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
await expect(runtime.prewarmGatewayImage()).rejects.toThrow(
'supports macOS only',
)
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
@@ -102,24 +106,15 @@ describe('container-runtime factory', () => {
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
})
it('syncs the VM cache before deferred image loading reads the manifest', async () => {
const ensureSynced = mock(async () => {
throw new Error('cache sync sentinel')
})
it('builds a runtime whose image loader pulls directly through nerdctl', async () => {
const runtime = buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'darwin',
vmCache: {
ensureSynced,
},
})
await expect(
runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12'),
).rejects.toThrow('cache sync sentinel')
expect(ensureSynced).toHaveBeenCalledTimes(1)
expect(runtime).toBeDefined()
})
it('leaves both directories in place when new OpenClaw state already exists', async () => {

View File

@@ -4,11 +4,15 @@
*/
import { describe, expect, it, mock } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
import { ContainerNameInUseError } from '../../../../src/lib/vm/errors'
const PROJECT_DIR = '/tmp/openclaw'
const GATEWAY_IMAGE_REF = 'ghcr.io/openclaw/openclaw:2026.4.12'
const OPENCLAW_NAME_RELEASE_WAIT = { timeoutMs: 10_000, intervalMs: 100 }
const defaultSpec = {
hostPort: 18789,
hostHome: '/Users/me/.browseros/vm/openclaw',
@@ -34,6 +38,10 @@ describe('ContainerRuntime', () => {
{ force: true },
undefined,
)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_NAME_RELEASE_WAIT,
)
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
@@ -41,7 +49,7 @@ describe('ContainerRuntime', () => {
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: GATEWAY_IMAGE_REF,
image: OPENCLAW_IMAGE,
restart: 'unless-stopped',
ports: [
{
@@ -66,6 +74,62 @@ describe('ContainerRuntime', () => {
)
})
it('reconciles and retries when gateway create reports name-in-use', async () => {
const deps = createDeps()
deps.shell.createContainer = mock(async () => {
if (deps.shell.createContainer.mock.calls.length === 1) {
throw new ContainerNameInUseError(
OPENCLAW_GATEWAY_CONTAINER_NAME,
'nerdctl create',
1,
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
)
}
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway(defaultSpec)
expect(deps.shell.createContainer).toHaveBeenCalledTimes(2)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(2)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
expect(deps.shell.startContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('bounds gateway create retries when the name stays in use', async () => {
const deps = createDeps()
deps.shell.createContainer = mock(async () => {
throw new ContainerNameInUseError(
OPENCLAW_GATEWAY_CONTAINER_NAME,
'nerdctl create',
1,
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
)
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.startGateway(defaultSpec)).rejects.toBeInstanceOf(
ContainerNameInUseError,
)
expect(deps.shell.createContainer).toHaveBeenCalledTimes(3)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(3)
expect(deps.shell.startContainer).not.toHaveBeenCalled()
})
it('uses OPENCLAW_IMAGE as a direct image override', async () => {
const previous = process.env.OPENCLAW_IMAGE
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
@@ -137,7 +201,7 @@ describe('ContainerRuntime', () => {
'/mnt/browseros/vm/openclaw:/home/node',
'--add-host',
'host.containers.internal:192.168.5.2',
GATEWAY_IMAGE_REF,
OPENCLAW_IMAGE,
]),
undefined,
)
@@ -150,6 +214,45 @@ describe('ContainerRuntime', () => {
{ force: true },
undefined,
)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
OPENCLAW_NAME_RELEASE_WAIT,
)
})
it('reconciles and retries when setup create reports name-in-use', async () => {
const deps = createDeps()
let setupCreateCount = 0
deps.shell.runCommand = mock(async (args: string[]) => {
if (args[0] === 'create') {
setupCreateCount += 1
if (setupCreateCount === 1) {
return {
exitCode: 1,
stdout: '',
stderr: `name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup" is already used`,
}
}
}
return { exitCode: 0, stdout: '', stderr: '' }
})
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(
runtime.runGatewaySetupCommand(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
),
).resolves.toBe(0)
expect(setupCreateCount).toBe(2)
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
})
it('tails and fetches gateway logs through the new transport', async () => {
@@ -175,6 +278,70 @@ describe('ContainerRuntime', () => {
)
expect(logs).toEqual(['log line'])
})
it('prewarms the gateway image without creating a container', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.prewarmGatewayImage()
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
'openclaw',
undefined,
)
expect(deps.shell.createContainer).not.toHaveBeenCalled()
})
it('detects when the gateway container uses the current image', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(async () => OPENCLAW_IMAGE)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
expect(deps.shell.containerImageRef).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('treats a digest-qualified current image ref as current', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(
async () => `${OPENCLAW_IMAGE}@sha256:${'a'.repeat(64)}`,
)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
})
it('detects when the gateway container uses an old image', async () => {
const deps = createDeps()
deps.shell.containerImageRef.mockImplementation(
async () => 'ghcr.io/openclaw/openclaw:old',
)
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
})
})
function createDeps() {
@@ -190,6 +357,8 @@ function createDeps() {
startContainer: mock(async () => {}),
stopContainer: mock(async () => {}),
removeContainer: mock(async () => {}),
containerImageRef: mock(async () => OPENCLAW_IMAGE),
waitForContainerNameRelease: mock(async () => {}),
exec: mock(async () => 0),
runCommand: mock(
async (_args: string[], onLog?: (line: string) => void) => {
@@ -201,7 +370,7 @@ function createDeps() {
},
loader: {
ensureImageLoaded: mock(async () => {}),
ensureAgentImageLoaded: mock(async () => GATEWAY_IMAGE_REF),
ensureAgentImageLoaded: mock(async () => OPENCLAW_IMAGE),
},
}
}

View File

@@ -8,7 +8,10 @@ import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import {
resolveSupportedOpenClawProvider,
UnsupportedOpenClawProviderError,
@@ -23,11 +26,13 @@ type MutableOpenClawService = OpenClawService & {
token: string
restart: ReturnType<typeof mock>
runtime: {
ensureReady?: () => Promise<void>
ensureReady?: (_onLog?: (_line: string) => void) => Promise<void>
isPodmanAvailable?: () => Promise<boolean>
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
isHealthy?: (_hostPort?: number) => Promise<boolean>
isReady: (_hostPort?: number) => Promise<boolean>
prewarmGatewayImage?: (_onLog?: (_line: string) => void) => Promise<void>
isGatewayCurrent?: () => Promise<boolean>
pullImage?: (
_image: string,
_onLog?: (_line: string) => void,
@@ -87,6 +92,60 @@ describe('OpenClawService', () => {
return forced >= 65000 ? forced - 10 : forced + 10
}
it('prewarms the VM and gateway image', async () => {
const ensureReady = mock(async () => {})
const prewarmGatewayImage = mock(async () => {})
const logs: string[] = []
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
ensureReady,
isReady: async () => false,
prewarmGatewayImage,
}
await service.prewarm((line) => logs.push(line))
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(prewarmGatewayImage).toHaveBeenCalledTimes(1)
expect(ensureReady.mock.calls[0]?.length).toBe(0)
expect(prewarmGatewayImage.mock.calls[0]?.length).toBe(0)
expect(logs).toContain('OpenClaw prewarm: ensuring BrowserOS VM is ready')
expect(logs).toContain(
`OpenClaw prewarm: ensuring image ${OPENCLAW_IMAGE} is available`,
)
expect(logs).toContain('OpenClaw prewarm: ready')
})
it('logs the overridden image ref during prewarm', async () => {
const originalImage = process.env.OPENCLAW_IMAGE
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
const ensureReady = mock(async () => {})
const prewarmGatewayImage = mock(async () => {})
const logs: string[] = []
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
ensureReady,
isReady: async () => false,
prewarmGatewayImage,
}
try {
await service.prewarm((line) => logs.push(line))
} finally {
if (originalImage === undefined) {
delete process.env.OPENCLAW_IMAGE
} else {
process.env.OPENCLAW_IMAGE = originalImage
}
}
expect(logs).toContain(
'OpenClaw prewarm: ensuring image localhost/openclaw:test is available',
)
})
it('creates agents through the cli client without role bootstrap files', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
const createAgent = mock(async () => ({
@@ -657,6 +716,7 @@ describe('OpenClawService', () => {
service.runtime = {
ensureReady,
isReady: async () => gatewayReady,
isGatewayCurrent: mock(async () => true),
startGateway,
waitForReady,
}
@@ -677,6 +737,77 @@ describe('OpenClawService', () => {
expect(probe).toHaveBeenCalledTimes(2)
})
it('serializes start across service instances sharing an OpenClaw dir', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
token: 'cli-token',
},
},
}),
)
let gatewayReady = false
let releaseStartGateway!: () => void
let notifyStartGatewayEntered!: () => void
const startGatewayEntered = new Promise<void>((resolve) => {
notifyStartGatewayEntered = resolve
})
const unblockStartGateway = new Promise<void>((resolve) => {
releaseStartGateway = resolve
})
const firstEnsureReady = mock(async () => {})
const secondEnsureReady = mock(async () => {})
const startGateway = mock(async () => {
notifyStartGatewayEntered()
await unblockStartGateway
gatewayReady = true
})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const firstService = new OpenClawService() as MutableOpenClawService
const secondService = new OpenClawService() as MutableOpenClawService
firstService.openclawDir = tempDir
secondService.openclawDir = tempDir
firstService.runtime = {
ensureReady: firstEnsureReady,
isReady: async () => gatewayReady,
isGatewayCurrent: async () => true,
startGateway,
waitForReady,
}
secondService.runtime = {
ensureReady: secondEnsureReady,
isReady: async () => gatewayReady,
isGatewayCurrent: async () => true,
startGateway,
waitForReady,
}
firstService.cliClient = { probe }
secondService.cliClient = { probe }
mockGatewayAuth()
const firstStart = firstService.start()
await startGatewayEntered
const secondStart = secondService.start()
await Bun.sleep(25)
const secondEnteredBeforeFirstFinished = secondEnsureReady.mock.calls.length
releaseStartGateway()
await Promise.all([firstStart, secondStart])
expect(secondEnteredBeforeFirstFinished).toBe(0)
expect(firstEnsureReady).toHaveBeenCalledTimes(1)
expect(secondEnsureReady).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledTimes(1)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(2)
})
it('does not restart a ready gateway when start is called again', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
@@ -700,6 +831,7 @@ describe('OpenClawService', () => {
service.runtime = {
ensureReady,
isReady: async () => true,
isGatewayCurrent: mock(async () => true),
startGateway,
waitForReady,
}
@@ -948,6 +1080,7 @@ describe('OpenClawService', () => {
isPodmanAvailable: async () => true,
ensureReady,
isReady,
isGatewayCurrent: mock(async () => true),
startGateway,
waitForReady,
}
@@ -971,6 +1104,71 @@ describe('OpenClawService', () => {
expect(isReady).toHaveBeenCalledTimes(2)
})
it('tryAutoStart reuses a ready gateway when the image is current', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({ gateway: { auth: { token: 'cli-token' } } }),
)
const ensureReady = mock(async () => {})
const isReady = mock(async () => true)
const isGatewayCurrent = mock(async () => true)
const startGateway = mock(async () => {})
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady,
isGatewayCurrent,
startGateway,
}
service.cliClient = { probe }
mockGatewayAuth()
await service.tryAutoStart()
expect(ensureReady).toHaveBeenCalledTimes(1)
expect(isGatewayCurrent).toHaveBeenCalledTimes(1)
expect(startGateway).not.toHaveBeenCalled()
expect(probe).toHaveBeenCalledTimes(1)
})
it('tryAutoStart recreates a ready gateway when the image is stale', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({ gateway: { auth: { token: 'cli-token' } } }),
)
const ensureReady = mock(async () => {})
const isReady = mock(async () => true)
const isGatewayCurrent = mock(async () => false)
const startGateway = mock(async () => {})
const waitForReady = mock(async () => true)
const probe = mock(async () => {})
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady,
isGatewayCurrent,
startGateway,
waitForReady,
}
service.cliClient = { probe }
mockGatewayAuth()
await service.tryAutoStart()
expect(startGateway).toHaveBeenCalledTimes(1)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
})
it('keeps openrouter model refs verbatim without rewriting dots', () => {
const provider = resolveSupportedOpenClawProvider({
providerType: 'openrouter',

View File

@@ -8,7 +8,6 @@ import { homedir } from 'node:os'
import { join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import {
getAgentCacheDir,
getBrowserosDir,
getCacheDir,
getVmCacheDir,
@@ -106,12 +105,4 @@ describe('getBrowserosDir', () => {
join(homedir(), '.browseros-dev', 'cache', 'vm'),
)
})
it('uses an agent image cache directory below vm cache', () => {
process.env.NODE_ENV = 'development'
expect(getAgentCacheDir()).toBe(
join(homedir(), '.browseros-dev', 'cache', 'vm', 'images'),
)
})
})

View File

@@ -34,8 +34,6 @@ const REQUIRED_INLINE_ENV_KEYS = [
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'SENTRY_DSN',
'BROWSEROS_VM_CACHE_PREFETCH',
'BROWSEROS_VM_CACHE_MANIFEST_URL',
] as const
const R2_ENV_KEYS = [
@@ -52,8 +50,6 @@ const INLINE_ENV_STUBS: Record<string, string> = {
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
POSTHOG_API_KEY: 'phc_test_stub',
SENTRY_DSN: 'https://stub@sentry.test/0',
BROWSEROS_VM_CACHE_PREFETCH: 'true',
BROWSEROS_VM_CACHE_MANIFEST_URL: 'https://stub.test/vm/manifest.json',
}
const R2_ENV_STUBS: Record<string, string> = {

View File

@@ -28,8 +28,6 @@ describe('loadServerConfig', () => {
delete process.env.BROWSEROS_INSTALL_ID
delete process.env.BROWSEROS_CLIENT_ID
delete process.env.BROWSEROS_AI_SDK_DEVTOOLS
delete process.env.BROWSEROS_VM_CACHE_PREFETCH
delete process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
})
afterEach(() => {
@@ -446,75 +444,6 @@ describe('loadServerConfig', () => {
if (!result.ok) return
assert.strictEqual(result.value.aiSdkDevtoolsEnabled, false)
})
it('defaults VM cache runtime sync settings', () => {
const result = loadServerConfig([
'bun',
'src/index.ts',
'--server-port=3000',
])
assert.strictEqual(result.ok, true)
if (!result.ok) return
assert.strictEqual(result.value.vmCachePrefetch, true)
assert.strictEqual(
result.value.vmCacheManifestUrl,
'https://cdn.browseros.com/vm/manifest.json',
)
})
})
describe('VM cache runtime sync', () => {
it('reads VM cache settings from env', () => {
process.env.BROWSEROS_VM_CACHE_PREFETCH = 'false'
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
' https://manifest.test/vm.json '
const result = loadServerConfig([
'bun',
'src/index.ts',
'--server-port=3000',
])
assert.strictEqual(result.ok, true)
if (!result.ok) return
assert.strictEqual(result.value.vmCachePrefetch, false)
assert.strictEqual(
result.value.vmCacheManifestUrl,
'https://manifest.test/vm.json',
)
})
it('reads VM cache settings from config with file precedence over env', () => {
process.env.BROWSEROS_VM_CACHE_PREFETCH = 'false'
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
'https://env.test/manifest.json'
const configPath = path.join(tempDir, 'config.json')
fs.writeFileSync(
configPath,
JSON.stringify({
ports: { server: 3000 },
vm_cache: {
prefetch: true,
manifest_url: ' https://config.test/vm/manifest.json ',
},
}),
)
const result = loadServerConfig([
'bun',
'src/index.ts',
`--config=${configPath}`,
])
assert.strictEqual(result.ok, true)
if (!result.ok) return
assert.strictEqual(result.value.vmCachePrefetch, true)
assert.strictEqual(
result.value.vmCacheManifestUrl,
'https://config.test/vm/manifest.json',
)
})
})
describe('AI SDK DevTools', () => {

View File

@@ -5,15 +5,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { ContainerCli } from '../../src/lib/container'
import { LimaCli, type VmManifest, VmRuntime } from '../../src/lib/vm'
import {
getCachedManifestPath,
getContainerdSocketPath,
VM_NAME,
} from '../../src/lib/vm/paths'
import { LimaCli, VmRuntime } from '../../src/lib/vm'
import { getContainerdSocketPath, VM_NAME } from '../../src/lib/vm/paths'
const LIVE_VM_SMOKE_TIMEOUT_MS = 10 * 60 * 1000
const liveIt = process.env.LIVE_VM_SMOKE === '1' ? it : it.skip
@@ -23,12 +19,6 @@ const templatePath = resolve(
'../../../../packages/build-tools/template/browseros-vm.yaml',
)
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {},
}
describe('BrowserOS VM live smoke', () => {
let root: string
let limaHome: string
@@ -36,9 +26,6 @@ describe('BrowserOS VM live smoke', () => {
beforeEach(async () => {
root = await mkdtemp('/tmp/bovm-')
limaHome = join(root, 'lima')
const manifestPath = getCachedManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
})
afterEach(async () => {

View File

@@ -4,10 +4,20 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises'
import { join } from 'node:path'
import { ContainerCli } from '../../../src/lib/container/container-cli'
import { ContainerCliError } from '../../../src/lib/vm/errors'
import {
ContainerCliError,
ContainerNameInUseError,
} from '../../../src/lib/vm/errors'
import { fakeSsh } from '../../__helpers__/fake-ssh'
describe('ContainerCli', () => {
@@ -42,6 +52,35 @@ describe('ContainerCli', () => {
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(false)
})
it('reads a container configured image ref', async () => {
const sshPath = await fakeSsh(
{ stdout: 'ghcr.io/openclaw/openclaw:2026.4.12\n' },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.containerImageRef('gateway')).resolves.toBe(
'ghcr.io/openclaw/openclaw:2026.4.12',
)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'inspect' '--format' '{{.Config.Image}}' 'gateway'`,
)
})
it('returns null when reading a missing container image ref', async () => {
const sshPath = await fakeSsh(
{
stderr: 'no such container',
exit: 1,
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.containerImageRef('missing')).resolves.toBeNull()
})
it('pulls images with progress and throws typed command errors', async () => {
const sshPath = await fakeSsh(
{ stdout: 'pulling\n', stderr: 'denied', exit: 2 },
@@ -61,21 +100,6 @@ describe('ContainerCli', () => {
expect(lines).toContain('denied')
})
it('loads images from guest tarballs and returns loaded refs', async () => {
const sshPath = await fakeSsh(
{ stdout: 'Loaded image(s): openclaw:v1\n' },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(
cli.loadImage('/mnt/browseros/cache/images/openclaw.tar.gz'),
).resolves.toEqual(['openclaw:v1'])
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'load' '-i' '/mnt/browseros/cache/images/openclaw.tar.gz'`,
)
})
it('creates containers from typed specs', async () => {
const sshPath = await fakeSsh({}, logPath)
const cli = await createCli(sshPath, tempDir)
@@ -149,6 +173,92 @@ describe('ContainerCli', () => {
)
})
it('inspects a container by name', async () => {
const sshPath = await fakeSsh(
{
stdout: JSON.stringify({
ID: 'abc123',
Name: 'gateway',
Config: { Image: 'openclaw:v1' },
State: { Status: 'running', Running: true },
}),
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).resolves.toEqual({
id: 'abc123',
name: 'gateway',
image: 'openclaw:v1',
status: 'running',
running: true,
})
await expect(readFile(logPath, 'utf8')).resolves.toContain(
"lima-browseros-vm 'nerdctl' 'container' 'inspect' '--format' '{{json .}}' 'gateway'",
)
})
it('returns null when inspected containers are absent', async () => {
const sshPath = await fakeSsh(
{ stderr: 'no such container', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).resolves.toBeNull()
})
it('does not treat unrelated not found errors as absent containers', async () => {
const sshPath = await fakeSsh(
{ stderr: 'network interface not found', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.inspectContainer('gateway')).rejects.toBeInstanceOf(
ContainerCliError,
)
})
it('waits until a container name is no longer resolvable', async () => {
const sshPath = await fakeSshContainerExistsThenMissing(tempDir, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(
cli.waitForContainerNameRelease('gateway', {
timeoutMs: 500,
intervalMs: 5,
}),
).resolves.toBeUndefined()
const inspectCalls = (await readFile(logPath, 'utf8'))
.split('\n')
.filter((line) => line.includes("'container' 'inspect'"))
expect(inspectCalls).toHaveLength(2)
})
it('classifies create name-store collisions as name-in-use errors', async () => {
const sshPath = await fakeSsh(
{
stderr:
'name-store error\nname "gateway" is already used by ID "abc123"',
exit: 1,
},
logPath,
)
const cli = await createCli(sshPath, tempDir)
const error = await cli
.createContainer({ name: 'gateway', image: 'openclaw:v1' })
.catch((err) => err)
expect(error).toBeInstanceOf(ContainerNameInUseError)
expect(error.containerName).toBe('gateway')
expect(error.stderr).toContain('name "gateway" is already used')
})
it('tolerates removal when the container is already absent', async () => {
const sshPath = await fakeSsh(
{ stderr: 'no such container', exit: 1 },
@@ -201,3 +311,31 @@ function sshConfigPath(tempDir: string): string {
function sshPrefix(configPath: string): string {
return `ARGS:-F ${configPath} lima-browseros-vm`
}
async function fakeSshContainerExistsThenMissing(
tempDir: string,
logPath: string,
): Promise<string> {
const path = join(tempDir, 'ssh-container-exists-then-missing')
const counterPath = join(tempDir, 'ssh-container-exists-then-missing.count')
const body = `#!/usr/bin/env bash
set -u
echo "ARGS:$*" >> "${logPath}"
count="$(cat "${counterPath}" 2>/dev/null || echo 0)"
next=$((count + 1))
printf '%s' "$next" > "${counterPath}"
case "$count" in
0)
printf '{"ID":"abc123","Name":"gateway","Config":{"Image":"openclaw:v1"},"State":{"Status":"exited","Running":false}}'
exit 0
;;
*)
echo "no such container" >&2
exit 1
;;
esac
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}

View File

@@ -3,197 +3,83 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_IMAGE } from '@browseros/shared/constants/openclaw'
import type { ContainerCli } from '../../../src/lib/container/container-cli'
import { ImageLoader } from '../../../src/lib/container/image-loader'
import { ContainerCliError, ImageLoadError } from '../../../src/lib/vm/errors'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import * as paths from '../../../src/lib/vm/paths'
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'agent-arm',
sizeBytes: 1,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'agent-x64',
sizeBytes: 1,
},
},
},
},
}
describe('ImageLoader', () => {
afterEach(() => {
mock.restore()
})
it('returns without loading when the image already exists', async () => {
it('returns without pulling when the image already exists', async () => {
const cli = new FakeContainerCli([true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const loader = new ImageLoader(cli as never)
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
await loader.ensureImageLoaded(OPENCLAW_IMAGE)
expect(cli.loadCalls).toEqual([])
expect(cli.pullCalls).toEqual([])
expect(cli.existsCalls).toEqual([OPENCLAW_IMAGE])
})
it('loads a missing image from the guest cache and verifies it exists', async () => {
it('pulls a missing image and verifies it exists', async () => {
const cli = new FakeContainerCli([false, true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const loader = new ImageLoader(cli as never)
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
await loader.ensureImageLoaded(OPENCLAW_IMAGE)
expect(cli.loadCalls).toEqual([
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
])
expect(cli.existsCalls).toEqual([
'ghcr.io/openclaw/openclaw:2026.4.12',
'ghcr.io/openclaw/openclaw:2026.4.12',
])
expect(cli.pullCalls).toEqual([OPENCLAW_IMAGE])
expect(cli.existsCalls).toEqual([OPENCLAW_IMAGE, OPENCLAW_IMAGE])
})
it('loads an agent image by manifest name and returns its image ref', async () => {
it('loads the OpenClaw agent image by manifest name', async () => {
const cli = new FakeContainerCli([false, true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const loader = new ImageLoader(cli as never)
await expect(loader.ensureAgentImageLoaded('openclaw')).resolves.toBe(
'ghcr.io/openclaw/openclaw:2026.4.12',
OPENCLAW_IMAGE,
)
expect(cli.loadCalls).toEqual([
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
])
expect(cli.existsCalls).toEqual([
'ghcr.io/openclaw/openclaw:2026.4.12',
'ghcr.io/openclaw/openclaw:2026.4.12',
])
expect(cli.pullCalls).toEqual([OPENCLAW_IMAGE])
})
it('returns an agent image ref without loading when already cached', async () => {
const cli = new FakeContainerCli([true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(loader.ensureAgentImageLoaded('openclaw')).resolves.toBe(
'ghcr.io/openclaw/openclaw:2026.4.12',
)
expect(cli.loadCalls).toEqual([])
expect(cli.existsCalls).toEqual(['ghcr.io/openclaw/openclaw:2026.4.12'])
})
it('throws ImageLoadError when the agent name is absent from the manifest', async () => {
it('throws ImageLoadError for unknown agent names', async () => {
const cli = new FakeContainerCli([])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const loader = new ImageLoader(cli as never)
const error = await loader
.ensureAgentImageLoaded('missing')
.catch((err) => err)
expect(error).toBeInstanceOf(ImageLoadError)
expect(error.message).toContain('no agent in manifest: missing')
expect(cli.existsCalls).toEqual([])
expect(cli.loadCalls).toEqual([])
})
it('throws ImageLoadError when the manifest lacks a tarball for the arch', async () => {
const missingArchManifest = {
...manifest,
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'agent-arm',
sizeBytes: 1,
},
},
},
},
} as unknown as VmManifest
const cli = new FakeContainerCli([false])
const loader = new ImageLoader(cli as never, missingArchManifest, 'x64')
const error = await loader
.ensureAgentImageLoaded('openclaw')
.catch((err) => err)
expect(error).toBeInstanceOf(ImageLoadError)
expect(error.message).toContain('no x64 tarball in manifest')
expect(cli.loadCalls).toEqual([])
})
it('resolves image tarballs against the configured BrowserOS root', async () => {
const cli = new FakeContainerCli([false, true])
const browserosRoot = '/tmp/browseros-custom-root'
const loader = new ImageLoader(
cli as never,
manifest,
'arm64',
browserosRoot,
)
const getImageCacheDir = spyOn(paths, 'getImageCacheDir')
const hostPathToGuest = spyOn(paths, 'hostPathToGuest')
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
expect(getImageCacheDir).toHaveBeenCalledWith(browserosRoot)
expect(hostPathToGuest).toHaveBeenCalledWith(
'/tmp/browseros-custom-root/cache/vm/images/openclaw-2026.4.12-arm64.tar.gz',
browserosRoot,
)
})
it('throws ImageLoadError when a loaded image is still absent', async () => {
const cli = new FakeContainerCli([false, false])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(
loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12'),
).rejects.toThrow(ImageLoadError)
})
it('throws ImageLoadError for unknown refs without loading', async () => {
const cli = new FakeContainerCli([false])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(loader.ensureImageLoaded('missing:v1')).rejects.toThrow(
await expect(loader.ensureAgentImageLoaded('missing')).rejects.toThrow(
ImageLoadError,
)
expect(cli.loadCalls).toEqual([])
expect(cli.pullCalls).toEqual([])
})
it('wraps ContainerCliError load failures as ImageLoadError', async () => {
it('throws ImageLoadError when pull succeeds but image is still absent', async () => {
const cli = new FakeContainerCli([false, false])
const loader = new ImageLoader(cli as never)
await expect(loader.ensureImageLoaded(OPENCLAW_IMAGE)).rejects.toThrow(
ImageLoadError,
)
})
it('wraps ContainerCliError pull failures as ImageLoadError', async () => {
const cli = new FakeContainerCli([false])
cli.loadError = new ContainerCliError('nerdctl load', 125, 'bad archive')
const loader = new ImageLoader(cli as never, manifest, 'arm64')
cli.pullError = new ContainerCliError('nerdctl pull', 1, 'network failed')
const loader = new ImageLoader(cli as never)
const error = await loader
.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
.ensureImageLoaded(OPENCLAW_IMAGE)
.catch((err) => err)
expect(error).toBeInstanceOf(ImageLoadError)
expect(error.cause).toBe(cli.loadError)
expect(error.cause).toBe(cli.pullError)
})
})
class FakeContainerCli
implements Pick<ContainerCli, 'imageExists' | 'loadImage'>
implements Pick<ContainerCli, 'imageExists' | 'pullImage'>
{
existsCalls: string[] = []
loadCalls: string[] = []
loadError: Error | null = null
pullCalls: string[] = []
pullError: Error | null = null
constructor(private readonly existsResponses: boolean[]) {}
@@ -202,9 +88,8 @@ class FakeContainerCli
return this.existsResponses.shift() ?? false
}
async loadImage(path: string): Promise<string[]> {
this.loadCalls.push(path)
if (this.loadError) throw this.loadError
return ['loaded']
async pullImage(ref: string): Promise<void> {
this.pullCalls.push(ref)
if (this.pullError) throw this.pullError
}
}

View File

@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
ProcessLockTimeoutError,
resolveProcessLockPath,
withProcessLock,
} from '../../src/lib/process-lock'
describe('process-lock', () => {
let tempDir: string
let lockDir: string
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'process-lock-'))
lockDir = join(tempDir, '.locks')
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
it('serializes concurrent callers for the same lock name', async () => {
const events: string[] = []
let releaseFirst!: () => void
const firstMayFinish = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const first = withProcessLock(
'openclaw-lifecycle',
{ lockDir },
async () => {
events.push('first:start')
await firstMayFinish
events.push('first:end')
},
)
while (!events.includes('first:start')) await Bun.sleep(1)
const second = withProcessLock(
'openclaw-lifecycle',
{
lockDir,
retryMinTimeoutMs: 5,
retryMaxTimeoutMs: 5,
},
async () => {
events.push('second')
},
)
await Bun.sleep(25)
expect(events).toEqual(['first:start'])
releaseFirst()
await Promise.all([first, second])
expect(events).toEqual(['first:start', 'first:end', 'second'])
})
it('releases the lock when the callback throws', async () => {
await expect(
withProcessLock('openclaw-lifecycle', { lockDir }, async () => {
throw new Error('boom')
}),
).rejects.toThrow('boom')
await expect(
withProcessLock('openclaw-lifecycle', { lockDir }, async () => 'ok'),
).resolves.toBe('ok')
})
it('fails with a structured timeout error when acquisition takes too long', async () => {
let releaseFirst!: () => void
const firstMayFinish = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const first = withProcessLock(
'openclaw-lifecycle',
{ lockDir },
async () => {
await firstMayFinish
},
)
await Bun.sleep(10)
try {
await expect(
withProcessLock(
'openclaw-lifecycle',
{
lockDir,
timeoutMs: 25,
retryMinTimeoutMs: 5,
retryMaxTimeoutMs: 5,
},
async () => undefined,
),
).rejects.toBeInstanceOf(ProcessLockTimeoutError)
} finally {
releaseFirst()
await first
}
})
it('sanitizes lock names into the lock directory', async () => {
const path = resolveProcessLockPath(lockDir, '../OpenClaw Lifecycle!')
expect(path).toBe(join(lockDir, 'OpenClaw-Lifecycle.lock'))
await withProcessLock(
'../OpenClaw Lifecycle!',
{ lockDir },
async () => undefined,
)
const entries = await readdir(lockDir)
expect(entries).not.toContain('..')
})
})

View File

@@ -1,431 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
ensureVmCacheAvailable,
ensureVmCacheSynced,
prefetchVmCache,
} from '../../../src/lib/vm/cache-sync'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import { getCachedManifestPath } from '../../../src/lib/vm/paths'
const CDN_BASE = 'https://cdn.test'
const MANIFEST_URL = `${CDN_BASE}/vm/manifest.json`
const TARBALL_KEY = 'vm/images/openclaw-2026.4.12-arm64.tar.gz'
const TARBALL_BYTES = new TextEncoder().encode('openclaw-tarball')
const TARBALL_SHA = sha256(TARBALL_BYTES)
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-24T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: TARBALL_KEY,
sha256: TARBALL_SHA,
sizeBytes: TARBALL_BYTES.byteLength,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'unused',
sizeBytes: 1,
},
},
},
},
}
describe('runtime VM cache sync', () => {
let root: string
let originalManifestUrl: string | undefined
beforeEach(async () => {
root = await mkdtemp('/tmp/browseros-vm-cache-sync-')
originalManifestUrl = process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
delete process.env.BROWSEROS_VM_CACHE_MANIFEST_URL
})
afterEach(async () => {
restoreEnv('BROWSEROS_VM_CACHE_MANIFEST_URL', originalManifestUrl)
await rm(root, { recursive: true, force: true })
})
it('downloads the host-arch tarball, verifies it, and writes the manifest last', async () => {
const calls: string[] = []
const fetchImpl = fakeVmCacheFetch(calls)
const result = await ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
expect(result).toEqual({
downloaded: [TARBALL_KEY],
manifestPath: getCachedManifestPath(root),
skipped: false,
})
expect(
JSON.parse(await readFile(getCachedManifestPath(root), 'utf8')),
).toEqual(manifest)
expect(await readFile(join(root, 'cache', TARBALL_KEY), 'utf8')).toBe(
'openclaw-tarball',
)
await expect(
stat(join(root, 'cache', `${TARBALL_KEY}.partial`)),
).rejects.toThrow()
})
it('uses the runtime env manifest URL and resolves artifacts beside it', async () => {
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL =
'https://artifacts.test/vm/manifest.json'
const calls: string[] = []
const fetchImpl = fakeVmCacheFetch(calls, {
manifestUrl: 'https://artifacts.test/vm/manifest.json',
tarballUrl: `https://artifacts.test/${TARBALL_KEY}`,
})
await ensureVmCacheSynced({
browserosRoot: root,
fetchImpl,
rawHostArch: 'arm64',
})
expect(calls).toEqual([
'https://artifacts.test/vm/manifest.json',
`https://artifacts.test/${TARBALL_KEY}`,
])
})
it('skips downloads when the matching manifest and tarball already exist', async () => {
await writeLocalManifest(root)
await writeLocalTarball(root)
const calls: string[] = []
const result = await ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl: fakeVmCacheFetch(calls),
rawHostArch: 'arm64',
})
expect(calls).toEqual([MANIFEST_URL])
expect(result.downloaded).toEqual([])
expect(result.skipped).toBe(true)
})
it('downloads a tarball when the manifest matches but the file is missing', async () => {
await writeLocalManifest(root)
const calls: string[] = []
const result = await ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl: fakeVmCacheFetch(calls),
rawHostArch: 'arm64',
})
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
expect(result.downloaded).toEqual([TARBALL_KEY])
expect(await readFile(join(root, 'cache', TARBALL_KEY), 'utf8')).toBe(
'openclaw-tarball',
)
})
it('uses an existing tarball when the local manifest is missing but the hash matches', async () => {
await writeLocalTarball(root)
const calls: string[] = []
const result = await ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl: fakeVmCacheFetch(calls),
rawHostArch: 'arm64',
})
expect(calls).toEqual([MANIFEST_URL])
expect(result.downloaded).toEqual([])
expect(result.skipped).toBe(true)
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
`${JSON.stringify(manifest, null, 2)}\n`,
)
})
it('shares concurrent prefetch calls through one in-flight sync', async () => {
const calls: string[] = []
let resolveManifest: (response: Response) => void = () => {}
const manifestResponse = new Promise<Response>((resolve) => {
resolveManifest = resolve
})
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (url === MANIFEST_URL) return manifestResponse
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}
const first = prefetchVmCache({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
const second = prefetchVmCache({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
expect(second).toBe(first)
expect(calls).toEqual([MANIFEST_URL])
resolveManifest(jsonResponse(manifest))
await expect(first).resolves.toEqual({
downloaded: [TARBALL_KEY],
manifestPath: getCachedManifestPath(root),
skipped: false,
})
await expect(second).resolves.toEqual({
downloaded: [TARBALL_KEY],
manifestPath: getCachedManifestPath(root),
skipped: false,
})
expect(calls).toEqual([MANIFEST_URL, `${CDN_BASE}/${TARBALL_KEY}`])
})
it('syncs different roots independently while another sync is in flight', async () => {
const otherRoot = await mkdtemp('/tmp/browseros-vm-cache-sync-other-')
try {
const calls: string[] = []
let resolveManifest: (response: Response) => void = () => {}
const manifestResponse = new Promise<Response>((resolve) => {
resolveManifest = resolve
})
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (calls.length === 1 && url === MANIFEST_URL) return manifestResponse
if (url === MANIFEST_URL) return jsonResponse(manifest)
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}
const first = prefetchVmCache({
browserosRoot: otherRoot,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
const second = ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
expect(second).not.toBe(first)
await second
resolveManifest(jsonResponse(manifest))
await first
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
`${JSON.stringify(manifest, null, 2)}\n`,
)
await expect(
readFile(getCachedManifestPath(otherRoot), 'utf8'),
).resolves.toBe(`${JSON.stringify(manifest, null, 2)}\n`)
expect(calls).toEqual([
MANIFEST_URL,
MANIFEST_URL,
`${CDN_BASE}/${TARBALL_KEY}`,
`${CDN_BASE}/${TARBALL_KEY}`,
])
} finally {
await rm(otherRoot, { recursive: true, force: true })
}
})
it('retries on-demand availability after an in-flight prefetch fails', async () => {
const calls: string[] = []
let resolveManifest: (response: Response) => void = () => {}
const manifestResponse = new Promise<Response>((resolve) => {
resolveManifest = resolve
})
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (calls.length === 1 && url === MANIFEST_URL) return manifestResponse
if (url === MANIFEST_URL) return jsonResponse(manifest)
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}
const first = prefetchVmCache({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
}).catch((error) => error)
const available = ensureVmCacheAvailable({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
})
resolveManifest(new Response('', { status: 503 }))
await expect(first).resolves.toBeInstanceOf(Error)
await available
await expect(readFile(getCachedManifestPath(root), 'utf8')).resolves.toBe(
`${JSON.stringify(manifest, null, 2)}\n`,
)
expect(calls).toEqual([
MANIFEST_URL,
MANIFEST_URL,
`${CDN_BASE}/${TARBALL_KEY}`,
])
})
it('clears failed in-flight syncs so a later call can retry', async () => {
const calls: string[] = []
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (calls.length === 1) return new Response('', { status: 503 })
if (url === MANIFEST_URL) return jsonResponse(manifest)
if (url === `${CDN_BASE}/${TARBALL_KEY}`)
return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}
await expect(
ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
}),
).rejects.toThrow('manifest fetch failed')
await expect(
ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
}),
).resolves.toEqual({
downloaded: [TARBALL_KEY],
manifestPath: getCachedManifestPath(root),
skipped: false,
})
expect(calls).toEqual([
MANIFEST_URL,
MANIFEST_URL,
`${CDN_BASE}/${TARBALL_KEY}`,
])
})
it('removes the partial file when sha256 verification fails', async () => {
const badBytes = new TextEncoder().encode('bad-tarball')
const fetchImpl = (async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
if (url === MANIFEST_URL) return jsonResponse(manifest)
if (url === `${CDN_BASE}/${TARBALL_KEY}`) return new Response(badBytes)
return new Response('', { status: 404 })
}) as typeof fetch
await expect(
ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl,
rawHostArch: 'arm64',
}),
).rejects.toThrow('sha256 mismatch')
await expect(stat(join(root, 'cache', TARBALL_KEY))).rejects.toThrow()
await expect(
stat(join(root, 'cache', `${TARBALL_KEY}.partial`)),
).rejects.toThrow()
})
it('rejects unsupported host architectures before fetching', async () => {
const calls: string[] = []
await expect(
ensureVmCacheSynced({
browserosRoot: root,
manifestUrl: MANIFEST_URL,
fetchImpl: fakeVmCacheFetch(calls),
rawHostArch: 'arm',
}),
).rejects.toThrow('unsupported host arch: arm')
expect(calls).toEqual([])
})
})
function fakeVmCacheFetch(
calls: string[],
opts?: { manifestUrl?: string; tarballUrl?: string },
): typeof fetch {
const manifestUrl = opts?.manifestUrl ?? MANIFEST_URL
const tarballUrl = opts?.tarballUrl ?? `${CDN_BASE}/${TARBALL_KEY}`
return (async (input: RequestInfo | URL): Promise<Response> => {
const url = String(input)
calls.push(url)
if (url === manifestUrl) return jsonResponse(manifest)
if (url === tarballUrl) return new Response(TARBALL_BYTES)
return new Response('', { status: 404 })
}) as typeof fetch
}
function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), {
headers: { 'content-type': 'application/json' },
})
}
async function writeLocalManifest(root: string): Promise<void> {
const path = getCachedManifestPath(root)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`)
}
async function writeLocalTarball(root: string): Promise<void> {
const path = join(root, 'cache', TARBALL_KEY)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, TARBALL_BYTES)
}
function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex')
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}

View File

@@ -8,7 +8,6 @@ import {
ContainerCliError,
ImageLoadError,
LimaCommandError,
ManifestMissingError,
VmError,
VmNotReadyError,
VmStateCorruptedError,
@@ -24,7 +23,6 @@ describe('VM errors', () => {
new LimaCommandError('limactl start', 7, 'bad lima'),
new ContainerCliError('nerdctl pull', 8, 'bad nerdctl'),
new ImageLoadError('openclaw:v1', 'bad image'),
new ManifestMissingError('/tmp/manifest.json'),
]
for (const error of errors) {
@@ -48,8 +46,30 @@ describe('VM errors', () => {
})
it('exports VM telemetry event names', () => {
expect(Object.keys(VM_TELEMETRY_EVENTS)).toEqual([
'ensureReadyStart',
'ensureReadyOk',
'ensureReadyBranch',
'create',
'start',
'stop',
'resetDetected',
'resetOk',
'nerdctlWaitStart',
'nerdctlWaitOk',
'nerdctlWaitPoll',
'nerdctlWaitTimeout',
'migrationOpenClawMoved',
'limaSpawn',
'limaExit',
'limaStderrChunk',
'provisionYamlWrite',
'provisionCreateStart',
'provisionCreateOk',
'provisionStartBegin',
'provisionStartOk',
])
expect(VM_TELEMETRY_EVENTS.ensureReadyStart).toBe('vm.ensure_ready.start')
expect(VM_TELEMETRY_EVENTS.downgradeDetected).toBe('vm.downgrade.detected')
expect(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout).toBe(
'vm.nerdctl_wait.timeout',
)

View File

@@ -12,14 +12,11 @@ describe('renderLimaTemplate', () => {
'minimumLimaVersion: 2.0.0\nmounts: []\nprobes: []\n',
{
vmStateDir: '/Users/me/.browseros/vm',
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
},
)
expect(yaml).toContain('mountPoint: "/mnt/browseros/vm"')
expect(yaml).toContain('location: "/Users/me/.browseros/vm"')
expect(yaml).toContain('mountPoint: "/mnt/browseros/cache/images"')
expect(yaml).toContain('location: "/Users/me/.browseros/cache/vm/images"')
expect(yaml).toContain('probes: []')
})
@@ -27,7 +24,6 @@ describe('renderLimaTemplate', () => {
expect(() =>
renderLimaTemplate('minimumLimaVersion: 2.0.0\n', {
vmStateDir: '/state',
imageCacheDir: '/images',
}),
).toThrow('mounts: [] marker')
})

View File

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

View File

@@ -14,10 +14,7 @@ import {
} from '../../../src/lib/browseros-dir'
import {
detectArch,
getCachedManifestPath,
getContainerdSocketPath,
getImageCacheDir,
getInstalledManifestPath,
getLimaHomeDir,
getVmCacheDir,
getVmStateDir,
@@ -81,17 +78,10 @@ describe('VM paths', () => {
)
})
it('builds cached and installed manifest paths', () => {
it('builds VM storage paths', () => {
const root = '/Users/foo/.browseros'
expect(getVmCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm')
expect(getImageCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm/images')
expect(getCachedManifestPath(root)).toBe(
'/Users/foo/.browseros/cache/vm/manifest.json',
)
expect(getInstalledManifestPath(root)).toBe(
'/Users/foo/.browseros/vm/manifest.json',
)
expect(getContainerdSocketPath(root)).toBe(
'/Users/foo/.browseros/lima/browseros-vm/sock/containerd.sock',
)
@@ -103,9 +93,6 @@ describe('VM paths', () => {
expect(hostPathToGuest('/Users/foo/.browseros/vm/openclaw/x', root)).toBe(
'/mnt/browseros/vm/openclaw/x',
)
expect(
hostPathToGuest('/Users/foo/.browseros/cache/vm/images/a.tar.gz', root),
).toBe('/mnt/browseros/cache/images/a.tar.gz')
})
it('rejects unmapped host paths', () => {

View File

@@ -3,7 +3,7 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
chmod,
mkdir,
@@ -12,43 +12,13 @@ import {
rm,
writeFile,
} from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { logger } from '../../../src/lib/logger'
import { join } from 'node:path'
import { VmNotReadyError } from '../../../src/lib/vm/errors'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import {
getCachedManifestPath,
getInstalledManifestPath,
VM_NAME,
} from '../../../src/lib/vm/paths'
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
import { VM_NAME } from '../../../src/lib/vm/paths'
import { VmRuntime } from '../../../src/lib/vm/vm-runtime'
import { fakeLimactl } from '../../__helpers__/fake-limactl'
import { fakeSsh } from '../../__helpers__/fake-ssh'
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'agent-arm',
sizeBytes: 1,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'agent-x64',
sizeBytes: 1,
},
},
},
},
}
describe('VmRuntime', () => {
let root: string
let limaHome: string
@@ -60,7 +30,6 @@ describe('VmRuntime', () => {
limaHome = join(root, 'lima')
logPath = join(root, 'limactl.log')
templatePath = join(root, 'browseros-vm.yaml')
await writeCachedManifest(root)
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
})
@@ -68,7 +37,7 @@ describe('VmRuntime', () => {
await rm(root, { recursive: true, force: true })
})
it('provisions a fresh VM, waits for rootless nerdctl, and installs the manifest', async () => {
it('provisions a fresh VM and waits for rootless nerdctl', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
@@ -88,59 +57,12 @@ describe('VmRuntime', () => {
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
await expect(
readFile(getInstalledManifestPath(root), 'utf8'),
).resolves.toContain(manifest.updatedAt)
await expect(
readFile(join(limaHome, `${VM_NAME}.yaml`), 'utf8'),
).resolves.toContain('mountPoint: "/mnt/browseros/vm"')
})
it('fills a missing VM cache before reading the cached manifest', async () => {
await rm(getCachedManifestPath(root), { force: true })
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const ensureCacheAvailable = mock(async () => {
await writeCachedManifest(root)
})
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
ensureCacheAvailable,
})
await runtime.ensureReady()
expect(ensureCacheAvailable).toHaveBeenCalledTimes(1)
await expect(
readFile(getInstalledManifestPath(root), 'utf8'),
).resolves.toContain(manifest.updatedAt)
})
it('surfaces cache sync failures before reading a missing manifest', async () => {
await rm(getCachedManifestPath(root), { force: true })
const ensureCacheAvailable = mock(async () => {
throw new Error('cache offline')
})
const runtime = new VmRuntime({
limactlPath: 'unused',
limaHome,
browserosRoot: root,
ensureCacheAvailable,
})
await expect(runtime.ensureReady()).rejects.toThrow('cache offline')
expect(ensureCacheAvailable).toHaveBeenCalledTimes(1)
})
it('returns fast when the VM is already running and manifests match', async () => {
await writeInstalledManifest(root)
it('returns fast when the VM is already running', async () => {
const limactlPath = await fakeLimactl(
{
list: {
@@ -170,7 +92,6 @@ describe('VmRuntime', () => {
})
it('starts an existing stopped VM without recreating it', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
@@ -198,7 +119,6 @@ describe('VmRuntime', () => {
})
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
@@ -293,92 +213,6 @@ describe('VmRuntime', () => {
)
})
it('logs upgrade mismatch and preserves the installed manifest until upgrade happens', async () => {
await writeInstalledManifest(root, '2026-04-21T00:00:00.000Z')
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
const originalWarn = logger.warn
const warnings: Array<{
message: string
meta?: Record<string, unknown>
}> = []
logger.warn = (message, meta) => warnings.push({ message, meta })
try {
await runtime.ensureReady()
} finally {
logger.warn = originalWarn
}
expect(warnings).toContainEqual({
message: VM_TELEMETRY_EVENTS.upgradeDetected,
meta: {
from: '2026-04-21T00:00:00.000Z',
to: '2026-04-22T00:00:00.000Z',
},
})
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-21T00:00:00.000Z')
})
it('logs downgrade mismatch and preserves a newer installed manifest', async () => {
await writeInstalledManifest(root, '2026-04-23T00:00:00.000Z')
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
const originalWarn = logger.warn
const warnings: Array<{
message: string
meta?: Record<string, unknown>
}> = []
logger.warn = (message, meta) => warnings.push({ message, meta })
try {
await runtime.ensureReady()
} finally {
logger.warn = originalWarn
}
expect(warnings).toContainEqual({
message: VM_TELEMETRY_EVENTS.downgradeDetected,
meta: {
from: '2026-04-23T00:00:00.000Z',
to: '2026-04-22T00:00:00.000Z',
},
})
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-23T00:00:00.000Z')
})
it('does not auto-reset when rootless nerdctl readiness fails', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
@@ -450,29 +284,6 @@ describe('VmRuntime', () => {
})
})
async function writeCachedManifest(root: string): Promise<void> {
const manifestPath = getCachedManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`)
}
async function writeInstalledManifest(
root: string,
updatedAt = manifest.updatedAt,
): Promise<void> {
const manifestPath = getInstalledManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(
manifestPath,
`${JSON.stringify({ ...manifest, updatedAt })}\n`,
)
}
async function readInstalledUpdatedAt(root: string): Promise<string> {
const raw = await readFile(getInstalledManifestPath(root), 'utf8')
return (JSON.parse(raw) as VmManifest).updatedAt
}
async function prepareReadySsh(
limaHome: string,
logPath: string,

View File

@@ -14,8 +14,6 @@ const config = {
executionDir: '/tmp/browseros-execution',
mcpAllowRemote: false,
aiSdkDevtoolsEnabled: false,
vmCachePrefetch: true,
vmCacheManifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
}
describe('Application.start', () => {
@@ -51,70 +49,45 @@ describe('Application.start', () => {
expect(loggerError).not.toHaveBeenCalled()
})
it('starts VM cache prefetch without blocking HTTP startup', async () => {
const { Application, createHttpServer, prefetchVmCache } =
it('starts OpenClaw prewarm without blocking HTTP startup', async () => {
const { Application, createHttpServer, openClawService } =
await setupApplicationTest()
let resolvePrefetch: (value: {
downloaded: string[]
manifestPath: string
skipped: boolean
}) => void = () => {}
const pendingPrefetch = new Promise<{
downloaded: string[]
manifestPath: string
skipped: boolean
}>((resolve) => {
resolvePrefetch = resolve
let resolvePrewarm: () => void = () => {}
const pendingPrewarm = new Promise<void>((resolve) => {
resolvePrewarm = resolve
})
prefetchVmCache.mockImplementation(() => pendingPrefetch)
openClawService.prewarm.mockImplementation(() => pendingPrewarm)
const app = new Application(config)
const startPromise = app.start()
const completedBeforePrefetch = await Promise.race([
const completedBeforePrewarm = await Promise.race([
startPromise.then(() => true),
Bun.sleep(25).then(() => false),
])
resolvePrefetch({
downloaded: [],
manifestPath: '/tmp/manifest.json',
skipped: true,
})
resolvePrewarm()
await startPromise
expect(completedBeforePrefetch).toBe(true)
expect(completedBeforePrewarm).toBe(true)
expect(createHttpServer).toHaveBeenCalledTimes(1)
expect(prefetchVmCache).toHaveBeenCalledWith({
manifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
})
expect(openClawService.prewarm).toHaveBeenCalledTimes(1)
expect(openClawService.tryAutoStart).toHaveBeenCalledTimes(1)
})
it('logs VM cache prefetch failures without failing startup', async () => {
const { Application, createHttpServer, loggerWarn, prefetchVmCache } =
it('logs and continues when OpenClaw prewarm fails', async () => {
const { Application, createHttpServer, loggerWarn, openClawService } =
await setupApplicationTest()
prefetchVmCache.mockImplementation(() =>
Promise.reject(new Error('cache offline')),
)
openClawService.prewarm.mockImplementation(async () => {
throw new Error('registry offline')
})
const app = new Application(config)
await app.start()
await Bun.sleep(0)
expect(createHttpServer).toHaveBeenCalledTimes(1)
expect(loggerWarn).toHaveBeenCalledWith(
'BrowserOS VM cache prefetch failed',
{
error: 'cache offline',
},
)
})
it('skips VM cache prefetch when disabled', async () => {
const { Application, prefetchVmCache } = await setupApplicationTest()
const app = new Application({ ...config, vmCachePrefetch: false })
await app.start()
expect(prefetchVmCache).not.toHaveBeenCalled()
expect(loggerWarn).toHaveBeenCalledWith('OpenClaw prewarm failed', {
error: 'registry offline',
})
})
})
@@ -126,7 +99,6 @@ async function setupApplicationTest() {
'../src/api/services/openclaw/openclaw-service'
)
const browserosDir = await import('../src/lib/browseros-dir')
const cacheSync = await import('../src/lib/vm/cache-sync')
const dbModule = await import('../src/lib/db')
const identityModule = await import('../src/lib/identity')
const loggerModule = await import('../src/lib/logger')
@@ -185,26 +157,24 @@ async function setupApplicationTest() {
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
const prewarm = mock(async () => {})
const tryAutoStart = mock(async () => {})
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
() =>
({
tryAutoStart: async () => {},
prewarm,
tryAutoStart,
}) as never,
)
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
() =>
({
tryAutoStart: async () => {},
prewarm,
tryAutoStart,
}) as never,
)
const prefetchVmCache = spyOn(cacheSync, 'prefetchVmCache')
prefetchVmCache.mockImplementation(async () => ({
downloaded: [],
manifestPath: '/tmp/manifest.json',
skipped: true,
}))
const { Application } = await import('../src/main')
return {
Application,
@@ -214,6 +184,6 @@ async function setupApplicationTest() {
loggerError,
loggerInfo,
loggerWarn,
prefetchVmCache,
openClawService: { prewarm, tryAutoStart },
}
}

View File

@@ -16,7 +16,6 @@
"globals": "^16.4.0",
"lefthook": "^2.0.12",
"picocolors": "^1.1.1",
"rimraf": "^6.0.1",
"typedoc": "^0.28.15",
"typescript": "^5.9.2",
},
@@ -196,6 +195,7 @@
"klavis": "^2.15.0",
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
"proper-lockfile": "^4.1.2",
"puppeteer-core": "24.23.0",
"ws": "^8.18.0",
"zod": "^3.24.2",
@@ -205,6 +205,7 @@
"@types/bun": "1.3.5",
"@types/debug": "^4.1.12",
"@types/node": "^24.3.3",
"@types/proper-lockfile": "^4.1.4",
"@types/sinon": "^21.0.0",
"@types/ws": "^8.5.13",
"async-mutex": "^0.5.0",
@@ -1829,12 +1830,16 @@
"@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="],
"@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="],
"@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="],
"@types/sinon": ["@types/sinon@21.0.0", "", { "dependencies": { "@types/sinonjs__fake-timers": "*" } }, "sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw=="],
"@types/sinonjs__fake-timers": ["@types/sinonjs__fake-timers@15.0.1", "", {}, "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w=="],
@@ -2669,7 +2674,7 @@
"giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="],
"glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3103,7 +3108,7 @@
"lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="],
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
@@ -3479,7 +3484,7 @@
"path-root-regex": ["path-root-regex@0.1.2", "", {}, "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ=="],
"path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"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=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
@@ -3569,6 +3574,8 @@
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
@@ -3829,13 +3836,15 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rimraf": ["rimraf@6.1.2", "", { "dependencies": { "glob": "^13.0.0", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
@@ -3921,7 +3930,7 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"signedsource": ["signedsource@1.0.0", "", {}, "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww=="],
@@ -4415,8 +4424,6 @@
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-exporter-base": "0.203.0", "@opentelemetry/otlp-transformer": "0.203.0", "@opentelemetry/sdk-logs": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-s0hys1ljqlMTbXx2XiplmMJg9wG570Z5lH7wMvrZX6lcODI56sG4HL03jklF63tBeyNwK2RV1/ntXGo3HgG4Qw=="],
"@google/gemini-cli-core/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"@google/gemini-cli-core/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@google/gemini-cli-core/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -4491,6 +4498,8 @@
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -4791,8 +4800,6 @@
"@sentry/bundler-plugin-core/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"@sentry/bundler-plugin-core/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="],
"@sentry/node/@opentelemetry/core": ["@opentelemetry/core@2.4.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-KtcyFHssTn5ZgDu6SXmUznS80OFs/wN7y6MyFRRcKU6TOw8hNcGxKvt8hsdaLJfhzUszNSjURetq5Qpkad14Gw=="],
@@ -4885,6 +4892,8 @@
"eventid/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
@@ -4895,6 +4904,8 @@
"find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fx-runner/commander": ["commander@2.9.0", "", { "dependencies": { "graceful-readlink": ">= 1.0.0" } }, "sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A=="],
@@ -4913,8 +4924,6 @@
"giget/nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="],
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"global-agent/serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
"global-directory/ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
@@ -4935,8 +4944,6 @@
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -5051,6 +5058,8 @@
"read-pkg/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"sinon/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
@@ -5351,8 +5360,6 @@
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw=="],
"@google/gemini-cli-core/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=="],
"@google/gemini-cli-core/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@google/gemini-cli-core/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
@@ -5529,8 +5536,6 @@
"@prisma/instrumentation/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"@sentry/bundler-plugin-core/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=="],
"@sentry/node/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.210.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CMtLxp+lYDriveZejpBND/2TmadrrhUfChyxzmkFtHaMDdSKfP59MAYyA0ICBvEBdm3iXwLcaj/8Ic/pnGw9Yg=="],
"@sentry/node/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
@@ -5565,8 +5570,6 @@
"giget/nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"global-agent/serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"graphql-config/@graphql-tools/url-loader/@graphql-tools/executor-graphql-ws": ["@graphql-tools/executor-graphql-ws@2.0.7", "", { "dependencies": { "@graphql-tools/executor-common": "^0.0.6", "@graphql-tools/utils": "^10.9.1", "@whatwg-node/disposablestack": "^0.0.6", "graphql-ws": "^6.0.6", "isomorphic-ws": "^5.0.0", "tslib": "^2.8.1", "ws": "^8.18.3" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q=="],
@@ -5761,24 +5764,16 @@
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="],
"@google/gemini-cli-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@google/genai/google-auth-library/gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@google/genai/google-auth-library/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"@google/genai/google-auth-library/gaxios/rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"fx-runner/which/is-absolute/is-relative": ["is-relative@0.1.3", "", {}, "sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"graphql-config/@graphql-tools/url-loader/@graphql-tools/executor-graphql-ws/@graphql-tools/executor-common": ["@graphql-tools/executor-common@0.0.6", "", { "dependencies": { "@envelop/core": "^5.3.0", "@graphql-tools/utils": "^10.9.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow=="],
"graphql-config/@graphql-tools/url-loader/@graphql-tools/executor-http/@graphql-hive/signal": ["@graphql-hive/signal@1.0.0", "", {}, "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag=="],
@@ -5831,8 +5826,6 @@
"@google/genai/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"graphql-config/@graphql-tools/url-loader/@graphql-tools/wrap/@graphql-tools/delegate/@graphql-tools/batch-execute": ["@graphql-tools/batch-execute@9.0.19", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA=="],
"publish-browser-extension/listr2/cli-truncate/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -5844,9 +5837,5 @@
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
}
}

View File

@@ -12,10 +12,16 @@
"dev:watch": "./tools/dev/run.sh watch",
"dev:watch:new": "./tools/dev/run.sh watch --new",
"dev:manual": "./tools/dev/run.sh watch --manual",
"dev:setup": "./tools/dev/setup.sh",
"dev:setup": "./tools/dev/run.sh setup",
"dev:cleanup": "./tools/dev/run.sh cleanup --target dev",
"dev:reset": "./tools/dev/run.sh reset --target dev",
"dev:cleanup:dogfood": "./tools/dev/run.sh cleanup --target dogfood",
"dev:reset:dogfood": "./tools/dev/run.sh reset --target dogfood",
"dev:cleanup:prod": "./tools/dev/run.sh cleanup --target prod",
"dev:reset:prod": "./tools/dev/run.sh reset --target prod",
"install:browseros-dogfood": "make -C tools/dogfood install",
"test:env": "./tools/dev/run.sh test",
"test:cleanup": "./tools/dev/run.sh cleanup",
"test:cleanup": "./tools/dev/run.sh cleanup --quick --yes",
"start:server": "bun run --filter @browseros/server --elide-lines=0 start",
"start:agent": "bun run --filter @browseros/agent dev",
"build": "bun run build:server && bun run build:agent",
@@ -28,20 +34,13 @@
"build:agent": "bun run codegen:agent && bun run --filter @browseros/agent build",
"codegen:agent": "bun run --filter @browseros/agent codegen",
"test": "bun run test:all",
"test:all": "bun run test:server && bun run test:agent && bun run test:eval && bun run test:build",
"test:server": "bun run --filter @browseros/server test",
"test:tools": "bun run --filter @browseros/server test:tools",
"test:cdp": "bun run --filter @browseros/server test:cdp",
"test:integration": "bun run --filter @browseros/server test:integration",
"test:agent": "bun run ./scripts/run-bun-test.ts ./apps/agent",
"test:eval": "bun run ./scripts/run-bun-test.ts ./apps/eval/tests",
"test:build": "bun run ./scripts/run-bun-test.ts ./scripts/build",
"test:all": "bun run ./scripts/run-test-suite.ts all",
"test:main": "bun run ./scripts/run-test-suite.ts main",
"typecheck": "bun run --filter '*' typecheck",
"lint": "bunx biome check",
"lint:fix": "bunx biome check --write --unsafe",
"gen:cdp": "bun scripts/codegen/cdp-protocol.ts",
"generate:models": "bun scripts/generate-models.ts",
"clean": "rimraf dist"
"generate:models": "bun scripts/generate-models.ts"
},
"repository": "browseros-ai/BrowserOS-server",
"author": "BrowserOS",
@@ -62,7 +61,6 @@
"globals": "^16.4.0",
"lefthook": "^2.0.12",
"picocolors": "^1.1.1",
"rimraf": "^6.0.1",
"typedoc": "^0.28.15",
"typescript": "^5.9.2"
},

View File

@@ -4,8 +4,4 @@ R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=browseros
# Public CDN base - used by cache:sync to GET manifest and artifacts
R2_PUBLIC_BASE_URL=https://cdn.browseros.com
# Dev mode routes cache to ~/.browseros-dev/cache/; unset for ~/.browseros/cache/
NODE_ENV=development

View File

@@ -1,6 +1,10 @@
# @browseros/build-tools
Builds agent image tarballs, publishes release artifacts to R2, and hydrates the local dev cache for agent tarballs.
Publishes BrowserOS release artifacts to R2 and owns the Lima VM template used by the server.
OpenClaw images are no longer repackaged by BrowserOS. The server pulls
`ghcr.io/openclaw/openclaw:<version>` directly into the BrowserOS Lima VM's
rootless containerd cache using `nerdctl pull`.
The BrowserOS VM is defined by a committed Lima template at `template/browseros-vm.yaml`. There is no custom disk build step; `limactl` consumes the template directly at runtime.
@@ -29,9 +33,6 @@ limactl shell browseros-vm-dev nerdctl info
SOCK="$(limactl list browseros-vm-dev --format '{{.Dir}}')/sock/containerd.sock"
test -S "$SOCK"
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
limactl shell browseros-vm-dev nerdctl load -i "$(ls dist/images/openclaw-*-arm64.tar.gz | head -1)"
limactl delete --force browseros-vm-dev
```
@@ -86,45 +87,3 @@ LIMA_HOME="$TMP_HOME" "$TMP_PREFIX/bin/limactl" delete --force browseros-smoke
rm -rf "$TMP_PREFIX" "$TMP_HOME"
```
## Build an agent tarball
The BrowserOS VM uses containerd + nerdctl. This host-side tarball builder still requires `podman` to pull and save OCI archives for release packaging.
```bash
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
```
## Smoke test an agent tarball
```bash
bun run --filter @browseros/build-tools smoke:tarball -- --agent openclaw --arch arm64 --tarball ./dist/images/openclaw-2026.4.12-arm64.tar.gz
```
## Emit a manifest
```bash
bun run --filter @browseros/build-tools emit-manifest -- --dist-dir packages/build-tools/dist
```
Publish workflows can update one agent slice at a time. Sliced publishing requires an existing R2 `vm/manifest.json` baseline; bootstrap first releases with `--slice full`.
```bash
bun run --filter @browseros/build-tools emit-manifest -- --slice agents:openclaw --merge-from https://cdn.browseros.com/vm/manifest.json
```
## Sync the dev cache
```bash
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
```
Pulls the published manifest and tarballs from R2 (`cdn.browseros.com/vm/`). Development cache files land under `~/.browseros-dev/cache/vm/images/`. Production-mode cache files land under `~/.browseros/cache/vm/images/`.
## Seed the dev cache from a local build
```bash
NODE_ENV=development bun run --filter @browseros/build-tools dev:seed:tarball
```
`dev:seed:tarball` hardcodes `arm64` (all devs are on Apple Silicon), builds the configured agent tarball, skips R2 entirely, and writes an arm64-only manifest + tarball into `~/.browseros-dev/cache/vm/`. It refuses to run unless `NODE_ENV=development`. Use this when you want to test the server against the latest configured agent tarball without publishing.

View File

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

View File

@@ -3,15 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"description": "BrowserOS release artifact producer and dev cache sync",
"description": "BrowserOS release artifact producer",
"scripts": {
"build:tarball": "bun run scripts/build-tarball.ts",
"emit-manifest": "bun run scripts/emit-manifest.ts",
"upload": "bun run scripts/upload-to-r2.ts",
"download": "bun run scripts/download-from-r2.ts",
"cache:sync": "bun run scripts/cache-sync.ts",
"dev:seed:tarball": "bun run scripts/seed-dev-agent-tarball.ts",
"smoke:tarball": "bun run scripts/smoke-tarball.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},

View File

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

View File

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

View File

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

View File

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

View File

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

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