mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 16:14:28 +00:00
Compare commits
1 Commits
fix/bdev-1
...
dev1/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6012304f6 |
@@ -1,152 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,208 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
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.>
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
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.>
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
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.>
|
||||
176
.github/workflows/publish-vm-agent-cache.yml
vendored
Normal file
176
.github/workflows/publish-vm-agent-cache.yml
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
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"
|
||||
62
.github/workflows/sync-internal-docs.yml
vendored
62
.github/workflows/sync-internal-docs.yml
vendored
@@ -1,62 +0,0 @@
|
||||
name: Sync internal-docs submodule
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
BRANCH="bot/sync-internal-docs-$(date -u +%Y%m%d-%H%M%S)"
|
||||
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"
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
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.")
|
||||
|
||||
gh pr merge "$PR_URL" --auto --squash --delete-branch
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -63,15 +63,15 @@ jobs:
|
||||
junit_path: test-results/server-root.xml
|
||||
needs_browser: false
|
||||
- suite: agent
|
||||
command: (cd apps/agent && bun run test)
|
||||
command: bun run test:agent
|
||||
junit_path: test-results/agent.xml
|
||||
needs_browser: false
|
||||
- suite: eval
|
||||
command: (cd apps/eval && bun run test)
|
||||
command: bun run test:eval
|
||||
junit_path: test-results/eval.xml
|
||||
needs_browser: false
|
||||
- suite: build
|
||||
command: bun run ./scripts/run-bun-test.ts ./scripts/build
|
||||
command: bun run test:build
|
||||
junit_path: test-results/build.xml
|
||||
needs_browser: false
|
||||
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule ".internal-docs"]
|
||||
path = .internal-docs
|
||||
url = git@github.com:browseros-ai/internal-docs.git
|
||||
branch = main
|
||||
|
||||
Submodule .internal-docs deleted from 590799ae1c
@@ -79,15 +79,14 @@ 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 and generate agent code
|
||||
# Install deps, generate agent code, and sync the VM cache
|
||||
bun run dev:setup
|
||||
|
||||
# Start the full dev environment
|
||||
bun run dev:watch
|
||||
```
|
||||
|
||||
`dev:watch` starts the server immediately. OpenClaw VM/image prewarm runs from
|
||||
the server startup path and pulls the configured GHCR image on demand.
|
||||
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -157,14 +156,9 @@ bun run build:server # Build production server resource artifacts and u
|
||||
bun run build:agent # Build agent extension
|
||||
|
||||
# Test
|
||||
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
|
||||
bun run test # Run standard tests
|
||||
bun run test:cdp # Run CDP-based tests
|
||||
bun run test:integration # Run integration tests
|
||||
|
||||
# Quality
|
||||
bun run lint # Check with Biome
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { ArrowLeft, Bot, Home } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type {
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import {
|
||||
cancelHarnessTurn,
|
||||
useAgentAdapters,
|
||||
useEnqueueHarnessMessage,
|
||||
useHarnessAgents,
|
||||
useRemoveHarnessQueuedMessage,
|
||||
useUpdateHarnessAgent,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { AgentRail } from './AgentRail'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ClawChat } from './ClawChat'
|
||||
import { ConversationHeader } from './ConversationHeader'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import {
|
||||
buildChatHistoryFromClawMessages,
|
||||
@@ -30,6 +25,162 @@ import { QueuePanel } from './QueuePanel'
|
||||
import { useAgentConversation } from './useAgentConversation'
|
||||
import { useHarnessChatHistory } from './useHarnessChatHistory'
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1 text-[11px] text-muted-foreground uppercase tracking-[0.18em]">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'Working on your request'
|
||||
? 'bg-amber-500'
|
||||
: status === 'Ready'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'Offline'
|
||||
? 'bg-muted-foreground/50'
|
||||
: 'bg-[var(--accent-orange)]',
|
||||
)}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentIdentity({
|
||||
name,
|
||||
meta,
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
meta: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">{name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs leading-5">
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationHeader({
|
||||
agentName,
|
||||
agentMeta,
|
||||
status,
|
||||
backLabel,
|
||||
backTarget,
|
||||
onGoHome,
|
||||
}: {
|
||||
agentName: string
|
||||
agentMeta: string
|
||||
status: string
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
}) {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between gap-4 border-border/50 border-b px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl lg:hidden"
|
||||
title={backLabel}
|
||||
>
|
||||
<BackIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={agentName} meta={agentMeta} />
|
||||
</div>
|
||||
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailHeader({ onGoHome }: { onGoHome: () => void }) {
|
||||
return (
|
||||
<div className="hidden h-14 items-center border-border/50 border-r border-b bg-background/70 px-4 lg:flex">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">
|
||||
Agents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailList({
|
||||
activeAgentId,
|
||||
agents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId: string
|
||||
agents: AgentEntry[]
|
||||
onSelectAgent: (entry: AgentEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{agents.map((entry) => {
|
||||
const active = entry.agentId === activeAgentId
|
||||
const modelName = getAgentEntryMeta(entry)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.agentId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(entry)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-3 py-3 text-left transition-all',
|
||||
active
|
||||
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8 shadow-sm'
|
||||
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl',
|
||||
active
|
||||
? 'bg-[var(--accent-orange)]/12 text-[var(--accent-orange)]'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={entry.name} meta={modelName} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentEntryMeta(agent: AgentEntry | undefined): string {
|
||||
if (agent?.source === 'agent-harness') {
|
||||
return getModelDisplayName(agent.model) ?? 'ACP agent'
|
||||
}
|
||||
return getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
|
||||
}
|
||||
|
||||
function AgentConversationController({
|
||||
agentId,
|
||||
initialMessage,
|
||||
@@ -138,7 +289,7 @@ function AgentConversationController({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col overflow-hidden">
|
||||
<ClawChat
|
||||
agentName={agentName}
|
||||
historyMessages={historyMessages}
|
||||
@@ -217,22 +368,6 @@ interface AgentCommandConversationProps {
|
||||
createAgentPath?: string
|
||||
}
|
||||
|
||||
function inferAdapterFromEntry(
|
||||
entry: AgentEntry | undefined,
|
||||
): HarnessAgentAdapter | 'unknown' {
|
||||
if (!entry) return 'unknown'
|
||||
if (entry.source === 'agent-harness') {
|
||||
// Harness entries don't carry the adapter on AgentEntry; the rail
|
||||
// / header read the harness record directly. This branch only runs
|
||||
// before the harness query resolves, so 'unknown' is correct — the
|
||||
// tile's bot fallback renders until data arrives.
|
||||
return 'unknown'
|
||||
}
|
||||
// OpenClaw-only entries (no harness shadow) are deprecated in
|
||||
// practice but the rail still tolerates them.
|
||||
return 'openclaw'
|
||||
}
|
||||
|
||||
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
variant = 'command',
|
||||
backPath = '/home',
|
||||
@@ -243,110 +378,60 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { agents } = useAgentCommandData()
|
||||
const { harnessAgents } = useHarnessAgents()
|
||||
const { adapters } = useAgentAdapters()
|
||||
const updateAgent = useUpdateHarnessAgent()
|
||||
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const harnessAgent = harnessAgents.find(
|
||||
(entry) => entry.id === resolvedAgentId,
|
||||
)
|
||||
const entry = agents.find((item) => item.agentId === resolvedAgentId)
|
||||
const fallbackName = entry?.name || resolvedAgentId || 'Agent'
|
||||
const fallbackAdapter = inferAdapterFromEntry(entry)
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const agentMeta = getAgentEntryMeta(agent)
|
||||
const initialMessage = searchParams.get('q')
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
|
||||
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
|
||||
const adapterId = harnessAgent?.adapter
|
||||
if (!adapterId) return null
|
||||
const descriptor = adapters.find((item) => item.id === adapterId)
|
||||
if (!descriptor?.health) return null
|
||||
return {
|
||||
healthy: descriptor.health.healthy,
|
||||
reason: descriptor.health.reason,
|
||||
}
|
||||
}, [adapters, harnessAgent?.adapter])
|
||||
|
||||
if (shouldRedirectHome) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
|
||||
const handleSelectHarnessAgent = (target: HarnessAgent) => {
|
||||
navigate(`${agentPathPrefix}/${target.id}`)
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
}
|
||||
|
||||
const handlePinToggle = (target: HarnessAgent | null, next: boolean) => {
|
||||
if (!target) return
|
||||
updateAgent.mutate({
|
||||
agentId: target.id,
|
||||
patch: { pinned: next },
|
||||
})
|
||||
}
|
||||
// Every visible agent runs through the harness now, so per-agent
|
||||
// runtime status doesn't gate chat the way OpenClaw's legacy
|
||||
// gateway lifecycle did. Show "Ready" once the agent record is
|
||||
// resolved from the rail, "Setup" otherwise.
|
||||
const statusCopy = agent ? 'Ready' : 'Setup'
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
||||
<div className="mx-auto flex h-full w-full max-w-[1480px] flex-col">
|
||||
{/* Shared top band — the rail's "Agents" header and the chat
|
||||
header live on one row so they're aligned by construction. */}
|
||||
<div className="flex shrink-0 items-stretch border-border/50 border-b">
|
||||
<div className="hidden min-h-[60px] w-[288px] shrink-0 items-center gap-3 border-border/50 border-r px-4 lg:flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="size-8 rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">
|
||||
Agents
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ConversationHeader
|
||||
agent={harnessAgent ?? null}
|
||||
fallbackName={fallbackName}
|
||||
fallbackAdapter={fallbackAdapter}
|
||||
adapterHealth={adapterHealth}
|
||||
backLabel={backLabel}
|
||||
backTarget={isPageVariant ? 'page' : 'home'}
|
||||
onGoHome={() => navigate(backPath)}
|
||||
onPinToggle={(next) =>
|
||||
handlePinToggle(harnessAgent ?? null, next)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto grid h-full w-full max-w-[1480px] lg:grid-cols-[288px_minmax(0,1fr)] lg:grid-rows-[3.5rem_minmax(0,1fr)]">
|
||||
<AgentRailHeader onGoHome={() => navigate(backPath)} />
|
||||
|
||||
{/* Body grid: rail list + chat. Both columns share the same
|
||||
top edge (the band above) so headers can never drift. */}
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] lg:grid-cols-[288px_minmax(0,1fr)]">
|
||||
<AgentRail
|
||||
agents={harnessAgents}
|
||||
adapters={adapters}
|
||||
activeAgentId={resolvedAgentId}
|
||||
onSelectAgent={handleSelectHarnessAgent}
|
||||
onPinToggle={(target, next) => handlePinToggle(target, next)}
|
||||
/>
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
agentMeta={agentMeta}
|
||||
status={statusCopy}
|
||||
backLabel={backLabel}
|
||||
backTarget={isPageVariant ? 'page' : 'home'}
|
||||
onGoHome={() => navigate(backPath)}
|
||||
/>
|
||||
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<AgentConversationController
|
||||
key={resolvedAgentId}
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() =>
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AgentRailList
|
||||
activeAgentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
|
||||
<AgentConversationController
|
||||
key={resolvedAgentId}
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() =>
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { type FC, useMemo } from 'react'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import { orderAgentsByPinThenRecency } from '@/entrypoints/app/agents/agents-list-order'
|
||||
import { AgentRailRow } from './AgentRailRow'
|
||||
|
||||
interface AgentRailProps {
|
||||
agents: HarnessAgent[]
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
activeAgentId: string
|
||||
onSelectAgent: (agent: HarnessAgent) => void
|
||||
onPinToggle: (agent: HarnessAgent, next: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-column scrollable list of agents. The "Agents" label + back
|
||||
* button live in the shared top band above (so the rail header and
|
||||
* the chat header sit on a single aligned strip rather than as two
|
||||
* separately-sized headers per column). Sort matches `/agents`:
|
||||
* pinned-first → recency, so the rail doesn't reshuffle as turns
|
||||
* transition every 5 s.
|
||||
*/
|
||||
export const AgentRail: FC<AgentRailProps> = ({
|
||||
agents,
|
||||
adapters,
|
||||
activeAgentId,
|
||||
onSelectAgent,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
const adapterHealth = useMemo(() => {
|
||||
const map = new Map<HarnessAgentAdapter, AgentAdapterHealth>()
|
||||
for (const adapter of adapters) {
|
||||
if (adapter.health) {
|
||||
map.set(adapter.id, {
|
||||
healthy: adapter.health.healthy,
|
||||
reason: adapter.health.reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [adapters])
|
||||
|
||||
const ordered = useMemo(() => orderAgentsByPinThenRecency(agents), [agents])
|
||||
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 space-y-1.5 overflow-y-auto px-3 py-3">
|
||||
{ordered.map((agent) => (
|
||||
<AgentRailRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
active={agent.id === activeAgentId}
|
||||
adapterHealth={adapterHealth.get(agent.adapter) ?? null}
|
||||
onSelect={() => onSelectAgent(agent)}
|
||||
onPinToggle={(next) => onPinToggle(agent, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { adapterLabel } from '@/entrypoints/app/agents/AdapterIcon'
|
||||
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
|
||||
import { AgentTile } from '@/entrypoints/app/agents/agent-row/AgentTile'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import { PinToggle } from '@/entrypoints/app/agents/agent-row/PinToggle'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AgentRailRowProps {
|
||||
agent: HarnessAgent
|
||||
active: boolean
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
onSelect: () => void
|
||||
onPinToggle: (next: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact rail row for the chat-screen sidebar. Slims `<AgentRowCard>`
|
||||
* down to the essentials that fit a ~280 px rail: tile + name + status
|
||||
* badge + pin star, with the adapter / model / reasoning chips on a
|
||||
* second line. Token totals, sparkline, last-message preview all stay
|
||||
* on the `/agents` page where rows are full-width.
|
||||
*/
|
||||
export const AgentRailRow: FC<AgentRailRowProps> = ({
|
||||
agent,
|
||||
active,
|
||||
adapterHealth,
|
||||
onSelect,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
const status = agent.status ?? 'unknown'
|
||||
const lastUsedAt = agent.lastUsedAt ?? null
|
||||
const pinned = agent.pinned ?? false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'group w-full rounded-2xl border px-3 py-3 text-left transition-colors',
|
||||
active
|
||||
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8'
|
||||
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<AgentTile
|
||||
adapter={agent.adapter}
|
||||
status={status}
|
||||
lastUsedAt={lastUsedAt}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-[14px] leading-5">
|
||||
{agent.name}
|
||||
</span>
|
||||
{status === 'working' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-amber-50 px-1.5 text-[10px] text-amber-900 hover:bg-amber-50"
|
||||
>
|
||||
Working
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'asleep' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
Asleep
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">
|
||||
Attention
|
||||
</Badge>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<PinToggle pinned={pinned} onToggle={onPinToggle} />
|
||||
</div>
|
||||
</div>
|
||||
<AgentSummaryChips
|
||||
adapter={agent.adapter}
|
||||
modelLabel={agent.modelId ?? null}
|
||||
reasoningEffort={agent.reasoningEffort ?? null}
|
||||
adapterHealth={adapterHealth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip-only label helper kept exported in case the tile row needs to
|
||||
* show "Codex agent" or similar in a future state. Inlined fallback for
|
||||
* the rare `unknown` adapter rendering path.
|
||||
*/
|
||||
export function railRowAdapterLabel(agent: HarnessAgent): string {
|
||||
return adapterLabel(agent.adapter)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { ArrowLeft, Home } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
|
||||
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
|
||||
import { formatTokens } from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import { PinToggle } from '@/entrypoints/app/agents/agent-row/PinToggle'
|
||||
import type { AgentLiveness } from '@/entrypoints/app/agents/LivenessDot'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ConversationHeaderProps {
|
||||
agent: HarnessAgent | null
|
||||
fallbackName: string
|
||||
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'unknown'
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
onPinToggle: (next: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip above the chat. Mirrors the `/agents` row card's title row +
|
||||
* summary chips so the user gets adapter health, pin state, and status
|
||||
* at a glance — but adds the meta line (last used · lifetime tokens ·
|
||||
* queued) that's specific to this surface.
|
||||
*
|
||||
* The mobile `lg:hidden` Back button is preserved so the small-screen
|
||||
* collapse keeps a navigable header without a sidebar.
|
||||
*/
|
||||
export const ConversationHeader: FC<ConversationHeaderProps> = ({
|
||||
agent,
|
||||
fallbackName,
|
||||
fallbackAdapter,
|
||||
adapterHealth,
|
||||
backLabel,
|
||||
backTarget,
|
||||
onGoHome,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
const adapter = agent?.adapter ?? fallbackAdapter
|
||||
const status: AgentLiveness = agent?.status ?? 'unknown'
|
||||
const lastUsedAt = agent?.lastUsedAt ?? null
|
||||
const pinned = agent?.pinned ?? false
|
||||
const queueCount = agent?.queue?.length ?? 0
|
||||
const tokens = agent?.tokens ?? null
|
||||
const lifetimeTotal = tokens
|
||||
? tokens.cumulative.input + tokens.cumulative.output
|
||||
: 0
|
||||
|
||||
const metaParts: string[] = []
|
||||
if (lastUsedAt !== null) metaParts.push(formatRelativeTime(lastUsedAt))
|
||||
if (lifetimeTotal > 0) metaParts.push(`${formatTokens(lifetimeTotal)} tokens`)
|
||||
if (queueCount > 0) {
|
||||
metaParts.push(queueCount === 1 ? '1 queued' : `${queueCount} queued`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60px] shrink-0 items-center justify-between gap-4 px-5 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 shrink-0 rounded-xl lg:hidden"
|
||||
title={backLabel}
|
||||
>
|
||||
<BackIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="group min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-semibold text-[15px] leading-6">
|
||||
{agent?.name || fallbackName}
|
||||
</span>
|
||||
{agent ? (
|
||||
<PinToggle pinned={pinned} onToggle={onPinToggle} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<AgentSummaryChips
|
||||
adapter={adapter}
|
||||
modelLabel={agent?.modelId ?? null}
|
||||
reasoningEffort={agent?.reasoningEffort ?? null}
|
||||
adapterHealth={adapterHealth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusPill
|
||||
status={status}
|
||||
hasActiveTurn={Boolean(agent?.activeTurnId)}
|
||||
/>
|
||||
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusPillProps {
|
||||
status: AgentLiveness
|
||||
hasActiveTurn: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Working / Asleep / Attention all get distinctive styling; idle keeps
|
||||
* the legacy emerald `Ready` pill so the default state is visually
|
||||
* calm. Defensive working: `idle + activeTurnId` falls through to the
|
||||
* working pill since the server says a turn is in flight.
|
||||
*/
|
||||
const StatusPill: FC<StatusPillProps> = ({ status, hasActiveTurn }) => {
|
||||
const effective: AgentLiveness =
|
||||
status === 'idle' && hasActiveTurn ? 'working' : status
|
||||
|
||||
const base =
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-0.5 text-[11px] uppercase tracking-[0.18em]'
|
||||
|
||||
if (effective === 'working') {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
base,
|
||||
'border-amber-200 bg-amber-50 text-amber-900 hover:bg-amber-50',
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-amber-500" />
|
||||
Working
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (effective === 'asleep') {
|
||||
return (
|
||||
<Badge variant="outline" className={cn(base, 'text-muted-foreground')}>
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/50" />
|
||||
Asleep
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (effective === 'error') {
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className={cn(base, 'border-destructive/30')}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-destructive-foreground" />
|
||||
Attention
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (effective === 'idle') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
base,
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-900 hover:bg-emerald-50',
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
Ready
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className={cn(base, 'text-muted-foreground')}>
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
|
||||
Setup
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
AgentAdapterHealth,
|
||||
AgentRowData,
|
||||
} from './agent-row/agent-row.types'
|
||||
import { compareAgentsByPinThenRecency } from './agents-list-order'
|
||||
import type { AgentListItem } from './agents-page-types'
|
||||
import type { AgentLiveness } from './LivenessDot'
|
||||
|
||||
@@ -57,18 +56,31 @@ export const AgentList: FC<AgentListProps> = ({
|
||||
return map
|
||||
}, [adapters])
|
||||
|
||||
// Sort: pinned rows first, then most recently used, then never-used
|
||||
// agents in id-stable order. The gateway's `main` agent stays
|
||||
// pinned-to-top when never touched so a fresh install has an
|
||||
// obvious starting point.
|
||||
const ordered = useMemo(() => {
|
||||
const withMeta = agents.map((agent) => {
|
||||
const harness = harnessAgentLookup?.get(agent.agentId)
|
||||
return {
|
||||
agent,
|
||||
id: agent.agentId,
|
||||
pinned: harness?.pinned ?? false,
|
||||
lastUsedAt: activity?.[agent.agentId]?.lastUsedAt ?? null,
|
||||
}
|
||||
})
|
||||
return withMeta
|
||||
.sort(compareAgentsByPinThenRecency)
|
||||
.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
const aSeed = a.agent.agentId === 'main' && a.lastUsedAt === null
|
||||
const bSeed = b.agent.agentId === 'main' && b.lastUsedAt === null
|
||||
if (aSeed && !bSeed) return -1
|
||||
if (!aSeed && bSeed) return 1
|
||||
const aValue = a.lastUsedAt ?? -Infinity
|
||||
const bValue = b.lastUsedAt ?? -Infinity
|
||||
if (aValue !== bValue) return bValue - aValue
|
||||
return a.agent.agentId.localeCompare(b.agent.agentId)
|
||||
})
|
||||
.map((entry) => entry.agent)
|
||||
}, [activity, agents, harnessAgentLookup])
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { HarnessAgent } from './agent-harness-types'
|
||||
import {
|
||||
compareAgentsByPinThenRecency,
|
||||
orderAgentsByPinThenRecency,
|
||||
} from './agents-list-order'
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string
|
||||
pinned?: boolean
|
||||
lastUsedAt?: number | null
|
||||
}): HarnessAgent {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.id,
|
||||
adapter: 'codex',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'session',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
pinned: input.pinned,
|
||||
lastUsedAt: input.lastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
describe('orderAgentsByPinThenRecency', () => {
|
||||
it('floats pinned agents to the top regardless of recency', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'a', pinned: false, lastUsedAt: 1_000 }),
|
||||
makeAgent({ id: 'b', pinned: true, lastUsedAt: 100 }),
|
||||
makeAgent({ id: 'c', pinned: false, lastUsedAt: 500 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['b', 'a', 'c'])
|
||||
})
|
||||
|
||||
it('sorts by lastUsedAt desc within each pin group', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'older-pin', pinned: true, lastUsedAt: 100 }),
|
||||
makeAgent({ id: 'newer-pin', pinned: true, lastUsedAt: 200 }),
|
||||
makeAgent({ id: 'older', pinned: false, lastUsedAt: 50 }),
|
||||
makeAgent({ id: 'newer', pinned: false, lastUsedAt: 80 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual([
|
||||
'newer-pin',
|
||||
'older-pin',
|
||||
'newer',
|
||||
'older',
|
||||
])
|
||||
})
|
||||
|
||||
it('seed-pins the gateway main agent above other never-used agents', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'aaa', pinned: false, lastUsedAt: null }),
|
||||
makeAgent({ id: 'main', pinned: false, lastUsedAt: null }),
|
||||
makeAgent({ id: 'zzz', pinned: false, lastUsedAt: null }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['main', 'aaa', 'zzz'])
|
||||
})
|
||||
|
||||
it('drops the main seed-pin once the agent has been used', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'aaa', pinned: false, lastUsedAt: 999 }),
|
||||
makeAgent({ id: 'main', pinned: false, lastUsedAt: 1 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['aaa', 'main'])
|
||||
})
|
||||
|
||||
it('puts never-used agents below recently-used ones', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'fresh', pinned: false, lastUsedAt: null }),
|
||||
makeAgent({ id: 'used', pinned: false, lastUsedAt: 100 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['used', 'fresh'])
|
||||
})
|
||||
|
||||
it('id-stable tiebreaks two agents with identical lastUsedAt', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'b', pinned: false, lastUsedAt: 100 }),
|
||||
makeAgent({ id: 'a', pinned: false, lastUsedAt: 100 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareAgentsByPinThenRecency', () => {
|
||||
it('produces the same order as the harness-shape helper', () => {
|
||||
const items = [
|
||||
{ id: 'older', pinned: false, lastUsedAt: 50 },
|
||||
{ id: 'newer', pinned: false, lastUsedAt: 80 },
|
||||
{ id: 'pinned', pinned: true, lastUsedAt: 1 },
|
||||
]
|
||||
const sorted = [...items].sort(compareAgentsByPinThenRecency)
|
||||
expect(sorted.map((item) => item.id)).toEqual(['pinned', 'newer', 'older'])
|
||||
})
|
||||
|
||||
it('seeds the main agent above other never-used rows', () => {
|
||||
const items = [
|
||||
{ id: 'zzz', pinned: false, lastUsedAt: null },
|
||||
{ id: 'main', pinned: false, lastUsedAt: null },
|
||||
]
|
||||
const sorted = [...items].sort(compareAgentsByPinThenRecency)
|
||||
expect(sorted.map((item) => item.id)).toEqual(['main', 'zzz'])
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { HarnessAgent } from './agent-harness-types'
|
||||
|
||||
/**
|
||||
* Stable ordering for index-shaped agent surfaces (the `/agents` rail
|
||||
* and the chat-screen rail at `/agents/:agentId`). Pinned rows float
|
||||
* to the top, then recency desc, with never-used agents falling to
|
||||
* the bottom in id-stable order. The gateway's `main` agent gets
|
||||
* seed-pinned to the top of the never-used group so a fresh install
|
||||
* has an obvious starting point even before the user has used it.
|
||||
*
|
||||
* NOT the same rule as the home grid (`orderHomeAgents`): home is
|
||||
* action-shaped — active-turn floats to the top — so users can
|
||||
* resume what's running. The chat rail keeps recency stable so it
|
||||
* doesn't reshuffle as turns transition every 5s.
|
||||
*/
|
||||
export function orderAgentsByPinThenRecency(
|
||||
agents: HarnessAgent[],
|
||||
): HarnessAgent[] {
|
||||
return [...agents].sort((a, b) => {
|
||||
const aPinned = a.pinned ?? false
|
||||
const bPinned = b.pinned ?? false
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
|
||||
const aSeed = a.id === 'main' && (a.lastUsedAt ?? null) === null
|
||||
const bSeed = b.id === 'main' && (b.lastUsedAt ?? null) === null
|
||||
if (aSeed && !bSeed) return -1
|
||||
if (!aSeed && bSeed) return 1
|
||||
|
||||
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
if (aValue !== bValue) return bValue - aValue
|
||||
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Same comparator, but operates over arbitrary records that carry
|
||||
* `pinned`, `lastUsedAt`, and an `id`-equivalent key. Used by the
|
||||
* `/agents` `AgentList` which pivots `AgentListItem` + harness
|
||||
* lookup into a sortable shape; both surfaces stay on identical
|
||||
* sort semantics through this adapter.
|
||||
*/
|
||||
export function compareAgentsByPinThenRecency<
|
||||
T extends { pinned: boolean; lastUsedAt: number | null; id: string },
|
||||
>(a: T, b: T): number {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
|
||||
const aSeed = a.id === 'main' && a.lastUsedAt === null
|
||||
const bSeed = b.id === 'main' && b.lastUsedAt === null
|
||||
if (aSeed && !bSeed) return -1
|
||||
if (!aSeed && bSeed) return 1
|
||||
|
||||
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
if (aValue !== bValue) return bValue - aValue
|
||||
|
||||
return a.id.localeCompare(b.id)
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
"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",
|
||||
|
||||
@@ -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 with the Server URL from BrowserOS settings
|
||||
browseros-cli init http://127.0.0.1:9000/mcp
|
||||
# Configure the CLI (auto-discovers running BrowserOS)
|
||||
browseros-cli init --auto # detects server URL and saves config
|
||||
|
||||
# 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`. If `browseros-cli health` cannot connect, copy the current Server URL from BrowserOS Settings > BrowserOS MCP and run `browseros-cli init <Server URL>` again.
|
||||
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).
|
||||
|
||||
### 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 > config file
|
||||
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > `~/.browseros/server.json` > config file
|
||||
|
||||
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init <Server URL>`.
|
||||
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init`.
|
||||
|
||||
## 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 or interactive)
|
||||
│ ├── init.go # Server URL configuration (URL arg, --auto, 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)
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
var autoDiscover bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "init [url]",
|
||||
Short: "Configure the BrowserOS server connection",
|
||||
@@ -32,8 +34,9 @@ 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
|
||||
|
||||
Modes:
|
||||
Three 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),
|
||||
@@ -46,9 +49,22 @@ 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()
|
||||
@@ -79,14 +95,12 @@ 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\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)
|
||||
output.Errorf(1, "cannot connect to %s: %v\nIs BrowserOS running?", baseURL, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
@@ -107,5 +121,6 @@ Modes:
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&autoDiscover, "auto", false, "Auto-discover server URL from ~/.browseros/server.json")
|
||||
rootCmd.AddCommand(cmd)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ Linux: Downloads AppImage (or .deb with --deb flag)
|
||||
|
||||
After installation:
|
||||
browseros-cli launch # start BrowserOS
|
||||
browseros-cli init <url> # configure the CLI with the Server URL`,
|
||||
browseros-cli init --auto # configure the CLI`,
|
||||
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 <url> # use the Server URL from BrowserOS settings")
|
||||
dim.Println(" browseros-cli init --auto # configure the CLI")
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -39,7 +38,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -65,7 +63,7 @@ If BrowserOS is already running, reports the server URL.`,
|
||||
|
||||
green.Printf("BrowserOS is ready at %s\n", url)
|
||||
fmt.Println()
|
||||
dim.Printf("Next: browseros-cli init %s\n", mcpEndpointURL(url))
|
||||
dim.Println("Next: browseros-cli init --auto")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -77,77 +75,39 @@ If BrowserOS is already running, reports the server URL.`,
|
||||
// Server probing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var commonBrowserOSPorts = []int{9100, 9200, 9300}
|
||||
|
||||
// probeRunningServer checks launch discovery, explicit config, and common ports for a running server.
|
||||
// probeRunningServer checks server.json, config, and common ports for a running server.
|
||||
func probeRunningServer() string {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
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
|
||||
}
|
||||
|
||||
if url := loadBrowserosServerURL(); url != "" && checkServerHealth(client, url) {
|
||||
// 1. server.json — written by BrowserOS on startup with the actual port
|
||||
if url := loadBrowserosServerURL(); url != "" && check(url) {
|
||||
return url
|
||||
}
|
||||
|
||||
if url := defaultServerURL(); url != "" && checkServerHealth(client, url) {
|
||||
// 2. Saved config / env var
|
||||
if url := defaultServerURL(); url != "" && check(url) {
|
||||
return url
|
||||
}
|
||||
|
||||
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 {
|
||||
// 3. Probe common BrowserOS ports as last resort
|
||||
for _, port := range []int{9100, 9200, 9300} {
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
if checkServerHealth(client, url) {
|
||||
if check(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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -157,8 +117,7 @@ func mcpEndpointURL(baseURL string) 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":
|
||||
@@ -312,11 +271,14 @@ 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 != "" && checkServerHealth(client, url) {
|
||||
return url, true
|
||||
}
|
||||
if url := probeCommonServerPorts(client); url != "" {
|
||||
return url, true
|
||||
if url := loadBrowserosServerURL(); url != "" {
|
||||
resp, err := client.Get(url + "/health")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return url, true
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Print(".")
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -287,15 +289,18 @@ 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 != "" {
|
||||
@@ -306,6 +311,33 @@ 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)
|
||||
|
||||
@@ -337,10 +369,8 @@ func validateServerURL(raw string) (string, error) {
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"BrowserOS server URL is not configured.\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" +
|
||||
" If BrowserOS is closed: browseros-cli launch\n" +
|
||||
" If not installed: browseros-cli install",
|
||||
" If BrowserOS is running: browseros-cli init --auto\n" +
|
||||
" If BrowserOS is closed: browseros-cli launch\n" +
|
||||
" If not installed: browseros-cli install",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browseros-cli/config"
|
||||
)
|
||||
|
||||
func TestSetVersionUpdatesRootCommand(t *testing.T) {
|
||||
@@ -105,76 +100,6 @@ 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{})
|
||||
|
||||
@@ -44,7 +44,10 @@ 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%s", c.BaseURL, err, connectionSetupInstructions())
|
||||
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 session, nil
|
||||
}
|
||||
@@ -184,7 +187,10 @@ 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%s", c.BaseURL, err, connectionSetupInstructions())
|
||||
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)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -199,14 +205,3 @@ 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"
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ browseros-cli install
|
||||
# Start BrowserOS
|
||||
browseros-cli launch
|
||||
|
||||
# Configure MCP settings with the Server URL from BrowserOS settings
|
||||
browseros-cli init http://127.0.0.1:9000/mcp
|
||||
# Auto-configure MCP settings for your AI tools
|
||||
browseros-cli init --auto
|
||||
|
||||
# Verify everything is working
|
||||
browseros-cli health
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -7,6 +7,11 @@ 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=
|
||||
|
||||
@@ -5,6 +5,9 @@ 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=
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
"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",
|
||||
@@ -118,7 +117,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -311,49 +311,17 @@ export class ChatService {
|
||||
contextChanges.length > 0
|
||||
? `${contextChanges.map((c) => `[Context: ${c}]`).join('\n')}\n\n`
|
||||
: ''
|
||||
|
||||
// Persist the *raw* user text in session.agent.messages so it
|
||||
// round-trips clean to the client's useChat state and to any
|
||||
// future history reload. The wrapped form (browser context +
|
||||
// <selected_text> + <USER_QUERY>) is built as a transient prompt
|
||||
// copy below — the LLM sees it, the user-visible state never
|
||||
// does.
|
||||
session.agent.appendUserMessage(request.message)
|
||||
const promptUserText = contextPrefix + userContent
|
||||
const wrappedUserMessageId =
|
||||
session.agent.messages[session.agent.messages.length - 1]?.id
|
||||
|
||||
const promptUiMessages = filterValidMessages(session.agent.messages).map(
|
||||
(msg) =>
|
||||
msg.id === wrappedUserMessageId && msg.role === 'user'
|
||||
? {
|
||||
...msg,
|
||||
parts: [{ type: 'text' as const, text: promptUserText }],
|
||||
}
|
||||
: msg,
|
||||
)
|
||||
session.agent.appendUserMessage(contextPrefix + userContent)
|
||||
|
||||
return createAgentUIStreamResponse({
|
||||
agent: session.agent.toolLoopAgent,
|
||||
uiMessages: promptUiMessages,
|
||||
uiMessages: filterValidMessages(session.agent.messages),
|
||||
abortSignal,
|
||||
onFinish: async ({ messages }: { messages: UIMessage[] }) => {
|
||||
// The agent loop returns `messages` containing the prompt-
|
||||
// wrapped user text. Restore the raw form before persisting
|
||||
// so subsequent turns see the clean text and the client's
|
||||
// local UIMessage matches what was originally typed.
|
||||
const restored = messages.map((msg) =>
|
||||
msg.id === wrappedUserMessageId && msg.role === 'user'
|
||||
? {
|
||||
...msg,
|
||||
parts: [{ type: 'text' as const, text: request.message }],
|
||||
}
|
||||
: msg,
|
||||
)
|
||||
session.agent.messages = filterValidMessages(restored)
|
||||
session.agent.messages = filterValidMessages(messages)
|
||||
logger.info('Agent execution complete', {
|
||||
conversationId: request.conversationId,
|
||||
totalMessages: restored.length,
|
||||
totalMessages: messages.length,
|
||||
})
|
||||
|
||||
if (session?.hiddenPageId) {
|
||||
|
||||
@@ -10,12 +10,19 @@ 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'
|
||||
|
||||
@@ -27,6 +34,13 @@ 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(
|
||||
@@ -63,9 +77,16 @@ 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 ImageLoader(shell)
|
||||
const loader = new DeferredImageLoader(shell, browserosRoot, input.vmCache)
|
||||
|
||||
return new ContainerRuntime({
|
||||
vm,
|
||||
@@ -101,6 +122,49 @@ 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({
|
||||
@@ -133,14 +197,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -8,33 +8,24 @@ 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).
|
||||
@@ -104,34 +95,14 @@ 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.createContainerWithNameReconcile(container, onLog)
|
||||
await this.shell.createContainer(container, onLog)
|
||||
await this.shell.startContainer(container.name)
|
||||
}
|
||||
|
||||
@@ -215,11 +186,10 @@ export class ContainerRuntime {
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.removeContainerAndWait(setupContainerName, onLog)
|
||||
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
|
||||
const image = await this.ensureGatewayImageLoaded(onLog)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
const createResult = await this.runSetupCreateWithNameReconcile(
|
||||
setupContainerName,
|
||||
const createResult = await this.shell.runCommand(
|
||||
[
|
||||
'create',
|
||||
'--name',
|
||||
@@ -260,74 +230,10 @@ export class ContainerRuntime {
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
|
||||
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,
|
||||
await this.shell.removeContainer(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -390,7 +296,7 @@ export class ContainerRuntime {
|
||||
}
|
||||
|
||||
private async ensureGatewayImageLoaded(onLog?: LogFn): Promise<string> {
|
||||
// Local image testing can override the pinned GHCR image with OPENCLAW_IMAGE.
|
||||
// Local image testing can bypass the synced VM manifest with OPENCLAW_IMAGE.
|
||||
const override = process.env.OPENCLAW_IMAGE?.trim()
|
||||
if (override) {
|
||||
await this.loader.ensureImageLoaded(override, onLog)
|
||||
@@ -399,10 +305,6 @@ 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,
|
||||
@@ -428,12 +330,3 @@ export class ContainerRuntime {
|
||||
return hostPathToGuest(path)
|
||||
}
|
||||
}
|
||||
|
||||
function imageMatchesExpectedRef(
|
||||
actual: string | null,
|
||||
expected: string,
|
||||
): boolean {
|
||||
return (
|
||||
actual === expected || actual?.startsWith(`${expected}@sha256:`) === true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,16 +10,13 @@
|
||||
|
||||
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,
|
||||
@@ -29,7 +26,10 @@ import type {
|
||||
ContainerRuntime,
|
||||
GatewayContainerSpec,
|
||||
} from './container-runtime'
|
||||
import { buildContainerRuntime } from './container-runtime-factory'
|
||||
import {
|
||||
buildContainerRuntime,
|
||||
type VmCacheRuntimeConfig,
|
||||
} from './container-runtime-factory'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
@@ -135,6 +135,7 @@ export interface OpenClawServiceConfig {
|
||||
browserosServerPort?: number
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}
|
||||
|
||||
export type OpenClawSessionSource =
|
||||
@@ -266,6 +267,7 @@ 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
|
||||
@@ -280,6 +282,7 @@ 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)
|
||||
@@ -292,6 +295,7 @@ 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 {
|
||||
@@ -314,6 +318,13 @@ 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()
|
||||
}
|
||||
@@ -350,23 +361,6 @@ 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)
|
||||
@@ -484,7 +478,7 @@ export class OpenClawService {
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
if (await this.isCurrentGatewayAvailable(this.hostPort)) {
|
||||
if (await this.isGatewayAvailable(this.hostPort)) {
|
||||
this.startGatewayLogTail()
|
||||
this.controlPlaneStatus = 'connecting'
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
@@ -879,7 +873,7 @@ export class OpenClawService {
|
||||
this.setPort(persistedPort)
|
||||
}
|
||||
|
||||
if (!(await this.isCurrentGatewayAvailable(this.hostPort))) {
|
||||
if (!(await this.isGatewayAvailable(this.hostPort))) {
|
||||
await this.ensureGatewayPortAllocated()
|
||||
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
|
||||
const ready = await this.runtime.waitForReady(
|
||||
@@ -993,6 +987,7 @@ 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()
|
||||
@@ -1014,16 +1009,10 @@ export class OpenClawService {
|
||||
if (persistedPort !== null) {
|
||||
this.setPort(persistedPort)
|
||||
}
|
||||
const currentPortReady = await this.isGatewayPortReady(this.hostPort)
|
||||
if (
|
||||
currentPortReady &&
|
||||
(await this.isGatewayAuthenticated(this.hostPort))
|
||||
) {
|
||||
if (await this.isGatewayAvailable(this.hostPort)) {
|
||||
return
|
||||
}
|
||||
const hostPort = await allocateGatewayPort(this.openclawDir, {
|
||||
excludePort: currentPortReady ? this.hostPort : undefined,
|
||||
})
|
||||
const hostPort = await allocateGatewayPort(this.openclawDir)
|
||||
if (hostPort !== this.hostPort) {
|
||||
logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`)
|
||||
logger.info('Allocated OpenClaw gateway host port', { hostPort })
|
||||
@@ -1033,10 +1022,7 @@ 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',
|
||||
@@ -1060,11 +1046,6 @@ 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
|
||||
|
||||
@@ -1523,14 +1504,8 @@ export class OpenClawService {
|
||||
})
|
||||
await previous.catch(() => undefined)
|
||||
try {
|
||||
return await withProcessLock(
|
||||
'openclaw-lifecycle',
|
||||
{ lockDir: join(this.openclawDir, '.locks') },
|
||||
async () => {
|
||||
logger.debug('OpenClaw lifecycle operation started', { operation })
|
||||
return await fn()
|
||||
},
|
||||
)
|
||||
logger.debug('OpenClaw lifecycle operation started', { operation })
|
||||
return await fn()
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
@@ -1554,6 +1529,7 @@ export function configureOpenClawService(
|
||||
export function configureVmRuntime(config: {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}): OpenClawService {
|
||||
return configureOpenClawService(config)
|
||||
}
|
||||
@@ -1562,3 +1538,14 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
@@ -27,7 +26,7 @@ function readForcedGatewayPort(): number | null {
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_TCP_PORT) {
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
@@ -50,7 +49,7 @@ export async function readPersistedGatewayPort(
|
||||
typeof parsed.gatewayPort === 'number' &&
|
||||
Number.isInteger(parsed.gatewayPort) &&
|
||||
parsed.gatewayPort > 0 &&
|
||||
parsed.gatewayPort <= MAX_TCP_PORT
|
||||
parsed.gatewayPort <= 65535
|
||||
) {
|
||||
return parsed.gatewayPort
|
||||
}
|
||||
@@ -83,26 +82,14 @@ function isPortAvailable(port: number): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
async function findAvailablePort(
|
||||
startPort: number,
|
||||
excludePort?: number,
|
||||
): Promise<number> {
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
let port = startPort
|
||||
while (port === excludePort || !(await isPortAvailable(port))) {
|
||||
while (!(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
|
||||
@@ -110,7 +97,6 @@ export interface AllocateGatewayPortOptions {
|
||||
*/
|
||||
export async function allocateGatewayPort(
|
||||
openclawDir: string,
|
||||
opts: AllocateGatewayPortOptions = {},
|
||||
): Promise<number> {
|
||||
const forcedPort = readForcedGatewayPort()
|
||||
if (forcedPort !== null) {
|
||||
@@ -119,17 +105,10 @@ export async function allocateGatewayPort(
|
||||
}
|
||||
|
||||
const persisted = await readPersistedGatewayPort(openclawDir)
|
||||
if (
|
||||
persisted !== null &&
|
||||
persisted !== opts.excludePort &&
|
||||
(await isPortAvailable(persisted))
|
||||
) {
|
||||
if (persisted !== null && (await isPortAvailable(persisted))) {
|
||||
return persisted
|
||||
}
|
||||
const port = await findAvailablePort(
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
opts.excludePort,
|
||||
)
|
||||
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
await writePersistedGatewayPort(openclawDir, port)
|
||||
return port
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
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'
|
||||
|
||||
@@ -30,6 +31,8 @@ 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>
|
||||
@@ -226,6 +229,11 @@ 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
|
||||
@@ -272,6 +280,10 @@ 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,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -305,6 +317,8 @@ function getDefaults(cwd: string): PartialConfig {
|
||||
executionDir: cwd,
|
||||
mcpAllowRemote: false,
|
||||
aiSdkDevtoolsEnabled: false,
|
||||
vmCachePrefetch: true,
|
||||
vmCacheManifestUrl: EXTERNAL_URLS.VM_CACHE_MANIFEST,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +339,18 @@ 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),
|
||||
|
||||
@@ -19,6 +19,8 @@ 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
|
||||
|
||||
@@ -27,4 +29,6 @@ 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)[]
|
||||
|
||||
@@ -558,53 +558,13 @@ function mapToolUseToHistoryToolCall(
|
||||
}
|
||||
|
||||
function userContentToText(content: AcpxUserContent): string {
|
||||
if ('Text' in content) return unwrapBrowserosAcpUserMessage(content.Text)
|
||||
if ('Text' in content) return unwrapBrowserosAcpPrompt(content.Text)
|
||||
if ('Mention' in content) return content.Mention.content
|
||||
if ('Image' in content) return content.Image.source ? '[image]' : ''
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the BrowserOS ACP envelopes from a user-message text so HTTP
|
||||
* consumers (history endpoint, listing's `lastUserMessage`) see only
|
||||
* the user's actual question. Two layers are added on the wire today:
|
||||
*
|
||||
* 1. <role>…</role>\n\n<user_request>…</user_request> from
|
||||
* `buildBrowserosAcpPrompt` (outer).
|
||||
* 2. ## Browser Context + <selected_text> + <USER_QUERY> from
|
||||
* `apps/server/src/agent/format-message.ts` (inner).
|
||||
*
|
||||
* Each step is independently defensive — anchors that don't match are
|
||||
* skipped — so partially-wrapped text (older persisted records,
|
||||
* messages without a selection, future schema drift) gets best-
|
||||
* effort cleaning without throwing. The function is idempotent;
|
||||
* applying it to already-clean text is a no-op.
|
||||
*
|
||||
* TODO: drop this once acpx/runtime exposes a real system-prompt
|
||||
* surface so we can stop persisting the role block on every user
|
||||
* message. Tracked in the server architecture audit.
|
||||
*/
|
||||
export function unwrapBrowserosAcpUserMessage(raw: string): string {
|
||||
if (!raw) return raw
|
||||
let text = raw
|
||||
|
||||
// Order matters: the outer envelope is added AFTER
|
||||
// `escapePromptTagText` runs over the inner formatUserMessage
|
||||
// payload (see buildBrowserosAcpPrompt). So once the outer
|
||||
// <role>…</role>+<user_request>…</user_request> tags are stripped,
|
||||
// the inner content is still entity-escaped (`<USER_QUERY>`
|
||||
// not `<USER_QUERY>`). We decode entities BEFORE the inner-envelope
|
||||
// strips so their anchors actually match.
|
||||
text = stripOuterRoleEnvelope(text)
|
||||
text = decodeBasicEntities(text)
|
||||
text = stripBrowserContextHeader(text)
|
||||
text = stripSelectedTextBlock(text)
|
||||
text = unwrapUserQuery(text)
|
||||
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
function stripOuterRoleEnvelope(value: string): string {
|
||||
function unwrapBrowserosAcpPrompt(value: string): string {
|
||||
const prefix = `${BROWSEROS_ACP_AGENT_INSTRUCTIONS}
|
||||
|
||||
<user_request>
|
||||
@@ -612,41 +572,12 @@ function stripOuterRoleEnvelope(value: string): string {
|
||||
const suffix = `
|
||||
</user_request>`
|
||||
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return value
|
||||
return value.slice(prefix.length, -suffix.length)
|
||||
|
||||
// TODO: nikhil: remove this once acpx/runtime exposes system prompt support.
|
||||
return unescapePromptTagText(value.slice(prefix.length, -suffix.length))
|
||||
}
|
||||
|
||||
function stripBrowserContextHeader(value: string): string {
|
||||
// The `## Browser Context` block (when present) ends with the
|
||||
// `\n\n---\n\n` separator emitted by `formatBrowserContext`.
|
||||
// Anchored at the start of the string; non-greedy match through
|
||||
// the body; one removal.
|
||||
const match = value.match(/^## Browser Context\n[\s\S]*?\n\n---\n\n/)
|
||||
return match ? value.slice(match[0].length) : value
|
||||
}
|
||||
|
||||
function stripSelectedTextBlock(value: string): string {
|
||||
// Optional `<selected_text [attrs]>…</selected_text>\n\n` block
|
||||
// emitted by `formatUserMessage` when the user has a selection.
|
||||
return value.replace(
|
||||
/<selected_text(?:[^>]*)>\n[\s\S]*?\n<\/selected_text>\n\n/,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
function unwrapUserQuery(value: string): string {
|
||||
// `formatUserMessage` always wraps the user's typed text in
|
||||
// `<USER_QUERY>\n…\n</USER_QUERY>` — even when no browser context
|
||||
// or selection is present.
|
||||
const match = value.match(/^<USER_QUERY>\n([\s\S]*?)\n<\/USER_QUERY>$/)
|
||||
return match ? match[1] : value
|
||||
}
|
||||
|
||||
function decodeBasicEntities(value: string): string {
|
||||
// Reverse the three escapes the server applied via
|
||||
// `escapePromptTagText` so user-typed XML-like content (e.g.
|
||||
// `<USER_QUERY>` typed literally) renders as the user typed it.
|
||||
// Decode `&` last to avoid double-decoding sequences like
|
||||
// `&lt;` → `<` → `<`.
|
||||
function unescapePromptTagText(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
@@ -75,6 +75,10 @@ export function getVmDisksDir(): string {
|
||||
return getVmCacheDir()
|
||||
}
|
||||
|
||||
export function getAgentCacheDir(): string {
|
||||
return join(getVmCacheDir(), 'images')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringDir(): string {
|
||||
return join(getBrowserosDir(), 'lazy-monitoring')
|
||||
}
|
||||
@@ -112,7 +116,7 @@ export async function ensureBrowserosDir(): Promise<void> {
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getVmDisksDir(), { recursive: true })
|
||||
await mkdir(getAgentCacheDir(), { recursive: true })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -4,20 +4,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
ContainerCliError,
|
||||
ContainerNameInUseError,
|
||||
ContainerNameReleaseTimeoutError,
|
||||
} from '../vm/errors'
|
||||
import { ContainerCliError } from '../vm/errors'
|
||||
import { LimaCli } from '../vm/lima-cli'
|
||||
import type {
|
||||
ContainerInfo,
|
||||
ContainerSpec,
|
||||
LogFn,
|
||||
MountSpec,
|
||||
PortMapping,
|
||||
WaitForContainerNameReleaseOptions,
|
||||
} from './types'
|
||||
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
|
||||
|
||||
export function buildNerdctlCommand(args: string[]): string[] {
|
||||
return ['nerdctl', ...args]
|
||||
@@ -52,35 +41,17 @@ 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> {
|
||||
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)
|
||||
await this.runRequired(buildCreateArgs(spec), onLog)
|
||||
}
|
||||
|
||||
async startContainer(name: string, onLog?: LogFn): Promise<void> {
|
||||
@@ -106,36 +77,6 @@ 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
|
||||
@@ -250,65 +191,19 @@ function mountArg(mount: MountSpec): string {
|
||||
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
|
||||
}
|
||||
|
||||
function parseContainerInfo(
|
||||
stdout: string,
|
||||
fallbackName: string,
|
||||
): ContainerInfo {
|
||||
const line = stdout
|
||||
.trim()
|
||||
function parseLoadedImageRefs(stdout: string): string[] {
|
||||
return stdout
|
||||
.split('\n')
|
||||
.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,
|
||||
}
|
||||
.map((line) => line.match(/^Loaded image(?:\(s\))?:\s*(.+)$/i)?.[1]?.trim())
|
||||
.filter((ref): ref is string => !!ref)
|
||||
}
|
||||
|
||||
function isNoSuchContainer(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return (
|
||||
lower.includes('no such container') || lower.includes('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')
|
||||
)
|
||||
return lower.includes('no such container') || lower.includes('not found')
|
||||
}
|
||||
|
||||
function linesToOutput(lines: string[]): string {
|
||||
if (lines.length === 0) return ''
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,41 +4,87 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
OPENCLAW_AGENT_NAME,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { basename, join } from 'node:path'
|
||||
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) {}
|
||||
constructor(
|
||||
private readonly cli: ContainerCli,
|
||||
private readonly manifest: VmManifest,
|
||||
private readonly arch: Arch,
|
||||
private readonly browserosRoot?: string,
|
||||
) {}
|
||||
|
||||
/** 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.pullImage(ref, onLog)
|
||||
await this.cli.loadImage(guestPath, onLog)
|
||||
} catch (error) {
|
||||
if (error instanceof ContainerCliError) {
|
||||
throw new ImageLoadError(ref, `pull failed: ${error.stderr}`, error)
|
||||
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!(await this.cli.imageExists(ref))) {
|
||||
throw new ImageLoadError(ref, 'image not present after successful pull')
|
||||
throw new ImageLoadError(
|
||||
ref,
|
||||
`image not present after successful load of ${guestPath}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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}`)
|
||||
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
|
||||
}
|
||||
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
|
||||
return OPENCLAW_IMAGE
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,19 +38,6 @@ 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
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* @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))
|
||||
)
|
||||
}
|
||||
322
packages/browseros-agent/apps/server/src/lib/vm/cache-sync.ts
Normal file
322
packages/browseros-agent/apps/server/src/lib/vm/cache-sync.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -30,36 +30,8 @@ export class ContainerCliError extends VmError {
|
||||
command: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
message = `${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`,
|
||||
)
|
||||
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,3 +44,17 @@ 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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export * from './errors'
|
||||
export * from './lima-cli'
|
||||
export * from './lima-config'
|
||||
export * from './manifest'
|
||||
export * from './paths'
|
||||
export * from './telemetry'
|
||||
export * from './vm-runtime'
|
||||
|
||||
@@ -8,6 +8,7 @@ export function renderLimaTemplate(
|
||||
template: string,
|
||||
cfg: {
|
||||
vmStateDir: string
|
||||
imageCacheDir: string
|
||||
},
|
||||
): string {
|
||||
const mounts = [
|
||||
@@ -15,6 +16,9 @@ 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: []')) {
|
||||
|
||||
103
packages/browseros-agent/apps/server/src/lib/vm/manifest.ts
Normal file
103
packages/browseros-agent/apps/server/src/lib/vm/manifest.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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'
|
||||
@@ -53,6 +54,18 @@ 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')
|
||||
}
|
||||
@@ -97,7 +110,7 @@ export function resolveBundledLimactl(
|
||||
const candidate = join(limaRoot, 'bin', 'limactl')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled limactl not found at ${candidate}; refresh server resources from the build-tools README`,
|
||||
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
)
|
||||
}
|
||||
assertBundledLimaGuestAgent(limaRoot, hostArch)
|
||||
@@ -145,7 +158,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}; refresh server resources from the build-tools README`,
|
||||
`bundled Lima template not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
@@ -202,10 +215,16 @@ 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`)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,19 @@ 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',
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
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 { getVmStateDir, VM_NAME } from './paths'
|
||||
import {
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
writeInstalledManifest,
|
||||
} from './manifest'
|
||||
import { getImageCacheDir, getVmStateDir, VM_NAME } from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
@@ -24,6 +31,7 @@ export interface VmRuntimeDeps {
|
||||
browserosRoot?: string
|
||||
readinessTimeoutMs?: number
|
||||
readinessPollMs?: number
|
||||
ensureCacheAvailable?: () => Promise<void>
|
||||
}
|
||||
|
||||
export class VmRuntime {
|
||||
@@ -51,17 +59,34 @@ 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'
|
||||
: 'running'
|
||||
: versionComparison === 'upgrade'
|
||||
? 'running-upgrade-warn'
|
||||
: versionComparison === 'downgrade'
|
||||
? 'running-downgrade-warn'
|
||||
: 'running-same'
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
|
||||
branch,
|
||||
existingStatus: existing?.status ?? null,
|
||||
versionComparison,
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
@@ -76,11 +101,28 @@ 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,
|
||||
@@ -178,6 +220,14 @@ 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 {
|
||||
@@ -221,6 +271,7 @@ export class VmRuntime {
|
||||
|
||||
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
|
||||
vmStateDir: getVmStateDir(this.deps.browserosRoot),
|
||||
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ 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,
|
||||
@@ -60,7 +61,7 @@ export class Application {
|
||||
})
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
configureVmRuntime({ resourcesDir })
|
||||
configureVmRuntime({ resourcesDir, vmCache: this.vmCacheConfig() })
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -131,20 +132,17 @@ 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 {
|
||||
const openClawService = configureOpenClawService({
|
||||
configureOpenClawService({
|
||||
browserosServerPort: this.config.serverPort,
|
||||
resourcesDir,
|
||||
vmCache: this.vmCacheConfig(),
|
||||
})
|
||||
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),
|
||||
}),
|
||||
)
|
||||
.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),
|
||||
@@ -176,6 +174,7 @@ export class Application {
|
||||
private async initCoreServices(): Promise<void> {
|
||||
this.configureLogDirectory()
|
||||
await ensureBrowserosDir()
|
||||
this.startVmCachePrefetch()
|
||||
await cleanOldSessions()
|
||||
await seedSoulTemplate()
|
||||
await migrateBuiltinSkills()
|
||||
@@ -224,6 +223,25 @@ 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)
|
||||
|
||||
@@ -298,9 +298,7 @@ describe('ChatService Klavis session rebuilds', () => {
|
||||
const firstAgent = createFakeAgent()
|
||||
const secondAgent = createFakeAgent()
|
||||
agentToReturn = firstAgent
|
||||
let lastPromptUiMessages: MockMessage[] | undefined
|
||||
streamResponseHandler = async ({ onFinish, uiMessages }) => {
|
||||
lastPromptUiMessages = uiMessages
|
||||
await onFinish({ messages: uiMessages ?? [] })
|
||||
return new Response('ok')
|
||||
}
|
||||
@@ -350,24 +348,13 @@ describe('ChatService Klavis session rebuilds', () => {
|
||||
|
||||
expect(createAgentSpy.mock.calls.length - createCallsBefore).toBe(2)
|
||||
expect(firstAgent.dispose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Persisted form stays the raw user text — TKT-774. The Klavis
|
||||
// context-change notice and the formatted user envelope go only
|
||||
// into the transient prompt copy fed to the LLM.
|
||||
expect(secondAgent.messages).toHaveLength(2)
|
||||
const persistedRebuiltMessage =
|
||||
secondAgent.messages[1]?.parts[0]?.text ?? ''
|
||||
expect(persistedRebuiltMessage).toBe('check integrations again')
|
||||
|
||||
// Prompt copy (what the agent loop actually saw) carries the
|
||||
// context-change prefix so the model knows about the new tools.
|
||||
const promptRebuiltMessage =
|
||||
lastPromptUiMessages?.at(-1)?.parts[0]?.text ?? ''
|
||||
expect(promptRebuiltMessage).toContain(
|
||||
const rebuiltMessage = secondAgent.messages[1]?.parts[0]?.text ?? ''
|
||||
expect(rebuiltMessage).toContain(
|
||||
'Klavis app integration tools are now available for the following connected apps: slack.',
|
||||
)
|
||||
expect(promptRebuiltMessage).not.toContain('klavis:pending')
|
||||
expect(promptRebuiltMessage).not.toContain('klavis:connected')
|
||||
expect(rebuiltMessage).not.toContain('klavis:pending')
|
||||
expect(rebuiltMessage).not.toContain('klavis:connected')
|
||||
})
|
||||
|
||||
it('does not rebuild a session with no enabled managed apps when Klavis connects', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
@@ -83,10 +83,6 @@ 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()
|
||||
})
|
||||
|
||||
@@ -106,15 +102,24 @@ describe('container-runtime factory', () => {
|
||||
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
|
||||
})
|
||||
|
||||
it('builds a runtime whose image loader pulls directly through nerdctl', async () => {
|
||||
it('syncs the VM cache before deferred image loading reads the manifest', async () => {
|
||||
const ensureSynced = mock(async () => {
|
||||
throw new Error('cache sync sentinel')
|
||||
})
|
||||
const runtime = buildContainerRuntime({
|
||||
resourcesDir,
|
||||
projectDir: join(root, 'project'),
|
||||
browserosRoot: root,
|
||||
platform: 'darwin',
|
||||
vmCache: {
|
||||
ensureSynced,
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime).toBeDefined()
|
||||
await expect(
|
||||
runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12'),
|
||||
).rejects.toThrow('cache sync sentinel')
|
||||
expect(ensureSynced).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves both directories in place when new OpenClaw state already exists', async () => {
|
||||
|
||||
@@ -4,15 +4,11 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
import {
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } 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 OPENCLAW_NAME_RELEASE_WAIT = { timeoutMs: 10_000, intervalMs: 100 }
|
||||
const GATEWAY_IMAGE_REF = 'ghcr.io/openclaw/openclaw:2026.4.12'
|
||||
const defaultSpec = {
|
||||
hostPort: 18789,
|
||||
hostHome: '/Users/me/.browseros/vm/openclaw',
|
||||
@@ -38,10 +34,6 @@ 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,
|
||||
@@ -49,7 +41,7 @@ describe('ContainerRuntime', () => {
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: OPENCLAW_IMAGE,
|
||||
image: GATEWAY_IMAGE_REF,
|
||||
restart: 'unless-stopped',
|
||||
ports: [
|
||||
{
|
||||
@@ -74,62 +66,6 @@ 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'
|
||||
@@ -201,7 +137,7 @@ describe('ContainerRuntime', () => {
|
||||
'/mnt/browseros/vm/openclaw:/home/node',
|
||||
'--add-host',
|
||||
'host.containers.internal:192.168.5.2',
|
||||
OPENCLAW_IMAGE,
|
||||
GATEWAY_IMAGE_REF,
|
||||
]),
|
||||
undefined,
|
||||
)
|
||||
@@ -214,45 +150,6 @@ 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 () => {
|
||||
@@ -278,70 +175,6 @@ 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() {
|
||||
@@ -357,8 +190,6 @@ 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) => {
|
||||
@@ -370,7 +201,7 @@ function createDeps() {
|
||||
},
|
||||
loader: {
|
||||
ensureImageLoaded: mock(async () => {}),
|
||||
ensureAgentImageLoaded: mock(async () => OPENCLAW_IMAGE),
|
||||
ensureAgentImageLoaded: mock(async () => GATEWAY_IMAGE_REF),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@ 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,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
resolveSupportedOpenClawProvider,
|
||||
UnsupportedOpenClawProviderError,
|
||||
@@ -26,13 +23,11 @@ type MutableOpenClawService = OpenClawService & {
|
||||
token: string
|
||||
restart: ReturnType<typeof mock>
|
||||
runtime: {
|
||||
ensureReady?: (_onLog?: (_line: string) => void) => Promise<void>
|
||||
ensureReady?: () => 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,
|
||||
@@ -92,60 +87,6 @@ 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 () => ({
|
||||
@@ -716,7 +657,6 @@ describe('OpenClawService', () => {
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => gatewayReady,
|
||||
isGatewayCurrent: mock(async () => true),
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -737,77 +677,6 @@ 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 })
|
||||
@@ -831,7 +700,6 @@ describe('OpenClawService', () => {
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => true,
|
||||
isGatewayCurrent: mock(async () => true),
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -1080,7 +948,6 @@ describe('OpenClawService', () => {
|
||||
isPodmanAvailable: async () => true,
|
||||
ensureReady,
|
||||
isReady,
|
||||
isGatewayCurrent: mock(async () => true),
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -1104,71 +971,6 @@ 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',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import {
|
||||
getAgentCacheDir,
|
||||
getBrowserosDir,
|
||||
getCacheDir,
|
||||
getVmCacheDir,
|
||||
@@ -105,4 +106,12 @@ 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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,8 @@ 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 = [
|
||||
@@ -50,6 +52,8 @@ 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> = {
|
||||
|
||||
@@ -28,6 +28,8 @@ 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(() => {
|
||||
@@ -444,6 +446,75 @@ 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', () => {
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { ContainerCli } from '../../src/lib/container'
|
||||
import { LimaCli, VmRuntime } from '../../src/lib/vm'
|
||||
import { getContainerdSocketPath, VM_NAME } from '../../src/lib/vm/paths'
|
||||
import { LimaCli, type VmManifest, VmRuntime } from '../../src/lib/vm'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
VM_NAME,
|
||||
} from '../../src/lib/vm/paths'
|
||||
|
||||
const LIVE_VM_SMOKE_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const liveIt = process.env.LIVE_VM_SMOKE === '1' ? it : it.skip
|
||||
@@ -19,6 +23,12 @@ 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
|
||||
@@ -26,6 +36,9 @@ 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 () => {
|
||||
|
||||
@@ -15,11 +15,7 @@ import type {
|
||||
AcpRuntime as AcpxCoreRuntime,
|
||||
} from 'acpx/runtime'
|
||||
import { createRuntimeStore } from 'acpx/runtime'
|
||||
import { formatUserMessage } from '../../../src/agent/format-message'
|
||||
import {
|
||||
AcpxRuntime,
|
||||
unwrapBrowserosAcpUserMessage,
|
||||
} from '../../../src/lib/agents/acpx-runtime'
|
||||
import { AcpxRuntime } from '../../../src/lib/agents/acpx-runtime'
|
||||
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
|
||||
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
|
||||
|
||||
@@ -309,242 +305,6 @@ open <example.com>
|
||||
])
|
||||
})
|
||||
|
||||
it('strips the inner formatUserMessage envelope from history payloads', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(cwd, stateDir)
|
||||
const timestamp = '2026-04-29T20:00:00.000Z'
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Browser bot',
|
||||
adapter: 'codex',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
// Wrapped form persisted to the session record. Note that the
|
||||
// inner formatUserMessage envelope's tags (`<selected_text>`,
|
||||
// `<USER_QUERY>`) are escaped to `<…>` because
|
||||
// `buildBrowserosAcpPrompt` runs `escapePromptTagText` over the
|
||||
// entire payload before adding the outer envelope.
|
||||
const wrapped = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>
|
||||
|
||||
<user_request>
|
||||
## Browser Context
|
||||
**Active Tab:** Tab 1 (Page ID: 101) - "Example" (https://example.com)
|
||||
|
||||
---
|
||||
|
||||
<selected_text (from "Example" — https://example.com)>
|
||||
quoted selection
|
||||
</selected_text>
|
||||
|
||||
<USER_QUERY>
|
||||
summarise this
|
||||
</USER_QUERY>
|
||||
</user_request>`
|
||||
const record: AcpSessionRecord = {
|
||||
schema: 'acpx.session.v1',
|
||||
acpxRecordId: agent.sessionKey,
|
||||
acpSessionId: 'sid-1',
|
||||
agentSessionId: 'inner-1',
|
||||
agentCommand: 'codex --acp',
|
||||
cwd,
|
||||
name: agent.sessionKey,
|
||||
createdAt: timestamp,
|
||||
lastUsedAt: timestamp,
|
||||
lastSeq: 0,
|
||||
eventLog: {
|
||||
active_path: '',
|
||||
segment_count: 0,
|
||||
max_segment_bytes: 0,
|
||||
max_segments: 0,
|
||||
},
|
||||
closed: false,
|
||||
messages: [
|
||||
{
|
||||
User: {
|
||||
id: 'user-1',
|
||||
content: [{ Text: wrapped }],
|
||||
},
|
||||
},
|
||||
],
|
||||
updated_at: timestamp,
|
||||
cumulative_token_usage: {},
|
||||
request_token_usage: {},
|
||||
acpx: {},
|
||||
}
|
||||
await createRuntimeStore({ stateDir }).save(record)
|
||||
|
||||
const history = await new AcpxRuntime({ cwd, stateDir }).getHistory({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
})
|
||||
|
||||
expect(history.items[0]?.text).toBe('summarise this')
|
||||
})
|
||||
|
||||
describe('unwrapBrowserosAcpUserMessage', () => {
|
||||
it('returns clean text for input that has no envelope', () => {
|
||||
expect(unwrapBrowserosAcpUserMessage('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(unwrapBrowserosAcpUserMessage('')).toBe('')
|
||||
})
|
||||
|
||||
it('strips a fully wrapped message and decodes escapes', () => {
|
||||
// On-wire form: `escapePromptTagText` escapes the inner tags
|
||||
// before the outer envelope is added.
|
||||
const wrapped = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>
|
||||
|
||||
<user_request>
|
||||
## Browser Context
|
||||
**Active Tab:** Tab 1 (Page ID: 101) - "Example" (https://example.com)
|
||||
|
||||
---
|
||||
|
||||
<USER_QUERY>
|
||||
look at example
|
||||
</USER_QUERY>
|
||||
</user_request>`
|
||||
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe('look at example')
|
||||
})
|
||||
|
||||
it('strips the inner envelope when only the inner wrapper is present', () => {
|
||||
// Plain (un-escaped) inner-envelope-only input — covers the
|
||||
// hypothetical case where some future code path stores the
|
||||
// unwrapped-outer form directly.
|
||||
const innerOnly = `## Browser Context
|
||||
**Active Tab:** Tab 1
|
||||
|
||||
---
|
||||
|
||||
<USER_QUERY>
|
||||
just inner
|
||||
</USER_QUERY>`
|
||||
expect(unwrapBrowserosAcpUserMessage(innerOnly)).toBe('just inner')
|
||||
})
|
||||
|
||||
it('strips the outer envelope when only the outer wrapper is present', () => {
|
||||
const outerOnly = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>
|
||||
|
||||
<user_request>
|
||||
just outer
|
||||
</user_request>`
|
||||
expect(unwrapBrowserosAcpUserMessage(outerOnly)).toBe('just outer')
|
||||
})
|
||||
|
||||
it('removes a selected_text block with attribute string', () => {
|
||||
const wrapped = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>
|
||||
|
||||
<user_request>
|
||||
<selected_text (from "Title" — https://example.com)>
|
||||
selection body
|
||||
</selected_text>
|
||||
|
||||
<USER_QUERY>
|
||||
question with selection
|
||||
</USER_QUERY>
|
||||
</user_request>`
|
||||
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe(
|
||||
'question with selection',
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent — applying twice equals applying once', () => {
|
||||
const wrapped = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>
|
||||
|
||||
<user_request>
|
||||
## Browser Context
|
||||
ctx
|
||||
|
||||
---
|
||||
|
||||
<USER_QUERY>
|
||||
hello
|
||||
</USER_QUERY>
|
||||
</user_request>`
|
||||
const once = unwrapBrowserosAcpUserMessage(wrapped)
|
||||
const twice = unwrapBrowserosAcpUserMessage(once)
|
||||
expect(twice).toBe(once)
|
||||
expect(twice).toBe('hello')
|
||||
})
|
||||
|
||||
it('round-trips formatUserMessage output back to the user typed text', () => {
|
||||
const userText = 'fix the OAuth redirect after login'
|
||||
const formatted = formatUserMessage(userText, {
|
||||
activeTab: {
|
||||
id: 1,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
},
|
||||
})
|
||||
// Mirror what acpx-runtime.ts's buildBrowserosAcpPrompt does
|
||||
// on the wire: escape the inner payload (so its tags survive
|
||||
// round-trip serialisation) and then wrap with <role>…</role>
|
||||
// + <user_request>…</user_request>. Constants/escape rules
|
||||
// are duplicated here so the test pins the exact serialised
|
||||
// shape rather than the helpers that produce it.
|
||||
const escapeForPrompt = (value: string) =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
const ROLE = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>`
|
||||
const wrapped = `${ROLE}
|
||||
|
||||
<user_request>
|
||||
${escapeForPrompt(formatted)}
|
||||
</user_request>`
|
||||
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe(userText)
|
||||
})
|
||||
|
||||
it('preserves user-typed angle-brackets via the entity decode', () => {
|
||||
// `escapePromptTagText` escapes every `<` and `>` in the
|
||||
// payload — including the inner envelope's own tags AND any
|
||||
// user-typed tag-like content. The on-wire form below is what
|
||||
// a user typing `<USER_QUERY>foo</USER_QUERY>` literally
|
||||
// produces after formatUserMessage + buildBrowserosAcpPrompt.
|
||||
const wrapped = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>
|
||||
|
||||
<user_request>
|
||||
<USER_QUERY>
|
||||
<USER_QUERY>foo</USER_QUERY>
|
||||
</USER_QUERY>
|
||||
</user_request>`
|
||||
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe(
|
||||
'<USER_QUERY>foo</USER_QUERY>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('continues the turn when runtime config control is unavailable', async () => {
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
|
||||
@@ -4,20 +4,10 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ContainerCli } from '../../../src/lib/container/container-cli'
|
||||
import {
|
||||
ContainerCliError,
|
||||
ContainerNameInUseError,
|
||||
} from '../../../src/lib/vm/errors'
|
||||
import { ContainerCliError } from '../../../src/lib/vm/errors'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
describe('ContainerCli', () => {
|
||||
@@ -52,35 +42,6 @@ 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 },
|
||||
@@ -100,6 +61,21 @@ 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)
|
||||
@@ -173,92 +149,6 @@ 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 },
|
||||
@@ -311,31 +201,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -3,83 +3,197 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { OPENCLAW_IMAGE } from '@browseros/shared/constants/openclaw'
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
||||
import type { ContainerCli } from '../../../src/lib/container/container-cli'
|
||||
import { ImageLoader } from '../../../src/lib/container/image-loader'
|
||||
import { ContainerCliError, ImageLoadError } from '../../../src/lib/vm/errors'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import * as paths from '../../../src/lib/vm/paths'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'agent-x64',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('ImageLoader', () => {
|
||||
it('returns without pulling when the image already exists', async () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('returns without loading when the image already exists', async () => {
|
||||
const cli = new FakeContainerCli([true])
|
||||
const loader = new ImageLoader(cli as never)
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await loader.ensureImageLoaded(OPENCLAW_IMAGE)
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(cli.pullCalls).toEqual([])
|
||||
expect(cli.existsCalls).toEqual([OPENCLAW_IMAGE])
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('pulls a missing image and verifies it exists', async () => {
|
||||
it('loads a missing image from the guest cache and verifies it exists', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const loader = new ImageLoader(cli as never)
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await loader.ensureImageLoaded(OPENCLAW_IMAGE)
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(cli.pullCalls).toEqual([OPENCLAW_IMAGE])
|
||||
expect(cli.existsCalls).toEqual([OPENCLAW_IMAGE, 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',
|
||||
])
|
||||
})
|
||||
|
||||
it('loads the OpenClaw agent image by manifest name', async () => {
|
||||
it('loads an agent image by manifest name and returns its image ref', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const loader = new ImageLoader(cli as never)
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await expect(loader.ensureAgentImageLoaded('openclaw')).resolves.toBe(
|
||||
OPENCLAW_IMAGE,
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
)
|
||||
|
||||
expect(cli.pullCalls).toEqual([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',
|
||||
])
|
||||
})
|
||||
|
||||
it('throws ImageLoadError for unknown agent names', async () => {
|
||||
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 () => {
|
||||
const cli = new FakeContainerCli([])
|
||||
const loader = new ImageLoader(cli as never)
|
||||
|
||||
await expect(loader.ensureAgentImageLoaded('missing')).rejects.toThrow(
|
||||
ImageLoadError,
|
||||
)
|
||||
expect(cli.pullCalls).toEqual([])
|
||||
})
|
||||
|
||||
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.pullError = new ContainerCliError('nerdctl pull', 1, 'network failed')
|
||||
const loader = new ImageLoader(cli as never)
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
const error = await loader
|
||||
.ensureImageLoaded(OPENCLAW_IMAGE)
|
||||
.ensureAgentImageLoaded('missing')
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ImageLoadError)
|
||||
expect(error.cause).toBe(cli.pullError)
|
||||
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(
|
||||
ImageLoadError,
|
||||
)
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('wraps ContainerCliError load failures as ImageLoadError', async () => {
|
||||
const cli = new FakeContainerCli([false])
|
||||
cli.loadError = new ContainerCliError('nerdctl load', 125, 'bad archive')
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
const error = await loader
|
||||
.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ImageLoadError)
|
||||
expect(error.cause).toBe(cli.loadError)
|
||||
})
|
||||
})
|
||||
|
||||
class FakeContainerCli
|
||||
implements Pick<ContainerCli, 'imageExists' | 'pullImage'>
|
||||
implements Pick<ContainerCli, 'imageExists' | 'loadImage'>
|
||||
{
|
||||
existsCalls: string[] = []
|
||||
pullCalls: string[] = []
|
||||
pullError: Error | null = null
|
||||
loadCalls: string[] = []
|
||||
loadError: Error | null = null
|
||||
|
||||
constructor(private readonly existsResponses: boolean[]) {}
|
||||
|
||||
@@ -88,8 +202,9 @@ class FakeContainerCli
|
||||
return this.existsResponses.shift() ?? false
|
||||
}
|
||||
|
||||
async pullImage(ref: string): Promise<void> {
|
||||
this.pullCalls.push(ref)
|
||||
if (this.pullError) throw this.pullError
|
||||
async loadImage(path: string): Promise<string[]> {
|
||||
this.loadCalls.push(path)
|
||||
if (this.loadError) throw this.loadError
|
||||
return ['loaded']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* @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('..')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ContainerCliError,
|
||||
ImageLoadError,
|
||||
LimaCommandError,
|
||||
ManifestMissingError,
|
||||
VmError,
|
||||
VmNotReadyError,
|
||||
VmStateCorruptedError,
|
||||
@@ -23,6 +24,7 @@ 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) {
|
||||
@@ -46,30 +48,8 @@ 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',
|
||||
)
|
||||
|
||||
@@ -12,11 +12,14 @@ 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: []')
|
||||
})
|
||||
|
||||
@@ -24,6 +27,7 @@ describe('renderLimaTemplate', () => {
|
||||
expect(() =>
|
||||
renderLimaTemplate('minimumLimaVersion: 2.0.0\n', {
|
||||
vmStateDir: '/state',
|
||||
imageCacheDir: '/images',
|
||||
}),
|
||||
).toThrow('mounts: [] marker')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { ManifestMissingError } from '../../../src/lib/vm/errors'
|
||||
import {
|
||||
agentForArch,
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
type VmManifest,
|
||||
writeInstalledManifest,
|
||||
} from '../../../src/lib/vm/manifest'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'c',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'd',
|
||||
sizeBytes: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('VM manifest helpers', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'browseros-vm-manifest-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads the cached manifest', async () => {
|
||||
const manifestPath = join(root, 'cache', 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
|
||||
await expect(readCachedManifest(root)).resolves.toEqual(manifest)
|
||||
})
|
||||
|
||||
it('throws ManifestMissingError when cached manifest is absent', async () => {
|
||||
await expect(readCachedManifest(root)).rejects.toThrow(ManifestMissingError)
|
||||
})
|
||||
|
||||
it('returns null for a missing installed manifest', async () => {
|
||||
await expect(readInstalledManifest(root)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('reads the installed manifest', async () => {
|
||||
const manifestPath = join(root, 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
|
||||
await expect(readInstalledManifest(root)).resolves.toEqual(manifest)
|
||||
})
|
||||
|
||||
it('throws on malformed installed manifest JSON', async () => {
|
||||
const manifestPath = join(root, 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, '{not-json')
|
||||
|
||||
await expect(readInstalledManifest(root)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('writes the installed manifest atomically', async () => {
|
||||
await writeInstalledManifest(manifest, root)
|
||||
|
||||
const raw = await readFile(join(root, 'vm', 'manifest.json'), 'utf8')
|
||||
expect(JSON.parse(raw)).toEqual(manifest)
|
||||
})
|
||||
|
||||
it('compares installed and cached versions', () => {
|
||||
const older = { ...manifest, updatedAt: '2026-04-21T00:00:00.000Z' }
|
||||
const newer = { ...manifest, updatedAt: '2026-04-23T00:00:00.000Z' }
|
||||
|
||||
expect(compareVersions(null, manifest)).toBe('fresh')
|
||||
expect(compareVersions(manifest, manifest)).toBe('same')
|
||||
expect(compareVersions(older, manifest)).toBe('upgrade')
|
||||
expect(compareVersions(newer, manifest)).toBe('downgrade')
|
||||
})
|
||||
|
||||
it('compares ISO timestamp versions with time-of-day precision', () => {
|
||||
const morning = {
|
||||
...manifest,
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
}
|
||||
const afternoon = {
|
||||
...manifest,
|
||||
updatedAt: '2026-04-22T15:00:00.000Z',
|
||||
}
|
||||
|
||||
expect(compareVersions(morning, afternoon)).toBe('upgrade')
|
||||
expect(compareVersions(afternoon, morning)).toBe('downgrade')
|
||||
})
|
||||
|
||||
it('returns the requested agent tarball for an arch', () => {
|
||||
expect(agentForArch(manifest, 'openclaw', 'arm64')).toEqual({
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarball: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'c',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when an agent or arch is absent', () => {
|
||||
expect(() => agentForArch(manifest, 'missing', 'arm64')).toThrow(
|
||||
'missing agent',
|
||||
)
|
||||
expect(() =>
|
||||
agentForArch(manifest, 'openclaw', 'x64' as never),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
} from '../../../src/lib/browseros-dir'
|
||||
import {
|
||||
detectArch,
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
getImageCacheDir,
|
||||
getInstalledManifestPath,
|
||||
getLimaHomeDir,
|
||||
getVmCacheDir,
|
||||
getVmStateDir,
|
||||
@@ -78,10 +81,17 @@ describe('VM paths', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('builds VM storage paths', () => {
|
||||
it('builds cached and installed manifest paths', () => {
|
||||
const root = '/Users/foo/.browseros'
|
||||
|
||||
expect(getVmCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm')
|
||||
expect(getImageCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm/images')
|
||||
expect(getCachedManifestPath(root)).toBe(
|
||||
'/Users/foo/.browseros/cache/vm/manifest.json',
|
||||
)
|
||||
expect(getInstalledManifestPath(root)).toBe(
|
||||
'/Users/foo/.browseros/vm/manifest.json',
|
||||
)
|
||||
expect(getContainerdSocketPath(root)).toBe(
|
||||
'/Users/foo/.browseros/lima/browseros-vm/sock/containerd.sock',
|
||||
)
|
||||
@@ -93,6 +103,9 @@ 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', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
@@ -12,13 +12,43 @@ import {
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { logger } from '../../../src/lib/logger'
|
||||
import { VmNotReadyError } from '../../../src/lib/vm/errors'
|
||||
import { VM_NAME } from '../../../src/lib/vm/paths'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getInstalledManifestPath,
|
||||
VM_NAME,
|
||||
} from '../../../src/lib/vm/paths'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
|
||||
import { VmRuntime } from '../../../src/lib/vm/vm-runtime'
|
||||
import { fakeLimactl } from '../../__helpers__/fake-limactl'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'agent-x64',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('VmRuntime', () => {
|
||||
let root: string
|
||||
let limaHome: string
|
||||
@@ -30,6 +60,7 @@ 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')
|
||||
})
|
||||
|
||||
@@ -37,7 +68,7 @@ describe('VmRuntime', () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('provisions a fresh VM and waits for rootless nerdctl', async () => {
|
||||
it('provisions a fresh VM, waits for rootless nerdctl, and installs the manifest', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
@@ -57,12 +88,59 @@ 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('returns fast when the VM is already running', async () => {
|
||||
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)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
@@ -92,6 +170,7 @@ describe('VmRuntime', () => {
|
||||
})
|
||||
|
||||
it('starts an existing stopped VM without recreating it', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
@@ -119,6 +198,7 @@ describe('VmRuntime', () => {
|
||||
})
|
||||
|
||||
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
@@ -213,6 +293,92 @@ 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: {} },
|
||||
@@ -284,6 +450,29 @@ 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,
|
||||
|
||||
@@ -14,6 +14,8 @@ const config = {
|
||||
executionDir: '/tmp/browseros-execution',
|
||||
mcpAllowRemote: false,
|
||||
aiSdkDevtoolsEnabled: false,
|
||||
vmCachePrefetch: true,
|
||||
vmCacheManifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
|
||||
}
|
||||
|
||||
describe('Application.start', () => {
|
||||
@@ -49,45 +51,70 @@ describe('Application.start', () => {
|
||||
expect(loggerError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts OpenClaw prewarm without blocking HTTP startup', async () => {
|
||||
const { Application, createHttpServer, openClawService } =
|
||||
it('starts VM cache prefetch without blocking HTTP startup', async () => {
|
||||
const { Application, createHttpServer, prefetchVmCache } =
|
||||
await setupApplicationTest()
|
||||
let resolvePrewarm: () => void = () => {}
|
||||
const pendingPrewarm = new Promise<void>((resolve) => {
|
||||
resolvePrewarm = resolve
|
||||
let resolvePrefetch: (value: {
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}) => void = () => {}
|
||||
const pendingPrefetch = new Promise<{
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}>((resolve) => {
|
||||
resolvePrefetch = resolve
|
||||
})
|
||||
openClawService.prewarm.mockImplementation(() => pendingPrewarm)
|
||||
prefetchVmCache.mockImplementation(() => pendingPrefetch)
|
||||
|
||||
const app = new Application(config)
|
||||
const startPromise = app.start()
|
||||
const completedBeforePrewarm = await Promise.race([
|
||||
const completedBeforePrefetch = await Promise.race([
|
||||
startPromise.then(() => true),
|
||||
Bun.sleep(25).then(() => false),
|
||||
])
|
||||
resolvePrewarm()
|
||||
resolvePrefetch({
|
||||
downloaded: [],
|
||||
manifestPath: '/tmp/manifest.json',
|
||||
skipped: true,
|
||||
})
|
||||
await startPromise
|
||||
|
||||
expect(completedBeforePrewarm).toBe(true)
|
||||
expect(completedBeforePrefetch).toBe(true)
|
||||
expect(createHttpServer).toHaveBeenCalledTimes(1)
|
||||
expect(openClawService.prewarm).toHaveBeenCalledTimes(1)
|
||||
expect(openClawService.tryAutoStart).toHaveBeenCalledTimes(1)
|
||||
expect(prefetchVmCache).toHaveBeenCalledWith({
|
||||
manifestUrl: 'https://cdn.browseros.com/vm/manifest.json',
|
||||
})
|
||||
})
|
||||
|
||||
it('logs and continues when OpenClaw prewarm fails', async () => {
|
||||
const { Application, createHttpServer, loggerWarn, openClawService } =
|
||||
it('logs VM cache prefetch failures without failing startup', async () => {
|
||||
const { Application, createHttpServer, loggerWarn, prefetchVmCache } =
|
||||
await setupApplicationTest()
|
||||
openClawService.prewarm.mockImplementation(async () => {
|
||||
throw new Error('registry offline')
|
||||
})
|
||||
prefetchVmCache.mockImplementation(() =>
|
||||
Promise.reject(new Error('cache offline')),
|
||||
)
|
||||
const app = new Application(config)
|
||||
|
||||
await app.start()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(createHttpServer).toHaveBeenCalledTimes(1)
|
||||
expect(loggerWarn).toHaveBeenCalledWith('OpenClaw prewarm failed', {
|
||||
error: 'registry offline',
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,6 +126,7 @@ 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')
|
||||
@@ -157,24 +185,26 @@ async function setupApplicationTest() {
|
||||
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
|
||||
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
|
||||
|
||||
const prewarm = mock(async () => {})
|
||||
const tryAutoStart = mock(async () => {})
|
||||
|
||||
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
prewarm,
|
||||
tryAutoStart,
|
||||
tryAutoStart: async () => {},
|
||||
}) as never,
|
||||
)
|
||||
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
prewarm,
|
||||
tryAutoStart,
|
||||
tryAutoStart: async () => {},
|
||||
}) 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,
|
||||
@@ -184,6 +214,6 @@ async function setupApplicationTest() {
|
||||
loggerError,
|
||||
loggerInfo,
|
||||
loggerWarn,
|
||||
openClawService: { prewarm, tryAutoStart },
|
||||
prefetchVmCache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"globals": "^16.4.0",
|
||||
"lefthook": "^2.0.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"typedoc": "^0.28.15",
|
||||
"typescript": "^5.9.2",
|
||||
},
|
||||
@@ -195,7 +196,6 @@
|
||||
"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,7 +205,6 @@
|
||||
"@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",
|
||||
@@ -1830,16 +1829,12 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -2674,7 +2669,7 @@
|
||||
|
||||
"giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="],
|
||||
|
||||
"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": ["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-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
@@ -3108,7 +3103,7 @@
|
||||
|
||||
"lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -3484,7 +3479,7 @@
|
||||
|
||||
"path-root-regex": ["path-root-regex@0.1.2", "", {}, "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ=="],
|
||||
|
||||
"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-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
@@ -3574,8 +3569,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -3836,15 +3829,13 @@
|
||||
|
||||
"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@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -3930,7 +3921,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@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"signedsource": ["signedsource@1.0.0", "", {}, "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww=="],
|
||||
|
||||
@@ -4424,6 +4415,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -4498,8 +4491,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -4800,6 +4791,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -4892,8 +4885,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4904,8 +4895,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4924,6 +4913,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4944,6 +4935,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5058,8 +5051,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5360,6 +5351,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5536,6 +5529,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5570,6 +5565,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5764,16 +5761,24 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5826,6 +5831,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5837,5 +5844,9 @@
|
||||
"@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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,10 @@
|
||||
"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/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",
|
||||
"dev:setup": "./tools/dev/setup.sh",
|
||||
"install:browseros-dogfood": "make -C tools/dogfood install",
|
||||
"test:env": "./tools/dev/run.sh test",
|
||||
"test:cleanup": "./tools/dev/run.sh cleanup --quick --yes",
|
||||
"test:cleanup": "./tools/dev/run.sh cleanup",
|
||||
"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",
|
||||
@@ -34,13 +28,20 @@
|
||||
"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 ./scripts/run-test-suite.ts all",
|
||||
"test:main": "bun run ./scripts/run-test-suite.ts main",
|
||||
"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",
|
||||
"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"
|
||||
"generate:models": "bun scripts/generate-models.ts",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"repository": "browseros-ai/BrowserOS-server",
|
||||
"author": "BrowserOS",
|
||||
@@ -61,6 +62,7 @@
|
||||
"globals": "^16.4.0",
|
||||
"lefthook": "^2.0.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"typedoc": "^0.28.15",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
|
||||
@@ -4,4 +4,8 @@ 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
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# @browseros/build-tools
|
||||
|
||||
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`.
|
||||
Builds agent image tarballs, publishes release artifacts to R2, and hydrates the local dev cache for agent tarballs.
|
||||
|
||||
The BrowserOS VM is defined by a committed Lima template at `template/browseros-vm.yaml`. There is no custom disk build step; `limactl` consumes the template directly at runtime.
|
||||
|
||||
@@ -33,6 +29,9 @@ 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
|
||||
```
|
||||
|
||||
@@ -87,3 +86,45 @@ 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.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "openclaw",
|
||||
"image": "ghcr.io/openclaw/openclaw",
|
||||
"version": "2026.4.12"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,9 +3,15 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "BrowserOS release artifact producer",
|
||||
"description": "BrowserOS release artifact producer and dev cache sync",
|
||||
"scripts": {
|
||||
"build:tarball": "bun run scripts/build-tarball.ts",
|
||||
"emit-manifest": "bun run scripts/emit-manifest.ts",
|
||||
"upload": "bun run scripts/upload-to-r2.ts",
|
||||
"download": "bun run scripts/download-from-r2.ts",
|
||||
"cache:sync": "bun run scripts/cache-sync.ts",
|
||||
"dev:seed:tarball": "bun run scripts/seed-dev-agent-tarball.ts",
|
||||
"smoke:tarball": "bun run scripts/smoke-tarball.ts",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { parseArch, podmanArch } from './common/arch'
|
||||
import { type Bundle, tarballKey } from './common/manifest'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
arch: { type: 'string' },
|
||||
'output-dir': { type: 'string', default: './dist/images' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.agent || !values.arch) {
|
||||
console.error(
|
||||
'usage: build:tarball -- --agent <name> --arch <arm64|x64> [--output-dir ./dist/images]',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const arch = parseArch(values.arch)
|
||||
const outDir = values['output-dir']
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
const agent = bundle.agents.find(({ name }) => name === values.agent)
|
||||
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
|
||||
|
||||
const ref = `${agent.image}:${agent.version}`
|
||||
const tarballPath = path.join(
|
||||
outDir,
|
||||
path.basename(tarballKey(agent.name, agent.version, arch)),
|
||||
)
|
||||
const tarPath = tarballPath.slice(0, -'.gz'.length)
|
||||
|
||||
await rm(tarballPath, { force: true })
|
||||
await rm(`${tarballPath}.sha256`, { force: true })
|
||||
await rm(tarPath, { force: true })
|
||||
await spawnChecked([
|
||||
'podman',
|
||||
'pull',
|
||||
'--os',
|
||||
'linux',
|
||||
'--arch',
|
||||
podmanArch(arch),
|
||||
ref,
|
||||
])
|
||||
await spawnChecked([
|
||||
'podman',
|
||||
'save',
|
||||
'--format=oci-archive',
|
||||
'--output',
|
||||
tarPath,
|
||||
ref,
|
||||
])
|
||||
await spawnChecked(['gzip', '-9', '-f', tarPath])
|
||||
|
||||
const sha = await sha256File(tarballPath)
|
||||
const size = (await stat(tarballPath)).size
|
||||
await writeFile(
|
||||
`${tarballPath}.sha256`,
|
||||
`${sha} ${path.basename(tarballPath)}\n`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
key: tarballKey(agent.name, agent.version, arch),
|
||||
path: tarballPath,
|
||||
sha256: sha,
|
||||
sizeBytes: size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
async function spawnChecked(argv: string[]): Promise<void> {
|
||||
const proc = Bun.spawn(argv, {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { homedir, arch as hostArch } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import { ARCHES, type Arch } from './common/arch'
|
||||
import { fetchWithTimeout } from './common/fetch'
|
||||
import type { AgentManifest, Artifact } from './common/manifest'
|
||||
import { verifySha256 } from './common/sha256'
|
||||
|
||||
type ChunkSink = ReturnType<ReturnType<typeof Bun.file>['writer']>
|
||||
|
||||
export interface PlanItem {
|
||||
key: string
|
||||
destPath: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export function planSync(opts: {
|
||||
local: AgentManifest | null
|
||||
remote: AgentManifest
|
||||
cacheRoot: string
|
||||
arches: Arch[]
|
||||
}): PlanItem[] {
|
||||
const out: PlanItem[] = []
|
||||
for (const arch of opts.arches) {
|
||||
for (const [name, agent] of Object.entries(opts.remote.agents)) {
|
||||
maybeAdd(
|
||||
out,
|
||||
agent.tarballs[arch],
|
||||
opts.local?.agents[name]?.tarballs[arch],
|
||||
opts.cacheRoot,
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function selectSyncArches(
|
||||
allArches: boolean,
|
||||
rawHostArch = hostArch(),
|
||||
): Arch[] {
|
||||
if (allArches) return [...ARCHES]
|
||||
if (rawHostArch === 'arm64') return ['arm64']
|
||||
if (rawHostArch === 'x64' || rawHostArch === 'ia32') return ['x64']
|
||||
throw new Error(`unsupported host arch: ${rawHostArch}`)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
'manifest-url': { type: 'string' },
|
||||
'all-arches': { type: 'boolean' },
|
||||
'cache-dir': { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
const cdnBase =
|
||||
process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
|
||||
const manifestUrl = values['manifest-url'] ?? `${cdnBase}/vm/manifest.json`
|
||||
const cacheRoot = values['cache-dir'] ?? getCacheDir()
|
||||
const arches = selectSyncArches(values['all-arches'] ?? false)
|
||||
|
||||
const response = await fetchWithTimeout(manifestUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`manifest fetch failed: ${manifestUrl} (${response.status})`,
|
||||
)
|
||||
}
|
||||
const remote = (await response.json()) as AgentManifest
|
||||
|
||||
const localManifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
|
||||
const local = await readLocalManifest(localManifestPath)
|
||||
const plan = planSync({ local, remote, cacheRoot, arches })
|
||||
|
||||
if (plan.length === 0) {
|
||||
console.log('agent cache up to date')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`syncing ${plan.length} agent artifact(s)`)
|
||||
for (const item of plan) {
|
||||
await mkdir(path.dirname(item.destPath), { recursive: true })
|
||||
const partial = `${item.destPath}.partial`
|
||||
await downloadToFile(`${cdnBase}/${item.key}`, partial)
|
||||
await verifySha256(partial, item.sha256)
|
||||
await rename(partial, item.destPath)
|
||||
console.log(`synced ${item.key}`)
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(localManifestPath), { recursive: true })
|
||||
await writeFile(localManifestPath, `${JSON.stringify(remote, null, 2)}\n`)
|
||||
console.log(`manifest written to ${localManifestPath}`)
|
||||
}
|
||||
|
||||
function maybeAdd(
|
||||
out: PlanItem[],
|
||||
remote: Artifact,
|
||||
local: Artifact | undefined,
|
||||
cacheRoot: string,
|
||||
): void {
|
||||
if (local?.sha256 === remote.sha256) return
|
||||
out.push({
|
||||
key: remote.key,
|
||||
destPath: path.join(cacheRoot, remote.key),
|
||||
sha256: remote.sha256,
|
||||
})
|
||||
}
|
||||
|
||||
function getCacheDir(): string {
|
||||
const dirName =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return path.join(homedir(), dirName, PATHS.CACHE_DIR_NAME)
|
||||
}
|
||||
|
||||
export async function readLocalManifest(
|
||||
manifestPath: string,
|
||||
): Promise<AgentManifest | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(manifestPath, 'utf8')) as AgentManifest
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetchWithTimeout(url)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`download failed: ${url} (${response.status})`)
|
||||
}
|
||||
|
||||
const sink = Bun.file(dest).writer()
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
await pumpStream(reader, sink)
|
||||
} finally {
|
||||
await sink.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function pumpStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
sink: ChunkSink,
|
||||
): Promise<void> {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
sink.write(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type Arch = 'arm64' | 'x64'
|
||||
|
||||
export const ARCHES: readonly Arch[] = ['arm64', 'x64']
|
||||
|
||||
export function parseArch(raw: string): Arch {
|
||||
if (raw === 'arm64' || raw === 'x64') return raw
|
||||
throw new Error(`unknown arch: ${raw} (expected arm64|x64)`)
|
||||
}
|
||||
|
||||
export function podmanArch(arch: Arch): 'arm64' | 'amd64' {
|
||||
return arch === 'x64' ? 'amd64' : 'arm64'
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 30_000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: init.signal ?? controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === 'AbortError') {
|
||||
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ARCHES, type Arch } from './arch'
|
||||
|
||||
export interface Artifact {
|
||||
key: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface AgentEntry {
|
||||
image: string
|
||||
version: string
|
||||
tarballs: Record<Arch, Artifact>
|
||||
}
|
||||
|
||||
export interface AgentManifest {
|
||||
schemaVersion: 2
|
||||
updatedAt: string
|
||||
agents: Record<string, AgentEntry>
|
||||
}
|
||||
|
||||
export interface BundleAgent {
|
||||
name: string
|
||||
image: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface Bundle {
|
||||
agents: BundleAgent[]
|
||||
}
|
||||
|
||||
export interface ArtifactInput {
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface ArtifactInputs {
|
||||
agents: Record<string, Record<Arch, ArtifactInput>>
|
||||
}
|
||||
|
||||
export function tarballKey(name: string, version: string, arch: Arch): string {
|
||||
return `vm/images/${name}-${version}-${arch}.tar.gz`
|
||||
}
|
||||
|
||||
export function buildManifest(
|
||||
bundle: Bundle,
|
||||
inputs: ArtifactInputs,
|
||||
now: Date = new Date(),
|
||||
): AgentManifest {
|
||||
const agents: Record<string, AgentEntry> = {}
|
||||
for (const agent of bundle.agents) {
|
||||
const tarballs = {} as Record<Arch, Artifact>
|
||||
for (const arch of ARCHES) {
|
||||
const entry = inputs.agents[agent.name]?.[arch]
|
||||
if (!entry) {
|
||||
throw new Error(`missing tarball inputs for ${agent.name}/${arch}`)
|
||||
}
|
||||
tarballs[arch] = {
|
||||
key: tarballKey(agent.name, agent.version, arch),
|
||||
sha256: entry.sha256,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
}
|
||||
}
|
||||
agents[agent.name] = {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarballs,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
updatedAt: now.toISOString(),
|
||||
agents,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { once } from 'node:events'
|
||||
import { createReadStream, type ReadStream } from 'node:fs'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import {
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
type CompletedPart,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
UploadPartCommand,
|
||||
@@ -45,6 +45,10 @@ export function getBucket(): string {
|
||||
return required('R2_BUCKET')
|
||||
}
|
||||
|
||||
export function getCdnBase(): string {
|
||||
return process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
|
||||
}
|
||||
|
||||
/** Uploads a file to R2, using multipart uploads for large artifacts so failed parts can be retried. */
|
||||
export async function putFile(
|
||||
client: S3Client,
|
||||
@@ -71,7 +75,16 @@ export async function putFile(
|
||||
}
|
||||
|
||||
await sendWithRetry(
|
||||
() => putObjectFromFile(client, bucket, key, filePath, contentType, size),
|
||||
() =>
|
||||
client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(filePath),
|
||||
ContentLength: size,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
),
|
||||
opts,
|
||||
)
|
||||
}
|
||||
@@ -111,16 +124,15 @@ async function putFileMultipart(
|
||||
const contentLength = end - start + 1
|
||||
const result = await sendWithRetry(
|
||||
() =>
|
||||
uploadPartFromFile(
|
||||
client,
|
||||
bucket,
|
||||
key,
|
||||
filePath,
|
||||
UploadId,
|
||||
partNumber,
|
||||
start,
|
||||
end,
|
||||
contentLength,
|
||||
client.send(
|
||||
new UploadPartCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId,
|
||||
PartNumber: partNumber,
|
||||
Body: createReadStream(filePath, { start, end }),
|
||||
ContentLength: contentLength,
|
||||
}),
|
||||
),
|
||||
opts,
|
||||
)
|
||||
@@ -149,66 +161,6 @@ async function putFileMultipart(
|
||||
}
|
||||
}
|
||||
|
||||
async function putObjectFromFile(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
filePath: string,
|
||||
contentType: string,
|
||||
contentLength: number,
|
||||
): Promise<unknown> {
|
||||
const body = createReadStream(filePath)
|
||||
try {
|
||||
return await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentLength: contentLength,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
await destroyReadStream(body)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPartFromFile(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
filePath: string,
|
||||
uploadId: string,
|
||||
partNumber: number,
|
||||
start: number,
|
||||
end: number,
|
||||
contentLength: number,
|
||||
): Promise<{ ETag?: string }> {
|
||||
const body = createReadStream(filePath, { start, end })
|
||||
try {
|
||||
return await client.send(
|
||||
new UploadPartCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
Body: body,
|
||||
ContentLength: contentLength,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
await destroyReadStream(body)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function destroyReadStream(stream: ReadStream): Promise<void> {
|
||||
stream.destroy()
|
||||
if (stream.closed) return
|
||||
await once(stream, 'close').catch(() => undefined)
|
||||
}
|
||||
|
||||
/** Retries part uploads by rerunning the command factory, which recreates consumed request bodies. */
|
||||
async function sendWithRetry<T>(
|
||||
send: () => Promise<T>,
|
||||
@@ -231,3 +183,48 @@ async function sendWithRetry<T>(
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export async function putBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
body: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentLength: Buffer.byteLength(body),
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
)
|
||||
const body = response.Body as
|
||||
| { transformToByteArray(): Promise<Uint8Array> }
|
||||
| undefined
|
||||
if (!body) throw new Error(`missing response body for R2 key: ${key}`)
|
||||
const bytes = await body.transformToByteArray()
|
||||
return new TextDecoder().decode(bytes)
|
||||
} catch (error) {
|
||||
const cause = error as {
|
||||
name?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
if (cause.name === 'NoSuchKey' || cause.$metadata?.httpStatusCode === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
|
||||
export async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function verifySha256(
|
||||
path: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const actual = await sha256File(path)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createR2Client, getBody, getBucket } from './common/r2'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
key: { type: 'string' },
|
||||
out: { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.key || !values.out) {
|
||||
console.error('usage: download -- --key <r2-key> --out <path>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const body = await getBody(createR2Client(), getBucket(), values.key)
|
||||
if (body === null) {
|
||||
throw new Error(
|
||||
`R2 key not found: ${values.key}. Publish a full manifest before publishing slices.`,
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(values.out), { recursive: true })
|
||||
await writeFile(values.out, body)
|
||||
console.log(`downloaded ${values.key} to ${values.out}`)
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { ARCHES } from './common/arch'
|
||||
import { fetchWithTimeout } from './common/fetch'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type AgentManifest,
|
||||
type ArtifactInputs,
|
||||
type Bundle,
|
||||
type BundleAgent,
|
||||
buildManifest,
|
||||
tarballKey,
|
||||
} from './common/manifest'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
'dist-dir': { type: 'string', default: './dist' },
|
||||
out: { type: 'string' },
|
||||
slice: { type: 'string', default: 'full' },
|
||||
'merge-from': { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
const distDir = values['dist-dir']
|
||||
const slice = values.slice
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
|
||||
if (slice !== 'full' && !slice.startsWith('agents:')) {
|
||||
throw new Error(`unknown slice: ${slice}`)
|
||||
}
|
||||
|
||||
const baseline = values['merge-from']
|
||||
? await loadBaseline(values['merge-from'])
|
||||
: null
|
||||
if (slice !== 'full' && !baseline) {
|
||||
throw new Error(`--slice ${slice} requires --merge-from`)
|
||||
}
|
||||
|
||||
const manifest = await buildSlicedManifest({ bundle, distDir, slice, baseline })
|
||||
const outPath = values.out ?? path.join(distDir, 'manifest.json')
|
||||
await mkdir(path.dirname(outPath), { recursive: true })
|
||||
await writeFile(outPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
console.log(`wrote ${outPath} (slice=${slice})`)
|
||||
|
||||
async function buildSlicedManifest(opts: {
|
||||
bundle: Bundle
|
||||
distDir: string
|
||||
slice: string
|
||||
baseline: AgentManifest | null
|
||||
}): Promise<AgentManifest> {
|
||||
if (opts.slice === 'full') {
|
||||
return buildManifest(
|
||||
opts.bundle,
|
||||
await readAllInputs(opts.bundle, opts.distDir),
|
||||
)
|
||||
}
|
||||
|
||||
const baseline = opts.baseline
|
||||
if (!baseline) throw new Error(`--slice ${opts.slice} requires --merge-from`)
|
||||
const updatedAt = new Date().toISOString()
|
||||
|
||||
if (opts.slice.startsWith('agents:')) {
|
||||
const name = opts.slice.slice('agents:'.length)
|
||||
const agent = opts.bundle.agents.find((entry) => entry.name === name)
|
||||
if (!agent) throw new Error(`unknown agent: ${name}`)
|
||||
|
||||
return {
|
||||
...baseline,
|
||||
schemaVersion: 2,
|
||||
updatedAt,
|
||||
agents: {
|
||||
...baseline.agents,
|
||||
[name]: await readAgentEntry(agent, opts.distDir),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unknown slice: ${opts.slice}`)
|
||||
}
|
||||
|
||||
async function readAllInputs(
|
||||
bundle: Bundle,
|
||||
distDir: string,
|
||||
): Promise<ArtifactInputs> {
|
||||
const agents: ArtifactInputs['agents'] = {}
|
||||
for (const agent of bundle.agents) {
|
||||
agents[agent.name] = {} as ArtifactInputs['agents'][string]
|
||||
for (const arch of ARCHES) {
|
||||
const artifactPath = path.join(
|
||||
distDir,
|
||||
'images',
|
||||
path.basename(tarballKey(agent.name, agent.version, arch)),
|
||||
)
|
||||
agents[agent.name][arch] = await readArtifactInput(artifactPath)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
async function readAgentEntry(
|
||||
agent: BundleAgent,
|
||||
distDir: string,
|
||||
): Promise<AgentEntry> {
|
||||
const tarballs = {} as AgentEntry['tarballs']
|
||||
for (const arch of ARCHES) {
|
||||
const key = tarballKey(agent.name, agent.version, arch)
|
||||
const artifactPath = path.join(distDir, 'images', path.basename(key))
|
||||
tarballs[arch] = { key, ...(await readArtifactInput(artifactPath)) }
|
||||
}
|
||||
return { image: agent.image, version: agent.version, tarballs }
|
||||
}
|
||||
|
||||
async function readArtifactInput(
|
||||
filePath: string,
|
||||
): Promise<{ sha256: string; sizeBytes: number }> {
|
||||
return {
|
||||
sha256: await sha256File(filePath),
|
||||
sizeBytes: (await stat(filePath)).size,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBaseline(src: string): Promise<AgentManifest> {
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
const response = await fetchWithTimeout(src)
|
||||
if (!response.ok) {
|
||||
throw new Error(`baseline fetch failed: ${src} (${response.status})`)
|
||||
}
|
||||
return (await response.json()) as AgentManifest
|
||||
}
|
||||
|
||||
return JSON.parse(await readFile(src, 'utf8')) as AgentManifest
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bun
|
||||
import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import type { Arch } from './common/arch'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type AgentManifest,
|
||||
type Artifact,
|
||||
type Bundle,
|
||||
type BundleAgent,
|
||||
tarballKey,
|
||||
} from './common/manifest'
|
||||
import { sha256File, verifySha256 } from './common/sha256'
|
||||
|
||||
export const DEV_ARCH: Arch = 'arm64'
|
||||
|
||||
export interface BuiltAgentArtifact {
|
||||
agent: BundleAgent
|
||||
key: string
|
||||
path: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface DevAgentEntry extends Omit<AgentEntry, 'tarballs'> {
|
||||
tarballs: Partial<Record<Arch, Artifact>>
|
||||
}
|
||||
|
||||
export interface DevAgentManifest extends Omit<AgentManifest, 'agents'> {
|
||||
agents: Record<string, DevAgentEntry>
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await seedDevAgentTarballs()
|
||||
}
|
||||
|
||||
export async function seedDevAgentTarballs(): Promise<void> {
|
||||
assertDevelopment()
|
||||
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = await readBundle(pkgRoot)
|
||||
const distImagesDir = path.join(pkgRoot, 'dist', 'images')
|
||||
const cacheRoot = devCacheRoot()
|
||||
const artifacts: BuiltAgentArtifact[] = []
|
||||
|
||||
for (const agent of bundle.agents) {
|
||||
await buildTarball(pkgRoot, agent, distImagesDir)
|
||||
const artifact = await readBuiltArtifact(agent, distImagesDir)
|
||||
await seedArtifact(cacheRoot, artifact)
|
||||
artifacts.push(artifact)
|
||||
}
|
||||
|
||||
const manifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
|
||||
await mkdir(path.dirname(manifestPath), { recursive: true })
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(buildDevManifest(artifacts), null, 2)}\n`,
|
||||
)
|
||||
console.log(`manifest written to ${manifestPath}`)
|
||||
}
|
||||
|
||||
export function buildDevManifest(
|
||||
artifacts: BuiltAgentArtifact[],
|
||||
now: Date = new Date(),
|
||||
): DevAgentManifest {
|
||||
const agents: Record<string, DevAgentEntry> = {}
|
||||
for (const artifact of artifacts) {
|
||||
agents[artifact.agent.name] = {
|
||||
image: artifact.agent.image,
|
||||
version: artifact.agent.version,
|
||||
tarballs: {
|
||||
[DEV_ARCH]: {
|
||||
key: artifact.key,
|
||||
sha256: artifact.sha256,
|
||||
sizeBytes: artifact.sizeBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
updatedAt: now.toISOString(),
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
async function readBundle(pkgRoot: string): Promise<Bundle> {
|
||||
return JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
}
|
||||
|
||||
async function buildTarball(
|
||||
pkgRoot: string,
|
||||
agent: BundleAgent,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
console.log(`building ${agent.name} ${DEV_ARCH} tarball`)
|
||||
await spawnChecked(
|
||||
[
|
||||
'bun',
|
||||
'run',
|
||||
'scripts/build-tarball.ts',
|
||||
'--',
|
||||
'--agent',
|
||||
agent.name,
|
||||
'--arch',
|
||||
DEV_ARCH,
|
||||
'--output-dir',
|
||||
outputDir,
|
||||
],
|
||||
pkgRoot,
|
||||
)
|
||||
}
|
||||
|
||||
async function readBuiltArtifact(
|
||||
agent: BundleAgent,
|
||||
distImagesDir: string,
|
||||
): Promise<BuiltAgentArtifact> {
|
||||
const key = tarballKey(agent.name, agent.version, DEV_ARCH)
|
||||
const filePath = path.join(distImagesDir, path.basename(key))
|
||||
await assertExists(filePath, agent.name)
|
||||
return {
|
||||
agent,
|
||||
key,
|
||||
path: filePath,
|
||||
sha256: await sha256File(filePath),
|
||||
sizeBytes: (await stat(filePath)).size,
|
||||
}
|
||||
}
|
||||
|
||||
async function seedArtifact(
|
||||
cacheRoot: string,
|
||||
artifact: BuiltAgentArtifact,
|
||||
): Promise<void> {
|
||||
const dest = path.join(cacheRoot, artifact.key)
|
||||
if (await matchesExisting(dest, artifact.sha256)) {
|
||||
console.log(`cache hit: ${artifact.key}`)
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(dest), { recursive: true })
|
||||
await copyFile(artifact.path, dest)
|
||||
await verifySha256(dest, artifact.sha256)
|
||||
console.log(`seeded ${artifact.key}`)
|
||||
}
|
||||
|
||||
function assertDevelopment(): void {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
'dev:seed:tarball refuses to run without NODE_ENV=development; it writes to ~/.browseros-dev/cache/vm/',
|
||||
)
|
||||
}
|
||||
|
||||
function devCacheRoot(): string {
|
||||
return path.join(
|
||||
homedir(),
|
||||
PATHS.DEV_BROWSEROS_DIR_NAME,
|
||||
PATHS.CACHE_DIR_NAME,
|
||||
)
|
||||
}
|
||||
|
||||
async function assertExists(
|
||||
filePath: string,
|
||||
agentName: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await stat(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
throw new Error(`build did not produce ${agentName} tarball at ${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function matchesExisting(
|
||||
filePath: string,
|
||||
expectedSha: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return (await sha256File(filePath)) === expectedSha
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnChecked(argv: string[], cwd: string): Promise<void> {
|
||||
const proc = Bun.spawn(argv, {
|
||||
cwd,
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) {
|
||||
throw new Error(`${argv.join(' ')} exited with code ${code}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createGunzip } from 'node:zlib'
|
||||
import { parseArch, podmanArch } from './common/arch'
|
||||
import type { Bundle } from './common/manifest'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
arch: { type: 'string' },
|
||||
tarball: { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.agent || !values.arch || !values.tarball) {
|
||||
console.error(
|
||||
'usage: smoke:tarball -- --agent <name> --arch <arm64|x64> --tarball <path.tar.gz>',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const arch = parseArch(values.arch)
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
const agent = bundle.agents.find(({ name }) => name === values.agent)
|
||||
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
|
||||
|
||||
const ref = `${agent.image}:${agent.version}`
|
||||
const tarball = await maybeDecompress(values.tarball)
|
||||
|
||||
try {
|
||||
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
|
||||
await spawnChecked(['podman', 'load', '--input', tarball.path])
|
||||
const inspected = await inspectImage(ref)
|
||||
if (inspected.Os !== 'linux') {
|
||||
throw new Error(`expected linux image, got ${inspected.Os ?? '<missing>'}`)
|
||||
}
|
||||
if (inspected.Architecture !== podmanArch(arch)) {
|
||||
throw new Error(
|
||||
`expected ${podmanArch(arch)} image, got ${inspected.Architecture ?? '<missing>'}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
|
||||
if (tarball.cleanupDir) {
|
||||
await rm(tarball.cleanupDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
console.log('tarball smoke test passed')
|
||||
|
||||
async function maybeDecompress(
|
||||
tarballPath: string,
|
||||
): Promise<{ path: string; cleanupDir?: string }> {
|
||||
if (!tarballPath.endsWith('.gz')) return { path: tarballPath }
|
||||
|
||||
const cleanupDir = await mkdtemp(path.join(tmpdir(), 'browseros-tar-smoke-'))
|
||||
const tarPath = path.join(cleanupDir, 'image.tar')
|
||||
await pipeline(
|
||||
createReadStream(tarballPath),
|
||||
createGunzip(),
|
||||
createWriteStream(tarPath),
|
||||
)
|
||||
return { path: tarPath, cleanupDir }
|
||||
}
|
||||
|
||||
async function inspectImage(ref: string): Promise<{
|
||||
Architecture?: string
|
||||
Os?: string
|
||||
}> {
|
||||
const stdout = await spawnCapture([
|
||||
'podman',
|
||||
'inspect',
|
||||
'--type',
|
||||
'image',
|
||||
'--format',
|
||||
'{{json .}}',
|
||||
ref,
|
||||
])
|
||||
return JSON.parse(stdout) as { Architecture?: string; Os?: string }
|
||||
}
|
||||
|
||||
async function spawnCapture(argv: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' })
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
if (code !== 0) {
|
||||
throw new Error(
|
||||
`${argv[0]} exited ${code}\n${stderr.trim() || stdout.trim()}`,
|
||||
)
|
||||
}
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function spawnChecked(argv: string[]): Promise<void> {
|
||||
await spawnCapture(argv)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createR2Client, getBucket, putFile } from './common/r2'
|
||||
import { createR2Client, getBucket, putBody, putFile } from './common/r2'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
@@ -8,6 +9,7 @@ const { values } = parseArgs({
|
||||
file: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
'content-type': { type: 'string' },
|
||||
'sidecar-sha': { type: 'boolean' },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -22,6 +24,19 @@ const bucket = getBucket()
|
||||
try {
|
||||
await putFile(client, bucket, values.key, values.file, contentType)
|
||||
console.log(`uploaded ${values.file} to ${bucket}/${values.key}`)
|
||||
|
||||
if (values['sidecar-sha']) {
|
||||
const sha = await sha256File(values.file)
|
||||
const filename = values.file.split('/').pop() ?? values.file
|
||||
await putBody(
|
||||
client,
|
||||
bucket,
|
||||
`${values.key}.sha256`,
|
||||
`${sha} ${filename}\n`,
|
||||
'text/plain; charset=utf-8',
|
||||
)
|
||||
console.log(`uploaded sha256 to ${bucket}/${values.key}.sha256`)
|
||||
}
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { parseArch, podmanArch } from '../scripts/common/arch'
|
||||
|
||||
describe('arch helpers', () => {
|
||||
it('normalizes BrowserOS arches for podman', () => {
|
||||
expect(podmanArch('arm64')).toBe('arm64')
|
||||
expect(podmanArch('x64')).toBe('amd64')
|
||||
})
|
||||
|
||||
it('parses supported release arches', () => {
|
||||
expect(parseArch('arm64')).toBe('arm64')
|
||||
expect(parseArch('x64')).toBe('x64')
|
||||
})
|
||||
|
||||
it('rejects unsupported release arches', () => {
|
||||
expect(() => parseArch('amd64')).toThrow('unknown arch: amd64')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
type PlanItem,
|
||||
planSync,
|
||||
readLocalManifest,
|
||||
selectSyncArches,
|
||||
} from '../scripts/cache-sync'
|
||||
import type { AgentManifest } from '../scripts/common/manifest'
|
||||
import { sha256File } from '../scripts/common/sha256'
|
||||
import { buildDevManifest } from '../scripts/seed-dev-agent-tarball'
|
||||
|
||||
const openclaw = {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
}
|
||||
|
||||
const claudeCode = {
|
||||
image: 'ghcr.io/anthropics/claude-code',
|
||||
version: '2026.4.10',
|
||||
}
|
||||
|
||||
function manifest(tarSha: string, includeSecondAgent = false): AgentManifest {
|
||||
const agents: AgentManifest['agents'] = {
|
||||
openclaw: {
|
||||
...openclaw,
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: `${tarSha}-arm64`,
|
||||
sizeBytes: 201,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: `${tarSha}-x64`,
|
||||
sizeBytes: 202,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (includeSecondAgent) {
|
||||
agents['claude-code'] = {
|
||||
...claudeCode,
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/claude-code-2026.4.10-arm64.tar.gz',
|
||||
sha256: `${tarSha}-claude-arm64`,
|
||||
sizeBytes: 301,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/claude-code-2026.4.10-x64.tar.gz',
|
||||
sha256: `${tarSha}-claude-x64`,
|
||||
sizeBytes: 302,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
function keys(plan: PlanItem[]): string[] {
|
||||
return plan.map((item) => item.key)
|
||||
}
|
||||
|
||||
describe('planSync', () => {
|
||||
it('downloads every selected-arch agent artifact for a fresh cache', () => {
|
||||
const remote = manifest('t1')
|
||||
|
||||
expect(
|
||||
keys(planSync({ local: null, remote, cacheRoot: '/c', arches: ['x64'] })),
|
||||
).toEqual(['vm/images/openclaw-2026.4.12-x64.tar.gz'])
|
||||
})
|
||||
|
||||
it('does nothing when the local manifest matches the remote manifest', () => {
|
||||
const remote = manifest('t1')
|
||||
|
||||
expect(
|
||||
planSync({ local: remote, remote, cacheRoot: '/c', arches: ['x64'] }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('downloads only agent artifacts whose sha256 changed', () => {
|
||||
const local = manifest('old-tar')
|
||||
const remote = manifest('new-tar')
|
||||
|
||||
expect(
|
||||
keys(planSync({ local, remote, cacheRoot: '/c', arches: ['x64'] })),
|
||||
).toEqual(['vm/images/openclaw-2026.4.12-x64.tar.gz'])
|
||||
})
|
||||
|
||||
it('supports syncing all release arches', () => {
|
||||
const remote = manifest('t1')
|
||||
|
||||
expect(
|
||||
planSync({
|
||||
local: null,
|
||||
remote,
|
||||
cacheRoot: '/c',
|
||||
arches: ['arm64', 'x64'],
|
||||
}),
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('selects host arch by default and both arches when requested', () => {
|
||||
expect(selectSyncArches(false, 'x64')).toEqual(['x64'])
|
||||
expect(selectSyncArches(true, 'x64')).toEqual(['arm64', 'x64'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readLocalManifest', () => {
|
||||
let dir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (!dir) return
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
dir = null
|
||||
})
|
||||
|
||||
it('returns null only when the local manifest is absent', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-cache-manifest-'))
|
||||
|
||||
await expect(
|
||||
readLocalManifest(path.join(dir, 'missing.json')),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces corrupt local manifest files', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-cache-manifest-'))
|
||||
const manifestPath = path.join(dir, 'manifest.json')
|
||||
await writeFile(manifestPath, '{not json')
|
||||
|
||||
await expect(readLocalManifest(manifestPath)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDevManifest', () => {
|
||||
it('builds an arm64-only dev manifest from freshly built artifacts', () => {
|
||||
const manifest = buildDevManifest(
|
||||
[
|
||||
{
|
||||
agent: {
|
||||
name: 'openclaw',
|
||||
image: openclaw.image,
|
||||
version: openclaw.version,
|
||||
},
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
path: '/tmp/openclaw.tar.gz',
|
||||
sha256: 'fresh-arm64',
|
||||
sizeBytes: 404,
|
||||
},
|
||||
],
|
||||
new Date('2026-04-23T00:00:00.000Z'),
|
||||
)
|
||||
|
||||
expect(manifest.schemaVersion).toBe(2)
|
||||
expect(manifest.updatedAt).toBe('2026-04-23T00:00:00.000Z')
|
||||
expect(manifest.agents.openclaw.image).toBe(openclaw.image)
|
||||
expect(manifest.agents.openclaw.version).toBe(openclaw.version)
|
||||
expect(manifest.agents.openclaw.tarballs.arm64).toEqual({
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'fresh-arm64',
|
||||
sizeBytes: 404,
|
||||
})
|
||||
expect(Object.hasOwn(manifest.agents.openclaw.tarballs, 'x64')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('emit-manifest', () => {
|
||||
let dir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (!dir) return
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
dir = null
|
||||
})
|
||||
|
||||
it('rejects the retired vm slice', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-vm-'))
|
||||
|
||||
const result = await runEmitManifest(
|
||||
[
|
||||
'--slice',
|
||||
'vm',
|
||||
'--dist-dir',
|
||||
path.join(dir, 'dist'),
|
||||
'--out',
|
||||
path.join(dir, 'manifest.json'),
|
||||
],
|
||||
false,
|
||||
)
|
||||
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.stderr).toContain('unknown slice: vm')
|
||||
})
|
||||
|
||||
it('merges an agent slice while preserving other agents from the baseline', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-agent-'))
|
||||
const distDir = path.join(dir, 'dist')
|
||||
await writeAgentFiles(distDir)
|
||||
|
||||
const baseline = manifest('old-tar', true)
|
||||
const baselinePath = path.join(dir, 'baseline.json')
|
||||
const outPath = path.join(dir, 'manifest.json')
|
||||
await writeJson(baselinePath, baseline)
|
||||
|
||||
await runEmitManifest([
|
||||
'--slice',
|
||||
'agents:openclaw',
|
||||
'--dist-dir',
|
||||
distDir,
|
||||
'--merge-from',
|
||||
baselinePath,
|
||||
'--out',
|
||||
outPath,
|
||||
])
|
||||
|
||||
const merged = JSON.parse(await readFile(outPath, 'utf8')) as AgentManifest
|
||||
expect(merged.schemaVersion).toBe(2)
|
||||
expect(merged.agents['claude-code']).toEqual(baseline.agents['claude-code'])
|
||||
expect(merged.agents.openclaw.tarballs.arm64.sha256).toBe(
|
||||
await sha256File(
|
||||
path.join(distDir, 'images/openclaw-2026.4.12-arm64.tar.gz'),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('fails slice emission without a merge baseline', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-fail-'))
|
||||
|
||||
const result = await runEmitManifest(
|
||||
[
|
||||
'--slice',
|
||||
'agents:openclaw',
|
||||
'--dist-dir',
|
||||
path.join(dir, 'dist'),
|
||||
'--out',
|
||||
path.join(dir, 'out.json'),
|
||||
],
|
||||
false,
|
||||
)
|
||||
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'--slice agents:openclaw requires --merge-from',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function writeAgentFiles(distDir: string): Promise<void> {
|
||||
await mkdir(path.join(distDir, 'images'), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(distDir, 'images/openclaw-2026.4.12-arm64.tar.gz'),
|
||||
'arm tarball',
|
||||
)
|
||||
await writeFile(
|
||||
path.join(distDir, 'images/openclaw-2026.4.12-x64.tar.gz'),
|
||||
'x64 tarball',
|
||||
)
|
||||
}
|
||||
|
||||
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
async function runEmitManifest(
|
||||
args: string[],
|
||||
expectSuccess = true,
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const proc = Bun.spawn(
|
||||
['bun', 'run', 'scripts/emit-manifest.ts', '--', ...args],
|
||||
{
|
||||
cwd: path.join(import.meta.dir, '..'),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
)
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
|
||||
if (expectSuccess && code !== 0) {
|
||||
throw new Error(`emit-manifest failed: ${stderr || stdout}`)
|
||||
}
|
||||
|
||||
return { code, stdout, stderr }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
type ArtifactInputs,
|
||||
type Bundle,
|
||||
buildManifest,
|
||||
tarballKey,
|
||||
} from '../scripts/common/manifest'
|
||||
import { verifySha256 } from '../scripts/common/sha256'
|
||||
|
||||
const bundle: Bundle = {
|
||||
agents: [
|
||||
{
|
||||
name: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const inputs: ArtifactInputs = {
|
||||
agents: {
|
||||
openclaw: {
|
||||
arm64: { sha256: 'tar-arm', sizeBytes: 21 },
|
||||
x64: { sha256: 'tar-x64', sizeBytes: 22 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('manifest helpers', () => {
|
||||
it('builds release artifact keys', () => {
|
||||
expect(tarballKey('openclaw', '2026.4.12', 'x64')).toBe(
|
||||
'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds an agents-only manifest from bundle metadata and artifact inputs', () => {
|
||||
const manifest = buildManifest(
|
||||
bundle,
|
||||
inputs,
|
||||
new Date('2026-04-22T00:00:00.000Z'),
|
||||
)
|
||||
|
||||
for (const field of ['vm' + 'Version', 'vm' + 'Disk']) {
|
||||
expect(Object.hasOwn(manifest, field)).toBe(false)
|
||||
}
|
||||
expect(manifest).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'tar-x64',
|
||||
sizeBytes: 22,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails when required tarball inputs are missing', () => {
|
||||
expect(() =>
|
||||
buildManifest(bundle, {
|
||||
agents: { openclaw: { arm64: inputs.agents.openclaw.arm64 } },
|
||||
} as unknown as ArtifactInputs),
|
||||
).toThrow('missing tarball inputs for openclaw/x64')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sha256 helpers', () => {
|
||||
let dir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (!dir) return
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
dir = null
|
||||
})
|
||||
|
||||
it('verifies matching file content and rejects mismatches', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'browseros-build-tools-'))
|
||||
const filePath = join(dir, 'artifact.txt')
|
||||
await writeFile(filePath, 'browseros\n')
|
||||
|
||||
await expect(
|
||||
verifySha256(
|
||||
filePath,
|
||||
'8e4e07174da39a48ab7aa9a1bebd3adcddff43172c0b19fcbe921cc47c599f62',
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
await expect(verifySha256(filePath, 'bad')).rejects.toThrow(
|
||||
'sha256 mismatch',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["scripts/**/*", "tests/**/*", "package.json"]
|
||||
"include": ["scripts/**/*", "tests/**/*", "package.json", "bundle.json"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const OPENCLAW_AGENT_NAME = 'openclaw'
|
||||
export const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:2026.4.12'
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
|
||||
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
|
||||
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'
|
||||
|
||||
@@ -13,6 +13,8 @@ export const EXTERNAL_URLS = {
|
||||
OPENAI_AUTH: 'https://auth.openai.com/oauth/authorize',
|
||||
OPENAI_TOKEN: 'https://auth.openai.com/oauth/token',
|
||||
SKILLS_CATALOG: 'https://cdn.browseros.com/skills/v1/catalog.json',
|
||||
VM_CACHE_CDN_BASE: 'https://cdn.browseros.com',
|
||||
VM_CACHE_MANIFEST: 'https://cdn.browseros.com/vm/manifest.json',
|
||||
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
|
||||
GITHUB_OAUTH_TOKEN: 'https://github.com/login/oauth/access_token',
|
||||
GITHUB_COPILOT_API: 'https://api.githubcopilot.com',
|
||||
|
||||
@@ -10,12 +10,16 @@ const REQUIRED_PROD_VARS = [
|
||||
'CODEGEN_SERVICE_URL',
|
||||
'POSTHOG_API_KEY',
|
||||
'SENTRY_DSN',
|
||||
'BROWSEROS_VM_CACHE_PREFETCH',
|
||||
'BROWSEROS_VM_CACHE_MANIFEST_URL',
|
||||
]
|
||||
const INLINED_ENV_VARS = [
|
||||
...REQUIRED_PROD_VARS,
|
||||
'NODE_ENV',
|
||||
'LOG_LEVEL',
|
||||
] as const
|
||||
const BOOLEAN_PROD_VARS = ['BROWSEROS_VM_CACHE_PREFETCH'] as const
|
||||
const URL_PROD_VARS = ['BROWSEROS_VM_CACHE_MANIFEST_URL'] as const
|
||||
const PROD_ENV_PATH = join('apps', 'server', '.env.production')
|
||||
const PROD_ENV_TEMPLATE_PATH = join('apps', 'server', '.env.production.example')
|
||||
|
||||
@@ -72,6 +76,28 @@ function validateProductionEnv(envVars: Record<string, string>): void {
|
||||
`Production build requires variables: ${missing.join(', ')} (set them in ${PROD_ENV_PATH} or process env).`,
|
||||
)
|
||||
}
|
||||
const invalidBooleans = BOOLEAN_PROD_VARS.filter((name) => {
|
||||
const value = envVars[name]
|
||||
return value !== 'true' && value !== 'false'
|
||||
})
|
||||
if (invalidBooleans.length > 0) {
|
||||
throw new Error(
|
||||
`Production build requires boolean variables to be "true" or "false": ${invalidBooleans.join(', ')}.`,
|
||||
)
|
||||
}
|
||||
const invalidUrls = URL_PROD_VARS.filter((name) => {
|
||||
try {
|
||||
new URL(envVars[name])
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
if (invalidUrls.length > 0) {
|
||||
throw new Error(
|
||||
`Production build requires absolute URL variables: ${invalidUrls.join(', ')}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadBuildConfigOptions {
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
type TestCommand = {
|
||||
label: string
|
||||
cwd?: string
|
||||
argv: readonly [string, ...string[]]
|
||||
}
|
||||
|
||||
const projectRoot = resolve(import.meta.dir, '..')
|
||||
const bun = process.execPath
|
||||
|
||||
const testSuites = {
|
||||
all: [
|
||||
{
|
||||
label: 'server tests',
|
||||
cwd: resolve(projectRoot, 'apps/server'),
|
||||
argv: [bun, 'run', 'test'],
|
||||
},
|
||||
{
|
||||
label: 'agent tests',
|
||||
cwd: resolve(projectRoot, 'apps/agent'),
|
||||
argv: [bun, 'run', 'test'],
|
||||
},
|
||||
{
|
||||
label: 'eval tests',
|
||||
cwd: resolve(projectRoot, 'apps/eval'),
|
||||
argv: [bun, 'run', 'test'],
|
||||
},
|
||||
{
|
||||
label: 'build script tests',
|
||||
argv: [bun, 'run', './scripts/run-bun-test.ts', './scripts/build'],
|
||||
},
|
||||
],
|
||||
main: [
|
||||
{
|
||||
label: 'server tools tests',
|
||||
cwd: resolve(projectRoot, 'apps/server'),
|
||||
argv: [bun, 'run', 'test:tools'],
|
||||
},
|
||||
{
|
||||
label: 'server integration tests',
|
||||
cwd: resolve(projectRoot, 'apps/server'),
|
||||
argv: [bun, 'run', 'test:integration'],
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, readonly TestCommand[]>
|
||||
|
||||
type TestSuiteName = keyof typeof testSuites
|
||||
|
||||
function isTestSuiteName(value: string): value is TestSuiteName {
|
||||
return value in testSuites
|
||||
}
|
||||
|
||||
/** Prevents multi-step suites from overwriting a single shared JUnit report path. */
|
||||
function buildCommandEnv(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env }
|
||||
delete env.BROWSEROS_JUNIT_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
function runCommand(command: TestCommand): number {
|
||||
console.log(`\n==> ${command.label}`)
|
||||
const result = spawnSync(command.argv[0], command.argv.slice(1), {
|
||||
cwd: command.cwd ?? projectRoot,
|
||||
env: buildCommandEnv(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.signal) {
|
||||
console.error(
|
||||
`Command terminated by signal ${result.signal}: ${command.label}`,
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const status = result.status ?? 1
|
||||
if (status !== 0) {
|
||||
console.error(`Command failed with exit code ${status}: ${command.label}`)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
/** Runs a named test suite without shell chaining so each step reports its own status. */
|
||||
function runSuite(suiteName: TestSuiteName): number {
|
||||
let exitCode = 0
|
||||
for (const command of testSuites[suiteName]) {
|
||||
const status = runCommand(command)
|
||||
if (status !== 0 && exitCode === 0) {
|
||||
exitCode = status
|
||||
}
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.error(
|
||||
`Usage: bun run ./scripts/run-test-suite.ts <${Object.keys(testSuites).join('|')}>`,
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const requestedSuite = process.argv[2]
|
||||
if (!requestedSuite || !isTestSuiteName(requestedSuite)) {
|
||||
printUsage()
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(runSuite(requestedSuite))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user