Compare commits

..

2 Commits

Author SHA1 Message Date
shivammittal274
639b2e915e fix(eval): log agent errors in task progress output for CI visibility 2026-03-21 23:31:20 +05:30
shivammittal274
d050afa802 chore(eval): remove unused env vars from workflow (OPENROUTER, OPENAI) 2026-03-21 23:21:35 +05:30
241 changed files with 5564 additions and 15090 deletions

View File

@@ -4,11 +4,6 @@ on:
schedule:
# Every Saturday at 06:00 UTC
- cron: '0 6 * * 6'
push:
branches: [main]
paths:
- 'packages/browseros-agent/apps/server/src/agent/**'
- 'packages/browseros-agent/apps/server/src/tools/**'
workflow_dispatch:
inputs:
config:
@@ -43,9 +38,6 @@ jobs:
working-directory: packages/browseros-agent
run: bun install --ignore-scripts && bun run build:agent-sdk
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Install captcha solver extension
working-directory: packages/browseros-agent/apps/eval
run: |
@@ -58,12 +50,11 @@ jobs:
env:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
BROWSEROS_BINARY: /usr/bin/browseros
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
echo "Running eval with config: $EVAL_CONFIG"
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts -c "$EVAL_CONFIG"
bun run src/index.ts -c "$EVAL_CONFIG"
- name: Upload runs to R2
if: success()

View File

@@ -2,7 +2,7 @@ name: PR Conventional Commit Validation
on:
pull_request:
types: [opened, edited]
types: [opened, synchronize, reopened, edited]
permissions:
pull-requests: write

View File

@@ -1,148 +0,0 @@
name: Release Agent Extension
on:
workflow_dispatch:
concurrency:
group: release-agent-extension
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
defaults:
run:
working-directory: packages/browseros-agent/apps/agent
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun ci
working-directory: packages/browseros-agent
- name: Build and zip extension
run: bun run codegen && bun run zip
env:
VITE_PUBLIC_BROWSEROS_API: https://api.browseros.com
- name: Get version and zip path
id: version
run: |
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
ZIP_FILE=$(ls "$(pwd)/dist/"*-chrome.zip | head -n 1)
echo "zip_path=$ZIP_FILE" >> "$GITHUB_OUTPUT"
echo "zip_name=$(basename "$ZIP_FILE")" >> "$GITHUB_OUTPUT"
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
AGENT_PATH="packages/browseros-agent/apps/agent"
CURRENT_TAG="agent-extension-v${{ steps.version.outputs.version }}"
PREV_TAG=$(git tag -l "agent-extension-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Initial release" > /tmp/release-notes.md
else
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$AGENT_PATH")
if [ -z "$COMMITS" ]; then
echo "No notable changes." > /tmp/release-notes.md
else
echo "## What's Changed" > /tmp/release-notes.md
echo "" >> /tmp/release-notes.md
while IFS= read -r SHA; do
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
# Skip PR number if already in the commit subject (squash merges include it)
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
else
echo "- ${SUBJECT}" >> /tmp/release-notes.md
fi
done <<< "$COMMITS"
fi
fi
working-directory: ${{ github.workspace }}
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="agent-extension-v${{ steps.version.outputs.version }}"
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
TITLE="BrowserOS Agent Extension v${{ steps.version.outputs.version }}"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists, skipping tag creation"
else
git tag "$TAG" "$RELEASE_SHA"
fi
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag $TAG already on remote, skipping push"
else
git push origin "$TAG"
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists, updating"
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
gh release upload "$TAG" "${{ steps.version.outputs.zip_path }}" --clobber
else
gh release create "$TAG" \
--title "$TITLE" \
--notes-file /tmp/release-notes.md \
"${{ steps.version.outputs.zip_path }}"
fi
working-directory: ${{ github.workspace }}
- name: Update CHANGELOG.md via PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
DATE=$(date -u +"%Y-%m-%d")
BRANCH="docs/agent-extension-changelog-v${VERSION}"
CHANGELOG="packages/browseros-agent/apps/agent/CHANGELOG.md"
git checkout main
{
head -n 1 "$CHANGELOG"
echo ""
echo "## v${VERSION} (${DATE})"
echo ""
cat /tmp/release-notes.md
echo ""
tail -n +2 "$CHANGELOG"
} > /tmp/new-changelog.md
mv /tmp/new-changelog.md "$CHANGELOG"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add "$CHANGELOG"
git commit -m "docs: update agent extension changelog for v${VERSION}"
git push origin "$BRANCH"
gh pr create \
--title "docs: update agent extension changelog for v${VERSION}" \
--body "Auto-generated changelog update for BrowserOS Agent Extension v${VERSION}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --squash --auto || true
working-directory: ${{ github.workspace }}

View File

@@ -3,25 +3,16 @@ name: Release Agent SDK
on:
workflow_dispatch:
concurrency:
group: release-agent-sdk
cancel-in-progress: false
jobs:
publish:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
defaults:
run:
working-directory: packages/browseros-agent/packages/agent-sdk
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
@@ -40,129 +31,7 @@ jobs:
- name: Test
run: bun test
- name: Get version
id: version
run: |
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SDK_PATH="packages/browseros-agent/packages/agent-sdk"
CURRENT_TAG="agent-sdk-v${{ steps.version.outputs.version }}"
# Find the previous tag, excluding the current version's tag
# (which may already exist from a prior failed run)
PREV_TAG=$(git tag -l "agent-sdk-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Initial release" > /tmp/release-notes.md
else
# Get commits scoped to the SDK directory
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$SDK_PATH")
if [ -z "$COMMITS" ]; then
echo "No notable changes." > /tmp/release-notes.md
else
echo "## What's Changed" > /tmp/release-notes.md
echo "" >> /tmp/release-notes.md
# For each commit, find the associated PR and format with author
CONTRIBUTORS=""
while IFS= read -r SHA; do
# Get commit subject and author
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
AUTHOR=$(git log -1 --pretty=format:"%an" "$SHA")
GITHUB_USER=$(gh api "/repos/${{ github.repository }}/commits/${SHA}" --jq '.author.login // empty' 2>/dev/null)
# Find associated PR number
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
# Format line: skip PR number if already in the commit subject
# (squash merges include "(#123)" in the subject automatically)
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
else
echo "- ${SUBJECT}" >> /tmp/release-notes.md
fi
done <<< "$COMMITS"
fi
fi
working-directory: ${{ github.workspace }}
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="agent-sdk-v${{ steps.version.outputs.version }}"
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
TITLE="@browseros-ai/agent-sdk v${{ steps.version.outputs.version }}"
# Create or reuse tag (idempotent for re-runs)
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists, skipping tag creation"
else
git tag "$TAG" "$RELEASE_SHA"
fi
# Push tag (skip if already on remote)
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag $TAG already on remote, skipping push"
else
git push origin "$TAG"
fi
# Create or update release
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists, updating"
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
else
gh release create "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
fi
working-directory: ${{ github.workspace }}
- name: Update CHANGELOG.md via PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
DATE=$(date -u +"%Y-%m-%d")
BRANCH="docs/agent-sdk-changelog-v${VERSION}"
CHANGELOG="packages/browseros-agent/packages/agent-sdk/CHANGELOG.md"
# Return to main before branching
git checkout main
# Use head/tail to safely insert without sed quoting issues
{
head -n 1 "$CHANGELOG"
echo ""
echo "## v${VERSION} (${DATE})"
echo ""
cat /tmp/release-notes.md
echo ""
tail -n +2 "$CHANGELOG"
} > /tmp/new-changelog.md
mv /tmp/new-changelog.md "$CHANGELOG"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add "$CHANGELOG"
git commit -m "docs: update agent-sdk changelog for v${VERSION}"
git push origin "$BRANCH"
gh pr create \
--title "docs: update agent-sdk changelog for v${VERSION}" \
--body "Auto-generated changelog update for @browseros-ai/agent-sdk v${VERSION}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --squash --auto || true
working-directory: ${{ github.workspace }}

View File

@@ -1,167 +0,0 @@
name: Release CLI
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.1.0)"
required: true
type: string
concurrency:
group: release-cli
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: release-core
permissions:
contents: write
pull-requests: write
defaults:
run:
working-directory: packages/browseros-agent/apps/cli
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: packages/browseros-agent/apps/cli/go.mod
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.6"
- name: Run tests
run: go test ./... -v
- name: Run vet
run: go vet ./...
- name: Build all platforms
run: |
VERSION="${{ inputs.version }}"
LDFLAGS="-s -w -X main.version=${VERSION}"
DIST="dist"
mkdir -p "$DIST"
for pair in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
OS="${pair%/*}"
ARCH="${pair#*/}"
BIN="browseros-cli"
EXT=""
if [ "$OS" = "windows" ]; then EXT=".exe"; fi
echo "Building ${OS}/${ARCH}..."
GOOS=$OS GOARCH=$ARCH CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "${DIST}/${BIN}${EXT}" .
ARCHIVE="browseros-cli_${VERSION}_${OS}_${ARCH}"
if [ "$OS" = "windows" ]; then
(cd "$DIST" && zip "${ARCHIVE}.zip" "${BIN}${EXT}")
else
(cd "$DIST" && tar czf "${ARCHIVE}.tar.gz" "${BIN}")
fi
rm "${DIST}/${BIN}${EXT}"
done
(cd "$DIST" && sha256sum *.tar.gz *.zip > checksums.txt)
echo "=== Built artifacts ==="
ls -lh "$DIST"
- name: Install dependencies
run: bun install
working-directory: packages/browseros-agent
- name: Upload to CDN
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 }}
R2_UPLOAD_PREFIX: cli
CLI_VERSION: ${{ inputs.version }}
run: |
bun scripts/build/cli.ts \
--release \
--version "$CLI_VERSION" \
--binaries-dir apps/cli/dist
working-directory: packages/browseros-agent
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CLI_PATH="packages/browseros-agent/apps/cli"
TAG="browseros-cli-v${{ inputs.version }}"
CHANGELOG_FILE="/tmp/release-changelog.md"
PREV_TAG=$(git tag -l "browseros-cli-v*" --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
echo "Initial release of browseros-cli." > "$CHANGELOG_FILE"
else
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$CLI_PATH")
if [ -z "$COMMITS" ]; then
echo "No notable changes." > "$CHANGELOG_FILE"
else
echo "## What's Changed" > "$CHANGELOG_FILE"
echo "" >> "$CHANGELOG_FILE"
while IFS= read -r SHA; do
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
echo "- ${SUBJECT} (#${PR_NUM})" >> "$CHANGELOG_FILE"
else
echo "- ${SUBJECT}" >> "$CHANGELOG_FILE"
fi
done <<< "$COMMITS"
fi
fi
cat "$CHANGELOG_FILE" > /tmp/release-notes.md
cat >> /tmp/release-notes.md <<'EOF'
## Install `browseros-cli`
### macOS / Linux
```bash
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
```
### Windows
```powershell
irm https://cdn.browseros.com/cli/install.ps1 | iex
```
After install, run `browseros-cli init` to point the CLI at your BrowserOS MCP server.
EOF
working-directory: ${{ github.workspace }}
- name: Create tag and release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="browseros-cli-v${{ inputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
git tag -a "$TAG" -m "browseros-cli v${{ inputs.version }}"
git push origin "$TAG"
fi
CLI_DIST="packages/browseros-agent/apps/cli/dist"
gh release create "$TAG" \
--title "browseros-cli v${{ inputs.version }}" \
--notes-file /tmp/release-notes.md \
${CLI_DIST}/*
working-directory: ${{ github.workspace }}

View File

@@ -137,5 +137,4 @@ jobs:
echo "### :x: ${{ matrix.suite }} suite failed (exit code ${{ steps.test.outputs.exit_code }})" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

214
README.md
View File

@@ -6,7 +6,6 @@
[![Slack](https://img.shields.io/badge/Slack-Join%20us-4A154B?logo=slack&logoColor=white)](https://dub.sh/browserOS-slack)
[![Twitter](https://img.shields.io/twitter/follow/browserOS_ai?style=social)](https://twitter.com/browseros_ai)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Docs](https://img.shields.io/badge/Docs-docs.browseros.com-blue)](https://docs.browseros.com)
<br></br>
<a href="https://files.browseros.com/download/BrowserOS.dmg">
<img src="https://img.shields.io/badge/Download-macOS-black?style=flat&logo=apple&logoColor=white" alt="Download for macOS (beta)" />
@@ -23,183 +22,146 @@
<br />
</div>
BrowserOS is an open-source Chromium fork that runs AI agents natively. **The privacy-first alternative to ChatGPT Atlas, Perplexity Comet, and Dia.**
##
🌐 BrowserOS is an open-source Chromium fork that runs AI agents natively. **The privacy-first alternative to ChatGPT Atlas, Perplexity Comet, and Dia.**
Use your own API keys or run local models with Ollama. Your data never leaves your machine.
🔒 Use your own API keys or run local models with Ollama. Your data never leaves your machine.
> **[Documentation](https://docs.browseros.com)** · **[Discord](https://discord.gg/YKwjt5vuKr)** · **[Slack](https://dub.sh/browserOS-slack)** · **[Twitter](https://x.com/browserOS_ai)** · **[Feature Requests](https://github.com/browseros-ai/BrowserOS/issues/99)**
💡 Join our [Discord](https://discord.gg/YKwjt5vuKr) or [Slack](https://dub.sh/browserOS-slack) and help us build! Have feature requests? [Suggest here](https://github.com/browseros-ai/BrowserOS/issues/99).
## Quick Start
## Quick start
1. **Download and install** BrowserOS — [macOS](https://files.browseros.com/download/BrowserOS.dmg) · [Windows](https://files.browseros.com/download/BrowserOS_installer.exe) · [Linux (AppImage)](https://files.browseros.com/download/BrowserOS.AppImage) · [Linux (Debian)](https://cdn.browseros.com/download/BrowserOS.deb)
2. **Import your Chrome data** (optional) — bookmarks, passwords, extensions all carry over
3. **Connect your AI provider** — Claude, OpenAI, Gemini, ChatGPT Pro via OAuth, or local models via Ollama/LM Studio
1. Download and install BrowserOS:
- [macOS](https://files.browseros.com/download/BrowserOS.dmg)
- [Windows](https://files.browseros.com/download/BrowserOS_installer.exe)
- [Linux (AppImage)](https://files.browseros.com/download/BrowserOS.AppImage)
- [Linux (Debian)](https://cdn.browseros.com/download/BrowserOS.deb)
## Features
2. Import your Chrome data (optional)
| Feature | Description | Docs |
|---------|-------------|------|
| **AI Agent** | 53+ browser automation tools — navigate, click, type, extract data, all with natural language | [Guide](https://docs.browseros.com/getting-started) |
| **MCP Server** | Control the browser from Claude Code, Gemini CLI, or any MCP client | [Setup](https://docs.browseros.com/features/use-with-claude-code) |
| **Workflows** | Build repeatable browser automations with a visual graph builder | [Docs](https://docs.browseros.com/features/workflows) |
| **Cowork** | Combine browser automation with local file operations — research the web, save reports to your folder | [Docs](https://docs.browseros.com/features/cowork) |
| **Scheduled Tasks** | Run agents on autopilot — daily, hourly, or every few minutes | [Docs](https://docs.browseros.com/features/scheduled-tasks) |
| **Memory** | Persistent memory across conversations — your assistant remembers context over time | [Docs](https://docs.browseros.com/features/memory) |
| **SOUL.md** | Define your AI's personality and instructions in a single markdown file | [Docs](https://docs.browseros.com/features/soul-md) |
| **LLM Hub** | Compare Claude, ChatGPT, and Gemini responses side-by-side on any page | [Docs](https://docs.browseros.com/features/llm-chat-hub) |
| **40+ App Integrations** | Gmail, Slack, GitHub, Linear, Notion, Figma, Salesforce, and more via MCP | [Docs](https://docs.browseros.com/features/connect-apps) |
| **Vertical Tabs** | Side-panel tab management — stay organized even with 100+ tabs open | [Docs](https://docs.browseros.com/features/vertical-tabs) |
| **Ad Blocking** | uBlock Origin + Manifest V2 support — [10x more protection](https://docs.browseros.com/features/ad-blocking) than Chrome | [Docs](https://docs.browseros.com/features/ad-blocking) |
| **Cloud Sync** | Sync browser config and agent history across devices | [Docs](https://docs.browseros.com/features/sync) |
| **Skills** | Custom instruction sets that shape how your AI assistant behaves | [Docs](https://docs.browseros.com/features/skills) |
| **Smart Nudges** | Contextual suggestions to connect apps and use features at the right moment | [Docs](https://docs.browseros.com/features/smart-nudges) |
3. Connect your AI provider — use Claude, OpenAI, Gemini, or local models via Ollama and LMStudio.
4. Start automating!
## What makes BrowserOS special
- 🏠 Feels like home — same Chrome interface, all your extensions just work
- 🤖 AI agents that run on YOUR browser, not in the cloud
- 🔒 Privacy first — bring your own keys or run local models with Ollama. Your browsing history stays on your machine
- 🤝 [BrowserOS as MCP server](https://docs.browseros.com/features/use-with-claude-code) — control the browser from `claude-code`, `gemini-cli`, or any MCP client (31 tools)
- 🔄 [Workflows](https://docs.browseros.com/features/workflows) — build repeatable browser automations with a visual graph builder
- 📂 [Cowork](https://docs.browseros.com/features/cowork) — combine browser automation with local file operations. Research the web, save reports to your folder
- ⏰ [Scheduled Tasks](https://docs.browseros.com/features/scheduled-tasks) — run the agent on autopilot, daily or every few minutes
- 💬 [LLM Hub](https://docs.browseros.com/features/llm-chat-hub) — compare Claude, ChatGPT, and Gemini side-by-side on any page
- 🛡️ Built-in ad blocker — [10x more protection than Chrome](https://docs.browseros.com/features/ad-blocking) with uBlock Origin + Manifest V2 support
- 🚀 100% open source under AGPL-3.0
## Demos
### BrowserOS agent in action
### 🤖 BrowserOS agent in action
[![BrowserOS agent in action](docs/videos/browserOS-agent-in-action.gif)](https://www.youtube.com/watch?v=SoSFev5R5dI)
<br/><br/>
### Install [BrowserOS as MCP](https://docs.browseros.com/features/use-with-claude-code) and control it from `claude-code`
### 🎇 Install [BrowserOS as MCP](https://docs.browseros.com/features/use-with-claude-code) and control it from `claude-code`
https://github.com/user-attachments/assets/c725d6df-1a0d-40eb-a125-ea009bf664dc
<br/><br/>
### Use BrowserOS to chat
### 💬 Use BrowserOS to chat
https://github.com/user-attachments/assets/726803c5-8e36-420e-8694-c63a2607beca
<br/><br/>
### Use BrowserOS to scrape data
### Use BrowserOS to scrape data
https://github.com/user-attachments/assets/9f038216-bc24-4555-abf1-af2adcb7ebc0
<br/><br/>
## Install `browseros-cli`
## Why We're Building BrowserOS
Use `browseros-cli` to launch and control BrowserOS from the terminal or from AI coding agents like Claude Code.
For the first time since Netscape pioneered the web in 1994, AI gives us the chance to completely reimagine the browser. We've seen tools like Cursor deliver 10x productivity gains for developers—yet everyday browsing remains frustratingly archaic.
**macOS / Linux:**
You're likely juggling 70+ tabs, battling your browser instead of having it assist you. Routine tasks, like ordering something from amazon or filling a form should be handled seamlessly by AI agents.
```bash
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
```
At BrowserOS, we're convinced that AI should empower you by automating tasks locally and securely—keeping your data private. We are building the best browser for this future!
**Windows:**
## How we compare
```powershell
irm https://cdn.browseros.com/cli/install.ps1 | iex
```
<details>
<summary><b>vs Chrome</b></summary>
<br>
While we're grateful for Google open-sourcing Chromium, but Chrome hasn't evolved much in 10 years. No AI features, no automation, no MCP support.
</details>
After install, run `browseros-cli init` to connect the CLI to your running BrowserOS instance.
<details>
<summary><b>vs Brave</b></summary>
<br>
We love what Brave started, but they've spread themselves too thin with crypto, search, VPNs. We're laser-focused on AI-powered browsing.
</details>
## LLM Providers
<details>
<summary><b>vs Arc/Dia</b></summary>
<br>
Many loved Arc, but it was closed source. When they abandoned users, there was no recourse. We're 100% open source - fork it anytime!
</details>
BrowserOS works with any LLM. Bring your own keys, use OAuth, or run models locally.
<details>
<summary><b>vs Perplexity Comet</b></summary>
<br>
They're a search/ad company. Your browser history becomes their product. We keep everything local.
</details>
| Provider | Type | Auth |
|----------|------|------|
| Kimi K2.5 | Cloud (default) | Built-in |
| ChatGPT Pro/Plus | Cloud | [OAuth](https://docs.browseros.com/features/chatgpt) |
| GitHub Copilot | Cloud | [OAuth](https://docs.browseros.com/features/github-copilot) |
| Qwen Code | Cloud | [OAuth](https://docs.browseros.com/features/qwen-code) |
| Claude (Anthropic) | Cloud | API key |
| GPT-4o / o3 (OpenAI) | Cloud | API key |
| Gemini (Google) | Cloud | API key |
| Azure OpenAI | Cloud | API key |
| AWS Bedrock | Cloud | IAM credentials |
| OpenRouter | Cloud | API key |
| Ollama | Local | [Setup](https://docs.browseros.com/features/ollama) |
| LM Studio | Local | [Setup](https://docs.browseros.com/features/lm-studio) |
## How We Compare
| | BrowserOS | Chrome | Brave | Dia | Comet | Atlas |
|---|:---:|:---:|:---:|:---:|:---:|:---:|
| Open Source | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| AI Agent | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ |
| MCP Server | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Visual Workflows | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Cowork (files + browser) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Scheduled Tasks | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Bring Your Own Keys | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Local Models (Ollama) | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Local-first Privacy | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| Ad Blocking (MV2) | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
**Detailed comparisons:**
- [BrowserOS vs Chrome DevTools MCP](https://docs.browseros.com/comparisons/chrome-devtools-mcp) — developer-focused comparison for browser automation
- [BrowserOS vs Claude Cowork](https://docs.browseros.com/comparisons/claude-cowork) — getting real work done with AI
- [BrowserOS vs OpenClaw](https://docs.browseros.com/comparisons/openclaw) — everyday AI assistance
## Architecture
BrowserOS is a monorepo with two main subsystems: the **browser** (Chromium fork) and the **agent platform** (TypeScript/Go).
```
BrowserOS/
├── packages/browseros/ # Chromium fork + build system (Python)
│ ├── chromium_patches/ # Patches applied to Chromium source
│ ├── build/ # Build CLI and modules
│ └── resources/ # Icons, entitlements, signing
├── packages/browseros-agent/ # Agent platform (TypeScript/Go)
│ ├── apps/
│ │ ├── server/ # MCP server + AI agent loop (Bun)
│ │ ├── agent/ # Browser extension UI (WXT + React)
│ │ ├── cli/ # CLI tool (Go)
│ │ ├── eval/ # Benchmark framework
│ │ └── controller-ext/ # Chrome API bridge extension
│ │
│ └── packages/
│ ├── agent-sdk/ # Node.js SDK (npm: @browseros-ai/agent-sdk)
│ ├── cdp-protocol/ # CDP type bindings
│ └── shared/ # Shared constants
```
| Package | What it does |
|---------|-------------|
| [`packages/browseros`](packages/browseros/) | Chromium fork — patches, build system, signing |
| [`apps/server`](packages/browseros-agent/apps/server/) | Bun server exposing 53+ MCP tools and running the AI agent loop |
| [`apps/agent`](packages/browseros-agent/apps/agent/) | Browser extension — new tab, side panel chat, onboarding, settings |
| [`apps/cli`](packages/browseros-agent/apps/cli/) | Go CLI — control BrowserOS from the terminal or AI coding agents |
| [`apps/eval`](packages/browseros-agent/apps/eval/) | Benchmark framework — WebVoyager, Mind2Web evaluation |
| [`agent-sdk`](packages/browseros-agent/packages/agent-sdk/) | Node.js SDK for browser automation with natural language |
| [`cdp-protocol`](packages/browseros-agent/packages/cdp-protocol/) | Type-safe Chrome DevTools Protocol bindings |
<details>
<summary><b>vs ChatGPT Atlas</b></summary>
<br>
Your browsing data could be used for ads or to train their models. We keep your history and agent interactions strictly local.
</details>
## Contributing
We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRIBUTING.md) for details.
We'd love your help making BrowserOS better!
- [Report bugs](https://github.com/browseros-ai/BrowserOS/issues)
- [Suggest features](https://github.com/browseros-ai/BrowserOS/issues/99)
- [Join Discord](https://discord.gg/YKwjt5vuKr) · [Join Slack](https://dub.sh/browserOS-slack)
- [Follow on Twitter](https://x.com/browserOS_ai)
**Agent development** (TypeScript/Go) — see the [agent monorepo README](packages/browseros-agent/README.md) for setup instructions.
**Browser development** (C++/Python) — requires ~100GB disk space. See [`packages/browseros`](packages/browseros/) for build instructions.
## Credits
- [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) — BrowserOS uses some patches for enhanced privacy. Thanks to everyone behind this project!
- [The Chromium Project](https://www.chromium.org/) — at the core of BrowserOS, making it possible to exist in the first place.
- 🐛 [Report bugs](https://github.com/browseros-ai/BrowserOS/issues)
- 💡 [Suggest features](https://github.com/browseros-ai/BrowserOS/issues/99)
- 💬 [Join Discord](https://discord.gg/YKwjt5vuKr)
- 🐦 [Follow on Twitter](https://x.com/browserOS_ai)
## License
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
## Credits
- [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) - BrowserOS uses some patches for enhanced privacy. Thanks to everyone behind this project!
- [The Chromium Project](https://www.chromium.org/) - At the core of BrowserOS, making it possible to exist in the first place.
## Citation
If you use BrowserOS in your research or project, please cite:
```bibtex
@software{browseros2025,
author = {Sonti, Nithin and Sonti, Nikhil and {BrowserOS-team}},
title = {BrowserOS: The open-source Agentic browser},
url = {https://github.com/browseros-ai/BrowserOS},
year = {2025},
publisher = {GitHub},
license = {AGPL-3.0},
}
```
Copyright &copy; 2025 Felafax, Inc.
## Stargazers
Thank you to all our supporters!
[![Star History Chart](https://api.star-history.com/svg?repos=browseros-ai/BrowserOS&type=Date)](https://www.star-history.com/#browseros-ai/BrowserOS&Date)
##
<p align="center">
Built with ❤️ from San Francisco
</p>

View File

@@ -23,9 +23,6 @@
"group": "Core Features",
"pages": [
"features/bring-your-own-llm",
"features/chatgpt-pro-oauth",
"features/github-copilot-oauth",
"features/qwen-code-oauth",
"features/local-models",
"features/workflows",
"features/scheduled-tasks",

View File

@@ -13,33 +13,6 @@ See how to connect your own LLM in under a minute:
src="https://pub-80f8a01e6e8b4239ae53a7652ef85877.r2.dev/resources/feature-videos/1-bring-your-own-LLM.mov"
></video>
## Use Your Existing Subscription
Already paying for ChatGPT Pro, GitHub Copilot, or Qwen Code? Connect your existing account to BrowserOS with a single sign-in — no API keys, no extra cost.
<CardGroup cols={3}>
<Card href="/features/chatgpt-pro-oauth">
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
**ChatGPT Pro / Plus**
Sign in with your OpenAI account. Access GPT-5 Codex, GPT-5.4, and the full Codex lineup with up to 400K context.
</Card>
<Card href="/features/github-copilot-oauth">
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
**GitHub Copilot**
Sign in with your GitHub account. Access 19+ models including Claude, GPT-5, and Gemini through one subscription.
</Card>
<Card href="/features/qwen-code-oauth">
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
**Qwen Code**
Sign in with your Qwen account. Access Qwen 3 Coder with a 1 million token context window.
</Card>
</CardGroup>
---
## Which Model Should I Use?
| Mode | What works | Recommendation |

View File

@@ -1,56 +0,0 @@
---
title: "ChatGPT Pro / Plus"
description: "Use your ChatGPT subscription to power BrowserOS"
---
Connect your ChatGPT Pro or Plus subscription to BrowserOS and access GPT-5 Codex, GPT-5.4, and the full lineup of OpenAI's most advanced models — with up to 400K context. No API keys needed.
## Setup
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
![AI Settings screen](/images/setting-up-chatgpt/llm-screen.png)
**2.** Click **USE** on the **ChatGPT Plus/Pro** card. You'll be prompted to sign in with your OpenAI account.
![Login screen](/images/setting-up-chatgpt/login-screen.png)
**3.** Sign in with the OpenAI account that has your ChatGPT Pro or Plus subscription active, and accept the authorization.
![Accept authorization](/images/setting-up-chatgpt/accept-screen.png)
**4.** Once authorized, ChatGPT will appear as a provider in your settings. Select a model and start using it.
## Available Models
| Model | Context Window |
|-------|---------------|
| `gpt-5.4` | 400K |
| `gpt-5.3-codex` | 400K |
| `gpt-5.2-codex` | 400K |
| `gpt-5.2` | 200K |
| `gpt-5.1-codex` | 400K |
| `gpt-5.1-codex-max` | 400K |
| `gpt-5.1-codex-mini` | 400K |
| `gpt-5.1` | 200K |
<Info>
ChatGPT Pro subscribers have access to the full model lineup. ChatGPT Plus subscribers can access a subset of models depending on their plan. The available models will be shown automatically after you connect.
</Info>
<Tip>
The Codex models (e.g., `gpt-5.3-codex`) are optimized for code and reasoning tasks — ideal for complex browser automation workflows that involve form filling, data extraction, and multi-step navigation.
</Tip>
## Reasoning Settings
ChatGPT Pro includes additional settings for models that support reasoning:
- **Reasoning Effort** — Control how much the model "thinks" before responding. Options: none, low, medium, high.
- **Reasoning Summary** — Choose how reasoning is displayed. Options: auto, concise, detailed.
These settings are available in the provider configuration after connecting.
## Disconnecting
To disconnect your OpenAI account, go to **Settings**, find the ChatGPT Plus/Pro provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.

View File

@@ -1,60 +0,0 @@
---
title: "GitHub Copilot"
description: "Use your GitHub Copilot subscription to power BrowserOS"
---
Connect your GitHub Copilot subscription to BrowserOS and access 19+ models — including Claude, GPT-5, and Gemini — through a single GitHub sign-in. No API keys needed.
<Info>
**Free tier** includes GPT-5 Mini, Claude Haiku 4.5, GPT-4o, and GPT-4.1. **Copilot Pro** ($10/month) unlocks Claude Sonnet 4.6, Claude Opus 4.6, Gemini 3 Pro, GPT-5.4, and more.
</Info>
## Setup
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
![AI Settings screen](/images/setting-up-copilot/llm-screen.png)
**2.** Click **USE** on the **GitHub Copilot** card. A device code will appear — copy it, then click the link to open GitHub's device authorization page.
![Device code displayed](/images/setting-up-copilot/device-code.png)
**3.** Select your GitHub account to authorize.
![Select GitHub account](/images/setting-up-copilot/select-account.png)
**4.** Paste the device code and authorize BrowserOS to access your Copilot subscription.
![Authorize device](/images/setting-up-copilot/authorize-device.png)
**5.** Once authorized, GitHub Copilot will appear as a provider in your settings. Select a model and start using it.
## Available Models
### Free Tier
| Model | Context Window |
|-------|---------------|
| `gpt-5-mini` | 128K |
| `claude-haiku-4.5` | 128K |
| `gpt-4o` | 64K |
| `gpt-4.1` | 64K |
### Copilot Pro / Pro+
| Model | Context Window |
|-------|---------------|
| `claude-sonnet-4.6` | 200K |
| `claude-opus-4.6` | 200K |
| `gemini-2.5-pro` | 1M |
| `gemini-3-pro-preview` | 1M |
| `gpt-5.4` | 400K |
| `gpt-5.3-codex` | 400K |
| `gpt-5.2-codex` | 400K |
| `grok-code-fast-1` | 128K |
<Tip>
GitHub Copilot is the most versatile provider — one subscription gives you access to models from OpenAI, Anthropic, Google, and xAI. Great if you want to switch between models for different tasks.
</Tip>
## Disconnecting
To disconnect your GitHub account, go to **Settings**, find the GitHub Copilot provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.

View File

@@ -1,39 +0,0 @@
---
title: "Qwen Code"
description: "Use your Qwen Code account to power BrowserOS"
---
Connect your Qwen Code account to BrowserOS and access Alibaba's coding models with up to a **1 million token context window** — the largest of any provider we support. No API keys needed.
## Setup
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
![AI Settings screen](/images/setting-up-qwen/llm-screen.png)
**2.** Click **USE** on the **Qwen Code** card. You'll be prompted to sign in with your Qwen account.
![Select Qwen Code](/images/setting-up-qwen/select-qwen.png)
**3.** Sign in with your Alibaba Cloud / Qwen account to authorize BrowserOS.
![Qwen sign in](/images/setting-up-qwen/qwen-signin.png)
**4.** Once authorized, Qwen Code will appear as a provider in your settings. Select a model and start using it.
## Available Models
| Model | Context Window |
|-------|---------------|
| `coder-model` | 1M |
| `qwen3-coder-plus` | 1M |
| `qwen3-coder-flash` | 1M |
| `qwen3.5-plus` | 1M |
<Tip>
Qwen Code's 1 million token context window is ideal for tasks that involve long documents, entire documentation sites, or working across many browser tabs simultaneously — the agent can hold everything in context at once.
</Tip>
## Disconnecting
To disconnect your Qwen account, go to **Settings**, find the Qwen Code provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 KiB

View File

@@ -195,4 +195,3 @@ test-results/
.agent/
.llm/
.grove/
docs/plans/2026-03-24-models-dev-integration.md

View File

@@ -81,9 +81,6 @@ bun run dev:server # Build server for development
bun run dev:ext # Build extension for development
bun run dist:server # Build server for production (all targets)
bun run dist:ext # Build extension for production
# Refresh models.dev data
bun run generate:models # Fetches latest from models.dev/api.json
```
## Architecture

View File

@@ -1,6 +1,8 @@
# BrowserOS Agent
The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserOS) — contains the MCP server, agent UI, CLI, evaluation framework, and SDK.
Monorepo for the BrowserOS-agent -- contains 3 packages: agent-UI, server (which contains the agent loop) and controller-extension (which is used by the tools within the agent loop).
> **⚠️ NOTE:** This is only a submodule, the main project is at -- https://github.com/browseros-ai/BrowserOS
## Monorepo Structure
@@ -8,25 +10,17 @@ The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserO
apps/
server/ # Bun server - MCP endpoints + agent loop
agent/ # Agent UI (Chrome extension)
cli/ # Go CLI for controlling BrowserOS from the terminal
eval/ # Evaluation framework for benchmarking agents
controller-ext/ # BrowserOS Controller (Chrome extension for chrome.* APIs)
packages/
agent-sdk/ # Node.js SDK (@browseros-ai/agent-sdk)
cdp-protocol/ # Type-safe Chrome DevTools Protocol bindings
shared/ # Shared constants (ports, timeouts, limits)
```
| Package | Description |
|---------|-------------|
| `apps/server` | Bun server exposing MCP tools and running the agent loop |
| `apps/agent` | Agent UI Chrome extension for the chat interface |
| `apps/cli` | Go CLI — control BrowserOS from the terminal or AI coding agents |
| `apps/eval` | Benchmark framework — WebVoyager, Mind2Web evaluation |
| `apps/controller-ext` | BrowserOS Controller — bridges `chrome.*` APIs to the server via WebSocket |
| `packages/agent-sdk` | Node.js SDK for browser automation with natural language |
| `packages/cdp-protocol` | Auto-generated CDP type bindings used by the server |
| `apps/agent` | Agent UI - Chrome extension for the chat interface |
| `apps/controller-ext` | BrowserOS Controller - Chrome extension that bridges `chrome.*` APIs (tabs, bookmarks, history) to the server via WebSocket |
| `packages/shared` | Shared constants used across packages |
## Architecture

View File

@@ -1,6 +0,0 @@
# BrowserOS Agent Extension
## v0.0.52 (2026-03-26)
Initial release

View File

@@ -1,24 +1,16 @@
# BrowserOS Agent Extension
# BrowserOS Agent Chrome Extension
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](../../../../LICENSE)
The built-in browser extension that powers BrowserOS's AI interface — new tab with unified search, side panel chat, onboarding, and settings. Built with [WXT](https://wxt.dev) and React.
> For user-facing feature documentation, see [docs.browseros.com](https://docs.browseros.com).
The official Chrome extension for BrowserOS Agent, providing the UI layer for interacting with BrowserOS Core and Controllers. This extension enables intelligent browser automation, AI-powered search, and seamless integration with multiple LLM providers.
## Features
- **AI-Powered New Tab**: Custom new tab page with unified search across Google and AI assistants
- **Side Panel Chat**: Full-featured chat interface for interacting with BrowserOS
- **Side Panel Chat**: Full-featured chat interface for interacting with BrowserOS Core
- **Multi-Provider Support**: Connect to various LLM providers (OpenAI, Anthropic, Azure, Bedrock, and more)
- **MCP Integration**: Model Context Protocol support for extending AI capabilities
- **Visual Feedback**: Animated glow effect on tabs during AI agent operations
- **Privacy-First**: Local data handling with configurable provider settings
## How It Connects
The extension communicates with the [BrowserOS Server](../../apps/server/) running locally. The server handles the AI agent loop, MCP tools, and CDP connections — the extension provides the UI layer.
## Project Structure
```
@@ -88,20 +80,47 @@ Settings dashboard with multiple sections:
Content script that creates a visual indicator (pulsing orange glow) around the browser viewport when an AI agent is actively working on a tab.
## How Tools Are Used
### Bun
Bun is the exclusive runtime and package manager:
- All scripts use `bun run <script>` instead of npm
- Package installation via `bun install`
- Environment files automatically loaded (no dotenv needed)
- Enforced via `engines` field in `package.json`
```bash
bun install # Install dependencies
bun run dev # Development mode
bun run build # Production build
bun run lint # Run Biome linting
```
### Biome
Unified linter and formatter configured in `biome.json`:
- **Formatting**: 2-space indentation, single quotes, no semicolons
- **Linting**: Recommended rules plus custom rules for unused imports/variables
- **CSS Support**: Tailwind directives parsing enabled
- **Import Organization**: Automatic import sorting via assist actions
```bash
bun run lint # Check for issues
bun run lint:fix # Auto-fix issues
```
## Development
### Prerequisites
- [Bun](https://bun.sh) installed
- Chrome or Chromium-based browser
- BrowserOS Server running locally (for full functionality)
- BrowserOS Core running locally (for full functionality)
### Setup
```bash
# Copy environment file
cp .env.example .env.development
# Install dependencies
bun install
@@ -134,30 +153,12 @@ SENTRY_AUTH_TOKEN=your-token
### GraphQL Schema
Codegen requires a GraphQL schema. By default it uses the bundled `schema/schema.graphql`, so no extra setup is needed. If you have access to the original API source, you can set the following environment variable:
Codegen requires a GraphQL schema. By default it uses the bundled `schema/schema.graphql`, so no extra setup is needed. If you have access to the original API source, you can set the following environment variable
```env
GRAPHQL_SCHEMA_PATH=/path/to/api-repo/.../schema.graphql
```
## Development Tooling
### Bun
Bun is the exclusive runtime and package manager:
- All scripts use `bun run <script>` instead of npm
- Package installation via `bun install`
- Environment files automatically loaded (no dotenv needed)
- Enforced via `engines` field in `package.json`
### Biome
Unified linter and formatter configured in `biome.json`:
- **Formatting**: 2-space indentation, single quotes, no semicolons
- **Linting**: Recommended rules plus custom rules for unused imports/variables
- **CSS Support**: Tailwind directives parsing enabled
- **Import Organization**: Automatic import sorting via assist actions
## Scripts
| Script | Description |
@@ -168,5 +169,4 @@ Unified linter and formatter configured in `biome.json`:
| `bun run lint` | Run Biome linter |
| `bun run lint:fix` | Auto-fix linting issues |
| `bun run typecheck` | Run TypeScript type checking |
| `bun run codegen` | Generate GraphQL types |
| `bun run clean:cache` | Clear build caches |

View File

@@ -66,7 +66,7 @@ export const RunResultDialog: FC<RunResultDialogProps> = ({
return (
<Dialog open={!!run} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[70vw] sm:max-w-4xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{run.status === 'completed' ? (
@@ -94,7 +94,7 @@ export const RunResultDialog: FC<RunResultDialogProps> = ({
<p className="text-destructive text-sm">{run.result}</p>
</div>
) : run.result ? (
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4 [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4">
<MessageResponse>{run.result}</MessageResponse>
</div>
) : (

View File

@@ -1,4 +1,4 @@
import { Check, Plus } from 'lucide-react'
import { Check } from 'lucide-react'
import type { FC, PropsWithChildren } from 'react'
import { useState } from 'react'
import {
@@ -77,19 +77,6 @@ export const ChatProviderSelector: FC<
)
})}
</CommandGroup>
<div className="border-border border-t p-1">
<button
type="button"
className="flex w-full items-center gap-3 rounded-md p-2 text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={() => {
window.open('/app.html#/settings/ai', '_blank')
setOpen(false)
}}
>
<Plus className="h-4 w-4" />
Add Provider
</button>
</div>
</CommandList>
</Command>
</PopoverContent>

View File

@@ -14,7 +14,7 @@ export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
type="button"
onClick={onClick}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs transition-colors hover:bg-muted/50',
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs transition-colors hover:bg-muted/50',
getCreditTextColor(credits),
)}
title={`${credits} credits remaining`}

View File

@@ -38,7 +38,6 @@ import {
} from '@/lib/llm-providers/useOAuthProviderFlow'
import { track } from '@/lib/metrics/track'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import { DeviceCodeDialog } from './DeviceCodeDialog'
import {
DeleteRemoteLlmProviderDocument,
GetRemoteLlmProvidersDocument,
@@ -46,7 +45,6 @@ import {
import type { IncompleteProvider } from './IncompleteProviderCard'
import { IncompleteProvidersList } from './IncompleteProvidersList'
import { LlmProvidersHeader } from './LlmProvidersHeader'
import { McpPromoBanner } from './McpPromoBanner'
import { NewProviderDialog } from './NewProviderDialog'
import { ProviderTemplatesSection } from './ProviderTemplatesSection'
@@ -175,16 +173,6 @@ export const AISettingsPage: FC = () => {
saveProvider,
)
const activeDeviceCode =
chatgptPro.pendingDeviceCode ??
copilot.pendingDeviceCode ??
qwenCode.pendingDeviceCode
const clearActiveDeviceCode = () => {
chatgptPro.clearDeviceCode()
copilot.clearDeviceCode()
qwenCode.clearDeviceCode()
}
const oauthFlows: Record<
string,
{
@@ -359,8 +347,6 @@ export const AISettingsPage: FC = () => {
onAddProvider={handleAddProvider}
/>
<McpPromoBanner />
<ProviderTemplatesSection onUseTemplate={handleUseTemplate} />
<ConfiguredProvidersList
@@ -435,11 +421,6 @@ export const AISettingsPage: FC = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeviceCodeDialog
deviceCode={activeDeviceCode}
onClose={clearActiveDeviceCode}
/>
</div>
)
}

View File

@@ -1,81 +0,0 @@
import { Check, Copy, ExternalLink } from 'lucide-react'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { PendingDeviceCode } from '@/lib/llm-providers/useOAuthProviderFlow'
interface DeviceCodeDialogProps {
deviceCode: PendingDeviceCode | null
onClose: () => void
}
export const DeviceCodeDialog: FC<DeviceCodeDialogProps> = ({
deviceCode,
onClose,
}) => {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
if (!deviceCode) return
try {
await navigator.clipboard.writeText(deviceCode.userCode)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Clipboard API failed
}
}
return (
<Dialog open={!!deviceCode} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect to {deviceCode?.providerName}</DialogTitle>
<DialogDescription>
Paste this code on the {deviceCode?.providerName} page that just
opened in your browser.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<div className="flex items-center gap-3 rounded-xl border-2 border-[var(--accent-orange)]/40 border-dashed bg-[var(--accent-orange)]/5 px-6 py-4">
<code className="font-bold font-mono text-2xl text-foreground tracking-widest">
{deviceCode?.userCode}
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-center text-muted-foreground text-xs">
This dialog will close automatically once authentication completes.
</p>
{deviceCode?.verificationUri && (
<a
href={deviceCode.verificationUri}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[var(--accent-orange)] text-xs transition-colors hover:underline"
>
Open verification page
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,57 +0,0 @@
import { ArrowRight, Server, X } from 'lucide-react'
import { type FC, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import { MCP_PROMO_BANNER_CLICKED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
export const McpPromoBanner: FC = () => {
const [dismissed, setDismissed] = useState(false)
const navigate = useNavigate()
if (dismissed) return null
const handleClick = () => {
track(MCP_PROMO_BANNER_CLICKED_EVENT)
navigate('/settings/mcp')
}
return (
<div className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:shadow-md">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-orange)]/10">
<Server className="h-5 w-5 text-[var(--accent-orange)]" />
</div>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-semibold text-sm">
Use BrowserOS with Claude Code, Cursor & more
<span className="text-[var(--accent-orange)] text-xs">
(66+ tools)
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-orange)]/10 px-2.5 py-1 font-semibold text-[var(--accent-orange)] text-xs">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-orange)]" />
New
</span>
</p>
<p className="text-muted-foreground text-xs">
Connect your favorite coding tools to BrowserOS as an MCP server
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleClick}
className="shrink-0 border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
>
Set up
<ArrowRight className="ml-1 h-3 w-3" />
</Button>
<button
type="button"
onClick={() => setDismissed(true)}
className="shrink-0 rounded-sm p-1 text-muted-foreground opacity-50 transition-opacity hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
}

View File

@@ -1,13 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import {
CheckCircle2,
ChevronDown,
ExternalLink,
Loader2,
SearchIcon,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import { CheckCircle2, ExternalLink, Loader2, XCircle } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod/v3'
import { Button } from '@/components/ui/button'
@@ -54,12 +47,7 @@ import {
import { type TestResult, testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import {
getModelContextLength,
getModelsForProvider,
type ModelInfo,
} from './models'
import { getModelContextLength, getModelOptions } from './models'
const providerTypeEnum = z.enum([
'moonshot',
@@ -175,107 +163,6 @@ export const providerFormSchema = z
*/
export type ProviderFormValues = z.infer<typeof providerFormSchema>
function formatContextWindow(tokens: number): string {
if (tokens >= 1000000)
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`
if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`
return `${tokens}`
}
function ModelPickerList({
models,
selectedModelId,
onSelect,
onCustomSubmit,
onClose,
}: {
models: ModelInfo[]
selectedModelId: string
onSelect: (modelId: string) => void
onCustomSubmit: (modelId: string) => void
onClose: () => void
}) {
const [search, setSearch] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
const query = search.toLowerCase()
const filtered = query
? models.filter((m) => m.modelId.toLowerCase().includes(query))
: models
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && search) {
e.preventDefault()
onCustomSubmit(search)
}
if (e.key === 'Escape') {
onClose()
}
}
return (
<div ref={containerRef} className="rounded-md border">
<div className="flex items-center gap-2 border-b px-3">
<SearchIcon className="h-4 w-4 shrink-0 text-muted-foreground opacity-50" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search or type a custom model ID..."
className="flex h-9 w-full bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[200px] overflow-y-auto">
{filtered.length > 0 ? (
filtered.map((model) => {
const isSelected = selectedModelId === model.modelId
return (
<button
key={model.modelId}
type="button"
onClick={() => onSelect(model.modelId)}
className={cn(
'flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-accent',
isSelected && 'bg-accent font-medium',
)}
>
<span className="truncate">{model.modelId}</span>
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{formatContextWindow(model.contextLength)}
</span>
</button>
)
})
) : (
<div className="px-3 py-6 text-center text-muted-foreground text-sm">
No models match. Press Enter to use &quot;{search}&quot;
</div>
)}
</div>
</div>
)
}
/**
* Props for NewProviderDialog
* @public
@@ -301,9 +188,9 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
initialValues,
onSave,
}) => {
const [isCustomModel, setIsCustomModel] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [modelListOpen, setModelListOpen] = useState(false)
const { supports } = useCapabilities()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const kimiLaunch = useKimiLaunch()
@@ -374,7 +261,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
watchedSessionToken,
])
const modelInfoList = getModelsForProvider(watchedType as ProviderType)
// Get model options for current provider type
const modelOptions = getModelOptions(watchedType as ProviderType)
// Handle provider type change (user-initiated via Select)
const handleTypeChange = (newType: ProviderType) => {
@@ -384,13 +272,14 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
form.setValue('baseUrl', defaultUrl)
}
form.setValue('modelId', '')
setIsCustomModel(false)
}
// Auto-fill context window when model changes (only for new providers)
useEffect(() => {
if (initialValues?.id) return
if (watchedModelId) {
if (watchedModelId && watchedModelId !== 'custom') {
const contextLength = getModelContextLength(
watchedType as ProviderType,
watchedModelId,
@@ -401,6 +290,17 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
}
}, [watchedModelId, watchedType, form, initialValues?.id])
// Handle model selection (including custom option)
const handleModelChange = (value: string) => {
if (value === 'custom') {
setIsCustomModel(true)
form.setValue('modelId', '')
} else {
setIsCustomModel(false)
form.setValue('modelId', value)
}
}
// Reset form when initialValues change
useEffect(() => {
if (initialValues) {
@@ -425,6 +325,7 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
reasoningEffort: initialValues.reasoningEffort || 'high',
reasoningSummary: initialValues.reasoningSummary || 'auto',
})
setIsCustomModel(false)
}
}, [initialValues, form])
@@ -451,6 +352,7 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
reasoningEffort: 'high',
reasoningSummary: 'auto',
})
setIsCustomModel(false)
}
// Clear test result when dialog opens/closes
setTestResult(null)
@@ -909,51 +811,52 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
control={form.control}
name="modelId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormItem>
<FormLabel>Model *</FormLabel>
{modelInfoList.length === 0 ? (
<FormControl>
<Input
placeholder={
watchedType === 'azure'
? 'Enter your deployment name'
: watchedType === 'bedrock'
? 'e.g., anthropic.claude-3-5-sonnet-20241022-v2:0'
: 'Enter model ID'
}
{...field}
/>
</FormControl>
) : modelListOpen ? (
<ModelPickerList
models={modelInfoList}
selectedModelId={field.value}
onSelect={(modelId) => {
form.setValue('modelId', modelId)
setModelListOpen(false)
}}
onCustomSubmit={(modelId) => {
form.setValue('modelId', modelId)
setModelListOpen(false)
}}
onClose={() => setModelListOpen(false)}
/>
) : (
<button
type="button"
onClick={() => setModelListOpen(true)}
className={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs',
field.value
? 'text-foreground'
: 'text-muted-foreground',
{isCustomModel || modelOptions.length === 1 ? (
<>
<FormControl>
<Input
placeholder={
watchedType === 'azure'
? 'Enter your deployment name'
: watchedType === 'bedrock'
? 'e.g., anthropic.claude-3-5-sonnet-20241022-v2:0'
: 'Enter custom model ID'
}
{...field}
/>
</FormControl>
{modelOptions.length > 1 && (
<Button
type="button"
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => setIsCustomModel(false)}
>
Back to model list
</Button>
)}
</>
) : (
<Select
onValueChange={handleModelChange}
value={field.value}
>
<span className="truncate">
{field.value || 'Select a model...'}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{modelOptions.map((modelId) => (
<SelectItem key={modelId} value={modelId}>
{modelId === 'custom' ? '+ Custom model' : modelId}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FormMessage />
</FormItem>

View File

@@ -7,14 +7,12 @@ import { cn } from '@/lib/utils'
interface ProviderTemplateCardProps {
template: ProviderTemplate
highlighted?: boolean
isNew?: boolean
onUseTemplate: (template: ProviderTemplate) => void
}
export const ProviderTemplateCard: FC<ProviderTemplateCardProps> = ({
template,
highlighted = false,
isNew = false,
onUseTemplate,
}) => {
return (
@@ -22,19 +20,12 @@ export const ProviderTemplateCard: FC<ProviderTemplateCardProps> = ({
type="button"
onClick={() => onUseTemplate(template)}
className={cn(
'group relative flex w-full items-center gap-3 rounded-lg border bg-background p-4 text-left transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
'group flex w-full items-center gap-3 rounded-lg border bg-background p-4 text-left transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
highlighted
? 'border-orange-300/80 bg-orange-50/30 shadow-sm ring-1 ring-orange-300/45 dark:bg-orange-500/5'
: isNew
? 'border-2 border-[var(--accent-orange)]/50'
: 'border-border',
: 'border-border',
)}
>
{isNew && (
<span className="absolute -top-2 left-3 rounded-full bg-[var(--accent-orange)] px-2 py-0.5 font-semibold text-[9px] text-white uppercase tracking-wider">
New
</span>
)}
<div className="flex min-w-0 flex-1 items-center gap-3">
<ProviderIcon
type={template.id}

View File

@@ -58,21 +58,14 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
<CollapsibleContent>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredTemplates.map((template) => {
const isNew =
template.id === 'chatgpt-pro' ||
template.id === 'github-copilot' ||
template.id === 'qwen-code'
return (
<ProviderTemplateCard
key={template.id}
template={template}
highlighted={template.id === 'moonshot'}
isNew={isNew}
onUseTemplate={onUseTemplate}
/>
)
})}
{filteredTemplates.map((template) => (
<ProviderTemplateCard
key={template.id}
template={template}
highlighted={template.id === 'moonshot'}
onUseTemplate={onUseTemplate}
/>
))}
</div>
</CollapsibleContent>
</div>

View File

@@ -1,21 +1,98 @@
import {
getModelsDevModels,
type ModelsDevModel,
} from '@/lib/llm-providers/models-dev'
import type { ProviderType } from '@/lib/llm-providers/types'
/**
* Model information with context length
*/
export interface ModelInfo {
modelId: string
contextLength: number
supportsImages?: boolean
supportsReasoning?: boolean
supportsToolCall?: boolean
}
const CUSTOM_PROVIDER_MODELS: Partial<Record<ProviderType, ModelInfo[]>> = {
browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }],
/**
* Models data organized by provider type (matches backend AIProvider enum)
*/
export interface ModelsData {
anthropic: ModelInfo[]
openai: ModelInfo[]
'openai-compatible': ModelInfo[]
google: ModelInfo[]
openrouter: ModelInfo[]
azure: ModelInfo[]
ollama: ModelInfo[]
lmstudio: ModelInfo[]
bedrock: ModelInfo[]
browseros: ModelInfo[]
moonshot: ModelInfo[]
'chatgpt-pro': ModelInfo[]
'github-copilot': ModelInfo[]
'qwen-code': ModelInfo[]
}
/**
* Available models per provider with context lengths
* Based on: https://github.com/browseros-ai/BrowserOS-agent/blob/main/src/options/data/models.ts
*/
export const MODELS_DATA: ModelsData = {
moonshot: [{ modelId: 'kimi-k2.5', contextLength: 200000 }],
anthropic: [
{ modelId: 'claude-opus-4-5-20251101', contextLength: 200000 },
{ modelId: 'claude-haiku-4-5-20251001', contextLength: 200000 },
{ modelId: 'claude-sonnet-4-5-20250929', contextLength: 200000 },
{ modelId: 'claude-sonnet-4-20250514', contextLength: 200000 },
{ modelId: 'claude-opus-4-20250514', contextLength: 200000 },
{ modelId: 'claude-3-7-sonnet-20250219', contextLength: 200000 },
{ modelId: 'claude-3-5-haiku-20241022', contextLength: 200000 },
],
openai: [
{ modelId: 'gpt-5.2', contextLength: 200000 },
{ modelId: 'gpt-5.2-pro', contextLength: 200000 },
{ modelId: 'gpt-5', contextLength: 200000 },
{ modelId: 'gpt-5-mini', contextLength: 200000 },
{ modelId: 'gpt-5-nano', contextLength: 200000 },
{ modelId: 'gpt-4.1', contextLength: 200000 },
{ modelId: 'gpt-4.1-mini', contextLength: 200000 },
{ modelId: 'o4-mini', contextLength: 200000 },
{ modelId: 'o3-mini', contextLength: 200000 },
{ modelId: 'gpt-4o', contextLength: 128000 },
{ modelId: 'gpt-4o-mini', contextLength: 128000 },
],
'openai-compatible': [],
ollama: [],
google: [
{ modelId: 'gemini-3-pro-preview', contextLength: 1048576 },
{ modelId: 'gemini-3-flash-preview', contextLength: 1048576 },
{ modelId: 'gemini-2.5-flash', contextLength: 1048576 },
{ modelId: 'gemini-2.5-pro', contextLength: 1048576 },
],
openrouter: [
{ modelId: 'google/gemini-3-pro-preview', contextLength: 1048576 },
{ modelId: 'google/gemini-3-flash-preview', contextLength: 1048576 },
{ modelId: 'google/gemini-2.5-flash', contextLength: 1048576 },
{ modelId: 'anthropic/claude-opus-4.5', contextLength: 200000 },
{ modelId: 'anthropic/claude-haiku-4.5', contextLength: 200000 },
{ modelId: 'anthropic/claude-sonnet-4.5', contextLength: 200000 },
{ modelId: 'anthropic/claude-sonnet-4', contextLength: 200000 },
{ modelId: 'anthropic/claude-3.7-sonnet', contextLength: 200000 },
{ modelId: 'openai/gpt-4o', contextLength: 128000 },
{ modelId: 'openai/gpt-oss-120b', contextLength: 128000 },
{ modelId: 'openai/gpt-oss-20b', contextLength: 128000 },
{ modelId: 'qwen/qwen3-14b', contextLength: 131072 },
{ modelId: 'qwen/qwen3-8b', contextLength: 131072 },
],
azure: [],
ollama: [
{ modelId: 'qwen3:4b', contextLength: 262144 },
{ modelId: 'qwen3:8b', contextLength: 40960 },
{ modelId: 'qwen3:14b', contextLength: 40960 },
{ modelId: 'gpt-oss:20b', contextLength: 128000 },
{ modelId: 'gpt-oss:120b', contextLength: 128000 },
],
lmstudio: [
{ modelId: 'openai/gpt-oss-20b', contextLength: 128000 },
{ modelId: 'openai/gpt-oss-120b', contextLength: 128000 },
{ modelId: 'qwen/qwen3-vl-8b', contextLength: 131072 },
],
bedrock: [],
browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }],
'chatgpt-pro': [
{ modelId: 'gpt-5.4', contextLength: 400000 },
{ modelId: 'gpt-5.3-codex', contextLength: 400000 },
@@ -26,6 +103,32 @@ const CUSTOM_PROVIDER_MODELS: Partial<Record<ProviderType, ModelInfo[]>> = {
{ modelId: 'gpt-5.1-codex-mini', contextLength: 400000 },
{ modelId: 'gpt-5.1', contextLength: 200000 },
],
'github-copilot': [
// Free tier (unlimited with Pro)
{ modelId: 'gpt-5-mini', contextLength: 128000 },
{ modelId: 'claude-haiku-4.5', contextLength: 128000 },
{ modelId: 'gpt-4o', contextLength: 64000 },
{ modelId: 'gpt-4.1', contextLength: 64000 },
// Premium models (Pro: 300/mo, Pro+: 1500/mo)
{ modelId: 'claude-sonnet-4.6', contextLength: 128000 },
{ modelId: 'claude-sonnet-4.5', contextLength: 128000 },
{ modelId: 'claude-sonnet-4', contextLength: 128000 },
{ modelId: 'claude-opus-4.6', contextLength: 128000 },
{ modelId: 'claude-opus-4.5', contextLength: 128000 },
{ modelId: 'gemini-2.5-pro', contextLength: 128000 },
{ modelId: 'gemini-3-pro-preview', contextLength: 128000 },
{ modelId: 'gemini-3-flash-preview', contextLength: 128000 },
{ modelId: 'gemini-3.1-pro-preview', contextLength: 128000 },
{ modelId: 'gpt-5.4', contextLength: 272000 },
{ modelId: 'gpt-5.4-mini', contextLength: 128000 },
{ modelId: 'gpt-5.3-codex', contextLength: 272000 },
{ modelId: 'gpt-5.2-codex', contextLength: 272000 },
{ modelId: 'gpt-5.2', contextLength: 128000 },
{ modelId: 'gpt-5.1-codex', contextLength: 128000 },
{ modelId: 'gpt-5.1-codex-max', contextLength: 128000 },
{ modelId: 'gpt-5.1', contextLength: 128000 },
{ modelId: 'grok-code-fast-1', contextLength: 128000 },
],
'qwen-code': [
{ modelId: 'coder-model', contextLength: 1000000 },
{ modelId: 'qwen3-coder-plus', contextLength: 1000000 },
@@ -34,23 +137,25 @@ const CUSTOM_PROVIDER_MODELS: Partial<Record<ProviderType, ModelInfo[]>> = {
],
}
function fromModelsDevModel(m: ModelsDevModel): ModelInfo {
return {
modelId: m.id,
contextLength: m.contextWindow,
supportsImages: m.supportsImages,
supportsReasoning: m.supportsReasoning,
supportsToolCall: m.supportsToolCall,
}
}
/**
* Get models for a specific provider type
*/
export function getModelsForProvider(providerType: ProviderType): ModelInfo[] {
const custom = CUSTOM_PROVIDER_MODELS[providerType]
if (custom !== undefined) return custom
return getModelsDevModels(providerType).map(fromModelsDevModel)
return MODELS_DATA[providerType] || []
}
/**
* Get model options for select dropdown (model IDs + custom option)
*/
export function getModelOptions(providerType: ProviderType): string[] {
const models = getModelsForProvider(providerType)
const modelIds = models.map((m) => m.modelId)
return modelIds.length > 0 ? [...modelIds, 'custom'] : ['custom']
}
/**
* Get context length for a specific model
*/
export function getModelContextLength(
providerType: ProviderType,
modelId: string,
@@ -59,3 +164,14 @@ export function getModelContextLength(
const model = models.find((m) => m.modelId === modelId)
return model?.contextLength
}
/**
* Check if model ID is a custom (user-entered) value
*/
export function isCustomModel(
providerType: ProviderType,
modelId: string,
): boolean {
const models = getModelsForProvider(providerType)
return !models.some((m) => m.modelId === modelId)
}

View File

@@ -259,23 +259,11 @@ export const CreateGraph: FC = () => {
})
const onClickTest = async () => {
let backgroundWindow: chrome.windows.Window | undefined
try {
backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: true,
type: 'normal',
})
} catch {
// Fallback when no window context is available (e.g. all windows closed)
const tab = await chrome.tabs.create({
url: 'chrome://newtab',
active: true,
})
if (tab.windowId) {
backgroundWindow = await chrome.windows.get(tab.windowId)
}
}
const backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: true,
type: 'normal',
})
sendMessage({
text: 'Run a test of the graph you just created.',

View File

@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
import { clear } from 'idb-keyval'
import localforage from 'localforage'
import { Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect } from 'react'
@@ -10,7 +10,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { resetIdentity } from '@/lib/analytics/identify'
import { signOut } from '@/lib/auth/auth-client'
import { providersStorage } from '@/lib/llm-providers/storage'
import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage'
@@ -25,9 +24,8 @@ export const LogoutPage: FC = () => {
await providersStorage.removeValue()
await scheduledJobStorage.removeValue()
queryClient.clear()
await clear()
await localforage.clear()
resetIdentity()
await signOut()
navigate('/home', { replace: true })
}

View File

@@ -1,40 +1,31 @@
import {
Check,
Copy,
ExternalLink,
Loader2,
RefreshCw,
Server,
} from 'lucide-react'
import { type FC, useCallback, useState } from 'react'
import { toast } from 'sonner'
import { Check, Copy, ExternalLink, Globe, Server } from 'lucide-react'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import { MCP_SERVER_RESTARTED_EVENT } from '@/lib/constants/analyticsEvents'
import { sendServerMessage } from '@/lib/messaging/server/serverMessages'
import { track } from '@/lib/metrics/track'
interface MCPServerHeaderProps {
serverUrl: string | null
isLoading: boolean
error: string | null
onServerRestart?: () => void
title?: string
description?: string
remoteAccessEnabled?: boolean
}
const DOCS_URL = 'https://docs.browseros.com/features/use-with-claude-code'
const HEALTH_CHECK_TIMEOUT_MS = 60_000
const HEALTH_CHECK_INTERVAL_MS = 2_000
export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
serverUrl,
isLoading,
error,
onServerRestart,
title = 'BrowserOS MCP Server',
description = 'Connect BrowserOS to MCP clients like claude code, gemini and others.',
remoteAccessEnabled = false,
}) => {
const [isCopied, setIsCopied] = useState(false)
const [isRestarting, setIsRestarting] = useState(false)
const handleCopy = async () => {
if (!serverUrl) return
try {
await navigator.clipboard.writeText(serverUrl)
setIsCopied(true)
@@ -44,57 +35,6 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
}
}
const checkServerHealth = useCallback(async (): Promise<boolean> => {
try {
const result = await sendServerMessage('checkHealth', undefined)
return result.healthy
} catch {
return false
}
}, [])
const handleRestart = async () => {
setIsRestarting(true)
try {
const { getBrowserOSAdapter } = await import('@/lib/browseros/adapter')
const { BROWSEROS_PREFS } = await import('@/lib/browseros/prefs')
const adapter = getBrowserOSAdapter()
await adapter.setPref(BROWSEROS_PREFS.RESTART_SERVER, true)
const startTime = Date.now()
const waitForHealth = (): Promise<boolean> =>
new Promise((resolve) => {
const check = async () => {
if (Date.now() - startTime >= HEALTH_CHECK_TIMEOUT_MS) {
resolve(false)
return
}
if (await checkServerHealth()) {
resolve(true)
return
}
setTimeout(check, HEALTH_CHECK_INTERVAL_MS)
}
setTimeout(check, HEALTH_CHECK_INTERVAL_MS)
})
const healthy = await waitForHealth()
if (healthy) {
track(MCP_SERVER_RESTARTED_EVENT)
toast.success('Server restarted successfully')
onServerRestart?.()
} else {
toast.error('Server did not respond. Try restarting the browser.')
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'Failed to restart server',
)
} finally {
setIsRestarting(false)
}
}
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
@@ -103,21 +43,18 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
</div>
<div className="flex-1">
<div className="mb-1 flex items-center justify-between">
<h2 className="font-semibold text-xl">BrowserOS MCP Server</h2>
<h2 className="font-semibold text-xl">{title}</h2>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-[var(--accent-orange)]"
>
Docs
Setup a client
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<p className="mb-6 text-muted-foreground text-sm">
Connect BrowserOS to MCP clients like Claude Code, Gemini CLI and
others.
</p>
<p className="mb-6 text-muted-foreground text-sm">{description}</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<span className="whitespace-nowrap font-medium text-sm">
@@ -139,7 +76,6 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
onClick={handleCopy}
disabled={!serverUrl || isLoading}
className="shrink-0"
title="Copy URL"
>
{isCopied ? (
<Check className="h-4 w-4 text-green-600" />
@@ -147,22 +83,19 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleRestart}
disabled={isLoading || isRestarting}
className="shrink-0"
title="Restart server"
>
{isRestarting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
</div>
{remoteAccessEnabled && serverUrl && !isLoading && (
<div className="mt-3 flex items-start gap-2 rounded-lg bg-muted/50 px-3 py-2">
<Globe className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground text-xs">
External access is enabled. To connect from another device,
replace <span className="font-mono">127.0.0.1</span> with this
machine's IP address.
</p>
</div>
)}
</div>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import type { McpTool } from '@/lib/mcp/client'
import { sendServerMessage } from '@/lib/messaging/server/serverMessages'
import { MCPServerHeader } from './MCPServerHeader'
import { MCPToolsSection } from './MCPToolsSection'
import { QuickSetupSection } from './QuickSetupSection'
import { ServerSettingsCard } from './ServerSettingsCard'
/** @public */
export const MCPSettingsPage: FC = () => {
@@ -12,6 +12,8 @@ export const MCPSettingsPage: FC = () => {
const [urlLoading, setUrlLoading] = useState(true)
const [urlError, setUrlError] = useState<string | null>(null)
const [remoteAccessEnabled, setRemoteAccessEnabled] = useState(false)
const [tools, setTools] = useState<McpTool[]>([])
const [toolsLoading, setToolsLoading] = useState(false)
const [toolsError, setToolsError] = useState<string | null>(null)
@@ -80,10 +82,13 @@ export const MCPSettingsPage: FC = () => {
serverUrl={serverUrl}
isLoading={urlLoading}
error={urlError}
onServerRestart={loadServerUrlAndTools}
remoteAccessEnabled={remoteAccessEnabled}
/>
<QuickSetupSection serverUrl={serverUrl} />
<ServerSettingsCard
onServerRestart={loadServerUrlAndTools}
onRemoteAccessChange={setRemoteAccessEnabled}
/>
<MCPToolsSection
tools={tools}

View File

@@ -1,162 +0,0 @@
import { Check, Copy, Terminal } from 'lucide-react'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface QuickSetupSectionProps {
serverUrl: string | null
}
interface ClientConfig {
id: string
name: string
type: 'command' | 'json'
getSnippet: (url: string) => string
fileName?: string
}
const clients: ClientConfig[] = [
{
id: 'claude-code',
name: 'Claude Code',
type: 'command',
getSnippet: (url) =>
`claude mcp add --transport http browseros ${url} --scope user`,
},
{
id: 'gemini-cli',
name: 'Gemini CLI',
type: 'command',
getSnippet: (url) =>
`gemini mcp add local-server ${url} --transport http --scope user`,
},
{
id: 'codex',
name: 'Codex',
type: 'command',
getSnippet: (url) => `codex mcp add browseros ${url}`,
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
type: 'json',
fileName: 'claude_desktop_config.json',
getSnippet: (url) =>
JSON.stringify(
{
mcpServers: {
browserOS: {
command: 'npx',
args: ['mcp-remote', url],
},
},
},
null,
2,
),
},
{
id: 'openclaw',
name: 'OpenClaw',
type: 'json',
fileName: 'openclaw.json',
getSnippet: (url) =>
JSON.stringify(
{
mcpServers: {
browseros: { url },
},
},
null,
2,
),
},
]
const CopyButton: FC<{ text: string }> = ({ text }) => {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Clipboard API failed
}
}
return (
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
)
}
export const QuickSetupSection: FC<QuickSetupSectionProps> = ({
serverUrl,
}) => {
if (!serverUrl) return null
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Terminal className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<h2 className="mb-1 font-semibold text-xl">Quick Setup</h2>
<p className="mb-4 text-muted-foreground text-sm">
Copy and run the command for your tool
</p>
<Tabs defaultValue="claude-code">
<TabsList className="mb-3 flex-wrap">
{clients.map((client) => (
<TabsTrigger key={client.id} value={client.id}>
{client.name}
</TabsTrigger>
))}
</TabsList>
{clients.map((client) => {
const snippet = client.getSnippet(serverUrl)
return (
<TabsContent key={client.id} value={client.id}>
<div className="space-y-3">
{client.fileName && (
<p className="text-muted-foreground text-xs">
Add to{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
{client.fileName}
</code>
</p>
)}
<div className="flex items-start gap-2 rounded-lg border border-border bg-background px-3 py-2.5">
<pre className="flex-1 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs">
{client.type === 'command' && (
<span className="mr-1 text-muted-foreground">$</span>
)}
{snippet}
</pre>
<CopyButton text={snippet} />
</div>
</div>
</TabsContent>
)
})}
</Tabs>
</div>
</div>
</div>
)
}

View File

@@ -22,7 +22,9 @@ import {
SCHEDULED_TASK_TOGGLED_EVENT,
SCHEDULED_TASK_VIEW_RESULTS_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { track } from '@/lib/metrics/track'
import { DeleteScheduledJobDocument } from '@/lib/schedules/graphql/syncSchedulesDocument'
import {
scheduledJobRunStorage,
useScheduledJobRuns,
@@ -44,6 +46,8 @@ export const ScheduledTasksPage: FC = () => {
useScheduledJobs()
const { jobRuns, cancelJobRun } = useScheduledJobRuns()
const deleteRemoteJobMutation = useGraphqlMutation(DeleteScheduledJobDocument)
const [activeTab, setActiveTab] = useState<string | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingJob, setEditingJob] = useState<ScheduledJob | null>(null)
@@ -98,6 +102,7 @@ export const ScheduledTasksPage: FC = () => {
const confirmDelete = async () => {
if (deleteJobId) {
await removeJob(deleteJobId)
deleteRemoteJobMutation.mutate({ rowId: deleteJobId })
setDeleteJobId(null)
track(SCHEDULED_TASK_DELETED_EVENT)
}

View File

@@ -105,40 +105,18 @@ export const UsagePage: FC = () => {
</div>
<div className="rounded-xl border p-5">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<p className="flex items-center gap-2 font-semibold text-sm">
Need more credits?
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground uppercase tracking-wide">
Coming soon
</span>
</p>
<p className="text-muted-foreground text-xs">
Additional credit packages will be available soon
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/5 p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-[var(--accent-orange)]" />
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-semibold text-sm">Want unlimited usage?</p>
<p className="font-semibold text-sm">Need more credits?</p>
<p className="text-muted-foreground text-xs">
Add your own LLM provider no credit limits
Additional credit packages coming soon
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20"
asChild
>
<a href="/app.html#/settings/ai">Add Provider</a>
<Button variant="outline" size="sm" disabled className="opacity-50">
Add Credits
</Button>
</div>
</div>

View File

@@ -101,23 +101,11 @@ export const useRunWorkflow = () => {
setMessages([])
setWasCancelled(false)
let backgroundWindow: chrome.windows.Window | undefined
try {
backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: true,
type: 'normal',
})
} catch {
// Fallback when no window context is available (e.g. all windows closed)
const tab = await chrome.tabs.create({
url: 'chrome://newtab',
active: true,
})
if (tab.windowId) {
backgroundWindow = await chrome.windows.get(tab.windowId)
}
}
const backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: true,
type: 'normal',
})
sendMessage({
text: 'Run the workflow.',

View File

@@ -169,15 +169,8 @@ export const NewTabChat: FC = () => {
onDismissJtbdPopup={() => {}}
/>
)}
{agentUrlError && (
<ChatError
error={agentUrlError}
providerType={selectedProvider?.type}
/>
)}
{chatError && (
<ChatError error={chatError} providerType={selectedProvider?.type} />
)}
{agentUrlError && <ChatError error={agentUrlError} />}
{chatError && <ChatError error={chatError} />}
</main>
<div className="mx-auto w-full max-w-3xl flex-shrink-0 px-4 pb-2">

View File

@@ -32,7 +32,6 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
const {
data: graphqlData,
isLoading: isLoadingConversations,
isFetching,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
@@ -113,7 +112,6 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={fetchNextPage}
isRefreshing={isFetching && !isLoadingConversations}
/>
)
}

View File

@@ -12,7 +12,6 @@ interface ConversationListProps {
hasNextPage?: boolean
isFetchingNextPage?: boolean
onLoadMore?: () => void
isRefreshing?: boolean
}
export const ConversationList: FC<ConversationListProps> = ({
@@ -22,7 +21,6 @@ export const ConversationList: FC<ConversationListProps> = ({
hasNextPage,
isFetchingNextPage,
onLoadMore,
isRefreshing,
}) => {
const loadMoreRef = useRef<HTMLDivElement>(null)
@@ -59,12 +57,6 @@ export const ConversationList: FC<ConversationListProps> = ({
return (
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
<div className="w-full p-3">
{isRefreshing && (
<div className="flex items-center justify-center gap-2 pb-3 text-muted-foreground text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Fetching latest conversations</span>
</div>
)}
{!hasConversations ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />

View File

@@ -11,7 +11,7 @@ export const GetConversationsForHistoryDocument = graphql(`
nodes {
rowId
lastMessagedAt
conversationMessages(first: 2, orderBy: ORDER_INDEX_DESC) {
conversationMessages(last: 5, orderBy: ORDER_INDEX_ASC) {
nodes {
message
}

View File

@@ -36,7 +36,6 @@ export const Chat = () => {
stop,
agentUrlError,
chatError,
selectedProvider,
getActionForMessage,
liked,
onClickLike,
@@ -224,15 +223,8 @@ export const Chat = () => {
onDismissJtbdPopup={onDismissJtbdPopup}
/>
)}
{agentUrlError && (
<ChatError
error={agentUrlError}
providerType={selectedProvider?.type}
/>
)}
{chatError && (
<ChatError error={chatError} providerType={selectedProvider?.type} />
)}
{agentUrlError && <ChatError error={agentUrlError} />}
{chatError && <ChatError error={chatError} />}
</main>
<ChatFooter

View File

@@ -2,6 +2,11 @@ import { AlertCircle, RefreshCw } from 'lucide-react'
import type { FC } from 'react'
// import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import {
KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT,
KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
// --- Commented out for Kimi partnership launch (restore after) ---
// const SURVEY_DIRECTIONS = [
@@ -19,24 +24,20 @@ import { Button } from '@/components/ui/button'
interface ChatErrorProps {
error: Error
onRetry?: () => void
providerType?: string
}
function parseErrorMessage(
message: string,
providerType?: string,
): {
function parseErrorMessage(message: string): {
text: string
url?: string
isRateLimit?: boolean
isCreditsExhausted?: boolean
isConnectionError?: boolean
} {
const isBrowserosProvider = providerType === 'browseros'
// All chat requests go through the local BrowserOS agent server, so any
// fetch failure is always a local connection issue.
if (message.includes('Failed to fetch') || message.includes('fetch failed')) {
// Detect MCP server connection failures
if (
(message.includes('Failed to fetch') || message.includes('fetch failed')) &&
message.includes('127.0.0.1')
) {
return {
text: 'Unable to connect to BrowserOS agent. Follow below instructions.',
url: 'https://docs.browseros.com/troubleshooting/connection-issues',
@@ -44,12 +45,10 @@ function parseErrorMessage(
}
}
// Detect credit exhaustion from gateway (BrowserOS provider only)
// Detect credit exhaustion from gateway
if (
isBrowserosProvider &&
(message.includes('CREDITS_EXHAUSTED') ||
message.includes('Credits exhausted') ||
message.includes('Daily credits exhausted'))
message.includes('CREDITS_EXHAUSTED') ||
message.includes('Daily credits exhausted')
) {
return {
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
@@ -59,11 +58,8 @@ function parseErrorMessage(
}
}
// Detect BrowserOS rate limit (BrowserOS provider only)
if (
isBrowserosProvider &&
message.includes('BrowserOS LLM daily limit reached')
) {
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
if (message.includes('BrowserOS LLM daily limit reached')) {
return {
text: 'Add your own API key for unlimited usage.',
url: 'https://dub.sh/browseros-usage-limit',
@@ -87,13 +83,9 @@ function parseErrorMessage(
return { text: text || 'An unexpected error occurred', url }
}
export const ChatError: FC<ChatErrorProps> = ({
error,
onRetry,
providerType,
}) => {
export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
parseErrorMessage(error.message, providerType)
parseErrorMessage(error.message)
// --- Commented out for Kimi partnership launch (restore after) ---
// const surveyUrl = useMemo(
@@ -159,15 +151,31 @@ export const ChatError: FC<ChatErrorProps> = ({
View Usage & Billing
</a>
)}
{isRateLimit && providerType === 'browseros' && (
<a
href="/app.html#/settings/ai"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 px-3 py-1.5 font-medium text-[var(--accent-orange)] text-xs transition-colors hover:bg-[var(--accent-orange)]/20"
>
Add your own provider for unlimited usage
</a>
{isRateLimit && !isCreditsExhausted && (
<div className="flex flex-col items-center gap-1">
<p className="text-muted-foreground text-xs">
{/* biome-ignore lint/a11y/useValidAnchor: link with click tracking */}
<a
href="https://docs.browseros.com/features/bring-your-own-llm#kimi-k2-5-%E2%80%94-in-partnership-with-moonshot-ai"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
onClick={() => track(KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT)}
>
Learn how to get a Kimi API key
</a>
{' or '}
<a
href="https://platform.moonshot.ai"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
onClick={() => track(KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT)}
>
get your API key
</a>
</p>
</div>
)}
{onRetry && (
<Button

View File

@@ -76,6 +76,8 @@ export interface ChatSessionOptions {
isIntegrationsSynced?: boolean
}
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
export const useChatSession = (options?: ChatSessionOptions) => {
const {
selectedLlmProviderRef,
@@ -342,8 +344,12 @@ export const useChatSession = (options?: ChatSessionOptions) => {
reasoningEffort: provider?.reasoningEffort,
reasoningSummary: provider?.reasoningSummary,
browserContext,
origin: options?.origin ?? 'sidepanel',
userSystemPrompt: personalizationRef.current,
userSystemPrompt:
options?.origin === 'newtab'
? [personalizationRef.current, NEWTAB_SYSTEM_PROMPT]
.filter(Boolean)
.join('\n\n')
: personalizationRef.current,
userWorkingDir: workingDirRef.current,
supportsImages: provider?.supportsImages,
previousConversation,

View File

@@ -1,23 +0,0 @@
import { sentry } from '../sentry/sentry'
import { posthog } from './posthog'
/**
* Identify the current user across all analytics and error tracking services.
* Call this when the user logs in or when a stored session is restored.
*/
export function identify(user: { id: string; email?: string; name?: string }) {
sentry.setUser({ id: user.id, email: user.email })
posthog.identify(user.id, {
email: user.email,
name: user.name,
})
}
/**
* Clear user identity across all services.
* Call this when the user logs out.
*/
export function resetIdentity() {
sentry.setUser(null)
posthog.reset()
}

View File

@@ -1,6 +1,5 @@
import type { FC, PropsWithChildren } from 'react'
import { useEffect } from 'react'
import { identify, resetIdentity } from '@/lib/analytics/identify'
import { useSession } from './auth-client'
import { useSessionInfo } from './sessionStorage'
@@ -15,16 +14,6 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
session: data?.session,
user: data?.user,
})
if (data?.user?.id) {
identify({
id: data.user.id,
email: data.user.email,
name: data.user.name || undefined,
})
} else {
resetIdentity()
}
}
}, [data, isPending])

View File

@@ -67,10 +67,6 @@ export const QWEN_CODE_OAUTH_DISCONNECTED_EVENT =
/** @public */
export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added'
/** @public */
export const MCP_PROMO_BANNER_CLICKED_EVENT =
'settings.mcp_promo_banner.clicked'
/** @public */
export const MCP_EXTERNAL_ACCESS_ENABLED_EVENT =
'settings.mcp_external_access.enabled'

View File

@@ -1,10 +1,7 @@
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { QueryClient } from '@tanstack/react-query'
import {
type AsyncStorage,
PersistQueryClientProvider,
} from '@tanstack/react-query-persist-client'
import { del, get, set } from 'idb-keyval'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import localforage from 'localforage'
import type { FC, ReactNode } from 'react'
const queryClient = new QueryClient({
@@ -15,14 +12,8 @@ const queryClient = new QueryClient({
},
})
const idbStorage: AsyncStorage<string> = {
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
setItem: (key: string, value: string) => set(key, value),
removeItem: (key: string) => del(key),
}
const asyncStoragePersister = createAsyncStoragePersister({
storage: idbStorage,
storage: localforage,
})
export const QueryProvider: FC<{ children: ReactNode }> = ({ children }) => {

View File

@@ -1,35 +0,0 @@
import data from './models-dev-data.json'
export interface ModelsDevModel {
id: string
name: string
contextWindow: number
maxOutput: number
supportsImages: boolean
supportsReasoning: boolean
supportsToolCall: boolean
inputCost?: number
outputCost?: number
}
export interface ModelsDevProvider {
name: string
api?: string
doc: string
models: ModelsDevModel[]
}
const modelsDevData: Record<string, ModelsDevProvider> = data as Record<
string,
ModelsDevProvider
>
export function getModelsDevProvider(
providerId: string,
): ModelsDevProvider | undefined {
return modelsDevData[providerId]
}
export function getModelsDevModels(providerId: string): ModelsDevModel[] {
return modelsDevData[providerId]?.models ?? []
}

View File

@@ -1,4 +1,3 @@
import { getModelsDevProvider } from './models-dev'
import type { ProviderType } from './types'
/**
@@ -16,30 +15,6 @@ export interface ProviderTemplate {
apiKeyUrl?: string
}
function enrichTemplate(
providerId: ProviderType,
overrides: {
defaultModelId: string
defaultBaseUrl?: string
apiKeyUrl?: string
setupGuideUrl?: string
},
): ProviderTemplate {
const provider = getModelsDevProvider(providerId)
const model = provider?.models.find((m) => m.id === overrides.defaultModelId)
return {
id: providerId,
name: provider?.name ?? providerId,
defaultBaseUrl: overrides.defaultBaseUrl ?? provider?.api ?? '',
defaultModelId: overrides.defaultModelId,
supportsImages: model?.supportsImages ?? true,
contextWindow: model?.contextWindow ?? 128000,
...(overrides.apiKeyUrl && { apiKeyUrl: overrides.apiKeyUrl }),
...(overrides.setupGuideUrl && { setupGuideUrl: overrides.setupGuideUrl }),
}
}
/**
* Available provider templates for quick setup
* @public
@@ -82,12 +57,17 @@ export const providerTemplates: ProviderTemplate[] = [
apiKeyUrl: 'https://platform.moonshot.ai/console/api-keys',
setupGuideUrl: 'https://platform.moonshot.ai/console/api-keys',
},
enrichTemplate('openai', {
defaultModelId: 'gpt-5',
{
id: 'openai',
name: 'OpenAI',
defaultBaseUrl: 'https://api.openai.com/v1',
defaultModelId: 'gpt-4',
supportsImages: true,
contextWindow: 128000,
apiKeyUrl: 'https://platform.openai.com/api-keys',
setupGuideUrl:
'https://docs.browseros.com/features/bring-your-own-llm#openai',
}),
},
{
id: 'openai-compatible',
name: 'OpenAI Compatible',
@@ -96,18 +76,28 @@ export const providerTemplates: ProviderTemplate[] = [
supportsImages: true,
contextWindow: 128000,
},
enrichTemplate('anthropic', {
defaultModelId: 'claude-sonnet-4-6',
{
id: 'anthropic',
name: 'Anthropic',
defaultBaseUrl: 'https://api.anthropic.com/v1',
defaultModelId: 'claude-3-5-sonnet-20241022',
supportsImages: true,
contextWindow: 200000,
apiKeyUrl: 'https://console.anthropic.com/settings/keys',
setupGuideUrl:
'https://docs.browseros.com/features/bring-your-own-llm#claude',
}),
enrichTemplate('google', {
defaultModelId: 'gemini-2.5-flash',
},
{
id: 'google',
name: 'Gemini',
defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta',
defaultModelId: 'gemini-1.5-pro',
supportsImages: true,
contextWindow: 1000000,
apiKeyUrl: 'https://aistudio.google.com/app/apikey',
setupGuideUrl:
'https://docs.browseros.com/features/bring-your-own-llm#gemini',
}),
},
{
id: 'ollama',
name: 'Ollama',
@@ -118,28 +108,47 @@ export const providerTemplates: ProviderTemplate[] = [
setupGuideUrl:
'https://docs.browseros.com/features/bring-your-own-llm#ollama',
},
enrichTemplate('openrouter', {
defaultModelId: 'anthropic/claude-sonnet-4.5',
{
id: 'openrouter',
name: 'OpenRouter',
defaultBaseUrl: 'https://openrouter.ai/api/v1',
defaultModelId: 'openai/gpt-4-turbo',
supportsImages: true,
contextWindow: 128000,
apiKeyUrl: 'https://openrouter.ai/keys',
setupGuideUrl:
'https://docs.browseros.com/features/bring-your-own-llm#openrouter',
}),
enrichTemplate('lmstudio', {
defaultModelId: 'openai/gpt-oss-20b',
},
{
id: 'lmstudio',
name: 'LM Studio',
defaultBaseUrl: 'http://localhost:1234/v1',
defaultModelId: 'local-model',
supportsImages: false,
contextWindow: 32000,
setupGuideUrl:
'https://docs.browseros.com/features/bring-your-own-llm#lmstudio',
}),
enrichTemplate('azure', {
},
{
id: 'azure',
name: 'Azure',
defaultBaseUrl: '',
defaultModelId: '',
supportsImages: true,
contextWindow: 128000,
apiKeyUrl:
'https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI',
}),
enrichTemplate('bedrock', {
defaultModelId: 'anthropic.claude-sonnet-4-6',
},
{
id: 'bedrock',
name: 'AWS Bedrock',
defaultBaseUrl: '',
defaultModelId: '',
supportsImages: true,
contextWindow: 200000,
setupGuideUrl:
'https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started.html',
}),
},
]
/**

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { track } from '@/lib/metrics/track'
import {
@@ -20,18 +20,10 @@ export interface OAuthProviderFlowConfig {
clientAuth?: ClientAuthConfig
}
export interface PendingDeviceCode {
userCode: string
providerName: string
verificationUri: string
}
interface OAuthProviderFlowReturn {
status: { authenticated: boolean; email?: string } | null
disconnect: () => Promise<void>
startOAuthFlow: (agentServerUrl: string | undefined) => Promise<void>
pendingDeviceCode: PendingDeviceCode | null
clearDeviceCode: () => void
}
export function useOAuthProviderFlow(
@@ -43,8 +35,6 @@ export function useOAuthProviderFlow(
config.providerType,
)
const flowStartedRef = useRef(false)
const [pendingDeviceCode, setPendingDeviceCode] =
useState<PendingDeviceCode | null>(null)
// Auto-create provider when OAuth completes
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
@@ -67,7 +57,6 @@ export function useOAuthProviderFlow(
createdAt: now,
updatedAt: now,
})
setPendingDeviceCode(null)
track(config.completedEvent, { email: status.email })
toast.success(`${config.displayName} Connected`, {
description: status.email
@@ -115,10 +104,9 @@ export function useOAuthProviderFlow(
deviceData.verification_uri_complete ?? deviceData.verification_uri
window.open(verificationUri, '_blank')
track(config.startedEvent)
setPendingDeviceCode({
userCode: deviceData.user_code,
providerName: config.displayName,
verificationUri,
toast.info(`Enter code: ${deviceData.user_code}`, {
description: `Paste this code on the ${config.displayName} page that just opened.`,
duration: 60_000,
})
startTokenPolling(auth, deviceData, codeVerifier, async (token) => {
@@ -154,10 +142,9 @@ export function useOAuthProviderFlow(
window.open(data.verificationUri, '_blank')
startPolling()
track(config.startedEvent)
setPendingDeviceCode({
userCode: data.userCode,
providerName: config.displayName,
verificationUri: data.verificationUri,
toast.info(`Enter code: ${data.userCode}`, {
description: `Paste this code on the ${config.displayName} page that just opened.`,
duration: 60_000,
})
return
}
@@ -176,7 +163,5 @@ export function useOAuthProviderFlow(
status,
disconnect,
startOAuthFlow,
pendingDeviceCode,
clearDeviceCode: () => setPendingDeviceCode(null),
}
}

View File

@@ -22,13 +22,6 @@ export const scheduledJobRunStorage = storage.defineItem<ScheduledJobRun[]>(
},
)
export const pendingDeletionStorage = storage.defineItem<string[]>(
'local:scheduledJobsPendingDeletion',
{
fallback: [],
},
)
export function useScheduledJobs() {
const [jobs, setJobs] = useState<ScheduledJob[]>([])
@@ -61,11 +54,6 @@ export function useScheduledJobs() {
const removeJob = async (id: string) => {
await chrome.alarms.clear(getAlarmName(id))
const pending = (await pendingDeletionStorage.getValue()) ?? []
if (!pending.includes(id)) {
await pendingDeletionStorage.setValue([...pending, id])
}
const currentJobs = (await scheduledJobStorage.getValue()) ?? []
await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id))

View File

@@ -5,11 +5,10 @@ import { sentry } from '@/lib/sentry/sentry'
import { createAlarmFromJob } from './createAlarmFromJob'
import {
CreateScheduledJobDocument,
DeleteScheduledJobDocument,
GetScheduledJobsByProfileIdDocument,
UpdateScheduledJobDocument,
} from './graphql/syncSchedulesDocument'
import { pendingDeletionStorage, scheduledJobStorage } from './scheduleStorage'
import { scheduledJobStorage } from './scheduleStorage'
import type { ScheduledJob } from './scheduleTypes'
type RemoteScheduledJob = {
@@ -100,32 +99,6 @@ export async function syncSchedulesToBackend(
}
}
const pendingDeletions = new Set(
(await pendingDeletionStorage.getValue()) ?? [],
)
const resolvedDeletions = new Set<string>()
for (const rowId of pendingDeletions) {
if (remoteJobs.has(rowId)) {
try {
await execute(DeleteScheduledJobDocument, { rowId })
remoteJobs.delete(rowId)
resolvedDeletions.add(rowId)
} catch (error) {
sentry.captureException(error, {
extra: { jobId: rowId, context: 'sync-pending-deletion' },
})
}
} else {
resolvedDeletions.add(rowId)
}
}
const latestPending = (await pendingDeletionStorage.getValue()) ?? []
await pendingDeletionStorage.setValue(
latestPending.filter((id) => !resolvedDeletions.has(id)),
)
const localJobsMap = new Map(localJobs.map((j) => [j.id, j]))
const jobsToAddLocally: ScheduledJob[] = []
const jobsToUpdateLocally: ScheduledJob[] = []

View File

@@ -1,77 +0,0 @@
/**
* Sanitize Sentry event data by redacting values at keys that match known
* sensitive patterns. Used in `beforeSend` to prevent credentials from
* leaking into error reports.
*/
const REDACTED = '[REDACTED]'
const SENSITIVE_KEY_PATTERNS = [
'apikey',
'api_key',
'accesskeyid',
'secretaccesskey',
'sessiontoken',
'authorization',
'token',
'password',
'secret',
'credential',
]
function isSensitiveKey(key: string): boolean {
const lower = key.toLowerCase()
return SENSITIVE_KEY_PATTERNS.some((p) => lower.includes(p))
}
function sanitize<T>(obj: T): T {
if (obj === null || obj === undefined) return obj
if (
typeof obj === 'string' ||
typeof obj === 'number' ||
typeof obj === 'boolean'
) {
return obj
}
if (Array.isArray(obj)) {
return obj.map(sanitize) as T
}
if (typeof obj === 'object') {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = isSensitiveKey(key) ? REDACTED : sanitize(value)
}
return result as T
}
return obj
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Sentry event type varies by SDK
export function sanitizeEvent<E>(event: E): E {
const e = event as Record<string, any>
if (Array.isArray(e.breadcrumbs)) {
e.breadcrumbs = e.breadcrumbs.map((b: Record<string, unknown>) => ({
...b,
data: b.data ? sanitize(b.data) : b.data,
}))
}
if (e.contexts) {
e.contexts = sanitize(e.contexts)
}
if (e.extra) {
e.extra = sanitize(e.extra)
}
for (const value of e.exception?.values ?? []) {
for (const frame of value.stacktrace?.frames ?? []) {
if (frame.vars) {
frame.vars = sanitize(frame.vars)
}
}
}
return event
}

View File

@@ -1,21 +1,6 @@
import * as Sentry from '@sentry/react'
import { getBrowserOSAdapter } from '../browseros/adapter'
import { env } from '../env'
import { sanitizeEvent } from './sanitize'
/** Errors that are expected during normal operation and should not be reported */
const SUPPRESSED_ERRORS = ['The browser is shutting down', 'No current window']
function getExtensionPage(): string {
try {
const url = new URL(location.href)
// Extract the entry point name from the extension URL pathname
// e.g. chrome-extension://<id>/sidepanel.html -> sidepanel
return url.pathname.replace(/^\//, '').replace(/\.html$/, '') || 'unknown'
} catch {
return 'unknown'
}
}
if (env.VITE_PUBLIC_SENTRY_DSN) {
Sentry.init({
@@ -25,29 +10,6 @@ if (env.VITE_PUBLIC_SENTRY_DSN) {
sendDefaultPii: true,
environment: env.PROD ? 'production' : 'development',
release: chrome.runtime.getManifest().version,
beforeSend(event) {
const message = event.exception?.values?.[0]?.value ?? ''
if (SUPPRESSED_ERRORS.some((s) => message.includes(s))) {
return null
}
event.tags = {
...event.tags,
extensionPage: getExtensionPage(),
}
return sanitizeEvent(event)
},
integrations: [
Sentry.breadcrumbsIntegration({
console: true,
dom: true,
fetch: true,
xhr: true,
}),
],
})
;(async () => {

View File

@@ -44,9 +44,9 @@
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@sentry/react": "^10.31.0",
"@sentry/vite-plugin": "^4.6.1",
"@tanstack/query-async-storage-persister": "^5.95.2",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-persist-client": "^5.95.2",
"@tanstack/query-async-storage-persister": "^5.90.21",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query-persist-client": "^5.90.21",
"@types/cytoscape": "^3.31.0",
"@types/dompurify": "^3.2.0",
"@webext-core/messaging": "^2.3.0",
@@ -69,8 +69,8 @@
"eventsource-parser": "^3.0.6",
"graphql": "^16.12.0",
"hono": "^4.12.3",
"idb-keyval": "^6.2.2",
"klavis": "^2.15.0",
"localforage": "^1.10.0",
"lucide-react": "^0.562.0",
"motion": "^12.23.24",
"nanoid": "^5.1.6",

View File

@@ -54,18 +54,12 @@ export default defineConfig({
},
permissions: [
'topSites',
'storage',
'unlimitedStorage',
'scripting',
'tabs',
'tabGroups',
'storage',
'sidePanel',
'bookmarks',
'history',
'browserOS',
'alarms',
'webNavigation',
'downloads',
],
host_permissions: [
'http://127.0.0.1/*',

View File

@@ -1,7 +0,0 @@
# Production upload env for CLI installer scripts
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=browseros
R2_UPLOAD_PREFIX=cli

View File

@@ -1,2 +1 @@
browseros-cli
dist

View File

@@ -1,50 +0,0 @@
version: 2
project_name: browseros-cli
monorepo:
tag_prefix: browseros-cli-
builds:
- main: .
binary: browseros-cli
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Version }}
targets:
- darwin_amd64
- darwin_arm64
- linux_amd64
- linux_arm64
- windows_amd64
- windows_arm64
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- "none*"
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
release:
github:
owner: browseros-ai
name: BrowserOS
prerelease: auto
name_template: "browseros-cli v{{ .Version }}"

View File

@@ -1 +0,0 @@
# BrowserOS CLI

View File

@@ -18,9 +18,3 @@ vet:
test:
go test -tags integration -v -timeout 120s ./...
release-dry:
goreleaser release --snapshot --clean
release:
goreleaser release --clean

View File

@@ -1,58 +1,25 @@
# browseros-cli
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](../../../../LICENSE)
Command-line interface for controlling BrowserOS via MCP. Talks to the BrowserOS MCP server over JSON-RPC 2.0 / StreamableHTTP.
Command-line interface for controlling BrowserOS — launch and automate the browser from the terminal or from AI coding agents like Claude Code and Gemini CLI.
Communicates with the BrowserOS MCP server over JSON-RPC 2.0 / StreamableHTTP. All 53+ MCP tools are mapped to CLI commands.
## Install
### macOS / Linux
```bash
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
```
### Windows
```powershell
irm https://cdn.browseros.com/cli/install.ps1 | iex
```
### Build from Source
## Setup
Requires Go 1.25+.
```bash
make # Build binary
make install # Install to $GOPATH/bin
# Build
make
# First run — configure server connection
./browseros-cli init
```
## Quick Start
The `init` command prompts for your MCP server URL. Find it in:
**BrowserOS → Settings → BrowserOS MCP → Server URL**
```bash
# If BrowserOS is not installed yet
browseros-cli install # downloads BrowserOS for your platform
The port varies per installation (e.g., `http://127.0.0.1:9004/mcp`).
# If BrowserOS is installed but not running
browseros-cli launch # opens BrowserOS, waits for server
# Configure the CLI (auto-discovers running BrowserOS)
browseros-cli init --auto # detects server URL and saves config
# Verify connection
browseros-cli health
```
### Other init modes
```bash
browseros-cli init <url> # non-interactive — pass URL directly
browseros-cli init # interactive — prompts for URL
```
Config is saved to `~/.config/browseros-cli/config.yaml`. The CLI also auto-discovers the server from `~/.browseros/server.json` (written by BrowserOS on startup).
Config is saved to `~/.config/browseros-cli/config.yaml`.
## Usage
@@ -100,12 +67,6 @@ browseros-cli history recent
browseros-cli group list
```
## Use as MCP Server
BrowserOS exposes an MCP server that AI coding agents can connect to directly. The CLI is the easiest way to verify the connection and interact with tools from the terminal.
To connect Claude Code, Gemini CLI, or any MCP client, see the [MCP setup guide](https://docs.browseros.com/features/use-with-claude-code).
## Global Flags
| Flag | Env Var | Description |
@@ -116,9 +77,9 @@ To connect Claude Code, Gemini CLI, or any MCP client, see the [MCP setup guide]
| `--debug` | `BOS_DEBUG=1` | Debug output |
| `--timeout, -t` | | Request timeout (default: 2m) |
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > `~/.browseros/server.json` > config file
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > config file
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init`.
If no server URL is configured, the CLI exits with setup instructions instead of assuming a localhost port.
## Testing
@@ -169,9 +130,7 @@ apps/cli/
│ └── config.go # Config file (~/.config/browseros-cli/config.yaml)
├── cmd/
│ ├── root.go # Root command, global flags
│ ├── init.go # Server URL configuration (URL arg, --auto, interactive)
│ ├── install.go # install (download BrowserOS for current platform)
│ ├── launch.go # launch (find and start BrowserOS, wait for server)
│ ├── init.go # Server URL configuration
│ ├── open.go # open (new_page / new_hidden_page)
│ ├── nav.go # nav, back, forward, reload
│ ├── pages.go # pages, active, close
@@ -204,8 +163,4 @@ The CLI communicates with BrowserOS via two HTTP POST requests per command:
1. `initialize` — MCP handshake
2. `tools/call` — execute the actual tool
## Links
- [Documentation](https://docs.browseros.com)
- [MCP Setup Guide](https://docs.browseros.com/features/use-with-claude-code)
- [Changelog](./CHANGELOG.md)
All 54 MCP tools are mapped to CLI commands.

View File

@@ -17,10 +17,8 @@ import (
)
func init() {
var autoDiscover bool
cmd := &cobra.Command{
Use: "init [url]",
Use: "init",
Short: "Configure the BrowserOS server connection",
Long: `Set up the CLI by providing the MCP server URL from BrowserOS.
@@ -28,59 +26,33 @@ Open BrowserOS → Settings → BrowserOS MCP to find your Server URL.
The URL looks like: http://127.0.0.1:9004/mcp
The port varies per installation, so this step is required on first use.
Run again if your port changes.
Three modes:
browseros-cli init <url> Non-interactive, use the provided URL
browseros-cli init --auto Auto-discover from ~/.browseros/server.json
browseros-cli init Interactive prompt`,
Run again if your port changes.`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.MaximumNArgs(1),
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
dim := color.New(color.Faint)
var input string
fmt.Println()
bold.Println("BrowserOS CLI Setup")
fmt.Println()
fmt.Println("Open BrowserOS → Settings → BrowserOS MCP")
fmt.Println("Copy the Server URL shown there.")
fmt.Println()
dim.Println("It looks like: http://127.0.0.1:9004/mcp")
fmt.Println()
switch {
case len(args) == 1:
// Non-interactive: URL provided as argument
input = args[0]
reader := bufio.NewReader(os.Stdin)
fmt.Print("Server URL: ")
input, err := reader.ReadString('\n')
if err != nil {
output.Error("failed to read input", 1)
}
input = strings.TrimSpace(input)
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()
fmt.Println("Open BrowserOS → Settings → BrowserOS MCP")
fmt.Println("Copy the Server URL shown there.")
fmt.Println()
dim.Println("It looks like: http://127.0.0.1:9004/mcp")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
fmt.Print("Server URL: ")
line, err := reader.ReadString('\n')
if err != nil {
output.Error("failed to read input", 1)
}
input = strings.TrimSpace(line)
if input == "" {
output.Error("no URL provided", 1)
}
if input == "" {
output.Error("no URL provided", 1)
}
baseURL := normalizeServerURL(input)
@@ -116,6 +88,5 @@ Three modes:
},
}
cmd.Flags().BoolVar(&autoDiscover, "auto", false, "Auto-discover server URL from ~/.browseros/server.json")
rootCmd.AddCommand(cmd)
}

View File

@@ -1,247 +0,0 @@
package cmd
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"browseros-cli/output"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "install",
Short: "Download and install BrowserOS for the current platform",
Long: `Download BrowserOS for your platform and start the installation.
macOS: Downloads .dmg, mounts it, and copies BrowserOS to /Applications
Windows: Downloads installer .exe and launches it
Linux: Downloads AppImage (or .deb with --deb flag)
After installation:
browseros-cli launch # start BrowserOS
browseros-cli init --auto # configure the CLI`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dir, _ := cmd.Flags().GetString("dir")
deb, _ := cmd.Flags().GetBool("deb")
if deb && runtime.GOOS != "linux" {
output.Error("--deb is only available on Linux", 1)
}
downloadURL, filename := resolveDownload(deb)
destPath := filepath.Join(dir, filename)
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
dim := color.New(color.Faint)
bold.Printf("Downloading BrowserOS for %s...\n", platformDisplayName())
dim.Printf(" %s\n", downloadURL)
fmt.Println()
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(downloadURL)
if err != nil {
output.Errorf(1, "download failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
output.Errorf(1, "download failed: HTTP %d", resp.StatusCode)
}
file, err := os.Create(destPath)
if err != nil {
output.Errorf(1, "create file: %v", err)
}
written, err := io.Copy(file, resp.Body)
file.Close()
if err != nil {
os.Remove(destPath)
output.Errorf(1, "download interrupted: %v", err)
}
green.Printf("Downloaded %s (%.1f MB)\n", filename, float64(written)/(1024*1024))
fmt.Println()
runPostInstall(destPath, deb, dim)
fmt.Println()
bold.Println("Next steps:")
dim.Println(" browseros-cli launch # start BrowserOS")
dim.Println(" browseros-cli init --auto # configure the CLI")
},
}
cmd.Flags().String("dir", ".", "Directory to download the installer to")
cmd.Flags().Bool("deb", false, "Download .deb package instead of AppImage (Linux only)")
rootCmd.AddCommand(cmd)
}
func resolveDownload(deb bool) (url, filename string) {
switch runtime.GOOS {
case "darwin":
return "https://files.browseros.com/download/BrowserOS.dmg", "BrowserOS.dmg"
case "windows":
return "https://files.browseros.com/download/BrowserOS_installer.exe", "BrowserOS_installer.exe"
case "linux":
if deb {
return "https://cdn.browseros.com/download/BrowserOS.deb", "BrowserOS.deb"
}
return "https://files.browseros.com/download/BrowserOS.AppImage", "BrowserOS.AppImage"
default:
output.Errorf(1, "unsupported platform: %s/%s\n Download manually from https://browseros.com", runtime.GOOS, runtime.GOARCH)
return "", ""
}
}
func platformDisplayName() string {
switch runtime.GOOS {
case "darwin":
return "macOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}
func runPostInstall(path string, deb bool, dim *color.Color) {
switch runtime.GOOS {
case "darwin":
installMacOS(path, dim)
case "linux":
if deb {
dim.Println("Install the .deb package:")
fmt.Printf(" sudo dpkg -i %s\n", path)
} else {
os.Chmod(path, 0755)
dim.Printf("AppImage is ready to run: ./%s\n", filepath.Base(path))
}
case "windows":
fmt.Println("Launching installer...")
if err := exec.Command("cmd", "/c", "start", "", path).Run(); err != nil {
dim.Printf("Could not launch installer automatically. Run: %s\n", path)
} else {
dim.Println("Follow the installer prompts to complete setup.")
}
}
}
// installMacOS mounts the DMG and copies BrowserOS.app to /Applications.
func installMacOS(dmgPath string, dim *color.Color) {
fmt.Println("Mounting disk image...")
mountOut, err := exec.Command("hdiutil", "attach", dmgPath, "-nobrowse").Output()
if err != nil {
dim.Println("Could not mount DMG automatically.")
dim.Printf(" Open it manually: open %s\n", dmgPath)
return
}
// Find the mount point (last field of last line of hdiutil output)
mountPoint := ""
for _, line := range splitLines(string(mountOut)) {
fields := splitTabs(line)
if len(fields) > 0 {
mountPoint = fields[len(fields)-1]
}
}
if mountPoint == "" {
dim.Println("DMG mounted but could not determine mount point.")
dim.Printf(" Open it manually: open %s\n", dmgPath)
return
}
// Look for BrowserOS.app in the mounted volume
appSrc := filepath.Join(mountPoint, "BrowserOS.app")
if _, err := os.Stat(appSrc); err != nil {
dim.Printf("DMG mounted at %s but BrowserOS.app not found inside.\n", mountPoint)
dim.Printf(" Check the volume manually: open %s\n", mountPoint)
exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run()
return
}
appDest := "/Applications/BrowserOS.app"
fmt.Printf("Installing to %s...\n", appDest)
// Remove existing installation if present
os.RemoveAll(appDest)
// Copy using cp -R (preserves code signatures, symlinks, etc.)
if err := exec.Command("cp", "-R", appSrc, appDest).Run(); err != nil {
dim.Printf("Could not copy to /Applications (may need sudo).\n")
dim.Printf(" Try: sudo cp -R \"%s\" /Applications/\n", appSrc)
exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run()
return
}
// Unmount
exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run()
// Clean up DMG
os.Remove(dmgPath)
fmt.Println("BrowserOS installed to /Applications/BrowserOS.app")
}
func splitLines(s string) []string {
var lines []string
for _, line := range filepath.SplitList(s) {
lines = append(lines, line)
}
// filepath.SplitList uses : on unix, not newlines — use manual split
result := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
line := s[start:i]
if len(line) > 0 {
result = append(result, line)
}
start = i + 1
}
}
if start < len(s) {
result = append(result, s[start:])
}
return result
}
func splitTabs(s string) []string {
result := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\t' {
field := s[start:i]
if len(field) > 0 {
result = append(result, field)
}
start = i + 1
}
}
if start < len(s) {
field := s[start:]
if len(field) > 0 {
result = append(result, field)
}
}
return result
}

View File

@@ -1,287 +0,0 @@
package cmd
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"browseros-cli/output"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
// macOS bundle identifier — verified from BrowserOS.app/Contents/Info.plist
const browserOSBundleID = "com.browseros.BrowserOS"
func init() {
cmd := &cobra.Command{
Use: "launch",
Short: "Launch the BrowserOS application",
Long: `Find and launch the BrowserOS application.
Uses platform-native detection to find BrowserOS, launches it,
and waits for the server to become ready.
If BrowserOS is already running, reports the server URL.`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
green := color.New(color.FgGreen)
dim := color.New(color.Faint)
waitSecs, _ := cmd.Flags().GetInt("wait")
if url := probeRunningServer(); url != "" {
green.Printf("BrowserOS is already running at %s\n", url)
return
}
if !isBrowserOSInstalled() {
output.Error("BrowserOS is not installed.\n\n"+
" To install: browseros-cli install", 1)
}
fmt.Println("Launching BrowserOS...")
if err := startBrowserOS(); err != nil {
output.Errorf(1, "failed to launch: %v", err)
}
fmt.Print("Waiting for server")
url, ok := waitForServer(time.Duration(waitSecs) * time.Second)
fmt.Println()
if !ok {
output.Error("BrowserOS launched but server didn't respond within "+
fmt.Sprintf("%d seconds.\n", waitSecs)+
" Check if BrowserOS is fully loaded, then retry.", 1)
}
green.Printf("BrowserOS is ready at %s\n", url)
fmt.Println()
dim.Println("Next: browseros-cli init --auto")
},
}
cmd.Flags().Int("wait", 30, "Seconds to wait for server to start")
rootCmd.AddCommand(cmd)
}
// ---------------------------------------------------------------------------
// Server probing
// ---------------------------------------------------------------------------
// probeRunningServer checks server.json, config, and common ports for a running server.
func probeRunningServer() string {
check := func(baseURL string) bool {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(baseURL + "/health")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
// 1. server.json — written by BrowserOS on startup with the actual port
if url := loadBrowserosServerURL(); url != "" && check(url) {
return url
}
// 2. Saved config / env var
if url := defaultServerURL(); url != "" && check(url) {
return url
}
// 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 check(url) {
return url
}
}
return ""
}
// ---------------------------------------------------------------------------
// Platform-native installation detection
// ---------------------------------------------------------------------------
// isBrowserOSInstalled checks if BrowserOS is installed using platform-native methods.
//
// 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)
func isBrowserOSInstalled() bool {
switch runtime.GOOS {
case "darwin":
// open -Ra checks if Launch Services knows about the app without launching it.
// Works regardless of where the app is installed.
return exec.Command("open", "-Ra", "BrowserOS").Run() == nil
case "linux":
// .deb install puts `browseros` in /usr/bin/
if _, err := exec.LookPath("browseros"); err == nil {
return true
}
// .deb also creates browseros.desktop
for _, dir := range []string{
"/usr/share/applications",
filepath.Join(userHomeDir(), ".local/share/applications"),
} {
if _, err := os.Stat(filepath.Join(dir, "browseros.desktop")); err == nil {
return true
}
}
// AppImage — user may have it in ~/Downloads, ~/Applications, etc.
return findLinuxAppImage() != ""
case "windows":
// Chromium per-user install: %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe
if exePath := windowsBrowserOSExe(); exePath != "" {
if _, err := os.Stat(exePath); err == nil {
return true
}
}
// Fallback: check uninstall registry (per-user install uses HKCU)
for _, root := range []string{"HKCU", "HKLM"} {
key := root + `\Software\Microsoft\Windows\CurrentVersion\Uninstall\BrowserOS`
if exec.Command("reg", "query", key, "/v", "DisplayName").Run() == nil {
return true
}
}
return false
}
return false
}
// ---------------------------------------------------------------------------
// Platform-native launch
// ---------------------------------------------------------------------------
// startBrowserOS launches BrowserOS using platform-native methods.
//
// macOS: `open -b com.browseros.BrowserOS` — launches by bundle ID
// Linux: runs `browseros` binary or AppImage directly
// Windows: runs BrowserOS.exe from the known install path
func startBrowserOS() error {
switch runtime.GOOS {
case "darwin":
// Launch by bundle ID via Launch Services — no hardcoded paths needed.
return exec.Command("open", "-b", browserOSBundleID).Run()
case "linux":
// .deb install: browseros is in PATH
if p, err := exec.LookPath("browseros"); err == nil {
return startDetached(p)
}
// AppImage: run it directly
if appImage := findLinuxAppImage(); appImage != "" {
return startDetached(appImage)
}
// .desktop file: use gtk-launch (not xdg-open, which opens by MIME type)
if _, err := exec.LookPath("gtk-launch"); err == nil {
return exec.Command("gtk-launch", "browseros").Run()
}
return fmt.Errorf("BrowserOS found but could not determine how to launch it")
case "windows":
if exePath := windowsBrowserOSExe(); exePath != "" {
if _, err := os.Stat(exePath); err == nil {
return startDetached(exePath)
}
}
return fmt.Errorf("BrowserOS.exe not found at expected location")
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// startDetached starts a process in the background without inheriting stdio.
func startDetached(path string, args ...string) error {
cmd := exec.Command(path, args...)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
return cmd.Start()
}
// windowsBrowserOSExe returns the expected BrowserOS.exe path on Windows.
// Chromium per-user installs go to %LOCALAPPDATA%\<base_app_name>\Application\<binary>.
// base_app_name = "BrowserOS" (from chromium_install_modes.h)
func windowsBrowserOSExe() string {
localAppData := os.Getenv("LOCALAPPDATA")
if localAppData == "" {
return ""
}
return filepath.Join(localAppData, "BrowserOS", "Application", "BrowserOS.exe")
}
// findLinuxAppImage searches common locations for a BrowserOS AppImage.
func findLinuxAppImage() string {
home := userHomeDir()
if home == "" {
return ""
}
for _, dir := range []string{
home,
filepath.Join(home, "Applications"),
filepath.Join(home, "Downloads"),
"/opt",
} {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, "BrowserOS") && strings.HasSuffix(name, ".AppImage") {
return filepath.Join(dir, name)
}
}
}
return ""
}
// userHomeDir returns the home directory or empty string.
func userHomeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return home
}
// waitForServer polls until a BrowserOS server responds or timeout.
func waitForServer(maxWait time.Duration) (string, bool) {
client := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
// server.json is written by BrowserOS on startup with the actual port
if url := loadBrowserosServerURL(); url != "" {
resp, err := client.Get(url + "/health")
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return url, true
}
}
}
fmt.Print(".")
time.Sleep(1 * time.Second)
}
return "", false
}

View File

@@ -167,17 +167,10 @@ func envBool(key string) bool {
}
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 != "" {
@@ -185,6 +178,10 @@ func defaultServerURL() string {
}
}
if url := loadBrowserosServerURL(); url != "" {
return url
}
return ""
}
@@ -228,9 +225,6 @@ func validateServerURL(raw string) (string, error) {
}
return "", fmt.Errorf(
"BrowserOS server URL is not configured.\n\n" +
" If BrowserOS is running: browseros-cli init --auto\n" +
" If BrowserOS is closed: browseros-cli launch\n" +
" If not installed: browseros-cli install",
"BrowserOS server URL is not configured.\n Open BrowserOS -> Settings -> BrowserOS MCP and copy the Server URL.\n Then run: browseros-cli init",
)
}

View File

@@ -44,10 +44,7 @@ func (c *Client) connect(ctx context.Context) (*sdkmcp.ClientSession, error) {
session, err := sdkClient.Connect(ctx, transport, nil)
if err != nil {
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+
" If BrowserOS is running on a different port: browseros-cli init --auto\n"+
" If BrowserOS is not running: browseros-cli launch\n"+
" If not installed: browseros-cli install", c.BaseURL, err)
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n Is the server running? Try: browseros-cli init", c.BaseURL, err)
}
return session, nil
}
@@ -187,10 +184,7 @@ func (c *Client) Status() (map[string]any, error) {
func (c *Client) restGET(path string) (map[string]any, error) {
resp, err := c.HTTPClient.Get(c.BaseURL + path)
if err != nil {
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+
" If BrowserOS is running on a different port: browseros-cli init --auto\n"+
" If BrowserOS is not running: browseros-cli launch\n"+
" If not installed: browseros-cli install", c.BaseURL, err)
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n Try: browseros-cli init", c.BaseURL, err)
}
defer resp.Body.Close()

View File

@@ -1,115 +0,0 @@
#
# Install browseros-cli for Windows — downloads the latest release binary.
#
# Usage (PowerShell — save and run):
# Invoke-WebRequest -Uri "https://cdn.browseros.com/cli/install.ps1" -OutFile install.ps1
# .\install.ps1
# .\install.ps1 -Version "0.1.0" -Dir "C:\tools\browseros"
#
# Usage (one-liner, uses env vars for options):
# & { $env:BROWSEROS_VERSION="0.1.0"; irm https://cdn.browseros.com/cli/install.ps1 | iex }
#
param(
[string]$Version = "",
[string]$Dir = ""
)
$ErrorActionPreference = "Stop"
# TLS 1.2 — older PS 5.1 defaults to TLS 1.0
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$CdnBase = "https://cdn.browseros.com/cli"
$Binary = "browseros-cli"
# When piped via irm | iex, param() is ignored — fall back to env vars
if (-not $Version) { $Version = $env:BROWSEROS_VERSION }
if (-not $Dir) { $Dir = if ($env:BROWSEROS_DIR) { $env:BROWSEROS_DIR } else { "$env:LOCALAPPDATA\browseros-cli\bin" } }
# ── Resolve latest version ───────────────────────────────────────────────────
if (-not $Version) {
Write-Host "Fetching latest version..."
$Version = (Invoke-WebRequest -Uri "$CdnBase/latest/version.txt" -UseBasicParsing).Content.Trim()
if (-not $Version) {
Write-Error "Could not determine latest version. Try: -Version 0.1.0"
exit 1
}
}
if ($Version -notmatch '^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$') {
Write-Error "Unexpected version format: '$Version'"
exit 1
}
Write-Host "Installing browseros-cli v$Version..."
# ── Detect architecture ──────────────────────────────────────────────────────
# $env:PROCESSOR_ARCHITECTURE lies under x64 emulation on ARM64 Windows.
# Use .NET RuntimeInformation when available, fall back to PROCESSOR_ARCHITEW6432.
$Arch = "amd64"
try {
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
if ($osArch -eq [System.Runtime.InteropServices.Architecture]::Arm64) { $Arch = "arm64" }
} catch {
if ($env:PROCESSOR_ARCHITEW6432 -eq "ARM64" -or $env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
$Arch = "arm64"
}
}
if (-not [Environment]::Is64BitOperatingSystem) {
Write-Error "32-bit Windows is not supported."
exit 1
}
# ── Download and extract ─────────────────────────────────────────────────────
$Filename = "${Binary}_${Version}_windows_${Arch}.zip"
$Url = "$CdnBase/v$Version/$Filename"
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("browseros-cli-install-" + [System.IO.Path]::GetRandomFileName())
try {
New-Item -ItemType Directory -Path $TmpDir | Out-Null
$ZipPath = Join-Path $TmpDir $Filename
Write-Host "Downloading $Url..."
Invoke-WebRequest -Uri $Url -OutFile $ZipPath -UseBasicParsing
Expand-Archive -Path $ZipPath -DestinationPath $TmpDir -Force
$Exe = Get-ChildItem -Path $TmpDir -Filter "$Binary.exe" -File -Recurse | Select-Object -First 1
if (-not $Exe) {
Write-Error "Binary not found in archive."
exit 1
}
# ── Install ──────────────────────────────────────────────────────────────
if (-not (Test-Path $Dir)) {
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
}
Move-Item -Force $Exe.FullName (Join-Path $Dir "$Binary.exe")
Write-Host "Installed $Binary.exe to $Dir"
} finally {
if (Test-Path $TmpDir) { Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue }
}
# ── PATH ─────────────────────────────────────────────────────────────────────
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
$PathEntries = $UserPath -split ";" | Where-Object { $_ -ne "" }
if ($Dir -notin $PathEntries) {
Write-Host ""
Write-Host "Adding $Dir to your user PATH..."
[Environment]::SetEnvironmentVariable("Path", "$Dir;$UserPath", "User")
$env:Path = "$Dir;$env:Path"
Write-Host "Done. Restart your terminal for PATH changes to take effect."
}
Write-Host ""
Write-Host "Run 'browseros-cli --help' to get started."

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env bash
#
# Install browseros-cli — downloads the latest release binary for your platform.
#
# Usage:
# curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
#
# # Or with options:
# curl -fsSL https://cdn.browseros.com/cli/install.sh | bash -s -- --version 0.1.0 --dir /usr/local/bin
set -euo pipefail
CDN_BASE="https://cdn.browseros.com/cli"
BINARY="browseros-cli"
INSTALL_DIR="${HOME}/.browseros/bin"
# ── Parse arguments ──────────────────────────────────────────────────────────
VERSION=""
CUSTOM_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
[[ $# -lt 2 ]] && { echo "Error: --version requires a value" >&2; exit 1; }
VERSION="$2"; shift 2 ;;
--dir)
[[ $# -lt 2 ]] && { echo "Error: --dir requires a value" >&2; exit 1; }
CUSTOM_DIR="$2"; shift 2 ;;
--help)
echo "Usage: install.sh [--version VERSION] [--dir INSTALL_DIR]"
echo ""
echo " --version Install a specific version (default: latest)"
echo " --dir Install directory (default: ~/.browseros/bin)"
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ -n "$CUSTOM_DIR" ]] && INSTALL_DIR="$CUSTOM_DIR"
# ── Resolve latest version ───────────────────────────────────────────────────
if [[ -z "$VERSION" ]]; then
VERSION=$(curl -fsSL "${CDN_BASE}/latest/version.txt" | tr -d '[:space:]')
if [[ -z "$VERSION" ]]; then
echo "Error: could not determine latest version." >&2
echo " Try: install.sh --version 0.1.0" >&2
exit 1
fi
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "Error: unexpected version format: '$VERSION'" >&2
exit 1
fi
echo "Installing browseros-cli v${VERSION}..."
# ── Detect platform ──────────────────────────────────────────────────────────
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
darwin) OS="darwin" ;;
linux) OS="linux" ;;
*) echo "Error: unsupported OS: $OS" >&2; exit 1 ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) echo "Error: unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
# ── Download and extract ─────────────────────────────────────────────────────
FILENAME="${BINARY}_${VERSION}_${OS}_${ARCH}.tar.gz"
URL="${CDN_BASE}/v${VERSION}/${FILENAME}"
CHECKSUM_URL="${CDN_BASE}/v${VERSION}/checksums.txt"
TMPDIR_DL=$(mktemp -d)
trap 'rm -rf "$TMPDIR_DL"' EXIT
echo "Downloading ${URL}..."
curl -fSL --progress-bar -o "${TMPDIR_DL}/${FILENAME}" "$URL"
# Verify checksum if sha256sum/shasum is available
if curl -fsSL -o "${TMPDIR_DL}/checksums.txt" "$CHECKSUM_URL" 2>/dev/null; then
expected=$(awk -v filename="$FILENAME" '$2 == filename { print $1; exit }' "${TMPDIR_DL}/checksums.txt")
if [[ -n "$expected" ]]; then
if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "${TMPDIR_DL}/${FILENAME}" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "${TMPDIR_DL}/${FILENAME}" | awk '{print $1}')
else
actual=""
echo "Warning: no sha256sum/shasum found; skipping checksum verification." >&2
fi
if [[ -n "$actual" && "$actual" != "$expected" ]]; then
echo "Error: checksum mismatch (expected ${expected}, got ${actual})" >&2
exit 1
fi
[[ -n "$actual" ]] && echo "Checksum verified."
else
echo "Warning: checksum not found in checksums.txt; skipping verification." >&2
fi
else
echo "Warning: could not fetch checksums.txt; skipping checksum verification." >&2
fi
tar -xzf "${TMPDIR_DL}/${FILENAME}" -C "$TMPDIR_DL"
BINARY_PATH="${TMPDIR_DL}/${BINARY}"
if [[ ! -f "$BINARY_PATH" ]]; then
BINARY_PATH=$(find "$TMPDIR_DL" -type f -name "$BINARY" -print -quit)
fi
if [[ -z "$BINARY_PATH" || ! -f "$BINARY_PATH" ]]; then
echo "Error: binary not found in archive." >&2
exit 1
fi
# ── Install ──────────────────────────────────────────────────────────────────
mkdir -p "$INSTALL_DIR"
mv "$BINARY_PATH" "${INSTALL_DIR}/${BINARY}"
chmod +x "${INSTALL_DIR}/${BINARY}"
echo "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}"
# ── PATH hint ────────────────────────────────────────────────────────────────
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
echo ""
echo "Add browseros-cli to your PATH:"
echo ""
SHELL_NAME=$(basename "${SHELL:-/bin/bash}")
case "$SHELL_NAME" in
zsh) echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;;
fish) echo " fish_add_path ${INSTALL_DIR}" ;;
*) echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;;
esac
fi
echo ""
echo "Run 'browseros-cli --help' to get started."

View File

@@ -1,8 +1,6 @@
# BrowserOS Eval
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](../../../../LICENSE)
Evaluation framework for benchmarking BrowserOS browser automation agents. Runs tasks from standard datasets ([WebVoyager](https://arxiv.org/abs/2401.13919), [Mind2Web](https://arxiv.org/abs/2306.06070)), captures trajectories with screenshots, and grades results automatically.
Evaluation framework for benchmarking BrowserOS browser automation agents. Runs tasks from standard datasets (WebVoyager, Mind2Web), captures trajectories with screenshots, and grades results automatically.
## Prerequisites

View File

@@ -16,10 +16,7 @@
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
"headless": true
},
"graders": ["performance_grader"],
"grader_api_key_env": "OPENROUTER_API_KEY",

View File

@@ -23,10 +23,7 @@
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
"headless": true
},
"graders": ["performance_grader"],
"grader_api_key_env": "OPENROUTER_API_KEY",

View File

@@ -0,0 +1,23 @@
{
"agent": {
"type": "orchestrator-executor",
"orchestrator": {
"provider": "openrouter",
"model": "openai/gpt-4o",
"apiKey": "OPENROUTER_API_KEY",
"maxTurns": 3
},
"executor": {
"provider": "openrouter",
"model": "openai/gpt-4o",
"apiKey": "OPENROUTER_API_KEY"
}
},
"dataset": "../data/webvoyager_e2e_test.jsonl",
"output_dir": "../results/debug-test",
"num_workers": 1,
"browseros": {
"server_url": "http://127.0.0.1:9110"
},
"timeout_ms": 90000
}

View File

@@ -0,0 +1,21 @@
{
"agent": {
"type": "single",
"provider": "openai-compatible",
"model": "accounts/fireworks/models/kimi-k2p5",
"apiKey": "FIREWORKS_API_KEY",
"baseUrl": "https://api.fireworks.ai/inference/v1",
"supportsImages": true
},
"dataset": "../data/test-set.jsonl",
"output_dir": "../results/fireworks-minimax-k2p5-test-set",
"num_workers": 1,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110"
},
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/o4-mini-high",
"timeout_ms": 3600000
}

View File

@@ -9,20 +9,12 @@
"turnLimit": 100
},
"dataset": "../data/test-set.jsonl",
"output_dir": "../results/gemini-computer-use-test-set2",
"num_workers": 1,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
"server_url": "http://127.0.0.1:9110"
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["performance_grader"],
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",

View File

@@ -6,20 +6,11 @@
"apiKey": "OPENROUTER_API_KEY"
},
"dataset": "../data/mind2web.jsonl",
"output_dir": "../results/mind2web-full",
"num_workers": 5,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
"server_url": "http://127.0.0.1:9110"
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["performance_grader"],
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",

View File

@@ -0,0 +1,18 @@
{
"agent": {
"type": "single",
"provider": "openrouter",
"model": "openai/gpt-4.1",
"apiKey": "OPENROUTER_API_KEY"
},
"dataset": "../data/mind2web_e2e_test.jsonl",
"output_dir": "../results/mind2web-test",
"num_workers": 5,
"browseros": {
"server_url": "http://127.0.0.1:9110"
},
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",
"timeout_ms": 300000
}

View File

@@ -0,0 +1,32 @@
{
"agent": {
"type": "orchestrator-executor",
"orchestrator": {
"provider": "openai-compatible",
"model": "accounts/fireworks/models/kimi-k2p5",
"apiKey": "FIREWORKS_API_KEY",
"baseUrl": "https://api.fireworks.ai/inference/v1"
},
"executor": {
"provider": "clado-action",
"model": "qwen3-vl-30b-a3b-instruct",
"apiKey": "",
"baseUrl": "https://clado-ai--clado-browseros-action-actionmodel-generate.modal.run"
}
},
"dataset": "../data/webvoyager_e2e_test.jsonl",
"output_dir": "../results/orchestrator-executor-clado-webvoyager-test",
"num_workers": 3,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": true
},
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",
"timeout_ms": 1200000
}

View File

@@ -2,21 +2,24 @@
"agent": {
"type": "orchestrator-executor",
"orchestrator": {
"type": "single",
"provider": "openai-compatible",
"model": "accounts/fireworks/models/kimi-k2p5",
"apiKey": "FIREWORKS_API_KEY",
"baseUrl": "https://api.fireworks.ai/inference/v1"
"baseUrl": "https://api.fireworks.ai/inference/v1",
"supportsImages": true
},
"executor": {
"provider": "openai-compatible",
"model": "accounts/fireworks/models/kimi-k2p5",
"apiKey": "FIREWORKS_API_KEY",
"baseUrl": "https://api.fireworks.ai/inference/v1"
"baseUrl": "https://api.fireworks.ai/inference/v1",
"supportsImages": true
}
},
"dataset": "../data/webbench-2of4-50.jsonl",
"num_workers": 10,
"restart_server_per_task": true,
"dataset": "../data/webvoyager_e2e_test.jsonl",
"output_dir": "../results/orchestrator-executor-webvoyager-test",
"num_workers": 3,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
@@ -25,12 +28,8 @@
"load_extensions": false,
"headless": false
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["performance_grader"],
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",
"timeout_ms": 1800000
"timeout_ms": 1200000
}

View File

@@ -0,0 +1,25 @@
{
"agent": {
"type": "single",
"provider": "openai-compatible",
"model": "accounts/fireworks/models/kimi-k2p5",
"apiKey": "FIREWORKS_API_KEY",
"baseUrl": "https://api.fireworks.ai/inference/v1",
"supportsImages": true
},
"dataset": "../data/webvoyager_e2e_test.jsonl",
"output_dir": "../results/tool-loop-webvoyager-test",
"num_workers": 3,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": true
},
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",
"timeout_ms": 1200000
}

View File

@@ -8,20 +8,16 @@
"supportsImages": true
},
"dataset": "../data/webvoyager.jsonl",
"output_dir": "../results/webvoyager-cdp-server",
"num_workers": 3,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
"headless": true
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["performance_grader"],
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",

View File

@@ -0,0 +1,25 @@
{
"agent": {
"type": "single",
"provider": "openai-compatible",
"model": "accounts/fireworks/models/kimi-k2p5",
"apiKey": "FIREWORKS_API_KEY",
"baseUrl": "https://api.fireworks.ai/inference/v1",
"supportsImages": true
},
"dataset": "../data/webvoyager_e2e_test.jsonl",
"output_dir": "../results/webvoyager-test",
"num_workers": 3,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": true
},
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",
"timeout_ms": 1200000
}

View File

@@ -9,22 +9,14 @@
"turnLimit": 100
},
"dataset": "../data/test-set.jsonl",
"output_dir": "../results/yutori-navigator",
"num_workers": 1,
"restart_server_per_task": true,
"browseros": {
"server_url": "http://127.0.0.1:9110",
"base_cdp_port": 9010,
"base_server_port": 9110,
"base_extension_port": 9310,
"load_extensions": false,
"headless": false
"server_url": "http://127.0.0.1:9110"
},
"captcha": {
"api_key_env": "NOPECHA_API_KEY"
},
"graders": ["performance_grader"],
"timeout_ms": 1200000,
"grader_api_key_env": "OPENROUTER_API_KEY",
"grader_base_url": "https://openrouter.ai/api/v1",
"grader_model": "openai/gpt-4.1",
"timeout_ms": 1200000
"grader_model": "openai/gpt-4.1"
}

View File

@@ -1,220 +0,0 @@
/**
* Test script for Clado API endpoints (grounding + action models)
*
* Usage:
* bun apps/eval/scripts/test-clado-api.ts [screenshot-path]
*
* If no screenshot provided, captures one from a running BrowserOS server.
*/
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const ACTION_URL =
'https://clado-ai--clado-browseros-action-actionmodel-generate.modal.run'
const ACTION_HEALTH_URL =
'https://clado-ai--clado-browseros-action-actionmodel-health.modal.run'
const GROUNDING_URL =
'https://clado-ai--clado-browseros-grounding-groundingmodel-generate.modal.run'
const GROUNDING_HEALTH_URL =
'https://clado-ai--clado-browseros-grounding-groundingmodel-health.modal.run'
async function checkHealth(name: string, url: string): Promise<boolean> {
console.log(`\n--- ${name} health check ---`)
console.log(` URL: ${url}`)
const start = performance.now()
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) })
const elapsed = ((performance.now() - start) / 1000).toFixed(2)
const body = await resp.text()
console.log(` Status: ${resp.status} (${elapsed}s)`)
console.log(` Body: ${body.slice(0, 200)}`)
return resp.ok
} catch (err) {
const elapsed = ((performance.now() - start) / 1000).toFixed(2)
console.log(
` FAILED (${elapsed}s): ${err instanceof Error ? err.message : err}`,
)
return false
}
}
async function testGenerate(
name: string,
url: string,
payload: Record<string, unknown>,
): Promise<Record<string, unknown> | null> {
console.log(`\n--- ${name} generate ---`)
console.log(` URL: ${url}`)
console.log(` Instruction: ${payload.instruction}`)
console.log(
` Image size: ${((payload.image_base64 as string).length / 1024).toFixed(0)} KB (base64)`,
)
if (payload.history) console.log(` History: ${payload.history}`)
const start = performance.now()
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(120_000),
})
const elapsed = ((performance.now() - start) / 1000).toFixed(2)
if (!resp.ok) {
const body = await resp.text()
console.log(` FAILED: HTTP ${resp.status} (${elapsed}s)`)
console.log(` Body: ${body.slice(0, 400)}`)
return null
}
const result = (await resp.json()) as Record<string, unknown>
console.log(` Status: ${resp.status} (${elapsed}s)`)
console.log(` Action: ${result.action}`)
if (result.x !== null && result.x !== undefined)
console.log(` Coordinates: (${result.x}, ${result.y})`)
if (result.text)
console.log(` Text: ${(result.text as string).slice(0, 100)}`)
if (result.key) console.log(` Key: ${result.key}`)
if (result.inference_time_seconds)
console.log(` Inference: ${result.inference_time_seconds}s`)
// Show thinking if present
const raw = result.raw_response as string | undefined
if (raw) {
const thinkMatch = raw.match(/<thinking>([\s\S]*?)<\/thinking>/)
if (thinkMatch) {
const thinking = thinkMatch[1].trim()
console.log(
` Thinking: ${thinking.slice(0, 200)}${thinking.length > 200 ? '...' : ''}`,
)
}
}
return result
} catch (err) {
const elapsed = ((performance.now() - start) / 1000).toFixed(2)
console.log(
` FAILED (${elapsed}s): ${err instanceof Error ? err.message : err}`,
)
return null
}
}
async function loadScreenshot(path?: string): Promise<string> {
if (path) {
const resolved = resolve(path)
console.log(`Loading screenshot: ${resolved}`)
const data = await readFile(resolved)
return data.toString('base64')
}
// Try to capture from a running BrowserOS server
const serverUrl = process.env.BROWSEROS_URL || 'http://127.0.0.1:9110'
console.log(
`No screenshot path provided. Trying to capture from ${serverUrl}...`,
)
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
const { StreamableHTTPClientTransport } = await import(
'@modelcontextprotocol/sdk/client/streamableHttp.js'
)
const client = new Client({ name: 'clado-test', version: '1.0.0' })
const transport = new StreamableHTTPClientTransport(
new URL(`${serverUrl}/mcp`),
{ requestInit: { headers: { 'X-BrowserOS-Source': 'sdk-internal' } } },
)
try {
await client.connect(transport)
const result = (await client.callTool({
name: 'take_screenshot',
arguments: { format: 'png', page: 1 },
})) as { content: Array<{ type: string; data?: string }> }
const imageContent = result.content?.find((c) => c.type === 'image')
if (!imageContent?.data)
throw new Error('No image data in screenshot response')
console.log(
`Captured screenshot (${(imageContent.data.length / 1024).toFixed(0)} KB base64)`,
)
return imageContent.data
} finally {
try {
await transport.close()
} catch {}
}
}
async function main() {
const screenshotPath = process.argv[2]
console.log('=== Clado API Test ===\n')
// Health checks (parallel)
const [actionHealthy, groundingHealthy] = await Promise.all([
checkHealth('Action Model', ACTION_HEALTH_URL),
checkHealth('Grounding Model', GROUNDING_HEALTH_URL),
])
if (!actionHealthy && !groundingHealthy) {
console.log('\nBoth endpoints are down. Exiting.')
process.exit(1)
}
// Load screenshot
let imageBase64: string
try {
imageBase64 = await loadScreenshot(screenshotPath)
} catch (err) {
console.log(
`\nFailed to load screenshot: ${err instanceof Error ? err.message : err}`,
)
console.log(
'Provide a screenshot path: bun apps/eval/scripts/test-clado-api.ts path/to/screenshot.png',
)
process.exit(1)
}
const instruction = 'Click on the search button or search bar'
// Test grounding model
if (groundingHealthy) {
await testGenerate('Grounding Model', GROUNDING_URL, {
instruction,
image_base64: imageBase64,
})
} else {
console.log('\nSkipping grounding model (unhealthy)')
}
// Test action model (no history)
if (actionHealthy) {
const result = await testGenerate('Action Model (step 1)', ACTION_URL, {
instruction,
image_base64: imageBase64,
history: 'None',
})
// Test action model with history (simulate multi-turn)
if (result && result.action === 'click') {
await testGenerate('Action Model (step 2, with history)', ACTION_URL, {
instruction: 'Type "hello world" in the search bar',
image_base64: imageBase64,
history: `click(${result.x}, ${result.y})`,
})
}
} else {
console.log('\nSkipping action model (unhealthy)')
}
console.log('\n=== Done ===')
}
main().catch((err) => {
console.error('Fatal:', err)
process.exit(1)
})

View File

@@ -47,7 +47,7 @@ interface RunSummary {
runId: string
configName: string
date: string
avgScore: number
passRate: number
total: number
completed: number
failed: number
@@ -135,20 +135,20 @@ const runs: RunSummary[] = manifests
const failed = m.tasks.filter((t) => t.status === 'failed').length
const timeout = m.tasks.filter((t) => t.status === 'timeout').length
let scoredCount = 0
let scoreSum = 0
let graded = 0
let passed = 0
for (const task of m.tasks) {
if (!task.graderResults) continue
for (const name of PASS_FAIL_GRADER_ORDER) {
if (task.graderResults[name]) {
scoredCount++
scoreSum += task.graderResults[name].score ?? 0
graded++
if (task.graderResults[name].pass) passed++
break
}
}
}
const avgScore = scoredCount > 0 ? (scoreSum / scoredCount) * 100 : 0
const passRate = graded > 0 ? passed / graded : 0
const durations = m.tasks
.filter((t) => t.durationMs > 0)
.map((t) => t.durationMs)
@@ -170,7 +170,7 @@ const runs: RunSummary[] = manifests
runId: m.runId,
configName,
date,
avgScore,
passRate,
total,
completed,
failed,
@@ -242,7 +242,7 @@ const html = `<!DOCTYPE html>
.stat-value.big { font-size: 2.5rem; font-weight: 700; }
.pass { color: #3fb950; }
.fail { color: #f85149; }
.neutral { color: #f0883e; }
.neutral { color: #8b949e; }
.trend-up { color: #3fb950; }
.trend-down { color: #f85149; }
.trend-flat { color: #8b949e; }
@@ -314,7 +314,7 @@ const html = `<!DOCTYPE html>
<th>Model</th>
<th>Dataset</th>
<th>Architecture</th>
<th>Score</th>
<th>Pass Rate</th>
<th>Tasks</th>
<th>Timeout</th>
<th>Avg Duration</th>
@@ -327,6 +327,7 @@ const html = `<!DOCTYPE html>
.reverse()
.map((r) => {
const viewerUrl = `viewer.html?run=${encodeURIComponent(r.runId)}`
const passed = Math.round(r.passRate * r.total)
const archLabel =
r.agentType === 'orchestrator-executor'
? 'Orch-Exec'
@@ -341,7 +342,7 @@ const html = `<!DOCTYPE html>
<td class="mono" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(r.model)}">${escHtml(r.model)}</td>
<td>${escHtml(r.dataset)}</td>
<td>${escHtml(archLabel)}</td>
<td class="${r.avgScore >= 75 ? 'pass' : r.avgScore >= 40 ? 'neutral' : 'fail'}">${r.avgScore.toFixed(1)}%</td>
<td class="${r.passRate >= 0.7 ? 'pass' : r.passRate >= 0.4 ? 'neutral' : 'fail'}">${(r.passRate * 100).toFixed(1)}% <span style="color:#6e7681;font-size:11px;">(${passed}/${r.total})</span></td>
<td>${r.total}</td>
<td class="${r.timeout > 0 ? 'neutral' : ''}">${r.timeout}</td>
<td>${(r.avgDurationMs / 1000).toFixed(0)}s</td>
@@ -385,12 +386,10 @@ const html = `<!DOCTYPE html>
: latest.agentType === 'single' ? 'Single Agent (Tool Loop)'
: latest.agentType === 'gemini-computer-use' ? 'Gemini Computer Use'
: latest.agentType || 'Unknown';
var scoreColor = latest.avgScore >= 75 ? '#3fb950' : latest.avgScore >= 40 ? '#f0883e' : '#f85149';
el.innerHTML =
'<div class="config-detail"><span class="cd-label">Architecture</span><span class="cd-value">' + archLabel + '</span></div>' +
'<div class="config-detail"><span class="cd-label">Model</span><span class="cd-value">' + (latest.model || 'unknown') + '</span></div>' +
'<div class="config-detail"><span class="cd-label">Dataset</span><span class="cd-value">' + (latest.dataset || 'unknown') + '</span></div>' +
'<div class="config-detail"><span class="cd-label">Latest Score</span><span class="cd-value" style="color:' + scoreColor + ';">' + latest.avgScore.toFixed(1) + '%</span></div>' +
'<div class="config-detail"><span class="cd-label">Tasks</span><span class="cd-value">' + latest.total + '</span></div>' +
'<div class="config-detail"><span class="cd-label">Runs</span><span class="cd-value">' + runs.length + '</span></div>';
}
@@ -401,16 +400,15 @@ const html = `<!DOCTYPE html>
if (runs.length === 0) { el.innerHTML = ''; return; }
var latest = runs[runs.length - 1];
var prev = runs.length >= 2 ? runs[runs.length - 2] : null;
var best = Math.max.apply(null, runs.map(function(r) { return r.avgScore; }));
var delta = prev ? latest.avgScore - prev.avgScore : 0;
var best = Math.max.apply(null, runs.map(function(r) { return r.passRate; }));
var delta = prev ? latest.passRate - prev.passRate : 0;
var sign = delta > 0 ? '+' : '';
var trendCls = delta > 0 ? 'trend-up' : delta < 0 ? 'trend-down' : 'trend-flat';
var latestColor = latest.avgScore >= 75 ? 'pass' : latest.avgScore >= 40 ? 'neutral' : 'fail';
el.innerHTML =
'<div class="stat-card"><div class="stat-label">Latest Score</div><div class="stat-value big ' + latestColor + '">' + latest.avgScore.toFixed(1) + '%</div></div>' +
'<div class="stat-card"><div class="stat-label">Trend</div><div class="stat-value ' + trendCls + '">' + (prev ? sign + delta.toFixed(1) + ' pp' : 'N/A') + '</div></div>' +
'<div class="stat-card"><div class="stat-label">Best Score</div><div class="stat-value pass">' + best.toFixed(1) + '%</div></div>' +
'<div class="stat-card"><div class="stat-label">Latest Pass Rate</div><div class="stat-value big ' + (latest.passRate >= 0.7 ? 'pass' : 'fail') + '">' + (latest.passRate * 100).toFixed(1) + '%</div></div>' +
'<div class="stat-card"><div class="stat-label">Trend</div><div class="stat-value ' + trendCls + '">' + (prev ? sign + (delta * 100).toFixed(1) + ' pp' : 'N/A') + '</div></div>' +
'<div class="stat-card"><div class="stat-label">Best Score</div><div class="stat-value pass">' + (best * 100).toFixed(1) + '%</div></div>' +
'<div class="stat-card"><div class="stat-label">Avg Duration</div><div class="stat-value">' + (latest.avgDurationMs / 1000).toFixed(0) + 's</div></div>' +
'<div class="stat-card"><div class="stat-label">Runs</div><div class="stat-value">' + runs.length + '</div></div>';
}
@@ -438,7 +436,7 @@ const html = `<!DOCTYPE html>
return;
}
var scores = runs.map(function(r) { return r.avgScore; });
var scores = runs.map(function(r) { return r.passRate * 100; });
var minY = Math.max(0, Math.floor(Math.min.apply(null, scores) / 10) * 10 - 10);
var maxY = Math.min(100, Math.ceil(Math.max.apply(null, scores) / 10) * 10 + 10);
if (minY === maxY) { minY = Math.max(0, minY - 10); maxY = Math.min(100, maxY + 10); }
@@ -465,7 +463,7 @@ const html = `<!DOCTYPE html>
ctx.strokeStyle = '#58a6ff'; ctx.lineWidth = 2; ctx.beginPath();
runs.forEach(function(r, i) {
var px = pad.left + (runs.length === 1 ? plotW / 2 : (i / (runs.length - 1)) * plotW);
var py2 = pad.top + plotH - ((r.avgScore - minY) / (maxY - minY)) * plotH;
var py2 = pad.top + plotH - ((r.passRate * 100 - minY) / (maxY - minY)) * plotH;
if (i === 0) ctx.moveTo(px, py2); else ctx.lineTo(px, py2);
});
ctx.stroke();
@@ -473,10 +471,10 @@ const html = `<!DOCTYPE html>
// Dots
runs.forEach(function(r, i) {
var px = pad.left + (runs.length === 1 ? plotW / 2 : (i / (runs.length - 1)) * plotW);
var py2 = pad.top + plotH - ((r.avgScore - minY) / (maxY - minY)) * plotH;
var py2 = pad.top + plotH - ((r.passRate * 100 - minY) / (maxY - minY)) * plotH;
dotPositions.push({ x: px, y: py2, run: r });
ctx.beginPath(); ctx.arc(px, py2, 4, 0, Math.PI * 2);
ctx.fillStyle = r.avgScore >= 75 ? '#3fb950' : r.avgScore >= 40 ? '#f0883e' : '#f85149';
ctx.fillStyle = r.passRate >= 0.7 ? '#3fb950' : '#f85149';
ctx.fill(); ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 2; ctx.stroke();
});
}
@@ -493,10 +491,11 @@ const html = `<!DOCTYPE html>
if (closest && closestDist < 40) {
var r = closest.run;
var passed = Math.round(r.passRate * r.total);
document.getElementById('tt-date').textContent = r.date;
document.getElementById('tt-score').textContent = r.avgScore.toFixed(1) + '%';
document.getElementById('tt-score').style.color = r.avgScore >= 75 ? '#3fb950' : r.avgScore >= 40 ? '#f0883e' : '#f85149';
document.getElementById('tt-detail').textContent = 'score ' + r.avgScore.toFixed(1) + '% \\u00B7 ' + r.total + ' tasks \\u00B7 ' + (r.avgDurationMs / 1000).toFixed(0) + 's avg \\u00B7 ' + r.model;
document.getElementById('tt-score').textContent = (r.passRate * 100).toFixed(1) + '%';
document.getElementById('tt-score').style.color = r.passRate >= 0.7 ? '#3fb950' : '#f85149';
document.getElementById('tt-detail').textContent = passed + '/' + r.total + ' pass \\u00B7 ' + (r.avgDurationMs / 1000).toFixed(0) + 's avg \\u00B7 ' + r.model;
tooltip.style.display = 'block';
var tx = closest.x + 12, ty = closest.y - 50;
@@ -509,7 +508,7 @@ const html = `<!DOCTYPE html>
ctx.beginPath(); ctx.arc(closest.x, closest.y, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(88, 166, 255, 0.3)'; ctx.fill();
ctx.beginPath(); ctx.arc(closest.x, closest.y, 5, 0, Math.PI * 2);
ctx.fillStyle = r.avgScore >= 75 ? '#3fb950' : r.avgScore >= 40 ? '#f0883e' : '#f85149'; ctx.fill();
ctx.fillStyle = r.passRate >= 0.7 ? '#3fb950' : '#f85149'; ctx.fill();
ctx.strokeStyle = '#e6edf3'; ctx.lineWidth = 2; ctx.stroke();
canvas.style.cursor = 'pointer';
} else {
@@ -585,7 +584,7 @@ console.log(` View at: ${cdnBaseUrl}/report.html`)
// Print summary
console.log('\nScore trend:')
for (const run of runs.slice(-10)) {
const bar = '\u2588'.repeat(Math.round(run.avgScore / 5))
const pct = run.avgScore.toFixed(0).padStart(3)
const bar = '\u2588'.repeat(Math.round(run.passRate * 20))
const pct = (run.passRate * 100).toFixed(0).padStart(3)
console.log(` ${run.date} ${pct}% ${bar}`)
}

View File

@@ -11,7 +11,6 @@
import type { ResolvedAgentConfig } from '@browseros/server/agent/types'
import { Browser } from '@browseros/server/browser'
import { CdpBackend } from '@browseros/server/browser/backends/cdp'
import { CaptchaWaiter } from '../../capture/captcha-waiter'
import { DEFAULT_TIMEOUT_MS } from '../../constants'
import type {
EvalConfig,
@@ -162,13 +161,6 @@ export class OrchestratorExecutorEvaluator implements AgentEvaluator {
const browser = new Browser(cdp, CONTROLLER_STUB)
capture.screenshot.setBrowser(browser)
const captchaWaiter = config.captcha
? new CaptchaWaiter({
waitTimeoutMs: config.captcha.wait_timeout_ms,
pollIntervalMs: config.captcha.poll_interval_ms,
})
: null
try {
// Build capture callbacks (same pattern as single-agent.ts)
const callbacks: ExecutorCallbacks = {
@@ -180,12 +172,6 @@ export class OrchestratorExecutorEvaluator implements AgentEvaluator {
},
onToolCallFinish: async () => {
try {
if (captchaWaiter) {
await captchaWaiter.waitIfCaptchaPresent(
browser,
capture.getActivePageId(),
)
}
const screenshotNum = await capture.screenshot.capture(
capture.getActivePageId(),
)

View File

@@ -1,13 +1,9 @@
import { randomUUID } from 'node:crypto'
import {
AiSdkAgent,
formatUserMessage,
} from '@browseros/server/agent/tool-loop'
import { AiSdkAgent } from '@browseros/server/agent/tool-loop'
import type { ResolvedAgentConfig } from '@browseros/server/agent/types'
import { Browser } from '@browseros/server/browser'
import { CdpBackend } from '@browseros/server/browser/backends/cdp'
import { registry } from '@browseros/server/tools/registry'
import { CaptchaWaiter } from '../capture/captcha-waiter'
import { DEFAULT_TIMEOUT_MS } from '../constants'
import type { EvalConfig, TaskMetadata } from '../types'
import { resolveProviderConfig } from '../utils/resolve-provider-config'
@@ -79,13 +75,6 @@ export class SingleAgentEvaluator implements AgentEvaluator {
}
: undefined
const captchaWaiter = config.captcha
? new CaptchaWaiter({
waitTimeoutMs: config.captcha.wait_timeout_ms,
pollIntervalMs: config.captcha.poll_interval_ms,
})
: null
let agent: AiSdkAgent | null = null
try {
@@ -102,11 +91,8 @@ export class SingleAgentEvaluator implements AgentEvaluator {
capture,
async (signal) => {
if (!agent) throw new Error('Agent was not initialized')
// Format prompt with browser context so the agent knows what page it's on
// (same formatting as chat-service.ts → formatUserMessage)
const prompt = formatUserMessage(task.query, browserContext)
const result = await agent.toolLoopAgent.generate({
prompt,
prompt: task.query,
abortSignal: signal,
experimental_onToolCallStart: ({ toolCall }) => {
@@ -120,12 +106,6 @@ export class SingleAgentEvaluator implements AgentEvaluator {
experimental_onToolCallFinish: async () => {
try {
if (captchaWaiter) {
await captchaWaiter.waitIfCaptchaPresent(
browser,
capture.getActivePageId(),
)
}
const screenshotNum = await capture.screenshot.capture(
capture.getActivePageId(),
)

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