Compare commits

...

39 Commits

Author SHA1 Message Date
Nikhil Sonti
f2c697dd08 fix: address PR review feedback for #603
- Return "unknown" for unrecognized args in commandName to avoid
  sending arbitrary user input to PostHog
- Revert goreleaser to {{ .Env.POSTHOG_API_KEY }} (intentional hard
  fail — release builds must have the key set)
- go mod tidy to fix posthog-go direct/indirect marker
- Add POSTHOG_API_KEY to .env.production.example
2026-03-27 12:00:29 -07:00
Nikhil Sonti
a61ec32438 feat: add PostHog usage analytics to CLI
Add anonymous command-level analytics to browseros-cli using the PostHog
Go SDK. Tracks which commands are executed, their success/failure status,
and duration — no PII or person profiles.

- New analytics package with Init/Track/Close singleton
- Distinct ID resolves from server's browseros_id (server.json), falls
  back to CLI-generated UUID (~/.config/browseros-cli/install_id)
- API key injected at build time via ldflags (dev builds = silent no-op)
- Server now writes browseros_id into server.json for cross-surface
  identity correlation
2026-03-27 11:45:17 -07:00
Nikhil
1c5ffdf878 fix: harden cli installer bootstrap (#601)
* fix: harden cli installer bootstrap

* refactor: rework 0327-harden_cli_installers based on feedback
2026-03-27 11:24:16 -07:00
Nikhil
39a7d49c25 feat: add workspace-centric bdev cli (#585)
* fix: clean-up bdev

* feat: add workspace-centric bdev cli

* fix: address review comments for 0326-bdev_cli_redesign

* fix: address review feedback for PR #585

* fix: address review feedback for PR #585
2026-03-27 08:48:23 -07:00
shivammittal274
ed948f4b59 Feat/cli launch ready v2 (#600)
* test: temporarily allow release workflow on any branch

* fix(cli): restore main-only guard, remove goreleaser dependency

Replaces GoReleaser (Pro-only monorepo feature) with plain go build.
Tested: RC release created successfully on branch with all 6 binaries.

* fix(cli): fix hdiutil mount detection, update README with install/launch/init flow
2026-03-27 20:20:17 +05:30
shivammittal274
aad5bc16fd Feat/cli launch ready v2 (#599)
* test: temporarily allow release workflow on any branch

* fix(cli): restore main-only guard, remove goreleaser dependency

Replaces GoReleaser (Pro-only monorepo feature) with plain go build.
Tested: RC release created successfully on branch with all 6 binaries.

* fix(cli): remove -quiet from hdiutil so mount point is detected
2026-03-27 20:17:13 +05:30
Dani Akash
cee318a40b fix: improve chat history freshness and reduce query payload (#598)
* fix: add refresh indicator to chat history when fetching latest conversations

Show a non-blocking "Fetching latest conversations" indicator at the top
of the history list while the cached data is being refreshed. Users can
still interact with the cached conversation list during the refresh.

* perf: reduce chat history query payload — fetch last 2 messages instead of 5

The conversation list only displays the last user message as a preview.
Fetching 5 messages per conversation was wasteful — each message contains
the full UIMessage object (tool calls, reasoning, etc.) multiplied by
50 conversations per page. Reduced to last 2 which is sufficient to
find the last user message in a user→assistant exchange.

* perf: use first+DESC instead of last+ASC to push LIMIT down to SQL

PostGraphile's `last: N` doesn't map to SQL LIMIT — it uses a padded
LIMIT 10 and slices in application code. Changing to `first: 2` with
ORDER_INDEX_DESC generates a true SQL LIMIT 2, reducing rows scanned
from 500 to 100 per page (50 conversations × 2 vs 10 messages each).

No UX impact — extractLastUserMessage() filters by role regardless
of message order.

* chore: update react query packages

* feat: replace localforage with idb-keyval
2026-03-27 19:49:47 +05:30
Dani Akash
febaf58f91 fix: guard filesystem tools behind workspace selection and handle mid-conversation changes (#595)
* fix: remove filesystem tools when no workspace is selected

- Make workingDir optional on ResolvedAgentConfig
- Remove resolveSessionDir() fallback that always created a session dir,
  masking the no-workspace state and keeping filesystem tools available
- Gate buildFilesystemToolSet() on workingDir being defined
- Add workspace change detection mid-conversation — rebuilds the agent
  session when workspace is added, removed, or switched (same pattern
  as existing MCP server change detection)
- download_file falls back to tmpdir() when no workspace is set
- Memory/soul tools are unaffected — they use ~/BrowserOS/ paths

* fix: sanitize message history when session rebuilds with different tools

When a session is rebuilt due to workspace or MCP changes, the carried-over
message history may contain tool parts for tools that no longer exist in
the new session. The AI SDK validates messages against the current toolset
and rejects parts with no matching schema.

- Add toolNames getter to AiSdkAgent exposing registered tool names
- Add sanitizeMessagesForToolset() to strip tool parts referencing
  removed tools from carried-over messages
- Apply sanitization in both MCP and workspace session rebuilds

* fix: prepend tool-change context to user message on session rebuild

When workspace or MCP integrations change mid-conversation, prepend a
[Context: ...] block to the user's message explaining what changed.
This prevents the LLM from hallucinating tool usage based on patterns
in the carried-over conversation history.

Context messages vary by change type:
- Workspace removed: lists unavailable filesystem tools, suggests
  selecting a working directory
- Workspace added: confirms filesystem tools are available with path
- Workspace switched: notes the new working directory
- MCP changed: notes that some integration tools may have changed

Only fires on the first message after a rebuild. Invisible in the UI.

* fix: make MCP change context specific about which apps were added/removed

Diff the old and new MCP server keys to produce specific context like:
- "The following app integrations were disconnected: Gmail, Slack."
- "The following app integrations were connected: Linear."
instead of a generic "some tools may no longer be available" message.

* refactor: extract shared rebuildSession helper in ChatService

Eliminates the duplicated 20-line dispose→create→sanitize→store flow
that existed separately in both the MCP and workspace change-detection
blocks.

Co-authored-by: Dani Akash <DaniAkash@users.noreply.github.com>

* test: add sanitizeMessagesForToolset test suite

Tests for the message sanitization that runs when a session rebuilds
with a different toolset (workspace or MCP change mid-conversation):

- Preserves messages with no tool parts
- Preserves tool parts when tool is in the toolset
- Strips tool parts when tool is NOT in the toolset
- Strips multiple removed tool parts from same message
- Keeps browser tools while removing filesystem tools
- Removes messages that become empty after stripping
- Preserves non-tool parts (reasoning, step-start, file)
- Returns same references when no filtering needed
- Handles empty message array and empty toolset

* style: fix biome formatting in chat-service.ts

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-03-27 18:30:25 +05:30
Dani Akash
aacb47f7ee feat: isolate new-tab agent navigation from origin tab (#593)
* feat: isolate new-tab agent navigation from origin tab

Add origin-aware navigation isolation so the agent never navigates
away from the new-tab chat UI. This is a two-layer defense:

1. Prompt adaptation: When origin is 'newtab', the system prompt's
   execution and tool-selection sections are rewritten to prohibit
   navigating the active tab and default all lookups to new_page.

2. Tool-level guards: navigate_page and close_page reject attempts
   to act on the origin tab when in newtab mode, returning an error
   that teaches the agent to self-correct.

The client now sends an `origin` field ('sidepanel' | 'newtab')
instead of injecting a soft NEWTAB_SYSTEM_PROMPT that LLMs could
ignore. Backwards compatible — defaults to 'sidepanel'.

Closes TKT-592, addresses TKT-564

* test: add newtab origin navigation guard tests

- 14 new prompt tests verifying the system prompt adapts correctly
  for newtab vs sidepanel origin (execution rules, tool selection table,
  absence of conflicting single-tab guidance)
- 6 new integration tests for navigate_page and close_page guards:
  rejects origin tab in newtab mode, allows non-origin tabs, allows
  all tabs in sidepanel mode, backwards compatible with no session
2026-03-27 12:06:32 +05:30
Dani Akash
b3003542d8 docs: overhaul READMEs across all major packages (#594)
* docs: overhaul READMEs across all major packages

- Root README: restructure with feature table, LLM provider table,
  comparison matrix, architecture map, and docs link
- New: packages/browseros/README.md (Chromium fork build system)
- New: apps/server/README.md (MCP server + agent loop)
- New: packages/cdp-protocol/README.md (CDP type bindings)
- Polish: agent-sdk (badges, prerequisites, multi-step example, links)
- Polish: cli (badges, install section, MCP server section, links)
- Polish: agent extension (badges, WXT mention, architecture context)
- Polish: eval (badges, paper links)

* fix: address review — consistent tool count and correct default port

- CLI README: "54 MCP tools" → "53+ MCP tools" to match root and server docs
- Agent SDK README: localhost:3000 → localhost:9100 to match documented default

* docs: add detailed comparison links to How We Compare section

* docs: update comparison table with verified competitor data

Research all 5 competitors via official websites and docs:
- Chrome: no AI agent, Gemini Nano only, MV3 weakening ad blocking
- Brave: BYOM feature, local models via BYOM, Shields ad blocking, MV2+MV3
- Dia: Skills-based AI, no BYOK, cloud AI, acquired by Atlassian
- Comet: full cloud-based agent, built-in ad blocking, extensions on desktop
- Atlas: standalone Chromium browser with Agent Mode, 30-day cloud memory

Renamed Arc/Dia column to just Dia (Arc is sunset).

* docs: simplify comparison table with clean checkmarks and key differentiators

* docs: update browseros-agent README — remove submodule note, add missing packages
2026-03-27 11:59:04 +05:30
Nikhil
aba7a10430 chore: server release (#592) 2026-03-26 19:13:56 -07:00
Nikhil
b7462aa042 fix(cli): move install instructions below What's Changed in release notes (#591)
The installer block was appearing above the changelog. Reorder so
What's Changed comes first and install instructions follow.
2026-03-26 18:16:23 -07:00
Nikhil
883bcc9670 fix: clean up README CLI wording and add Vertical Tabs feature (#590)
- Simplify CLI section: remove confusing MCP jargon, clarify it works
  from terminal and AI coding agents
- Replace "point the CLI at your MCP server" with plain language
- Add Vertical Tabs to the features list
2026-03-26 18:05:54 -07:00
Nikhil
279b41fdc4 feat(cli): add install commands to GitHub release notes (#589)
* feat(cli): add install commands to release notes

* fix(cli): add install header to release workflow
2026-03-26 18:04:58 -07:00
Nikhil
220577b41c feat: add CDN-hosted CLI installer flow (#588)
* feat: add CDN upload flow for cli installers

* fix: move cli install docs to top-level readme

* fix: bun.lock update
2026-03-26 17:41:03 -07:00
Nikhil
03b45013a6 feat(cli): add install scripts for macOS, Linux, and Windows (#587)
* feat(cli): add install scripts for macOS, Linux, and Windows

Bash script (install.sh) for macOS/Linux and PowerShell script
(install.ps1) for Windows. Both download the correct platform binary
from GitHub Releases with checksum verification, version resolution,
and PATH setup.

* fix(cli): address PR review comments for install scripts

- Add checksum verification to install.ps1 using Get-FileHash
- Add warnings on all checksum skip paths in install.sh
- Use grep -F (fixed-string) instead of regex for filename matching
- Add ?per_page=100 to GitHub API call in install.ps1
- Use random temp directory name in install.ps1 to avoid collisions

* fix(cli): address installer review feedback
2026-03-26 17:05:21 -07:00
shivammittal274
aa85907212 Feat/cli launch ready v2 (#582)
* fix(cli): use full path for dist artifacts in release step

* test: temporarily allow release workflow on any branch

* fix(cli): restore main-only guard, remove goreleaser dependency

Replaces GoReleaser (Pro-only monorepo feature) with plain go build.
Tested: RC release created successfully on branch with all 6 binaries.
2026-03-27 01:28:04 +05:30
Nikhil
085352a6f0 fix(ui): resolve MCP promo banner dismiss button overlapping with text (#581)
Move dismiss button from absolute positioning to inline flex child,
preventing it from overlapping with the "Set up" button.
2026-03-26 12:54:00 -07:00
shivammittal274
c0578d0e53 Feat/cli launch ready v2 (#580)
* fix(cli): update goreleaser tag_prefix to match browseros-cli-v* format

* fix(cli): replace goreleaser with plain go build for releases

GoReleaser free version cannot parse prefixed tags (browseros-cli-v*).
monorepo.tag_prefix is a Pro-only feature.

Replaced with direct go build + gh release create:
- Builds all 6 targets with go build (verified locally)
- Creates tar.gz/zip archives with checksums
- Uses gh release create to publish
- No external tool dependency
2026-03-27 01:12:25 +05:30
shivammittal274
663c18ee97 fix(cli): update goreleaser tag_prefix to match browseros-cli-v* format (#579) 2026-03-27 01:07:36 +05:30
Dani Akash
48727750b4 fix: change CLI tag format from cli/v* to browseros-cli-v* (#578)
GoReleaser free cannot parse slash-prefixed tags (cli/v0.0.1) as semver.
Switch to browseros-cli-v0.0.1 format which is valid semver after
stripping the prefix. Remove the monorepo config (GoReleaser Pro only).
2026-03-27 00:58:13 +05:30
Dani Akash
30a3a96a57 fix: add monorepo tag prefix for goreleaser to parse cli/ tags (#576) 2026-03-27 00:50:38 +05:30
shivammittal274
6773ce39da ci(cli): manual dispatch release workflow (#574)
* ci(cli): change release workflow to manual dispatch from main

- Trigger via Actions UI with a version input (e.g. "0.1.0")
- Only runs on main branch
- Creates git tag cli/v<version> automatically
- Then GoReleaser builds all 6 binaries and creates the GitHub Release

* feat: add scoped release notes, changelog PR, and idempotent tags to CLI workflow

- Add concurrency group to prevent parallel releases
- Add scoped release notes from commits touching the CLI directory
- Pass release notes to goreleaser via --release-notes flag
- Make tag creation idempotent for safe re-runs
- Tag the saved release SHA, not HEAD after branching
- Add CHANGELOG.md and auto-update via PR with auto-merge
- Add pull-requests: write permission

---------

Co-authored-by: Dani Akash <DaniAkash@users.noreply.github.com>
2026-03-27 00:41:08 +05:30
github-actions[bot]
342a3e4a07 docs: update agent extension changelog for v0.0.52 (#573)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-26 19:01:46 +00:00
Dani Akash
09406ea794 feat: add release workflow for agent extension (#572)
* feat: add release workflow for agent extension

Adds a workflow_dispatch workflow that builds the WXT extension,
creates a .zip for sideloading, generates scoped release notes with
contributors and PR links, creates a GitHub release with the zip
attached, and opens an auto-merge PR to update CHANGELOG.md.

* fix: correct API URL to api.browseros.com

* fix: remove duplicate PR numbers and contributors from extension release notes

Apply the same fixes from the agent-sdk workflow:
- Skip PR number if already in commit subject (squash merges)
- Remove custom Contributors section (GitHub auto-generates one)
- Clean up unused variables

* fix: use absolute path for extension zip in release upload

* fix: wxt zip already builds, use correct output path

- Remove separate build step since wxt zip runs the build internally
- Fix zip path from .output/*.zip to dist/*-chrome.zip

* fix: run codegen before wxt zip to generate graphql types
2026-03-27 00:29:47 +05:30
Dani Akash
1f00cbc9cc feat: add release workflow for agent extension (#566)
* feat: add release workflow for agent extension

Adds a workflow_dispatch workflow that builds the WXT extension,
creates a .zip for sideloading, generates scoped release notes with
contributors and PR links, creates a GitHub release with the zip
attached, and opens an auto-merge PR to update CHANGELOG.md.

* fix: correct API URL to api.browseros.com

* fix: remove duplicate PR numbers and contributors from extension release notes

Apply the same fixes from the agent-sdk workflow:
- Skip PR number if already in commit subject (squash merges)
- Remove custom Contributors section (GitHub auto-generates one)
- Clean up unused variables

* fix: use absolute path for extension zip in release upload

* fix: wxt zip already builds, use correct output path

- Remove separate build step since wxt zip runs the build internally
- Fix zip path from .output/*.zip to dist/*-chrome.zip
2026-03-27 00:23:04 +05:30
Dani Akash
422a829f5e fix: remove duplicate PR numbers and contributors from release notes (#571)
- Skip adding PR number if already present in the commit subject
  (squash merges include "(#123)" automatically)
- Remove custom Contributors section since GitHub auto-generates one
  with avatars at the bottom of every release
2026-03-27 00:07:13 +05:30
github-actions[bot]
ed109fcedf docs: update agent-sdk changelog for v0.0.7 (#570)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-26 18:31:39 +00:00
Dani Akash
19af96d08e chore: bump @browseros-ai/agent-sdk to 0.0.7 (#569) 2026-03-27 00:00:35 +05:30
Dani Akash
e0304b203c chore: bump @browseros-ai/agent-sdk to 0.0.6 (#568) 2026-03-26 23:53:35 +05:30
Nikhil
af65bdbcfb feat(build): add build:server:ci script with --compile-only flag (#567)
Add a compile-only mode to the server build pipeline for CI/CD
environments that don't have R2 credentials. The --compile-only flag
skips resource staging and upload, producing only compiled binaries.
2026-03-26 11:21:39 -07:00
Dani Akash
d79c2a4123 feat: create GitHub release with changelog on agent-sdk publish (#564)
* feat: create GitHub release with changelog on agent-sdk publish

After publishing to npm, the workflow now:
- Tags the commit as agent-sdk-v<version>
- Generates release notes from commits that modified the agent-sdk
  directory since the last agent-sdk release tag
- Creates a GitHub release with those notes

First release will show "Initial release" since no previous tag exists.

* feat: update CHANGELOG.md on agent-sdk release

Add a CHANGELOG.md for @browseros-ai/agent-sdk and update the release
workflow to prepend a versioned entry with the release notes before
creating the GitHub release. The changelog is committed to main
automatically.

* fix: address review issues in agent-sdk release workflow

- Add explicit permissions: contents: write
- Replace sed with head/tail for safe CHANGELOG insertion (fixes
  double-quote and backslash corruption in commit messages)
- Handle empty release notes with "No notable changes." fallback
- Make git tag idempotent for workflow reruns (2>/dev/null || true)

* fix: use PR with auto-merge for changelog updates

Direct push to main fails due to branch protection requiring PRs.
Instead, create a branch, open a PR, and auto-merge via squash.

* feat: add contributors and PR links to agent-sdk release notes

Release notes now include PR numbers (linked automatically by GitHub),
GitHub usernames for each commit author, and a contributors section
at the bottom. All scoped to commits that modified the agent-sdk path.

* fix: reorder release steps and fix tag/idempotency issues

- Capture release SHA before any branching so the tag always points
  to the main commit that was built and published to npm
- Reorder: generate notes → publish → tag/release → changelog PR
  (changelog is lowest-stakes, runs last)
- Make tag push and release create idempotent for safe re-runs
  (fall back to gh release edit if release already exists)
- Add || true to gh pr merge --auto in case auto-merge is not enabled
- Explicit git checkout main before creating changelog branch

* fix: explicit error handling for tag/release and contributor dedup

- Replace silent || true guards with explicit checks that log what's
  happening (tag exists, remote tag exists, release exists) so errors
  are visible instead of swallowed
- Fix contributor dedup: use grep -qw (word match) instead of grep -qF
  (substring match) so "dan" isn't excluded when "dansmith" exists

* fix: exclude current version tag when finding previous release

On re-runs, the current version's tag already exists on the remote, so
PREV_TAG resolves to it and git log produces empty output. Filter it
out so release notes are generated against the actual previous version.

* ci: prevent concurrent agent-sdk release runs

Add concurrency group so multiple dispatches queue instead of racing
on the same tag/release/PR.
2026-03-26 23:38:14 +05:30
shivammittal274
e3d57e5347 feat(cli): production-ready CLI with auto-launch, install, and cross-platform builds (#555)
* feat(cli): production-ready CLI with auto-launch, install, and cross-platform builds

- init: accept URL argument and --auto flag for non-interactive setup
- install: new command to download BrowserOS app for current platform
- launch: auto-detect and launch BrowserOS when server is not running
- discovery: prefer server.json (live) over config.yaml (may be stale)
- errors: actionable messages guiding users to init/install
- goreleaser: cross-platform builds for 6 targets (darwin/linux/windows × amd64/arm64)
- ci: GitHub Actions workflow to release CLI binaries on cli/v* tag push

* fix(cli): check health status code and add progress dots during launch

- Health check in newClient() now verifies HTTP 200, not just no error
- waitForServer prints dots during the 30s poll so users know it's working

* refactor(cli): make launch an explicit command, remove auto-launch from newClient

- launch: new explicit command to find and open BrowserOS app
- launch: probes server.json, config, and common ports before launching
- launch: if already running, reports URL instead of launching again
- init --auto: uses port probing to find running servers
- install --deb: errors on non-Linux instead of silently downloading DMG
- error messages: guide users to launch/install/init explicitly
- removed: auto-launch from newClient() — CLI never does something surprising

* fix(cli): platform-native detection, launch, and install for all OSes

Detection (isBrowserOSInstalled):
- macOS: uses `open -Ra` to query Launch Services (no hardcoded paths)
- Linux: checks /usr/bin/browseros (.deb), browseros.desktop, AppImage search
- Windows: checks %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe
  and HKCU/HKLM uninstall registry keys

Launch (startBrowserOS):
- macOS: `open -b com.browseros.BrowserOS` (bundle ID, not path)
- Linux: `browseros` binary, AppImage, or `gtk-launch browseros`
  (fixed: was using xdg-open which opens by MIME type, not desktop files)
- Windows: runs BrowserOS.exe from known Chromium per-user install path
  (fixed: was using `cmd /c start BrowserOS` which doesn't resolve)

Install (runPostInstall):
- macOS: hdiutil attach → cp -R to /Applications → hdiutil detach
- Linux: chmod +x for AppImage, dpkg -i instruction for .deb
- Windows: launches installer exe
- --deb flag now errors on non-Linux platforms

Removed auto-launch from newClient() — CLI never does surprising things.

Sources verified from:
- packages/browseros/build/common/context.py (binary names per platform)
- packages/browseros/build/modules/package/linux.py (.deb structure, .desktop file)
- packages/browseros/chromium_patches/chrome/install_static/chromium_install_modes.h
  (Windows base_app_name="BrowserOS", registry GUID, install paths)
- /Applications/BrowserOS.app/Contents/Info.plist (bundle ID)
2026-03-26 23:12:55 +05:30
Dani Akash
392312f203 ci: only run PR title validation on open and edit (#565)
Remove synchronize and reopened triggers since this workflow only
validates the PR title, which doesn't change on new commits or reopen.
2026-03-26 23:06:11 +05:30
Dani Akash
0f193055c7 fix: broaden connection error detection for main page and sidepanel (#563)
* fix: broaden connection error detection for main page and sidepanel

The connection error check required both "Failed to fetch" AND
"127.0.0.1" in the error message. On the main page, the browser
only produces "Failed to fetch" without the IP, so users saw a
generic "Something went wrong" instead of the troubleshooting link.

Broaden detection to also match "localhost" and bare "Failed to fetch"
errors that don't contain an external URL. Also pass providerType in
NewTabChat so provider-specific errors render correctly.

Closes #526

* fix: simplify connection error detection

All chat requests go through the local BrowserOS agent server, so any
"Failed to fetch" error is always a local connection issue. Remove the
unnecessary 127.0.0.1/localhost/URL checks.

* fix: pass providerType to agentUrlError ChatError instances
2026-03-26 20:55:40 +05:30
Dani Akash
f45cb58889 fix: stop sending port-in-use errors to Sentry (#558)
Port conflicts are expected — Chromium retries with a different port.
These errors were flooding Sentry (14k+ events) without user impact.

- handleStartupError: move Sentry.captureException below the
  port-in-use check so it only fires for unexpected startup errors
- handleControllerStartupError: skip Sentry capture for port errors
- index.ts: exit early for port errors before Sentry capture
2026-03-26 09:32:18 +05:30
shivammittal274
37ead6d129 fix: add cursor-pointer to credit badge in sidepanel (#554) 2026-03-26 00:09:58 +05:30
Nikhil
5ea9463030 fix: widen scheduled task results dialog and add horizontal scroll for tables (#549)
- Change dialog width from sm:max-w-2xl (672px) to sm:w-[70vw] sm:max-w-4xl
  so it takes 70% of viewport width, capped at 896px
- Add overflow-x-auto on table wrappers so wide tables scroll horizontally
  instead of being clipped

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:27:46 -07:00
shivammittal274
dde35ccbd5 feat: integrate models.dev for dynamic LLM provider/model data (#547)
* feat: integrate models.dev for dynamic LLM provider/model data (#TKT-657)

Replace hardcoded model lists with data sourced from models.dev so new
providers and models appear automatically when the community adds them.

- Add build script (scripts/generate-models.ts) that fetches models.dev/api.json
  and outputs a compact JSON with 10 providers and 520 models
- Replace hardcoded MODELS_DATA (50 models) with dynamic models.dev lookups
- Add searchable model combobox (Popover + Command) replacing plain Select dropdown
- Enrich provider templates with models.dev metadata (context window, image support)
- Keep chatgpt-pro, qwen-code, browseros, openai-compatible as hardcoded providers

* fix: address review — remove ollama-cloud mapping, fix default models, remove dead code

- Remove ollama from PROVIDER_MAP (ollama-cloud has cloud models, not local)
- Add ollama to CUSTOM_PROVIDER_MODELS with empty list (users type custom IDs)
- Update defaultModelIds to ones that exist in models.dev data:
  openrouter → anthropic/claude-sonnet-4.5
  lmstudio → openai/gpt-oss-20b
  bedrock → anthropic.claude-sonnet-4-6
- Remove dead isCustomModel export
- Regenerate models-dev-data.json (9 providers, 486 models)

* fix: model suggestion list focus/dismiss behavior

- List only opens when input is focused or user types
- Clicking a model selects it and closes the list
- Clicking outside (blur) dismisses the list
- onMouseDown preventDefault on list items prevents blur race condition

* refactor: extract ModelPickerList component with proper open/close UX

- Collapsed state: Select-like trigger showing selected model + chevron
- Expanded state: search input + scrollable filtered list, inline
- Click outside or Escape to close, Enter to submit custom model
- Extracted as separate component (reduces dialog nesting, testable)
- No more setTimeout hacks for blur handling

* chore: remove plan doc from repo
2026-03-25 02:41:07 +05:30
164 changed files with 12985 additions and 4723 deletions

View File

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

View File

@@ -0,0 +1,148 @@
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,16 +3,25 @@ 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
@@ -31,7 +40,129 @@ 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 }}

143
.github/workflows/release-cli.yml vendored Normal file
View File

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

214
README.md
View File

@@ -6,6 +6,7 @@
[![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)" />
@@ -22,146 +23,183 @@
<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.
💡 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).
> **[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)**
## 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)
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
2. Import your Chrome data (optional)
## Features
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
| 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) |
## 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/>
## Why We're Building BrowserOS
## Install `browseros-cli`
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.
Use `browseros-cli` to launch and control BrowserOS from the terminal or from AI coding agents like Claude Code.
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.
**macOS / Linux:**
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!
```bash
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
```
## How we compare
**Windows:**
<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>
```powershell
irm https://cdn.browseros.com/cli/install.ps1 | iex
```
<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>
After install, run `browseros-cli init` to connect the CLI to your running BrowserOS instance.
<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>
## LLM Providers
<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>
BrowserOS works with any LLM. Bring your own keys, use OAuth, or run models locally.
<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>
| 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 |
## Contributing
We'd love your help making BrowserOS better!
We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRIBUTING.md) for details.
- 🐛 [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)
- [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.
## 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

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

View File

@@ -81,6 +81,9 @@ 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,8 +1,6 @@
# BrowserOS Agent
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
The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserOS) — contains the MCP server, agent UI, CLI, evaluation framework, and SDK.
## Monorepo Structure
@@ -10,17 +8,25 @@ Monorepo for the BrowserOS-agent -- contains 3 packages: agent-UI, server (which
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/controller-ext` | BrowserOS Controller - Chrome extension that bridges `chrome.*` APIs (tabs, bookmarks, history) to the server via WebSocket |
| `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 |
| `packages/shared` | Shared constants used across packages |
## Architecture

View File

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

View File

@@ -1,16 +1,24 @@
# BrowserOS Agent Chrome Extension
# BrowserOS Agent Extension
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.
[![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).
## 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 Core
- **Side Panel Chat**: Full-featured chat interface for interacting with BrowserOS
- **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
```
@@ -80,47 +88,20 @@ 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 Core running locally (for full functionality)
- BrowserOS Server running locally (for full functionality)
### Setup
```bash
# Copy environment file
cp .env.example .env.development
# Install dependencies
bun install
@@ -153,12 +134,30 @@ 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 |
@@ -169,4 +168,5 @@ GRAPHQL_SCHEMA_PATH=/path/to/api-repo/.../schema.graphql
| `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:max-w-2xl">
<DialogContent className="sm:w-[70vw] sm:max-w-4xl">
<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">
<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">
<MessageResponse>{run.result}</MessageResponse>
</div>
) : (

View File

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

View File

@@ -17,7 +17,7 @@ export const McpPromoBanner: FC = () => {
}
return (
<div className="relative flex items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:shadow-md">
<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>
@@ -48,7 +48,7 @@ export const McpPromoBanner: FC = () => {
<button
type="button"
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-sm p-1 text-muted-foreground opacity-50 transition-opacity hover:opacity-100"
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>

View File

@@ -1,6 +1,13 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { CheckCircle2, ExternalLink, Loader2, XCircle } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import {
CheckCircle2,
ChevronDown,
ExternalLink,
Loader2,
SearchIcon,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod/v3'
import { Button } from '@/components/ui/button'
@@ -47,7 +54,12 @@ 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 { getModelContextLength, getModelOptions } from './models'
import { cn } from '@/lib/utils'
import {
getModelContextLength,
getModelsForProvider,
type ModelInfo,
} from './models'
const providerTypeEnum = z.enum([
'moonshot',
@@ -163,6 +175,107 @@ 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
@@ -188,9 +301,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()
@@ -261,8 +374,7 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
watchedSessionToken,
])
// Get model options for current provider type
const modelOptions = getModelOptions(watchedType as ProviderType)
const modelInfoList = getModelsForProvider(watchedType as ProviderType)
// Handle provider type change (user-initiated via Select)
const handleTypeChange = (newType: ProviderType) => {
@@ -272,14 +384,13 @@ 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 && watchedModelId !== 'custom') {
if (watchedModelId) {
const contextLength = getModelContextLength(
watchedType as ProviderType,
watchedModelId,
@@ -290,17 +401,6 @@ 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) {
@@ -325,7 +425,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
reasoningEffort: initialValues.reasoningEffort || 'high',
reasoningSummary: initialValues.reasoningSummary || 'auto',
})
setIsCustomModel(false)
}
}, [initialValues, form])
@@ -352,7 +451,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
reasoningEffort: 'high',
reasoningSummary: 'auto',
})
setIsCustomModel(false)
}
// Clear test result when dialog opens/closes
setTestResult(null)
@@ -811,52 +909,51 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
control={form.control}
name="modelId"
render={({ field }) => (
<FormItem>
<FormItem className="flex flex-col">
<FormLabel>Model *</FormLabel>
{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>
)}
</>
{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)}
/>
) : (
<Select
onValueChange={handleModelChange}
value={field.value}
<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',
)}
>
<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>
<span className="truncate">
{field.value || 'Select a model...'}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
)}
<FormMessage />
</FormItem>

View File

@@ -1,98 +1,21 @@
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
}
/**
* 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': [],
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: [],
const CUSTOM_PROVIDER_MODELS: Partial<Record<ProviderType, ModelInfo[]>> = {
browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }],
'openai-compatible': [],
ollama: [],
'chatgpt-pro': [
{ modelId: 'gpt-5.4', contextLength: 400000 },
{ modelId: 'gpt-5.3-codex', contextLength: 400000 },
@@ -103,32 +26,6 @@ export const MODELS_DATA: ModelsData = {
{ 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 },
@@ -137,25 +34,23 @@ export const MODELS_DATA: ModelsData = {
],
}
/**
* Get models for a specific provider type
*/
function fromModelsDevModel(m: ModelsDevModel): ModelInfo {
return {
modelId: m.id,
contextLength: m.contextWindow,
supportsImages: m.supportsImages,
supportsReasoning: m.supportsReasoning,
supportsToolCall: m.supportsToolCall,
}
}
export function getModelsForProvider(providerType: ProviderType): ModelInfo[] {
return MODELS_DATA[providerType] || []
const custom = CUSTOM_PROVIDER_MODELS[providerType]
if (custom !== undefined) return custom
return getModelsDevModels(providerType).map(fromModelsDevModel)
}
/**
* 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,
@@ -164,14 +59,3 @@ 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

@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
import localforage from 'localforage'
import { clear } from 'idb-keyval'
import { Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect } from 'react'
@@ -25,7 +25,7 @@ export const LogoutPage: FC = () => {
await providersStorage.removeValue()
await scheduledJobStorage.removeValue()
queryClient.clear()
await localforage.clear()
await clear()
resetIdentity()
await signOut()

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ interface ConversationListProps {
hasNextPage?: boolean
isFetchingNextPage?: boolean
onLoadMore?: () => void
isRefreshing?: boolean
}
export const ConversationList: FC<ConversationListProps> = ({
@@ -21,6 +22,7 @@ export const ConversationList: FC<ConversationListProps> = ({
hasNextPage,
isFetchingNextPage,
onLoadMore,
isRefreshing,
}) => {
const loadMoreRef = useRef<HTMLDivElement>(null)
@@ -57,6 +59,12 @@ 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(last: 5, orderBy: ORDER_INDEX_ASC) {
conversationMessages(first: 2, orderBy: ORDER_INDEX_DESC) {
nodes {
message
}

View File

@@ -224,7 +224,12 @@ export const Chat = () => {
onDismissJtbdPopup={onDismissJtbdPopup}
/>
)}
{agentUrlError && <ChatError error={agentUrlError} />}
{agentUrlError && (
<ChatError
error={agentUrlError}
providerType={selectedProvider?.type}
/>
)}
{chatError && (
<ChatError error={chatError} providerType={selectedProvider?.type} />
)}

View File

@@ -34,11 +34,9 @@ function parseErrorMessage(
} {
const isBrowserosProvider = providerType === 'browseros'
// Detect MCP server connection failures (universal — affects all providers)
if (
(message.includes('Failed to fetch') || message.includes('fetch failed')) &&
message.includes('127.0.0.1')
) {
// 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')) {
return {
text: 'Unable to connect to BrowserOS agent. Follow below instructions.',
url: 'https://docs.browseros.com/troubleshooting/connection-issues',

View File

@@ -76,8 +76,6 @@ 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,
@@ -344,12 +342,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
reasoningEffort: provider?.reasoningEffort,
reasoningSummary: provider?.reasoningSummary,
browserContext,
userSystemPrompt:
options?.origin === 'newtab'
? [personalizationRef.current, NEWTAB_SYSTEM_PROMPT]
.filter(Boolean)
.join('\n\n')
: personalizationRef.current,
origin: options?.origin ?? 'sidepanel',
userSystemPrompt: personalizationRef.current,
userWorkingDir: workingDirRef.current,
supportsImages: provider?.supportsImages,
previousConversation,

View File

@@ -1,7 +1,10 @@
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import localforage from 'localforage'
import {
type AsyncStorage,
PersistQueryClientProvider,
} from '@tanstack/react-query-persist-client'
import { del, get, set } from 'idb-keyval'
import type { FC, ReactNode } from 'react'
const queryClient = new QueryClient({
@@ -12,8 +15,14 @@ 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: localforage,
storage: idbStorage,
})
export const QueryProvider: FC<{ children: ReactNode }> = ({ children }) => {

File diff suppressed because it is too large Load Diff

View File

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

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.90.21",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query-persist-client": "^5.90.21",
"@tanstack/query-async-storage-persister": "^5.95.2",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-persist-client": "^5.95.2",
"@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

@@ -0,0 +1,10 @@
# Production build env for CLI
POSTHOG_API_KEY=
# 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 +1,2 @@
browseros-cli
dist

View File

@@ -0,0 +1,50 @@
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 }} -X browseros-cli/analytics.posthogAPIKey={{ .Env.POSTHOG_API_KEY }}
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

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

View File

@@ -1,14 +1,16 @@
BINARY := browseros-cli
SOURCES := $(shell find . -name '*.go')
VERSION ?= dev
POSTHOG_API_KEY ?=
LDFLAGS := -X main.version=$(VERSION) -X browseros-cli/analytics.posthogAPIKey=$(POSTHOG_API_KEY)
$(BINARY): $(SOURCES)
go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY) .
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
.PHONY: install clean vet test
install:
go install -ldflags "-X main.version=$(VERSION)" .
go install -ldflags "$(LDFLAGS)" .
clean:
rm -f $(BINARY)
@@ -18,3 +20,9 @@ vet:
test:
go test -tags integration -v -timeout 120s ./...
release-dry:
goreleaser release --snapshot --clean
release:
goreleaser release --clean

View File

@@ -1,25 +1,58 @@
# browseros-cli
Command-line interface for controlling BrowserOS via MCP. Talks to the BrowserOS MCP server over JSON-RPC 2.0 / StreamableHTTP.
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](../../../../LICENSE)
## Setup
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
Requires Go 1.25+.
```bash
# Build
make
# First run — configure server connection
./browseros-cli init
make # Build binary
make install # Install to $GOPATH/bin
```
The `init` command prompts for your MCP server URL. Find it in:
**BrowserOS → Settings → BrowserOS MCP → Server URL**
## Quick Start
The port varies per installation (e.g., `http://127.0.0.1:9004/mcp`).
```bash
# If BrowserOS is not installed yet
browseros-cli install # downloads BrowserOS for your platform
Config is saved to `~/.config/browseros-cli/config.yaml`.
# 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).
## Usage
@@ -67,6 +100,12 @@ 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 |
@@ -77,9 +116,9 @@ browseros-cli group list
| `--debug` | `BOS_DEBUG=1` | Debug output |
| `--timeout, -t` | | Request timeout (default: 2m) |
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > config file
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > `~/.browseros/server.json` > config file
If no server URL is configured, the CLI exits with setup instructions instead of assuming a localhost port.
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init`.
## Testing
@@ -130,7 +169,9 @@ apps/cli/
│ └── config.go # Config file (~/.config/browseros-cli/config.yaml)
├── cmd/
│ ├── root.go # Root command, global flags
│ ├── init.go # Server URL configuration
│ ├── init.go # Server URL configuration (URL arg, --auto, interactive)
│ ├── install.go # install (download BrowserOS for current platform)
│ ├── launch.go # launch (find and start BrowserOS, wait for server)
│ ├── open.go # open (new_page / new_hidden_page)
│ ├── nav.go # nav, back, forward, reload
│ ├── pages.go # pages, active, close
@@ -163,4 +204,8 @@ The CLI communicates with BrowserOS via two HTTP POST requests per command:
1. `initialize` — MCP handshake
2. `tools/call` — execute the actual tool
All 54 MCP tools are mapped to CLI commands.
## Links
- [Documentation](https://docs.browseros.com)
- [MCP Setup Guide](https://docs.browseros.com/features/use-with-claude-code)
- [Changelog](./CHANGELOG.md)

View File

@@ -0,0 +1,129 @@
package analytics
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"browseros-cli/config"
"github.com/posthog/posthog-go"
)
var (
posthogAPIKey string // set via ldflags
posthogHost = "https://us.i.posthog.com"
)
const eventPrefix = "browseros.cli."
var svc *service
type service struct {
client posthog.Client
distinctID string
}
func Init(version string) {
if posthogAPIKey == "" {
return
}
distinctID := resolveDistinctID()
if distinctID == "" {
return
}
client, err := posthog.NewWithConfig(posthogAPIKey, posthog.Config{
Endpoint: posthogHost,
BatchSize: 10,
ShutdownTimeout: 3 * time.Second,
DefaultEventProperties: posthog.NewProperties().
Set("cli_version", version).
Set("os", runtime.GOOS).
Set("arch", runtime.GOARCH),
})
if err != nil {
return
}
svc = &service{client: client, distinctID: distinctID}
}
func Track(command string, success bool, duration time.Duration) {
if svc == nil {
return
}
svc.client.Enqueue(posthog.Capture{
DistinctId: svc.distinctID,
Event: eventPrefix + "command_executed",
Properties: posthog.NewProperties().
Set("command", command).
Set("success", success).
Set("duration_ms", duration.Milliseconds()).
Set("$process_person_profile", false),
})
}
func Close() {
if svc == nil {
return
}
svc.client.Close()
svc = nil
}
func resolveDistinctID() string {
if id := loadBrowserosID(); id != "" {
return id
}
return loadOrCreateInstallID()
}
func loadBrowserosID() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc struct {
BrowserosID string `json:"browseros_id"`
}
if json.Unmarshal(data, &sc) != nil {
return ""
}
return sc.BrowserosID
}
func loadOrCreateInstallID() string {
dir := config.Dir()
idPath := filepath.Join(dir, "install_id")
data, err := os.ReadFile(idPath)
if err == nil {
if id := strings.TrimSpace(string(data)); id != "" {
return id
}
}
id := generateUUID()
os.MkdirAll(dir, 0755)
os.WriteFile(idPath, []byte(id), 0644)
return id
}
func generateUUID() string {
var b [16]byte
rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 2
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

View File

@@ -0,0 +1,132 @@
package analytics
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"testing"
"time"
)
func TestGenerateUUID(t *testing.T) {
id := generateUUID()
uuidRe := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
if !uuidRe.MatchString(id) {
t.Errorf("generateUUID() = %q, does not match UUID v4 pattern", id)
}
id2 := generateUUID()
if id == id2 {
t.Error("generateUUID() returned the same value twice")
}
}
func TestLoadBrowserosID(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
// No server.json → empty
if got := loadBrowserosID(); got != "" {
t.Errorf("loadBrowserosID() = %q, want empty", got)
}
// server.json without browseros_id → empty
dir := filepath.Join(tmp, ".browseros")
os.MkdirAll(dir, 0755)
data, _ := json.Marshal(map[string]any{"server_port": 9100, "url": "http://127.0.0.1:9100"})
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
if got := loadBrowserosID(); got != "" {
t.Errorf("loadBrowserosID() = %q, want empty (no browseros_id field)", got)
}
// server.json with browseros_id → returns it
data, _ = json.Marshal(map[string]any{
"server_port": 9100,
"url": "http://127.0.0.1:9100",
"browseros_id": "test-uuid-1234",
})
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
if got := loadBrowserosID(); got != "test-uuid-1234" {
t.Errorf("loadBrowserosID() = %q, want %q", got, "test-uuid-1234")
}
}
func TestLoadOrCreateInstallID(t *testing.T) {
tmp := t.TempDir()
configDir := filepath.Join(tmp, "browseros-cli")
t.Setenv("XDG_CONFIG_HOME", tmp)
// First call creates the file
id := loadOrCreateInstallID()
if id == "" {
t.Fatal("loadOrCreateInstallID() returned empty string")
}
// File was persisted
data, err := os.ReadFile(filepath.Join(configDir, "install_id"))
if err != nil {
t.Fatalf("install_id file not created: %v", err)
}
if string(data) != id {
t.Errorf("persisted id = %q, want %q", string(data), id)
}
// Second call returns the same ID
id2 := loadOrCreateInstallID()
if id2 != id {
t.Errorf("loadOrCreateInstallID() = %q, want stable %q", id2, id)
}
}
func TestResolveDistinctID_PrefersBrowserosID(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("XDG_CONFIG_HOME", tmp)
// Write server.json with browseros_id
dir := filepath.Join(tmp, ".browseros")
os.MkdirAll(dir, 0755)
data, _ := json.Marshal(map[string]any{"browseros_id": "server-uuid"})
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
got := resolveDistinctID()
if got != "server-uuid" {
t.Errorf("resolveDistinctID() = %q, want %q", got, "server-uuid")
}
}
func TestResolveDistinctID_FallsBackToInstallID(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
t.Setenv("XDG_CONFIG_HOME", tmp)
// No server.json → should generate install_id
got := resolveDistinctID()
if got == "" {
t.Error("resolveDistinctID() returned empty string")
}
}
func TestInitNoopsWithoutAPIKey(t *testing.T) {
old := posthogAPIKey
posthogAPIKey = ""
defer func() { posthogAPIKey = old }()
Init("1.0.0")
if svc != nil {
t.Error("Init() created service without API key")
}
}
func TestTrackAndCloseNoopWithoutInit(t *testing.T) {
old := svc
svc = nil
defer func() { svc = old }()
// Should not panic
Track("test", true, time.Second)
Close()
}

View File

@@ -17,8 +17,10 @@ import (
)
func init() {
var autoDiscover bool
cmd := &cobra.Command{
Use: "init",
Use: "init [url]",
Short: "Configure the BrowserOS server connection",
Long: `Set up the CLI by providing the MCP server URL from BrowserOS.
@@ -26,33 +28,59 @@ 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.`,
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`,
Annotations: map[string]string{"group": "Setup:"},
Args: cobra.NoArgs,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
dim := color.New(color.Faint)
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()
var input string
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)
switch {
case len(args) == 1:
// Non-interactive: URL provided as argument
input = args[0]
if input == "" {
output.Error("no URL provided", 1)
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)
}
}
baseURL := normalizeServerURL(input)
@@ -88,5 +116,6 @@ Run again if your port changes.`,
},
}
cmd.Flags().BoolVar(&autoDiscover, "auto", false, "Auto-discover server URL from ~/.browseros/server.json")
rootCmd.AddCommand(cmd)
}

View File

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

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

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"browseros-cli/analytics"
"browseros-cli/config"
"browseros-cli/mcp"
"browseros-cli/output"
@@ -113,11 +114,27 @@ var rootCmd = &cobra.Command{
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
analytics.Init(version)
start := time.Now()
err := rootCmd.Execute()
analytics.Track(commandName(os.Args[1:]), err == nil, time.Since(start))
analytics.Close()
if err != nil {
os.Exit(1)
}
}
func commandName(args []string) string {
cmd, _, err := rootCmd.Find(args)
if err != nil || cmd == rootCmd {
return "unknown"
}
return cmd.CommandPath()
}
func init() {
cobra.AddTemplateFunc("helpHeader", helpHeader)
cobra.AddTemplateFunc("helpCmdCol", helpCmdCol)
@@ -167,10 +184,17 @@ 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 != "" {
@@ -178,10 +202,6 @@ func defaultServerURL() string {
}
}
if url := loadBrowserosServerURL(); url != "" {
return url
}
return ""
}
@@ -225,6 +245,9 @@ func validateServerURL(raw string) (string, error) {
}
return "", fmt.Errorf(
"BrowserOS server URL is not configured.\n Open BrowserOS -> Settings -> BrowserOS MCP and copy the Server URL.\n Then run: browseros-cli init",
"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",
)
}

View File

@@ -0,0 +1,25 @@
package cmd
import "testing"
func TestCommandName(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{"empty args", nil, "unknown"},
{"known command", []string{"health"}, "browseros-cli health"},
{"unknown command", []string{"nonexistent"}, "unknown"},
{"subcommand", []string{"bookmark", "search"}, "browseros-cli bookmark search"},
{"known with extra args", []string{"snap", "--enhanced"}, "browseros-cli snap"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := commandName(tt.args)
if got != tt.want {
t.Errorf("commandName(%v) = %q, want %q", tt.args, got, tt.want)
}
})
}
}

View File

@@ -4,20 +4,24 @@ go 1.25.7
require (
github.com/fatih/color v1.18.0
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/posthog/posthog-go v1.11.2
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modelcontextprotocol/go-sdk v1.4.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,8 +1,20 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -12,6 +24,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posthog/posthog-go v1.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q=
github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
@@ -21,6 +37,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -28,10 +46,11 @@ golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

@@ -0,0 +1,115 @@
#
# 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 — required for GitHub, older PS 5.1 defaults to TLS 1.0
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Repo = "browseros-ai/BrowserOS"
$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..."
$releases = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases?per_page=100"
$tag = ($releases `
| Where-Object { $_.tag_name -match "^browseros-cli-v" -and $_.tag_name -notmatch "-rc" } `
| Select-Object -First 1).tag_name
if (-not $tag) {
Write-Error "Could not determine latest version. Try: -Version 0.1.0"
exit 1
}
$Version = $tag -replace "^browseros-cli-v", ""
}
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 ─────────────────────────────────────────────────────
$Tag = "browseros-cli-v$Version"
$Filename = "${Binary}_${Version}_windows_${Arch}.zip"
$Url = "https://github.com/$Repo/releases/download/$Tag/$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

@@ -0,0 +1,153 @@
#!/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
REPO="browseros-ai/BrowserOS"
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
# Use per_page=1 with a tag name filter via the releases endpoint.
# The tags all start with "browseros-cli-v" so we grab page 1 of those.
VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100" \
| grep -o '"tag_name": *"browseros-cli-v[^"]*"' \
| grep -v -- "-rc" \
| head -1 \
| sed 's/.*browseros-cli-v//; s/"//')
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
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"
TAG="browseros-cli-v${VERSION}"
URL="https://github.com/${REPO}/releases/download/${TAG}/${FILENAME}"
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${TAG}/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,6 +1,8 @@
# BrowserOS Eval
Evaluation framework for benchmarking BrowserOS browser automation agents. Runs tasks from standard datasets (WebVoyager, Mind2Web), captures trajectories with screenshots, and grades results automatically.
[![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.
## Prerequisites

View File

@@ -1225,7 +1225,7 @@
const score = graders[firstKey].score;
if (typeof score === 'number') {
const pct = Math.round(score * 100);
return { label: pct + '%', cls: pct >= 75 ? 'pass' : 'fail' };
return { label: `${pct}%`, cls: pct >= 75 ? 'pass' : 'fail' };
}
const anyPass = keys.some((k) => graders[k].pass);
return { label: anyPass ? 'PASS' : 'FAIL', cls: anyPass ? 'pass' : 'fail' };

View File

@@ -0,0 +1,181 @@
# BrowserOS Server
MCP server and AI agent loop powering BrowserOS browser automation. This is the core backend — it connects to Chromium via CDP, exposes 53+ MCP tools, and runs the AI agent that interprets natural language into browser actions.
> **Runtime:** [Bun](https://bun.sh) · **Framework:** [Hono](https://hono.dev) · **AI:** [Vercel AI SDK](https://sdk.vercel.ai) · **License:** [AGPL-3.0](../../../../LICENSE)
## Architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ MCP Clients │
│ (Agent UI, Claude Code, Gemini CLI, browseros-cli) │
└──────────────────────────────────────────────────────────────────────┘
│ HTTP / SSE / StreamableHTTP
┌──────────────────────────────────────────────────────────────────────┐
│ BrowserOS Server (Bun) │
│ │
│ /mcp ─────── MCP tool endpoints (53+ tools) │
│ /chat ────── Agent streaming (AI SDK) │
│ /health ─── Health check │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Agent Loop │ │
│ │ ├── Multi-provider AI SDK (OpenAI, Anthropic, Google, ...) │ │
│ │ ├── Session & conversation management │ │
│ │ ├── Context overflow handling + compaction │ │
│ │ └── MCP client for external tool servers │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────┐ ┌────────────────────────────────────┐ │
│ │ CDP Tools │ │ Controller Tools │ │
│ │ (screenshots, │ │ (tabs, bookmarks, history, │ │
│ │ DOM, network, │ │ navigation, tab groups) │ │
│ │ console, input) │ │ │ │
│ └────────────────────┘ └────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
│ │
│ Chrome DevTools Protocol │ WebSocket
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────────┐
│ Chromium CDP │ │ Controller Extension │
│ (port 9000) │ │ (port 9300) │
│ │ │ │
│ DOM, network, │ │ chrome.tabs, chrome.history, │
│ input, screenshots │ │ chrome.bookmarks │
└─────────────────────┘ └─────────────────────────────────┘
```
## MCP Tools
53+ tools organized by category:
| Category | Tools |
|----------|-------|
| **Navigation** | `new_page`, `navigate`, `go_back`, `go_forward`, `reload` |
| **Input** | `click`, `type`, `press_key`, `hover`, `scroll`, `drag`, `fill`, `clear`, `focus`, `check`, `uncheck`, `select_option`, `upload_file` |
| **Observation** | `take_snapshot`, `take_enhanced_snapshot`, `extract_text`, `extract_links` |
| **Screenshots** | `take_screenshot`, `save_screenshot` |
| **Evaluation** | `evaluate_script` |
| **Pages** | `list_pages`, `active_page`, `close_page`, `new_hidden_page` |
| **Windows** | `window_list`, `window_create`, `window_close`, `window_activate` |
| **Bookmarks** | `bookmark_list`, `bookmark_create`, `bookmark_remove`, `bookmark_update`, `bookmark_move`, `bookmark_search` |
| **History** | `history_search`, `history_recent`, `history_delete`, `history_delete_range` |
| **Tab Groups** | `group_list`, `group_create`, `group_update`, `group_ungroup`, `group_close` |
| **Filesystem** | `ls`, `read`, `write`, `edit`, `find`, `grep`, `bash` |
| **Memory** | `read_core`, `update_core`, `read_soul`, `update_soul`, `search_memory`, `write_memory` |
| **DOM** | `dom`, `dom_search` |
| **Console** | `get_console_messages` |
| **Other** | `browseros_info`, `handle_dialog`, `wait_for`, `download`, `export_pdf`, `output_file`, `nudges` |
## Agent Loop
The agent loop uses the [Vercel AI SDK](https://sdk.vercel.ai) to orchestrate multi-step browser automation:
- **Multi-provider support** — OpenAI, Anthropic, Google, Azure, Bedrock, OpenRouter, Ollama, LM Studio, and any OpenAI-compatible endpoint
- **Session management** — conversations persist in a local SQLite database
- **Context overflow handling** — automatic message compaction when context windows fill up
- **MCP client** — connects to external MCP servers for additional tool access (40+ app integrations)
- **Tool adapter** — bridges MCP tool definitions to AI SDK tool format
### Provider Factory
The provider factory (`src/agent/provider-factory.ts`) creates AI SDK providers from runtime configuration, supporting hot-swapping between providers without restart.
## Skills System
Skills are custom instruction sets that shape agent behavior:
- **Catalog** (`src/skills/catalog.ts`) — registry of available skills
- **Defaults** (`src/skills/defaults/`) — built-in skill definitions
- **Loader** (`src/skills/loader.ts`) — loads skills from local and remote sources
- **Remote sync** (`src/skills/remote-sync.ts`) — syncs skills from the BrowserOS cloud
## Graph Executor (Workflows)
The graph executor (`src/graph/executor.ts`) runs visual workflow graphs built in the BrowserOS workflow editor. Each node in the graph maps to agent actions, conditionals, or data transformations.
## Directory Structure
```
apps/server/
├── src/
│ ├── index.ts # Server entry point
│ ├── main.ts # Server initialization
│ ├── api/ # HTTP route handlers
│ ├── agent/ # Agent loop
│ │ ├── ai-sdk-agent.ts # Main agent implementation
│ │ ├── provider-factory.ts# LLM provider factory
│ │ ├── session-store.ts # Conversation persistence
│ │ ├── compaction.ts # Context window management
│ │ ├── mcp-builder.ts # External MCP client setup
│ │ └── tool-adapter.ts # MCP → AI SDK tool bridge
│ ├── browser/ # Browser connection layer
│ ├── tools/ # MCP tool implementations
│ │ ├── navigation.ts
│ │ ├── input.ts
│ │ ├── snapshot.ts
│ │ ├── memory/
│ │ ├── filesystem/
│ │ └── ...
│ ├── skills/ # Skills system
│ ├── graph/ # Workflow graph executor
│ ├── lib/ # Shared utilities
│ └── rpc.ts # JSON-RPC type definitions
├── tests/
│ ├── tools/ # Tool-level tests
│ ├── sdk/ # SDK integration tests
│ └── server.integration.test.ts
├── graph/ # Workflow graph definitions
└── package.json
```
## Development
### Prerequisites
- [Bun](https://bun.sh) runtime
- A running BrowserOS instance (for CDP and controller connections)
### Setup
```bash
# Copy environment files
cp .env.example .env.development
# Start the server (with hot reload)
bun run start
```
See the [agent monorepo README](../../README.md) for full environment variable reference and `process-compose` setup.
### Testing
```bash
bun run test:tools # Tool-level tests
bun run test:integration # Full integration tests (requires running BrowserOS)
bun run test:sdk # SDK integration tests
```
### Building
```bash
# Build cross-platform server binaries
bun run build
# Build for specific targets
bun scripts/build/server.ts --target=darwin-arm64,linux-x64
# Build without uploading to R2
bun scripts/build/server.ts --target=all --no-upload
```
## Ports
| Port | Env Variable | Purpose |
|------|-------------|---------|
| 9100 | `BROWSEROS_SERVER_PORT` | HTTP server (MCP, chat, health) |
| 9000 | `BROWSEROS_CDP_PORT` | Chromium CDP (server connects as client) |
| 9300 | `BROWSEROS_EXTENSION_PORT` | WebSocket for controller extension |

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.79",
"version": "0.0.80",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",

View File

@@ -54,8 +54,14 @@ export class AiSdkAgent {
private _messages: UIMessage[],
private _mcpClients: Array<{ close(): Promise<void> }>,
private conversationId: string,
private _toolNames: Set<string>,
) {}
/** Tool names registered on this agent — used to sanitize messages during session rebuilds. */
get toolNames(): Set<string> {
return this._toolNames
}
static async create(config: AiSdkAgentConfig): Promise<AiSdkAgent> {
const contextWindow =
config.resolvedConfig.contextWindowSize ??
@@ -92,10 +98,15 @@ export class AiSdkAgent {
}
// Build browser tools from the unified tool registry
const originPageId = config.browserContext?.activeTab?.pageId
const allBrowserTools = buildBrowserToolSet(
config.registry,
config.browser,
config.resolvedConfig.workingDir,
{
origin: config.resolvedConfig.origin,
originPageId,
},
)
const browserTools = config.resolvedConfig.chatMode
? Object.fromEntries(
@@ -155,10 +166,11 @@ export class AiSdkAgent {
}
}
// Add filesystem tools (Pi coding agent) — skip in chat mode (read-only)
const filesystemTools = config.resolvedConfig.chatMode
? {}
: buildFilesystemToolSet(config.resolvedConfig.workingDir)
// Add filesystem tools — skip in chat mode (read-only) and when no workspace is selected
const filesystemTools =
!config.resolvedConfig.chatMode && config.resolvedConfig.workingDir
? buildFilesystemToolSet(config.resolvedConfig.workingDir)
: {}
const memoryTools = config.resolvedConfig.chatMode
? {}
: buildMemoryToolSet()
@@ -205,6 +217,7 @@ export class AiSdkAgent {
connectedApps: config.browserContext?.enabledMcpServers,
declinedApps: config.resolvedConfig.declinedApps,
skillsCatalog,
origin: config.resolvedConfig.origin,
})
// Configure compaction for context window management
@@ -263,6 +276,7 @@ export class AiSdkAgent {
[],
clients,
config.resolvedConfig.conversationId,
new Set(Object.keys(tools)),
)
}

View File

@@ -44,3 +44,37 @@ export function hasMessageContent(message: UIMessage): boolean {
export function filterValidMessages(messages: UIMessage[]): UIMessage[] {
return messages.filter(hasMessageContent)
}
/**
* Remove tool parts that reference tools not present in the given toolset.
*
* When a session is rebuilt with a different set of tools (e.g., workspace
* removed mid-conversation or MCP server disconnected), the carried-over
* message history may contain tool parts for tools that no longer exist.
* The AI SDK validates messages against the current toolset and rejects
* parts with no matching schema.
*
* Tool parts use the type format `tool-${toolName}` (static tools) or
* `dynamic-tool` (dynamic tools). This function filters out static tool
* parts whose tool name is not in the provided set.
*/
export function sanitizeMessagesForToolset(
messages: UIMessage[],
toolNames: Set<string>,
): UIMessage[] {
return messages
.map((msg) => {
const filteredParts = msg.parts.filter((part) => {
// Static tool parts have type `tool-${toolName}`
if (typeof part.type === 'string' && part.type.startsWith('tool-')) {
const toolName = part.type.slice(5)
if (!toolNames.has(toolName)) return false
}
return true
})
if (filteredParts.length === msg.parts.length) return msg
return { ...msg, parts: filteredParts }
})
.filter(hasMessageContent)
}

View File

@@ -193,19 +193,41 @@ You have persistent memory across sessions and an evolving personality. See the
function getExecution(
_exclude: Set<string>,
_options?: BuildSystemPromptOptions,
options?: BuildSystemPromptOptions,
): string {
return `<execution>
const isNewTab = options?.origin === 'newtab'
let executionContent = `<execution>
## Execution
### Philosophy
- Execute tasks end-to-end. Don't delegate ("I found the button, you can click it").
- Don't ask permission for routine steps. Act, then report.
- Do not refuse by default, attempt tasks even when outcomes are uncertain.
- For ambiguous/unclear requests, ask one targeted clarifying question.
- For ambiguous/unclear requests, ask one targeted clarifying question.`
if (isNewTab) {
executionContent += `
### New-Tab Origin Rules
You are operating from the user's **New Tab page**. The active tab (Page ID from Browser Context) is the chat UI itself.
**CRITICAL RULES:**
1. **NEVER call \`navigate_page\` on the active tab** — this would destroy the chat UI and navigate the user away.
2. **NEVER call \`close_page\` on the active tab** — same reason.
3. For ALL browsing tasks (including single-page lookups), use \`new_page\` (background) to open URLs.
4. For single-page lookups, open a background tab, extract data, then close it.
5. For multi-page research, open background tabs and group them with \`group_tabs\`.
### Multi-tab workflow`
} else {
executionContent += `
- Stay on the current page for single-page tasks. Use \`navigate_page\` to move within one tab.
### Multi-tab workflow
### Multi-tab workflow`
}
executionContent += `
When a task requires working on multiple pages simultaneously:
1. **Inform the user** that you're creating background tabs for the task.
2. **Open new tabs in background** using \`new_page\` (opens in background by default) — never steal focus from the user's current tab.
@@ -216,15 +238,23 @@ When a task requires working on multiple pages simultaneously:
7. **Never force-switch the user's active tab.** If you need user interaction on a background tab (e.g., login, CAPTCHA), tell the user which tab needs attention and let them switch manually.
8. **Never navigate the user's current tab** during a multi-tab task. The current tab is the user's anchor — use it only for reading (snapshots, content extraction). All navigation should happen on background tabs.
**Do NOT use \`create_hidden_window\` or \`new_hidden_page\` for user-requested tasks.** Hidden windows are invisible to the user and cannot be screenshotted. Use \`new_page\` (background mode) instead — tabs appear in the user's tab strip and can be inspected. Reserve hidden windows for automated/scheduled runs only.
**Do NOT use \`create_hidden_window\` or \`new_hidden_page\` for user-requested tasks.** Hidden windows are invisible to the user and cannot be screenshotted. Use \`new_page\` (background mode) instead — tabs appear in the user's tab strip and can be inspected. Reserve hidden windows for automated/scheduled runs only.`
For single-page lookups (e.g., "go to X and read Y"), use \`navigate_page\` on the current tab. Only create new tabs when the task requires multiple pages open simultaneously.
if (!isNewTab) {
executionContent += `
For single-page lookups (e.g., "go to X and read Y"), use \`navigate_page\` on the current tab. Only create new tabs when the task requires multiple pages open simultaneously.`
}
executionContent += `
### Tab retry discipline
When a background tab fails (404, wrong content, unexpected redirect):
- **Navigate the existing tab** to the correct URL with \`navigate_page\` — do NOT open a new tab for retries.
- If you must abandon a tab, close it with \`close_page\` before opening a replacement.
- Never let orphan tabs accumulate — each task should end with only the tabs that contain useful content.
- Never let orphan tabs accumulate — each task should end with only the tabs that contain useful content.`
executionContent += `
### Observe → Act → Verify
- **Before acting**: Take a snapshot to get interactive element IDs.
@@ -241,13 +271,38 @@ Some tools automatically include a fresh snapshot in their response (labeled "Ad
- 2FA → notify user, pause for completion
- Page not found (404) or server error (500) → report the error to the user
</execution>`
return executionContent
}
// -----------------------------------------------------------------------------
// section: tool-selection
// -----------------------------------------------------------------------------
function getToolSelection(): string {
function getToolSelection(
_exclude: Set<string>,
options?: BuildSystemPromptOptions,
): string {
const isNewTab = options?.origin === 'newtab'
const navTable = isNewTab
? `### Navigation: single-tab vs multi-tab
| Task | Approach |
|------|----------|
| Look up one page | \`new_page\` (background) → extract data → \`close_page\` |
| Research across multiple sites | \`new_page\` (background) for each site + \`group_tabs\` |
| Compare two pages side by side | \`new_page\` (background) × 2 + \`group_tabs\` |
| User says "open a new tab" | \`new_page\` (background) |
**Remember:** The active tab is the New Tab chat UI. Never navigate or close it.`
: `### Navigation: single-tab vs multi-tab
| Task | Approach |
|------|----------|
| Look up one page | \`navigate_page\` on current tab |
| Research across multiple sites | \`new_page\` (background) for each site + \`group_tabs\` |
| Compare two pages side by side | \`new_page\` (background) × 2 + \`group_tabs\` |
| User says "open a new tab" | \`new_page\` (background) — don't steal focus |`
return `<tool_selection>
## Tool Selection
@@ -268,13 +323,7 @@ function getToolSelection(): string {
- Prefer \`fill\` over \`press_key\` for text input. Use \`press_key\` for keyboard shortcuts (Enter, Escape, Tab, Ctrl+A, etc.).
- Prefer clicking links over \`navigate_page\` when the link is visible. Use \`navigate_page\` for direct URL access, back/forward, or reload.
### Navigation: single-tab vs multi-tab
| Task | Approach |
|------|----------|
| Look up one page | \`navigate_page\` on current tab |
| Research across multiple sites | \`new_page\` (background) for each site + \`group_tabs\` |
| Compare two pages side by side | \`new_page\` (background) × 2 + \`group_tabs\` |
| User says "open a new tab" | \`new_page\` (background) — don't steal focus |
${navTable}
### Connected apps: Strata vs browser
When an app is Connected, prefer Strata tools over browser automation. Strata is faster, more reliable, and works without navigating away from the user's current page.
@@ -668,7 +717,10 @@ const promptSections: Record<string, PromptSectionFn> = {
security: getSecurity,
capabilities: getCapabilities,
execution: getExecution,
'tool-selection': getToolSelection,
'tool-selection': (
_exclude: Set<string>,
options?: BuildSystemPromptOptions,
) => getToolSelection(_exclude, options),
'external-integrations': getExternalIntegrations,
'error-recovery': getErrorRecovery,
'memory-and-identity': getMemoryAndIdentity,
@@ -695,6 +747,8 @@ export interface BuildSystemPromptOptions {
/** Apps the user previously declined to connect (chose "do it manually"). */
declinedApps?: string[]
skillsCatalog?: string
/** Where the chat session originates from — determines navigation behavior. */
origin?: 'sidepanel' | 'newtab'
}
export function buildSystemPrompt(options?: BuildSystemPromptOptions): string {

View File

@@ -9,6 +9,8 @@ export interface AgentSession {
browserContext?: BrowserContext
/** MCP server names used when the session was created, for change detection. */
mcpServerKey?: string
/** Workspace directory when the session was created, for change detection. */
workingDir?: string
}
export class SessionStore {

View File

@@ -38,12 +38,14 @@ function contentToModelOutput(
export function buildBrowserToolSet(
registry: ToolRegistry,
browser: Browser,
workingDir: string,
workingDir: string | undefined,
session?: { origin?: 'sidepanel' | 'newtab'; originPageId?: number },
): ToolSet {
const toolSet: ToolSet = {}
const ctx: ToolContext = {
browser,
directories: { workingDir },
session,
}
for (const def of registry.all()) {

View File

@@ -35,7 +35,7 @@ export interface ResolvedAgentConfig {
reasoningSummary?: string
contextWindowSize?: number
userSystemPrompt?: string
workingDir: string
workingDir?: string
/** Whether the model supports image inputs (vision). Defaults to true. */
supportsImages?: boolean
/** Eval mode - enables window management tools. Defaults to false. */
@@ -46,6 +46,8 @@ export interface ResolvedAgentConfig {
isScheduledTask?: boolean
/** Apps the user previously declined to connect via MCP (chose "do it manually"). */
declinedApps?: string[]
/** Where the chat session originates from — determines navigation behavior. */
origin?: 'sidepanel' | 'newtab'
/** BrowserOS installation ID for credit-based tracking. */
browserosId?: string
}

View File

@@ -4,16 +4,16 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mkdir, utimes } from 'node:fs/promises'
import path from 'node:path'
import { createAgentUIStreamResponse, type UIMessage } from 'ai'
import { AiSdkAgent } from '../../agent/ai-sdk-agent'
import { formatUserMessage } from '../../agent/format-message'
import { filterValidMessages } from '../../agent/message-validation'
import type { SessionStore } from '../../agent/session-store'
import {
filterValidMessages,
sanitizeMessagesForToolset,
} from '../../agent/message-validation'
import type { AgentSession, SessionStore } from '../../agent/session-store'
import type { ResolvedAgentConfig } from '../../agent/types'
import type { Browser } from '../../browser/browser'
import { getSessionsDir } from '../../lib/browseros-dir'
import type { KlavisClient } from '../../lib/clients/klavis/klavis-client'
import { resolveLLMConfig } from '../../lib/clients/llm/config'
import { logger } from '../../lib/logger'
@@ -40,8 +40,6 @@ export class ChatService {
const llmConfig = await resolveLLMConfig(request, this.deps.browserosId)
const workingDir = await this.resolveSessionDir(request)
const agentConfig: ResolvedAgentConfig = {
conversationId: request.conversationId,
provider: llmConfig.provider,
@@ -59,16 +57,18 @@ export class ChatService {
reasoningSummary: request.reasoningSummary,
contextWindowSize: request.contextWindowSize,
userSystemPrompt: request.userSystemPrompt,
workingDir,
workingDir: request.userWorkingDir,
supportsImages: request.supportsImages,
chatMode: request.mode === 'chat',
isScheduledTask: request.isScheduledTask,
origin: request.origin,
declinedApps: request.declinedApps,
browserosId: this.deps.browserosId,
}
let session = sessionStore.get(request.conversationId)
let isNewSession = false
const contextChanges: string[] = []
// Build a stable key from enabled MCP servers for change detection
const mcpServerKey = this.buildMcpServerKey(request.browserContext)
@@ -80,23 +80,68 @@ export class ChatService {
previous: session.mcpServerKey,
current: mcpServerKey,
})
const previousMessages = session.agent.messages
await session.agent.dispose()
sessionStore.remove(request.conversationId)
const previousMcpKey = session.mcpServerKey
session = await this.rebuildSession(
session,
request,
agentConfig,
mcpServerKey,
)
const browserContext = await this.resolvePageIds(request.browserContext)
const agent = await AiSdkAgent.create({
resolvedConfig: agentConfig,
browser: this.deps.browser,
registry: this.deps.registry,
browserContext,
klavisClient: this.deps.klavisClient,
browserosId: this.deps.browserosId,
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
const oldServers = new Set(
(previousMcpKey ?? '').split(',').filter(Boolean),
)
const newServers = new Set(mcpServerKey.split(',').filter(Boolean))
const added = [...newServers].filter((s) => !oldServers.has(s))
const removed = [...oldServers].filter((s) => !newServers.has(s))
const parts: string[] = []
if (removed.length > 0) {
parts.push(
`The following app integrations were disconnected: ${removed.join(', ')}. Their tools are no longer available.`,
)
}
if (added.length > 0) {
parts.push(
`The following app integrations were connected: ${added.join(', ')}. Their tools are now available.`,
)
}
if (parts.length === 0) {
parts.push(
'Connected app integrations changed during this conversation. Use only tools that are currently registered.',
)
}
contextChanges.push(parts.join(' '))
}
// Detect workspace change mid-conversation → rebuild session
if (session && session.workingDir !== request.userWorkingDir) {
logger.info('Workspace changed mid-conversation, rebuilding session', {
conversationId: request.conversationId,
previous: session.workingDir ?? '(none)',
current: request.userWorkingDir ?? '(none)',
})
session = { agent, browserContext, mcpServerKey }
session.agent.messages = previousMessages
sessionStore.set(request.conversationId, session)
const previousWorkingDir = session.workingDir
session = await this.rebuildSession(
session,
request,
agentConfig,
mcpServerKey,
)
if (!request.userWorkingDir) {
contextChanges.push(
'The user disconnected the workspace during this conversation. Filesystem tools (filesystem_read, filesystem_write, filesystem_edit, filesystem_bash, filesystem_grep, filesystem_find, filesystem_ls) are no longer available. Return all output directly in chat. If the user asks for file operations, suggest they select a working directory from the chat toolbar.',
)
} else if (!previousWorkingDir) {
contextChanges.push(
`The user connected a workspace during this conversation. Filesystem tools are now available. Working directory: ${request.userWorkingDir}`,
)
} else {
contextChanges.push(
`The user switched workspace during this conversation. Filesystem tools now use the new working directory: ${request.userWorkingDir}`,
)
}
}
if (!session) {
@@ -141,7 +186,13 @@ export class ChatService {
browserosId: this.deps.browserosId,
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
})
session = { agent, hiddenWindowId, browserContext, mcpServerKey }
session = {
agent,
hiddenWindowId,
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
}
sessionStore.set(request.conversationId, session)
}
@@ -175,7 +226,13 @@ export class ChatService {
request.selectedText,
request.selectedTextSource,
)
session.agent.appendUserMessage(userContent)
// Prepend tool-change context when session was rebuilt mid-conversation
const contextPrefix =
contextChanges.length > 0
? `${contextChanges.map((c) => `[Context: ${c}]`).join('\n')}\n\n`
: ''
session.agent.appendUserMessage(contextPrefix + userContent)
return createAgentUIStreamResponse({
agent: session.agent.toolLoopAgent,
@@ -262,22 +319,44 @@ export class ChatService {
})
}
private async rebuildSession(
session: AgentSession,
request: ChatRequest,
agentConfig: ResolvedAgentConfig,
mcpServerKey: string,
): Promise<AgentSession> {
const previousMessages = session.agent.messages
await session.agent.dispose()
this.deps.sessionStore.remove(request.conversationId)
const browserContext = await this.resolvePageIds(request.browserContext)
const agent = await AiSdkAgent.create({
resolvedConfig: agentConfig,
browser: this.deps.browser,
registry: this.deps.registry,
browserContext,
klavisClient: this.deps.klavisClient,
browserosId: this.deps.browserosId,
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
})
const newSession: AgentSession = {
agent,
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
}
newSession.agent.messages = sanitizeMessagesForToolset(
previousMessages,
agent.toolNames,
)
this.deps.sessionStore.set(request.conversationId, newSession)
return newSession
}
private buildMcpServerKey(browserContext?: BrowserContext): string {
const managed = browserContext?.enabledMcpServers?.slice().sort() ?? []
const custom =
browserContext?.customMcpServers?.map((s) => s.url).sort() ?? []
return [...managed, ...custom].join(',')
}
private async resolveSessionDir(request: ChatRequest): Promise<string> {
const dir = request.userWorkingDir
? request.userWorkingDir
: path.join(getSessionsDir(), request.conversationId)
await mkdir(dir, { recursive: true })
if (!request.userWorkingDir) {
const now = new Date()
await utimes(dir, now, now).catch(() => {})
}
return dir
}
}

View File

@@ -45,6 +45,7 @@ export const ChatRequestSchema = AgentLLMConfigSchema.extend({
userWorkingDir: z.string().min(1).optional(),
supportsImages: z.boolean().optional().default(true),
mode: z.enum(['chat', 'agent']).optional().default('agent'),
origin: z.enum(['sidepanel', 'newtab']).optional().default('sidepanel'),
declinedApps: z.array(z.string()).optional(),
selectedText: z.string().optional(),
selectedTextSource: z

View File

@@ -20,6 +20,7 @@ import './lib/polyfill'
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
import { CommanderError } from 'commander'
import { loadServerConfig } from './config'
import { isPortInUseError } from './lib/port-binding'
import { Sentry } from './lib/sentry'
import { Application } from './main'
@@ -39,6 +40,9 @@ try {
if (error instanceof CommanderError) {
process.exit(error.exitCode)
}
if (isPortInUseError(error)) {
process.exit(EXIT_CODES.PORT_CONFLICT)
}
Sentry.captureException(error)
console.error('Failed to start server:', error)
process.exit(EXIT_CODES.GENERAL_ERROR)

View File

@@ -116,6 +116,7 @@ export class Application {
server_version: VERSION,
browseros_version: this.config.instanceBrowserosVersion,
chromium_version: this.config.instanceChromiumVersion,
browseros_id: identity.getBrowserOSId(),
})
} catch (error) {
logger.warn('Failed to write server config for auto-discovery', {
@@ -231,7 +232,6 @@ export class Application {
console.error(
`[FATAL] Failed to start ${serverName} on port ${port}: ${errorMsg}`,
)
Sentry.captureException(error)
if (isPortInUseError(error)) {
console.error(
@@ -240,6 +240,7 @@ export class Application {
process.exit(EXIT_CODES.PORT_CONFLICT)
}
Sentry.captureException(error)
process.exit(EXIT_CODES.GENERAL_ERROR)
}
@@ -255,7 +256,9 @@ export class Application {
{ port },
)
}
Sentry.captureException(error)
if (!isPortInUseError(error)) {
Sentry.captureException(error)
}
}
private logStartupSummary(controllerServerStarted: boolean): void {

View File

@@ -1,3 +1,4 @@
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import type { z } from 'zod'
import type { Browser } from '../browser/browser'
@@ -18,13 +19,19 @@ export type ToolHandler = (
) => Promise<void>
export interface ToolDirectories {
workingDir: string
workingDir?: string
resourcesDir?: string
}
export interface ToolSessionContext {
origin?: 'sidepanel' | 'newtab'
originPageId?: number
}
export type ToolContext = {
browser: Browser
directories: ToolDirectories
session?: ToolSessionContext
}
export function resolveWorkingPath(
@@ -32,7 +39,7 @@ export function resolveWorkingPath(
targetPath: string,
cwd?: string,
): string {
return resolve(cwd ?? ctx.directories.workingDir, targetPath)
return resolve(cwd ?? ctx.directories.workingDir ?? tmpdir(), targetPath)
}
export function defineTool<

View File

@@ -88,6 +88,17 @@ export const navigate_page = defineTool({
return
}
if (
ctx.session?.origin === 'newtab' &&
ctx.session.originPageId !== undefined &&
args.page === ctx.session.originPageId
) {
response.error(
'Cannot navigate the origin tab in new-tab mode — this would destroy the chat UI. Use `new_page` to open a background tab instead.',
)
return
}
switch (args.action) {
case 'url':
await ctx.browser.goto(args.page, args.url as string)
@@ -266,6 +277,17 @@ export const close_page = defineTool({
action: z.literal('close_page'),
}),
handler: async (args, ctx, response) => {
if (
ctx.session?.origin === 'newtab' &&
ctx.session.originPageId !== undefined &&
args.page === ctx.session.originPageId
) {
response.error(
'Cannot close the origin tab in new-tab mode — this would destroy the chat UI.',
)
return
}
await ctx.browser.closePage(args.page)
response.text(`Closed page ${args.page}`)
response.data({ page: args.page, action: 'close_page' })

View File

@@ -1,4 +1,5 @@
import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { z } from 'zod'
import { defineTool, resolveWorkingPath } from './framework'
@@ -121,10 +122,9 @@ export const download_file = defineTool({
}),
handler: async (args, ctx, response) => {
const resolvedDir = resolveWorkingPath(ctx, args.path, args.cwd)
await mkdir(ctx.directories.workingDir, { recursive: true })
const tempDir = await mkdtemp(
join(ctx.directories.workingDir, 'browseros-dl-'),
)
const baseDir = ctx.directories.workingDir ?? tmpdir()
await mkdir(baseDir, { recursive: true })
const tempDir = await mkdtemp(join(baseDir, 'browseros-dl-'))
try {
const { filePath, suggestedFilename } =

View File

@@ -0,0 +1,299 @@
/**
* @license
* Copyright 2025 BrowserOS
*
* Message Validation — Test Suite
*
* Tests for sanitizeMessagesForToolset, which strips tool parts from
* carried-over messages when a session is rebuilt with a different toolset
* (e.g., workspace removed or MCP server disconnected mid-conversation).
*
* Without this sanitization, the AI SDK throws a validation error because
* it finds tool parts in the message history that have no matching schema.
*/
import { describe, expect, it } from 'bun:test'
import type { UIMessage } from 'ai'
import {
hasMessageContent,
sanitizeMessagesForToolset,
} from '../../src/agent/message-validation'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeUserMessage(text: string, id?: string): UIMessage {
return {
id: id ?? crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text }],
}
}
function makeAssistantMessage(
parts: UIMessage['parts'],
id?: string,
): UIMessage {
return {
id: id ?? crypto.randomUUID(),
role: 'assistant',
parts,
}
}
// ---------------------------------------------------------------------------
// sanitizeMessagesForToolset
// ---------------------------------------------------------------------------
describe('sanitizeMessagesForToolset', () => {
const allTools = new Set([
'navigate_page',
'click',
'take_snapshot',
'filesystem_read',
'filesystem_write',
'memory_search',
])
const noFilesystemTools = new Set([
'navigate_page',
'click',
'take_snapshot',
'memory_search',
])
it('preserves messages with no tool parts', () => {
const messages: UIMessage[] = [
makeUserMessage('Hello'),
makeAssistantMessage([{ type: 'text', text: 'Hi there!' }]),
]
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
expect(result).toHaveLength(2)
expect(result[0].parts).toHaveLength(1)
expect(result[1].parts).toHaveLength(1)
})
it('preserves tool parts when tool is in the toolset', () => {
const messages: UIMessage[] = [
makeAssistantMessage([
{ type: 'text', text: 'Taking a snapshot...' },
{
type: 'tool-take_snapshot',
toolCallId: 'call-1',
toolName: 'take_snapshot',
state: 'result',
input: { page: 1 },
output: { content: 'snapshot data' },
} as unknown as UIMessage['parts'][number],
]),
]
const result = sanitizeMessagesForToolset(messages, allTools)
expect(result).toHaveLength(1)
expect(result[0].parts).toHaveLength(2)
})
it('strips tool parts when tool is NOT in the toolset', () => {
const messages: UIMessage[] = [
makeAssistantMessage([
{ type: 'text', text: 'Reading file...' },
{
type: 'tool-filesystem_read',
toolCallId: 'call-1',
toolName: 'filesystem_read',
state: 'result',
input: { path: '/tmp/test.txt' },
output: { content: 'file data' },
} as unknown as UIMessage['parts'][number],
]),
]
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
expect(result).toHaveLength(1)
// Only the text part should remain
expect(result[0].parts).toHaveLength(1)
expect(result[0].parts[0].type).toBe('text')
})
it('strips multiple removed tool parts from same message', () => {
const messages: UIMessage[] = [
makeAssistantMessage([
{ type: 'text', text: 'Working on files...' },
{
type: 'tool-filesystem_read',
toolCallId: 'call-1',
toolName: 'filesystem_read',
state: 'result',
input: { path: '/tmp/a.txt' },
output: {},
} as unknown as UIMessage['parts'][number],
{
type: 'tool-filesystem_write',
toolCallId: 'call-2',
toolName: 'filesystem_write',
state: 'result',
input: { path: '/tmp/b.txt', content: 'data' },
output: {},
} as unknown as UIMessage['parts'][number],
]),
]
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
expect(result).toHaveLength(1)
expect(result[0].parts).toHaveLength(1)
expect(result[0].parts[0].type).toBe('text')
})
it('keeps browser tool parts while removing filesystem tool parts', () => {
const messages: UIMessage[] = [
makeAssistantMessage([
{
type: 'tool-take_snapshot',
toolCallId: 'call-1',
toolName: 'take_snapshot',
state: 'result',
input: { page: 1 },
output: {},
} as unknown as UIMessage['parts'][number],
{
type: 'tool-filesystem_read',
toolCallId: 'call-2',
toolName: 'filesystem_read',
state: 'result',
input: { path: '/tmp/test.txt' },
output: {},
} as unknown as UIMessage['parts'][number],
]),
]
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
expect(result).toHaveLength(1)
expect(result[0].parts).toHaveLength(1)
expect((result[0].parts[0] as { type: string }).type).toBe(
'tool-take_snapshot',
)
})
it('removes messages that become empty after stripping', () => {
const messages: UIMessage[] = [
makeUserMessage('Read this file'),
makeAssistantMessage([
{
type: 'tool-filesystem_read',
toolCallId: 'call-1',
toolName: 'filesystem_read',
state: 'result',
input: { path: '/tmp/test.txt' },
output: {},
} as unknown as UIMessage['parts'][number],
]),
]
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
// The assistant message had only a tool part — after stripping, it's empty
// and should be filtered out by hasMessageContent
expect(result).toHaveLength(1)
expect(result[0].role).toBe('user')
})
it('preserves non-tool part types (reasoning, step-start, file)', () => {
const messages: UIMessage[] = [
makeAssistantMessage([
{ type: 'text', text: 'Let me think...' },
{
type: 'reasoning',
reasoning: 'Analyzing the request',
} as unknown as UIMessage['parts'][number],
{
type: 'step-start',
} as unknown as UIMessage['parts'][number],
]),
]
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
expect(result).toHaveLength(1)
expect(result[0].parts).toHaveLength(3)
})
it('returns same message references when no filtering needed', () => {
const messages: UIMessage[] = [
makeUserMessage('Hello'),
makeAssistantMessage([{ type: 'text', text: 'Hi!' }]),
]
const result = sanitizeMessagesForToolset(messages, noFilesystemTools)
// Messages that don't need filtering should be the same reference
expect(result[0]).toBe(messages[0])
expect(result[1]).toBe(messages[1])
})
it('handles empty message array', () => {
const result = sanitizeMessagesForToolset([], noFilesystemTools)
expect(result).toHaveLength(0)
})
it('handles empty toolset (all tools removed)', () => {
const messages: UIMessage[] = [
makeAssistantMessage([
{ type: 'text', text: 'Working...' },
{
type: 'tool-navigate_page',
toolCallId: 'call-1',
toolName: 'navigate_page',
state: 'result',
input: {},
output: {},
} as unknown as UIMessage['parts'][number],
]),
]
const result = sanitizeMessagesForToolset(messages, new Set())
expect(result).toHaveLength(1)
expect(result[0].parts).toHaveLength(1)
expect(result[0].parts[0].type).toBe('text')
})
})
// ---------------------------------------------------------------------------
// hasMessageContent (existing function, verify edge cases)
// ---------------------------------------------------------------------------
describe('hasMessageContent', () => {
it('rejects messages with empty parts array', () => {
const msg: UIMessage = {
id: '1',
role: 'assistant',
parts: [],
}
expect(hasMessageContent(msg)).toBe(false)
})
it('rejects messages with only whitespace text', () => {
const msg: UIMessage = {
id: '1',
role: 'assistant',
parts: [{ type: 'text', text: ' \n ' }],
}
expect(hasMessageContent(msg)).toBe(false)
})
it('accepts messages with non-text parts', () => {
const msg: UIMessage = {
id: '1',
role: 'assistant',
parts: [
{
type: 'tool-click',
toolCallId: 'call-1',
toolName: 'click',
state: 'result',
input: {},
output: {},
} as unknown as UIMessage['parts'][number],
],
}
expect(hasMessageContent(msg)).toBe(true)
})
})

View File

@@ -1195,3 +1195,120 @@ describe('nudges', () => {
expect(prompt).toContain('at most once')
})
})
// ---------------------------------------------------------------------------
// 15. NEW-TAB ORIGIN
//
// Why: When the user chats from the new-tab page, the active tab IS the chat
// UI. The agent must never navigate or close it. The prompt must adapt its
// execution and tool-selection sections to prohibit origin tab navigation
// and default all lookups to new_page (background).
// ---------------------------------------------------------------------------
describe('new-tab origin', () => {
/** Build a prompt with newtab origin */
function buildNewTab(overrides?: Partial<BuildSystemPromptOptions>): string {
return buildSystemPrompt({
workspaceDir: '/home/user/workspace',
soulContent: 'Be helpful and concise.',
origin: 'newtab',
...overrides,
})
}
// --- Execution section ---
it('includes New-Tab Origin Rules when origin is newtab', () => {
const prompt = buildNewTab()
expect(prompt).toContain('New-Tab Origin Rules')
expect(prompt).toContain('New Tab page')
expect(prompt).toContain('chat UI itself')
})
it('prohibits navigate_page on active tab in newtab mode', () => {
const prompt = buildNewTab()
expect(prompt).toContain('NEVER call `navigate_page` on the active tab')
})
it('prohibits close_page on active tab in newtab mode', () => {
const prompt = buildNewTab()
expect(prompt).toContain('NEVER call `close_page` on the active tab')
})
it('requires new_page for all browsing in newtab mode', () => {
const prompt = buildNewTab()
expect(prompt).toContain(
'For ALL browsing tasks (including single-page lookups), use `new_page`',
)
})
it('does NOT include single-tab navigate_page guidance in newtab mode', () => {
// The sidepanel prompt says "use navigate_page on the current tab" for
// single-page lookups. This must NOT appear in newtab mode.
const prompt = buildNewTab()
expect(prompt).not.toContain(
'For single-page lookups (e.g., "go to X and read Y"), use `navigate_page` on the current tab',
)
})
it('does NOT include "Stay on the current page" in newtab mode', () => {
const prompt = buildNewTab()
expect(prompt).not.toContain(
'Stay on the current page for single-page tasks',
)
})
it('still includes common execution sections in newtab mode', () => {
// Newtab mode should still have multi-tab workflow, observe-act-verify, etc.
const prompt = buildNewTab()
expect(prompt).toContain('Multi-tab workflow')
expect(prompt).toContain('Observe → Act → Verify')
expect(prompt).toContain('Tab retry discipline')
expect(prompt).toContain('CAPTCHA')
})
// --- Sidepanel (default) should NOT have newtab rules ---
it('does NOT include New-Tab Origin Rules in sidepanel mode', () => {
const prompt = buildRegular({ origin: 'sidepanel' })
expect(prompt).not.toContain('New-Tab Origin Rules')
})
it('does NOT include New-Tab Origin Rules when origin is undefined', () => {
const prompt = buildRegular()
expect(prompt).not.toContain('New-Tab Origin Rules')
})
it('includes single-tab navigate_page guidance in sidepanel mode', () => {
const prompt = buildRegular({ origin: 'sidepanel' })
expect(prompt).toContain(
'For single-page lookups (e.g., "go to X and read Y"), use `navigate_page` on the current tab',
)
})
// --- Tool selection section ---
it('tool selection table uses new_page for lookups in newtab mode', () => {
const prompt = buildNewTab()
expect(prompt).toContain(
'`new_page` (background) → extract data → `close_page`',
)
})
it('tool selection includes reminder about active tab in newtab mode', () => {
const prompt = buildNewTab()
expect(prompt).toContain(
'The active tab is the New Tab chat UI. Never navigate or close it.',
)
})
it('tool selection table uses navigate_page for lookups in sidepanel mode', () => {
const prompt = buildRegular({ origin: 'sidepanel' })
expect(prompt).toContain('`navigate_page` on current tab')
})
it('tool selection does NOT have newtab reminder in sidepanel mode', () => {
const prompt = buildRegular({ origin: 'sidepanel' })
expect(prompt).not.toContain('The active tab is the New Tab chat UI')
})
})

View File

@@ -0,0 +1,270 @@
/**
* New-tab origin navigation guards.
*
* When the chat session originates from the new-tab page, navigate_page and
* close_page must reject attempts to act on the origin tab. These are
* integration tests that run against a real browser to verify the guards
* work end-to-end through executeTool.
*/
import { describe, it } from 'bun:test'
import assert from 'node:assert'
import type { ToolContext, ToolDefinition } from '../../src/tools/framework'
import { executeTool } from '../../src/tools/framework'
import { close_page, navigate_page, new_page } from '../../src/tools/navigation'
import type { ToolResult } from '../../src/tools/response'
import { withBrowser } from '../__helpers__/with-browser'
function textOf(result: {
content: { type: string; text?: string }[]
}): string {
return result.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n')
}
function structuredOf<T>(result: { structuredContent?: unknown }): T {
assert.ok(result.structuredContent, 'Expected structuredContent')
return result.structuredContent as T
}
describe('new-tab origin navigation guards', () => {
// Helper: execute a tool with newtab session context
function executeWithSession(
ctx: { browser: ToolContext['browser'] },
tool: ToolDefinition,
args: unknown,
session: ToolContext['session'],
): Promise<ToolResult> {
const signal = AbortSignal.timeout(30_000)
return executeTool(
tool,
args,
{
browser: ctx.browser,
directories: { workingDir: process.cwd() },
session,
},
signal,
)
}
// -------------------------------------------------------------------------
// navigate_page guards
// -------------------------------------------------------------------------
it('navigate_page rejects navigation on origin tab in newtab mode', async () => {
await withBrowser(async ({ browser }) => {
// Use a new page as the simulated "origin tab"
const setupResult = await executeTool(
new_page,
{ url: 'about:blank' },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
const originPageId = structuredOf<{ pageId: number }>(setupResult).pageId
const result = await executeWithSession(
{ browser },
navigate_page,
{ page: originPageId, action: 'url', url: 'https://example.com' },
{ origin: 'newtab', originPageId },
)
assert.ok(result.isError, 'Expected navigate_page to be rejected')
assert.ok(
textOf(result).includes('Cannot navigate the origin tab'),
`Expected origin tab error, got: ${textOf(result)}`,
)
// Cleanup
await executeTool(
close_page,
{ page: originPageId },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
})
}, 60_000)
it('navigate_page allows navigation on non-origin tab in newtab mode', async () => {
await withBrowser(async ({ browser }) => {
const originResult = await executeTool(
new_page,
{ url: 'about:blank' },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
const originPageId = structuredOf<{ pageId: number }>(originResult).pageId
// Open a second tab — this is NOT the origin tab
const otherResult = await executeTool(
new_page,
{ url: 'about:blank' },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
const otherPageId = structuredOf<{ pageId: number }>(otherResult).pageId
const result = await executeWithSession(
{ browser },
navigate_page,
{ page: otherPageId, action: 'url', url: 'https://example.com' },
{ origin: 'newtab', originPageId },
)
assert.ok(
!result.isError,
`Expected success, got error: ${textOf(result)}`,
)
assert.ok(textOf(result).includes('Navigated to'))
// Cleanup
const noSession = { browser, directories: { workingDir: process.cwd() } }
await executeTool(
close_page,
{ page: otherPageId },
noSession,
AbortSignal.timeout(30_000),
)
await executeTool(
close_page,
{ page: originPageId },
noSession,
AbortSignal.timeout(30_000),
)
})
}, 60_000)
it('navigate_page works normally in sidepanel mode', async () => {
await withBrowser(async ({ browser }) => {
const setupResult = await executeTool(
new_page,
{ url: 'about:blank' },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
const pageId = structuredOf<{ pageId: number }>(setupResult).pageId
const result = await executeWithSession(
{ browser },
navigate_page,
{ page: pageId, action: 'url', url: 'https://example.com' },
{ origin: 'sidepanel', originPageId: pageId },
)
assert.ok(
!result.isError,
`Expected success, got error: ${textOf(result)}`,
)
assert.ok(textOf(result).includes('Navigated to'))
await executeTool(
close_page,
{ page: pageId },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
})
}, 60_000)
it('navigate_page works when session is undefined (backwards compat)', async () => {
await withBrowser(async ({ browser, execute }) => {
const setupResult = await execute(new_page, { url: 'about:blank' })
const pageId = structuredOf<{ pageId: number }>(setupResult).pageId
// execute() from withBrowser passes no session — simulates old clients
const result = await execute(navigate_page, {
page: pageId,
action: 'url',
url: 'https://example.com',
})
assert.ok(
!result.isError,
`Expected success, got error: ${textOf(result)}`,
)
await execute(close_page, { page: pageId })
})
}, 60_000)
// -------------------------------------------------------------------------
// close_page guards
// -------------------------------------------------------------------------
it('close_page rejects closing origin tab in newtab mode', async () => {
await withBrowser(async ({ browser }) => {
const setupResult = await executeTool(
new_page,
{ url: 'about:blank' },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
const originPageId = structuredOf<{ pageId: number }>(setupResult).pageId
const result = await executeWithSession(
{ browser },
close_page,
{ page: originPageId },
{ origin: 'newtab', originPageId },
)
assert.ok(result.isError, 'Expected close_page to be rejected')
assert.ok(
textOf(result).includes('Cannot close the origin tab'),
`Expected origin tab error, got: ${textOf(result)}`,
)
// Clean up the page we created (without newtab guard)
await executeTool(
close_page,
{ page: originPageId },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
})
}, 60_000)
it('close_page allows closing non-origin tab in newtab mode', async () => {
await withBrowser(async ({ browser }) => {
const originResult = await executeTool(
new_page,
{ url: 'about:blank' },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
const originPageId = structuredOf<{ pageId: number }>(originResult).pageId
const otherResult = await executeTool(
new_page,
{ url: 'about:blank' },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
const otherPageId = structuredOf<{ pageId: number }>(otherResult).pageId
const result = await executeWithSession(
{ browser },
close_page,
{ page: otherPageId },
{ origin: 'newtab', originPageId },
)
assert.ok(
!result.isError,
`Expected success, got error: ${textOf(result)}`,
)
assert.ok(textOf(result).includes(`Closed page ${otherPageId}`))
// Cleanup origin page
await executeTool(
close_page,
{ page: originPageId },
{ browser, directories: { workingDir: process.cwd() } },
AbortSignal.timeout(30_000),
)
})
}, 60_000)
})

View File

@@ -51,9 +51,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.90.21",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query-persist-client": "^5.90.21",
"@tanstack/query-async-storage-persister": "^5.95.2",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-persist-client": "^5.95.2",
"@types/cytoscape": "^3.31.0",
"@types/dompurify": "^3.2.0",
"@webext-core/messaging": "^2.3.0",
@@ -76,8 +76,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",
@@ -170,7 +170,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.79",
"version": "0.0.80",
"bin": {
"browseros-server": "./src/index.ts",
},
@@ -231,7 +231,7 @@
},
"packages/agent-sdk": {
"name": "@browseros-ai/agent-sdk",
"version": "0.0.5",
"version": "0.0.7",
"dependencies": {
"eventsource-parser": "^3.0.6",
"zod-to-json-schema": "^3.24.1",
@@ -1780,15 +1780,15 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/query-async-storage-persister": ["@tanstack/query-async-storage-persister@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.19", "@tanstack/query-persist-client-core": "5.91.18" } }, "sha512-edpZzybucsMxGiWOMy24io+5l4Lciw4bgv/N2EXQnSp0exS1siTOQbCAQET8jwStCEnaoEiS8ljChnfmnd2pkw=="],
"@tanstack/query-async-storage-persister": ["@tanstack/query-async-storage-persister@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2", "@tanstack/query-persist-client-core": "5.95.2" } }, "sha512-ZhPIHH8J833OVZhEWwwdOk0uhY94d9Wgdnq97JoQx4Ui4xx4Dh6e7WPUrjlUWo88Yqi4Ij+T1o/VR7Vlbnkbjw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="],
"@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.91.18", "", { "dependencies": { "@tanstack/query-core": "5.90.19" } }, "sha512-1FNvccVTFZph07dtA/4p5PRAVKfqVLPPxA8BXUoYjPOZP6T4qY1asItVkUFtUr6kBu48i0DBnEEZQLmK82BIFw=="],
"@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" } }, "sha512-Opfj34WZ594YXpEcZEs8WBiyPGrjrKlGILfk/Ss283uwWQ36C5nX3tRY/bBiXmM82KWauUuNvahwGwiyco/8cQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.19", "", { "dependencies": { "@tanstack/query-core": "5.90.19" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.21", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.18" }, "peerDependencies": { "@tanstack/react-query": "^5.90.19", "react": "^18 || ^19" } }, "sha512-ix9fVeS96QZxaMPRUwf+k6RlNLJxvu0WSjQp9nPiosxRqquxz0tJ5ErMsclZO9Q/jmVhoFm4FKEZ8mfTLBMoiQ=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.95.2", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.95.2" }, "peerDependencies": { "@tanstack/react-query": "^5.95.2", "react": "^18 || ^19" } }, "sha512-i3fvzD8gaLgQyFvRc/+iSUr60aL31tMN+5QM11zdPRg0K9CirIQjHD7WgXFBnD29KJDvcjcv7OrIBaPwZ+H9xw=="],
"@theguild/federation-composition": ["@theguild/federation-composition@0.21.3", "", { "dependencies": { "constant-case": "^3.0.4", "debug": "4.4.3", "json5": "^2.2.3", "lodash.sortby": "^4.7.0" }, "peerDependencies": { "graphql": "^16.0.0" } }, "sha512-+LlHTa4UbRpZBog3ggAxjYIFvdfH3UMvvBUptur19TMWkqU4+n3GmN+mDjejU+dyBXIG27c25RsiQP1HyvM99g=="],
@@ -2960,6 +2960,8 @@
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@@ -3186,7 +3188,7 @@
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="],
@@ -3232,8 +3234,6 @@
"local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="],
"localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
@@ -4552,7 +4552,7 @@
"@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@browseros/agent/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@browseros/agent/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
@@ -5104,8 +5104,6 @@
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"jszip/lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"jwa/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -5306,7 +5304,7 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],

View File

@@ -19,7 +19,9 @@
"start:agent": "bun ./scripts/build/controller-ext.ts && bun run --filter @browseros/agent dev",
"build": "bun run build:server && bun run build:agent && bun run build:ext",
"build:server": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all",
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --compile-only",
"build:server:test": "FORCE_COLOR=1 bun scripts/build/server.ts --target=darwin-arm64 --no-upload",
"upload:cli-installers": "bun scripts/build/cli.ts",
"start:server:test": "bun run build:server:test && set -a && . apps/server/.env.development && set +a && dist/prod/server/.tmp/binaries/browseros-server-darwin-arm64",
"build:agent:dev": "FORCE_COLOR=1 bun run --filter @browseros/agent --elide-lines=0 build:dev",
"build:agent": "bun run codegen:agent && bun run --filter @browseros/agent build",
@@ -34,6 +36,7 @@
"lint": "bunx biome check",
"lint:fix": "bunx biome check --write --unsafe",
"gen:cdp": "bun scripts/codegen/cdp-protocol.ts",
"generate:models": "bun scripts/generate-models.ts",
"clean": "rimraf dist"
},
"repository": "browseros-ai/BrowserOS-server",

View File

@@ -0,0 +1,12 @@
# @browseros-ai/agent-sdk
## v0.0.7 (2026-03-26)
## What's Changed
- chore: bump @browseros-ai/agent-sdk to 0.0.7 (#569) (#569) @DaniAkash
## Contributors
- @DaniAkash

View File

@@ -1,7 +1,17 @@
# @browseros-ai/agent-sdk
[![npm version](https://img.shields.io/npm/v/@browseros-ai/agent-sdk)](https://www.npmjs.com/package/@browseros-ai/agent-sdk)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](../../../../LICENSE)
Browser automation SDK for BrowserOS — navigate, interact, extract data, and verify page state using natural language.
Build automations that describe *what* to do, not *how* to do it. The SDK connects to a running BrowserOS instance and translates natural language instructions into browser actions using your choice of LLM provider.
## Prerequisites
- A running [BrowserOS](https://browseros.com) instance
- An API key for at least one [supported LLM provider](#llm-providers)
## Installation
```bash
@@ -17,7 +27,7 @@ import { Agent } from '@browseros-ai/agent-sdk'
import { z } from 'zod'
const agent = new Agent({
url: 'http://localhost:3000',
url: 'http://localhost:9100',
llm: {
provider: 'openai',
apiKey: process.env.OPENAI_API_KEY,
@@ -42,6 +52,40 @@ const { data } = await agent.extract('get all product names and prices', {
const { success, reason } = await agent.verify('user is logged in')
```
## Multi-Step Example
Combine navigation, actions, extraction, and verification for end-to-end automation:
```typescript
import { Agent } from '@browseros-ai/agent-sdk'
import { z } from 'zod'
const agent = new Agent({
url: 'http://localhost:9100',
llm: { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY },
})
// 1. Navigate
await agent.nav('https://news.ycombinator.com')
// 2. Extract data
const { data: stories } = await agent.extract('get the top 5 stories with title, points, and link', {
schema: z.array(z.object({
title: z.string(),
points: z.number(),
link: z.string(),
})),
})
// 3. Act on extracted data
await agent.act(`click on the story titled "${stories[0].title}"`)
// 4. Verify the result
const { success } = await agent.verify('the story page or external link has loaded')
console.log({ stories, navigationSuccess: success })
```
## API Reference
### `new Agent(options)`
@@ -105,8 +149,6 @@ const { success, reason } = await agent.verify('the form was submitted successfu
## LLM Providers
Supported providers:
| Provider | Config |
|----------|--------|
| OpenAI | `{ provider: 'openai', apiKey: '...' }` |
@@ -121,11 +163,11 @@ Supported providers:
## Progress Events
Track agent operations:
Track agent operations in real time:
```typescript
const agent = new Agent({
url: 'http://localhost:3000',
url: 'http://localhost:9100',
onProgress: (event) => {
console.log(`[${event.type}] ${event.message}`)
},
@@ -154,6 +196,13 @@ try {
}
```
## Links
- [Documentation](https://docs.browseros.com)
- [GitHub](https://github.com/browseros-ai/BrowserOS)
- [Changelog](./CHANGELOG.md)
- [Discord](https://discord.gg/YKwjt5vuKr)
## License
AGPL-3.0-or-later
[AGPL-3.0-or-later](../../../../LICENSE)

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros-ai/agent-sdk",
"version": "0.0.5",
"version": "0.0.7",
"description": "Browser automation SDK for BrowserOS - navigate, interact, extract data with natural language",
"type": "module",
"license": "AGPL-3.0-or-later",

View File

@@ -0,0 +1,67 @@
# @browseros/cdp-protocol
Type-safe Chrome DevTools Protocol bindings for BrowserOS.
> **Internal package** — auto-generated TypeScript types and API wrappers for all CDP domains. Used by `@browseros/server` to communicate with Chromium.
## Usage
Import domain types or domain API wrappers using subpath exports:
```typescript
// Import type definitions for a CDP domain
import type { NavigateParams, NavigateReturn } from '@browseros/cdp-protocol/domains/page'
// Import the API wrapper for a domain
import { PageAPI } from '@browseros/cdp-protocol/domain-apis/page'
// Core protocol API
import { ProtocolAPI } from '@browseros/cdp-protocol/protocol-api'
// Factory function
import { createAPI } from '@browseros/cdp-protocol/create-api'
```
## Supported Domains
All standard Chrome DevTools Protocol domains are supported:
| Category | Domains |
|----------|---------|
| **Page & DOM** | Page, DOM, DOMDebugger, DOMSnapshot, DOMStorage, CSS, Overlay |
| **Network** | Network, Fetch, IO, ServiceWorker, CacheStorage |
| **Input & Interaction** | Input, Emulation, DeviceOrientation, DeviceAccess |
| **JavaScript** | Runtime, Debugger, Console, Profiler, HeapProfiler |
| **Browser** | Browser, Target, Inspector, Extensions, PWA |
| **Performance** | Performance, PerformanceTimeline, Tracing, Memory |
| **Media** | Media, WebAudio, Cast |
| **Security** | Security, WebAuthn, FedCm |
| **Storage** | IndexedDB, Storage, FileSystem |
| **Other** | Accessibility, Animation, Audits, Autofill, BackgroundService, BluetoothEmulation, EventBreakpoints, HeadlessExperimental, LayerTree, Log, Preload, Schema, SystemInfo, Tethering |
| **BrowserOS Custom** | Bookmarks, History |
## Structure
```
src/generated/
├── domains/ # Type definitions for each CDP domain
│ ├── page.ts
│ ├── dom.ts
│ ├── network.ts
│ └── ...
├── domain-apis/ # API wrapper classes for each domain
│ ├── page.ts
│ ├── dom.ts
│ ├── network.ts
│ └── ...
├── protocol-api.ts # Unified protocol API
└── create-api.ts # API factory
```
## Regenerating Types
Types are auto-generated from the CDP protocol specification. The generated output lives in `src/generated/` and should not be edited manually.
## License
[AGPL-3.0-or-later](../../../../LICENSE)

View File

@@ -13,4 +13,5 @@ export interface ServerDiscoveryConfig {
server_version: string
browseros_version?: string
chromium_version?: string
browseros_id?: string
}

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { runCliInstallerUpload } from './cli/upload'
runCliInstallerUpload().catch((error) => {
const message = error instanceof Error ? error.message : String(error)
console.error(`\n✗ ${message}\n`)
process.exit(1)
})

View File

@@ -0,0 +1,52 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { parse } from 'dotenv'
import type { R2Config } from '../server/types'
const PROD_ENV_PATH = join('apps', 'cli', '.env.production')
const PROD_ENV_TEMPLATE_PATH = join('apps', 'cli', '.env.production.example')
function pickEnv(name: string, fileEnv: Record<string, string>): string {
const value = process.env[name] ?? fileEnv[name]
if (!value || value.trim().length === 0) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
function loadProdEnv(rootDir: string): Record<string, string> {
const prodEnvPath = join(rootDir, PROD_ENV_PATH)
if (!existsSync(prodEnvPath)) {
const templatePath = join(rootDir, PROD_ENV_TEMPLATE_PATH)
if (existsSync(templatePath)) {
throw new Error(
`Missing ${PROD_ENV_PATH}. Create it from ${PROD_ENV_TEMPLATE_PATH} before running upload:cli-installers.`,
)
}
throw new Error(
`Missing ${PROD_ENV_PATH}. The template file ${PROD_ENV_TEMPLATE_PATH} was not found.`,
)
}
return parse(readFileSync(prodEnvPath, 'utf-8'))
}
export interface CliUploadConfig {
r2: R2Config
}
export function loadCliUploadConfig(rootDir: string): CliUploadConfig {
const fileEnv = loadProdEnv(rootDir)
return {
r2: {
accountId: pickEnv('R2_ACCOUNT_ID', fileEnv),
accessKeyId: pickEnv('R2_ACCESS_KEY_ID', fileEnv),
secretAccessKey: pickEnv('R2_SECRET_ACCESS_KEY', fileEnv),
bucket: pickEnv('R2_BUCKET', fileEnv),
downloadPrefix: '',
uploadPrefix:
process.env.R2_UPLOAD_PREFIX ?? fileEnv.R2_UPLOAD_PREFIX ?? 'cli',
},
}
}

View File

@@ -0,0 +1,56 @@
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { log } from '../log'
import { createR2Client, joinObjectKey, uploadFileToObject } from '../server/r2'
import { loadCliUploadConfig } from './config'
const CDN_BASE_URL = 'https://cdn.browseros.com'
const INSTALLERS = [
{
filePath: join('apps', 'cli', 'scripts', 'install.sh'),
objectName: 'install.sh',
contentType: 'text/x-shellscript; charset=utf-8',
},
{
filePath: join('apps', 'cli', 'scripts', 'install.ps1'),
objectName: 'install.ps1',
contentType: 'text/plain; charset=utf-8',
},
] as const
export async function runCliInstallerUpload(): Promise<void> {
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
process.chdir(rootDir)
await uploadCliInstallers(rootDir)
}
export async function uploadCliInstallers(rootDir: string): Promise<void> {
const { r2 } = loadCliUploadConfig(rootDir)
const client = createR2Client(r2)
log.header('Uploading BrowserOS CLI installer scripts')
try {
for (const installer of INSTALLERS) {
const absolutePath = join(rootDir, installer.filePath)
if (!existsSync(absolutePath)) {
throw new Error(`Installer script not found: ${installer.filePath}`)
}
const objectKey = joinObjectKey(r2.uploadPrefix, installer.objectName)
log.step(`Uploading ${installer.filePath}`)
await uploadFileToObject(client, r2, objectKey, absolutePath, {
contentType: installer.contentType,
})
log.success(`Uploaded ${objectKey}`)
log.info(`${CDN_BASE_URL}/${objectKey}`)
}
log.done('CLI installer upload completed')
} finally {
client.destroy()
}
}

View File

@@ -21,16 +21,24 @@ export function parseBuildArgs(argv: string[]): BuildArgs {
)
.option('--upload', 'Upload artifact zips to R2')
.option('--no-upload', 'Skip zip upload to R2')
.option(
'--compile-only',
'Compile binaries only (skip R2 staging and upload)',
)
program.parse(argv, { from: 'user' })
const options = program.opts<{
target: string
manifest: string
upload: boolean
compileOnly: boolean
}>()
const compileOnly = options.compileOnly ?? false
return {
targets: resolveTargets(options.target),
manifestPath: options.manifest,
upload: options.upload ?? true,
upload: compileOnly ? false : (options.upload ?? true),
compileOnly,
}
}

View File

@@ -74,7 +74,14 @@ function validateProductionEnv(envVars: Record<string, string>): void {
}
}
export function loadBuildConfig(rootDir: string): BuildConfig {
export interface LoadBuildConfigOptions {
compileOnly?: boolean
}
export function loadBuildConfig(
rootDir: string,
options: LoadBuildConfigOptions = {},
): BuildConfig {
const fileEnv = loadProdEnv(rootDir)
const envVars = buildInlineEnv(fileEnv)
validateProductionEnv(envVars)
@@ -85,6 +92,10 @@ export function loadBuildConfig(rootDir: string): BuildConfig {
...process.env,
}
if (options.compileOnly) {
return { version: readServerVersion(rootDir), envVars, processEnv }
}
return {
version: readServerVersion(rootDir),
envVars,

View File

@@ -15,19 +15,14 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
process.chdir(rootDir)
const args = parseBuildArgs(argv)
const manifestPath = resolve(rootDir, args.manifestPath)
if (!existsSync(manifestPath)) {
throw new Error(`Manifest not found: ${manifestPath}`)
}
const buildConfig = loadBuildConfig(rootDir)
const manifest = loadManifest(manifestPath)
const distRoot = getDistProdRoot()
const buildConfig = loadBuildConfig(rootDir, {
compileOnly: args.compileOnly,
})
log.header(`Building BrowserOS server artifacts v${buildConfig.version}`)
log.info(`Targets: ${args.targets.map((target) => target.id).join(', ')}`)
log.info(`Manifest: ${manifestPath}`)
log.info(`Upload: ${args.upload ? 'enabled' : 'disabled'}`)
log.info(`Mode: ${args.compileOnly ? 'compile-only' : 'full'}`)
const compiled = await compileServerBinaries(
args.targets,
@@ -36,7 +31,26 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
buildConfig.version,
)
const client = createR2Client(buildConfig.r2)
if (args.compileOnly) {
log.done('Compile-only build completed')
for (const binary of compiled) {
log.info(`${binary.target.id}: ${binary.binaryPath}`)
}
return
}
const manifestPath = resolve(rootDir, args.manifestPath)
if (!existsSync(manifestPath)) {
throw new Error(`Manifest not found: ${manifestPath}`)
}
const manifest = loadManifest(manifestPath)
const distRoot = getDistProdRoot()
const r2 = buildConfig.r2
if (!r2) {
throw new Error('R2 configuration is required for full builds')
}
const client = createR2Client(r2)
const stagedArtifacts = []
try {
@@ -51,7 +65,7 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
binary.target,
rules,
client,
buildConfig.r2,
r2,
buildConfig.version,
)
stagedArtifacts.push(staged)
@@ -62,7 +76,7 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
stagedArtifacts,
buildConfig.version,
client,
buildConfig.r2,
r2,
args.upload,
)

View File

@@ -10,6 +10,10 @@ import {
import type { R2Config } from './types'
export interface UploadFileOptions {
contentType?: string
}
function createClientConfig(r2: R2Config): S3ClientConfig {
return {
region: 'auto',
@@ -81,6 +85,7 @@ export async function uploadFileToObject(
r2: R2Config,
key: string,
filePath: string,
options: UploadFileOptions = {},
): Promise<void> {
const data = await readFile(filePath)
await client.send(
@@ -88,7 +93,7 @@ export async function uploadFileToObject(
Bucket: r2.bucket,
Key: key,
Body: data,
ContentType: 'application/zip',
ContentType: options.contentType ?? 'application/zip',
}),
)
}

View File

@@ -21,6 +21,7 @@ export interface BuildArgs {
targets: BuildTarget[]
manifestPath: string
upload: boolean
compileOnly: boolean
}
export interface R2Config {
@@ -36,7 +37,7 @@ export interface BuildConfig {
version: string
envVars: Record<string, string>
processEnv: NodeJS.ProcessEnv
r2: R2Config
r2?: R2Config
}
export interface ResourceSource {

View File

@@ -0,0 +1,145 @@
/**
* Fetches models.dev/api.json and generates a compact models data file
* for BrowserOS. Run: bun scripts/generate-models.ts
*/
const API_URL = 'https://models.dev/api.json'
const OUTPUT_PATH = new URL(
'../apps/agent/lib/llm-providers/models-dev-data.json',
import.meta.url,
).pathname
interface ModelsDevModel {
id: string
name: string
family?: string
attachment: boolean
reasoning: boolean
tool_call: boolean
structured_output?: boolean
modalities: { input: string[]; output: string[] }
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
limit: { context: number; output: number; input?: number }
status?: string
release_date: string
last_updated: string
}
interface ModelsDevProvider {
id: string
name: string
npm: string
api?: string
doc: string
env: string[]
models: Record<string, ModelsDevModel>
}
interface OutputModel {
id: string
name: string
contextWindow: number
maxOutput: number
supportsImages: boolean
supportsReasoning: boolean
supportsToolCall: boolean
inputCost?: number
outputCost?: number
}
interface OutputProvider {
name: string
api?: string
doc: string
models: OutputModel[]
}
// models.dev ID → BrowserOS provider ID
const PROVIDER_MAP: Record<string, string> = {
anthropic: 'anthropic',
openai: 'openai',
google: 'google',
openrouter: 'openrouter',
azure: 'azure',
'amazon-bedrock': 'bedrock',
lmstudio: 'lmstudio',
moonshotai: 'moonshot',
'github-copilot': 'github-copilot',
}
function transformModel(model: ModelsDevModel): OutputModel | null {
if (model.status === 'deprecated') return null
const supportsImages =
model.attachment || model.modalities.input.includes('image')
return {
id: model.id,
name: model.name,
contextWindow: model.limit.context,
maxOutput: model.limit.output,
supportsImages,
supportsReasoning: model.reasoning,
supportsToolCall: model.tool_call,
...(model.cost && {
inputCost: model.cost.input,
outputCost: model.cost.output,
}),
}
}
async function main() {
console.log(`Fetching ${API_URL}...`)
const response = await fetch(API_URL)
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
const data: Record<string, ModelsDevProvider> = await response.json()
console.log(`Fetched ${Object.keys(data).length} providers`)
const output: Record<string, OutputProvider> = {}
for (const [modelsDevId, browserosId] of Object.entries(PROVIDER_MAP)) {
const provider = data[modelsDevId]
if (!provider) {
console.warn(`Provider not found in models.dev: ${modelsDevId}`)
continue
}
const models = Object.values(provider.models)
.map(transformModel)
.filter((m): m is OutputModel => m !== null)
.sort((a, b) => {
const dateA = provider.models[a.id]?.last_updated ?? ''
const dateB = provider.models[b.id]?.last_updated ?? ''
return dateB.localeCompare(dateA)
})
output[browserosId] = {
name: provider.name,
...(provider.api && { api: provider.api }),
doc: provider.doc,
models,
}
}
const totalModels = Object.values(output).reduce(
(sum, p) => sum + p.models.length,
0,
)
console.log(
`Generated ${Object.keys(output).length} providers with ${totalModels} models`,
)
await Bun.write(OUTPUT_PATH, JSON.stringify(output, null, 2))
console.log(`Written to ${OUTPUT_PATH}`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,133 @@
# BrowserOS Browser (Chromium Fork)
Custom Chromium build with AI agent integration, enhanced privacy patches, and native MCP support.
> Based on **Chromium 146.0.7680.31** · Built with Python 3.12+ · Licensed under [AGPL-3.0](../../LICENSE)
## What This Is
This package contains the BrowserOS browser build system — everything needed to fetch Chromium source, apply BrowserOS patches, and produce signed binaries for macOS, Windows, and Linux. The build system is a Python CLI that orchestrates the entire pipeline from source to distributable.
BrowserOS patches add:
- Native AI agent sidebar and new tab integration
- MCP server endpoints baked into the browser
- Enhanced privacy via [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) patches
- Custom branding, icons, and entitlements
- Keychain access group management (macOS)
- Sparkle auto-update framework (macOS)
## Prerequisites
| Requirement | Details |
|-------------|---------|
| **Disk space** | ~100 GB for Chromium source + build artifacts |
| **Python** | 3.12+ |
| **macOS** | Xcode + Command Line Tools |
| **Linux** | `build-essential`, `clang`, `lld`, and Chromium's [Linux deps](https://chromium.googlesource.com/chromium/src/+/main/docs/linux/build_instructions.md) |
| **Windows** | Visual Studio 2022, Windows SDK |
## Directory Structure
```
packages/browseros/
├── build/ # Build system (Python CLI)
│ ├── __main__.py # CLI entry point
│ ├── browseros.py # Main app definition
│ ├── modules/
│ │ ├── setup/ # Chromium source fetch and setup
│ │ ├── patches/ # Patch application logic
│ │ ├── apply/ # Apply patches to source tree
│ │ ├── extract/ # Extract patches from modified source
│ │ ├── feature/ # Feature flag management
│ │ ├── package/ # Binary packaging
│ │ ├── sign/ # Code signing (macOS, Windows)
│ │ ├── ota/ # Over-the-air update support
│ │ └── resources/ # Resource management
│ ├── config/ # Build configuration
│ └── features.yaml # Feature flag definitions
├── chromium_patches/ # BrowserOS patches applied to Chromium source
│ ├── chrome/browser/ # Browser UI and feature patches
│ ├── components/ # Component patches (e.g., os_crypt)
│ └── ... # Organized to mirror Chromium source tree
├── chromium_files/ # New files added to Chromium (not patches)
├── series_patches/ # Ordered patch series
├── resources/ # Icons, entitlements, signing resources
│ └── entitlements/ # macOS entitlements (app, helper, GPU, etc.)
├── tools/
│ └── bdev # Developer tool
├── CHROMIUM_VERSION # Pinned Chromium version (MAJOR.MINOR.BUILD.PATCH)
├── BASE_COMMIT # Base Chromium commit hash
├── pyproject.toml # Python project config
└── requirements.txt # Python dependencies
```
## Build System
The `browseros` CLI manages the full build lifecycle:
```bash
# Install the build system
pip install -e .
# Or use uv
uv pip install -e .
```
Key commands:
```bash
browseros setup # Fetch and prepare Chromium source
browseros apply # Apply all patches to Chromium source
browseros build # Build BrowserOS binary
browseros package # Package into distributable (DMG, installer, AppImage)
browseros sign # Code sign the binary (macOS/Windows)
```
## Patch System
BrowserOS applies patches on top of vanilla Chromium. Patches are organized in two directories:
- **`chromium_patches/`** — Individual file patches, organized to mirror the Chromium source tree. Each file here replaces or modifies the corresponding file in Chromium.
- **`series_patches/`** — Ordered patch series applied sequentially.
### Adding a New Patch
1. Make your changes in the Chromium source tree
2. Use `browseros extract` to pull changes back into patch format
3. Place the patch in the appropriate directory mirroring Chromium's structure
4. Test with a full `browseros apply && browseros build` cycle
### Chromium Version Pinning
The exact Chromium version is pinned in `CHROMIUM_VERSION`:
```
MAJOR=146
MINOR=0
BUILD=7680
PATCH=31
```
To update the base Chromium version, update this file and `BASE_COMMIT`, then resolve any patch conflicts.
## Signing (macOS)
macOS builds require code signing for Keychain access, Gatekeeper, and notarization:
- Entitlements are in `resources/entitlements/` (app, helper, GPU, renderer, etc.)
- Designated requirements pin to Team ID for Keychain persistence across updates
- The signing module is at `build/modules/sign/macos.py`
## Feature Flags
Feature flags are defined in `features.yaml` and control which BrowserOS-specific features are compiled into the build. The feature module (`build/modules/feature/`) manages flag resolution at build time.
## Related Resources
- [Chromium Build Instructions](https://chromium.googlesource.com/chromium/src/+/main/docs/linux/build_instructions.md)
- [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) — upstream privacy patches
- [BrowserOS Agent Platform](../browseros-agent/) — the TypeScript/Go agent system that runs inside the browser

View File

@@ -1,5 +0,0 @@
bros
bros-linux-amd64
bros-linux-arm64
bros-darwin-amd64
bros-darwin-arm64

View File

@@ -1,27 +1,27 @@
BINARY := bdev
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-X main.version=$(VERSION)"
PREFIX ?= /usr/local/bin
VERSION ?= dev
.PHONY: build install clean test
.PHONY: build install clean test fmt
build:
go build $(LDFLAGS) -o $(BINARY) .
go build -ldflags "-X github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/cmd.Version=$(VERSION)" -o $(BINARY) .
install:
go install $(LDFLAGS) .
clean:
rm -f $(BINARY)
install: build
mkdir -p $(PREFIX)
cp $(BINARY) $(PREFIX)/$(BINARY)
ifneq ($(shell uname -s),Darwin)
@echo "Skipping codesign on non-macOS host"
else
codesign --force --sign - $(PREFIX)/$(BINARY)
endif
@echo "Installed $(BINARY) to $(PREFIX)/$(BINARY)"
test:
go test ./...
build-linux:
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-linux-amd64 .
fmt:
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
build-linux-arm:
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-linux-arm64 .
build-darwin:
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-darwin-arm64 .
clean:
rm -f $(BINARY)

View File

@@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
func init() {
command := &cobra.Command{
Use: "abort",
Annotations: map[string]string{"group": "Conflict:"},
Short: "Abort conflict resolution and roll the pending files back",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ws, err := resolve.FindActive(appState.Registry, appState.CWD)
if err != nil {
return err
}
if err := engine.Abort(cmd.Context(), ws); err != nil {
return err
}
return renderResult(map[string]any{"workspace": ws.Name, "aborted": true}, func() {
fmt.Println(ui.Warning(fmt.Sprintf("Aborted conflict resolution for %s", ws.Name)))
})
},
}
rootCmd.AddCommand(command)
}

View File

@@ -0,0 +1,42 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
func init() {
var patchesRepo string
command := &cobra.Command{
Use: "add <name> <path>",
Aliases: []string{"register"},
Annotations: map[string]string{"group": "Workspace:"},
Short: "Register a Chromium checkout as a workspace",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
if err := ensureRepoConfigured(patchesRepo); err != nil {
return err
}
entry, err := appState.Registry.Add(args[0], args[1])
if err != nil {
return err
}
if err := appState.Save(); err != nil {
return err
}
return renderResult(map[string]any{
"workspace": entry,
"patches_repo": appState.Config.PatchesRepo,
}, func() {
fmt.Println(ui.Success("Registered workspace"))
fmt.Printf("%s %s\n", ui.Muted("name:"), entry.Name)
fmt.Printf("%s %s\n", ui.Muted("path:"), entry.Path)
fmt.Printf("%s %s\n", ui.Muted("repo:"), appState.Config.PatchesRepo)
})
},
}
command.Flags().StringVar(&patchesRepo, "patches-repo", "", "Path to packages/browseros")
rootCmd.AddCommand(command)
}

View File

@@ -0,0 +1,65 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
func init() {
var src string
var reset bool
var changed string
var rangeEnd string
command := &cobra.Command{
Use: "apply [workspace] [-- files...]",
Annotations: map[string]string{"group": "Core:"},
Short: "Apply repo patches to a workspace",
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
positional, filters := splitWorkspaceAndFilters(cmd, args)
if len(positional) > 1 {
return fmt.Errorf("expected at most one workspace name")
}
ws, err := resolveWorkspace(positional, src)
if err != nil {
return err
}
info, err := repoInfo()
if err != nil {
return err
}
result, err := engine.Apply(cmd.Context(), engine.ApplyOptions{
Workspace: ws,
Repo: info,
Reset: reset,
ChangedRef: changed,
RangeEnd: rangeEnd,
Filters: filters,
})
if err != nil {
return err
}
return renderResult(result, func() {
fmt.Println(ui.Title(fmt.Sprintf("Applied patches to %s", ws.Name)))
fmt.Printf("%s %s\n", ui.Muted("mode:"), result.Mode)
fmt.Printf("%s %d\n", ui.Muted("applied:"), len(result.Applied))
fmt.Printf("%s %d\n", ui.Muted("orphaned:"), len(result.Orphaned))
if len(result.Conflicts) > 0 {
fmt.Println(ui.Warning("Conflicts detected"))
for _, conflict := range result.Conflicts {
fmt.Printf(" %s\n", conflict.ChromiumPath)
}
fmt.Println(ui.Hint(`Run "bdev continue" after fixing the current conflict.`))
}
})
},
}
command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly")
command.Flags().BoolVar(&reset, "reset", false, "Reset patched files to BASE_COMMIT before applying")
command.Flags().StringVar(&changed, "changed", "", "Apply only patches changed in the given repo commit")
command.Flags().StringVar(&rangeEnd, "range-end", "", "End revision when using --changed as a range start")
rootCmd.AddCommand(command)
}

View File

@@ -1,151 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"time"
"bdev/internal/config"
"bdev/internal/engine"
"bdev/internal/git"
"bdev/internal/log"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var cloneCmd = &cobra.Command{
Use: "clone",
Short: "Fresh-apply all patches (for CI/new checkouts)",
Long: `Apply all patches from the patches repository onto the current
Chromium checkout. Used for CI builds and new checkout setup.
Unlike pull, clone does not compare existing state — it applies everything.`,
RunE: runClone,
}
var (
clonePatchesRepo string
cloneVerifyBase bool
cloneClean bool
cloneDryRun bool
cloneName string
)
func init() {
cloneCmd.Flags().StringVar(&clonePatchesRepo, "patches-repo", "", "path to BrowserOS packages directory")
cloneCmd.Flags().BoolVar(&cloneVerifyBase, "verify-base", false, "fail if HEAD != BASE_COMMIT")
cloneCmd.Flags().BoolVar(&cloneClean, "clean", false, "reset all modified files to BASE before applying")
cloneCmd.Flags().BoolVar(&cloneDryRun, "dry-run", false, "show what would be applied")
cloneCmd.Flags().StringVar(&cloneName, "name", "", "checkout name (default: directory name)")
rootCmd.AddCommand(cloneCmd)
}
func runClone(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting cwd: %w", err)
}
// Try loading existing context, or create one from flags
ctx, err := config.LoadContext()
if err != nil {
// No existing .bros/ — need --patches-repo
if clonePatchesRepo == "" {
return fmt.Errorf("no .bros/ found and --patches-repo not specified")
}
patchesRepo, err := filepath.Abs(clonePatchesRepo)
if err != nil {
return fmt.Errorf("resolving patches repo: %w", err)
}
baseCommit, err := config.ReadBaseCommit(patchesRepo)
if err != nil {
return err
}
name := cloneName
if name == "" {
name = filepath.Base(cwd)
}
brosDir := filepath.Join(cwd, config.BrosDirName)
cfg := &config.Config{
Name: name,
PatchesRepo: patchesRepo,
}
if !cloneDryRun {
if err := config.WriteConfig(brosDir, cfg); err != nil {
return err
}
_ = os.MkdirAll(filepath.Join(brosDir, "logs"), 0o755)
}
chromiumVersion, _ := config.ReadChromiumVersion(patchesRepo)
ctx = &config.Context{
Config: cfg,
State: &config.State{},
ChromiumDir: cwd,
BrosDir: brosDir,
PatchesRepo: patchesRepo,
PatchesDir: filepath.Join(patchesRepo, "chromium_patches"),
BaseCommit: baseCommit,
ChromiumVersion: chromiumVersion,
}
}
if cloneDryRun {
fmt.Println(ui.MutedStyle.Render("dry run — no files will be modified"))
fmt.Println()
}
opts := engine.CloneOpts{
VerifyBase: cloneVerifyBase,
Clean: cloneClean,
DryRun: cloneDryRun,
}
result, err := engine.Clone(ctx, opts)
if err != nil {
return err
}
// Reuse pull rendering
fmt.Println(ui.TitleStyle.Render("bdev clone"))
fmt.Println()
fmt.Printf(" %s %d patches applied\n",
ui.SuccessStyle.Render("+"), len(result.Applied))
if len(result.Conflicts) > 0 {
fmt.Printf(" %s %d conflicts\n",
ui.ErrorStyle.Render("x"), len(result.Conflicts))
}
if len(result.Deleted) > 0 {
fmt.Printf(" %s %d files deleted\n",
ui.DeletedPrefix, len(result.Deleted))
}
if len(result.Conflicts) > 0 {
fmt.Print(ui.RenderConflictReport(result.Conflicts))
}
if !cloneDryRun {
repoRev, _ := git.HeadRev(ctx.PatchesRepo)
ctx.State.LastPull = &config.SyncEvent{
PatchesRepoRev: repoRev,
BaseCommit: ctx.BaseCommit,
Timestamp: time.Now(),
FileCount: len(result.Applied) + len(result.Deleted),
}
_ = config.WriteState(ctx.BrosDir, ctx.State)
logger := log.New(ctx.BrosDir)
_ = logger.LogClone(ctx.BaseCommit, result)
}
if len(result.Conflicts) > 0 {
return fmt.Errorf("%d conflicts — see above for details", len(result.Conflicts))
}
return nil
}

View File

@@ -0,0 +1,49 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace"
"github.com/spf13/cobra"
)
func repoInfo() (*repo.Info, error) {
return appState.RepoInfo()
}
func resolveWorkspace(positional []string, src string) (workspace.Entry, error) {
name := ""
if len(positional) > 0 {
name = positional[0]
}
return appState.ResolveWorkspace(name, src)
}
func splitWorkspaceAndFilters(cmd *cobra.Command, args []string) ([]string, []string) {
atDash := cmd.ArgsLenAtDash()
if atDash == -1 {
return args, nil
}
return args[:atDash], args[atDash:]
}
func ensureRepoConfigured(override string) error {
if override == "" && appState.Config.PatchesRepo != "" {
return nil
}
root := override
if root == "" {
discovered, err := repo.Discover(appState.CWD)
if err != nil {
return fmt.Errorf(`unable to discover patches repo; pass --patches-repo or run from packages/browseros`)
}
root = discovered
}
info, err := repo.Load(root)
if err != nil {
return err
}
appState.Config.PatchesRepo = info.Root
return nil
}

View File

@@ -0,0 +1,39 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
func init() {
command := &cobra.Command{
Use: "continue",
Annotations: map[string]string{"group": "Conflict:"},
Short: "Advance to the next conflict after fixing the current one",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ws, err := resolve.FindActive(appState.Registry, appState.CWD)
if err != nil {
return err
}
result, err := engine.Continue(cmd.Context(), ws)
if err != nil {
return err
}
return renderResult(result, func() {
fmt.Println(ui.Success(fmt.Sprintf("Advanced conflict resolution for %s", ws.Name)))
if len(result.Conflicts) > 0 {
fmt.Println(ui.Warning("Next conflict"))
for _, conflict := range result.Conflicts {
fmt.Printf(" %s\n", conflict.ChromiumPath)
}
}
})
},
}
rootCmd.AddCommand(command)
}

View File

@@ -2,113 +2,52 @@ package cmd
import (
"fmt"
"strings"
"bdev/internal/config"
"bdev/internal/git"
"bdev/internal/patch"
"bdev/internal/ui"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
var diffCmd = &cobra.Command{
Use: "diff",
Short: "Preview what push or pull would do",
RunE: runDiff,
}
var diffDirection string
func init() {
diffCmd.Flags().StringVar(&diffDirection, "direction", "push", "\"push\" or \"pull\"")
rootCmd.AddCommand(diffCmd)
var src string
command := &cobra.Command{
Use: "diff [workspace]",
Annotations: map[string]string{"group": "Core:"},
Short: "Preview patch differences for a workspace",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ws, err := resolveWorkspace(args, src)
if err != nil {
return err
}
info, err := repoInfo()
if err != nil {
return err
}
status, err := engine.InspectWorkspace(cmd.Context(), ws, info)
if err != nil {
return err
}
return renderResult(status, func() {
fmt.Println(ui.Title(fmt.Sprintf("%s patch diff", ws.Name)))
printGroup("Needs apply", status.NeedsApply)
printGroup("Needs update", status.NeedsUpdate)
printGroup("Orphaned", status.Orphaned)
})
},
}
command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly")
rootCmd.AddCommand(command)
}
func runDiff(cmd *cobra.Command, args []string) error {
ctx, err := config.LoadContext()
if err != nil {
return err
func printGroup(title string, items []string) {
if len(items) == 0 {
fmt.Printf("%s %s\n", ui.Muted(title+":"), ui.Muted("none"))
return
}
switch diffDirection {
case "push":
return diffPush(ctx)
case "pull":
return diffPull(ctx)
default:
return fmt.Errorf("invalid direction %q — use \"push\" or \"pull\"", diffDirection)
fmt.Printf("%s\n", ui.Header(title+":"))
for _, item := range items {
fmt.Printf(" %s\n", strings.TrimSpace(item))
}
}
func diffPush(ctx *config.Context) error {
nameStatus, err := git.DiffNameStatus(ctx.ChromiumDir, ctx.BaseCommit)
if err != nil {
return err
}
if len(nameStatus) == 0 {
fmt.Println(ui.MutedStyle.Render("No local changes to push."))
return nil
}
fmt.Println(ui.TitleStyle.Render("bdev diff --direction push"))
fmt.Println()
for path, op := range nameStatus {
prefix := ui.ModifiedPrefix
switch op {
case patch.OpAdded:
prefix = ui.AddedPrefix
case patch.OpDeleted:
prefix = ui.DeletedPrefix
}
fmt.Printf(" %s %s\n", prefix, path)
}
fmt.Println()
fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("%d files would be pushed", len(nameStatus))))
return nil
}
func diffPull(ctx *config.Context) error {
repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir)
if err != nil {
return err
}
diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit)
if err != nil {
return err
}
localPatchSet, err := patch.ParseUnifiedDiff(diffOutput)
if err != nil {
return err
}
delta := patch.Compare(localPatchSet, repoPatchSet)
total := len(delta.NeedsUpdate) + len(delta.NeedsApply)
if total == 0 && len(delta.Deleted) == 0 {
fmt.Println(ui.MutedStyle.Render("Already up to date."))
return nil
}
fmt.Println(ui.TitleStyle.Render("bdev diff --direction pull"))
fmt.Println()
for _, f := range delta.NeedsUpdate {
fmt.Printf(" %s %s %s\n", ui.ModifiedPrefix, f, ui.MutedStyle.Render("(update)"))
}
for _, f := range delta.NeedsApply {
fmt.Printf(" %s %s %s\n", ui.AddedPrefix, f, ui.MutedStyle.Render("(new)"))
}
for _, f := range delta.Deleted {
fmt.Printf(" %s %s %s\n", ui.DeletedPrefix, f, ui.MutedStyle.Render("(delete)"))
}
fmt.Println()
fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("%d files would be changed", total+len(delta.Deleted))))
return nil
}

View File

@@ -0,0 +1,73 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
func init() {
var src string
var commit string
var rangeMode bool
var squash bool
var base string
command := &cobra.Command{
Use: "extract [workspace] [--range <start> <end>] [-- files...]",
Annotations: map[string]string{"group": "Core:"},
Short: "Extract workspace changes back to chromium_patches",
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
positional, filters := splitWorkspaceAndFilters(cmd, args)
workspaceArgs := positional
rangeStart := ""
rangeEnd := ""
if rangeMode {
if len(positional) < 2 || len(positional) > 3 {
return fmt.Errorf(`range mode expects "bdev extract [workspace] --range <start> <end>"`)
}
rangeStart = positional[len(positional)-2]
rangeEnd = positional[len(positional)-1]
workspaceArgs = positional[:len(positional)-2]
}
if len(workspaceArgs) > 1 {
return fmt.Errorf("expected at most one workspace name")
}
ws, err := resolveWorkspace(workspaceArgs, src)
if err != nil {
return err
}
info, err := repoInfo()
if err != nil {
return err
}
result, err := engine.Extract(cmd.Context(), engine.ExtractOptions{
Workspace: ws,
Repo: info,
Commit: commit,
RangeStart: rangeStart,
RangeEnd: rangeEnd,
Squash: squash,
Base: base,
Filters: filters,
})
if err != nil {
return err
}
return renderResult(result, func() {
fmt.Println(ui.Title(fmt.Sprintf("Extracted patches from %s", ws.Name)))
fmt.Printf("%s %s\n", ui.Muted("mode:"), result.Mode)
fmt.Printf("%s %d\n", ui.Muted("written:"), len(result.Written))
fmt.Printf("%s %d\n", ui.Muted("deleted:"), len(result.Deleted))
})
},
}
command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly")
command.Flags().StringVar(&commit, "commit", "", "Extract from a single commit")
command.Flags().BoolVar(&rangeMode, "range", false, "Extract from a commit range")
command.Flags().BoolVar(&squash, "squash", false, "Squash a range into a cumulative diff")
command.Flags().StringVar(&base, "base", "", "Override BASE_COMMIT for extraction")
rootCmd.AddCommand(command)
}

View File

@@ -1,115 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"bdev/internal/config"
"bdev/internal/git"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize a Chromium checkout for bdev",
Long: "Sets up a .bros/ directory in the current Chromium checkout,\nlinking it to a BrowserOS patches repository.",
RunE: runInit,
}
var (
initPatchesRepo string
initName string
)
func init() {
initCmd.Flags().StringVar(&initPatchesRepo, "patches-repo", "", "path to BrowserOS packages directory (required)")
initCmd.Flags().StringVar(&initName, "name", "", "human name for this checkout (default: directory name)")
_ = initCmd.MarkFlagRequired("patches-repo")
rootCmd.AddCommand(initCmd)
}
func runInit(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting cwd: %w", err)
}
if !config.LooksLikeChromium(cwd) {
return fmt.Errorf("current directory does not look like a Chromium checkout (missing chrome/, base/, or .git/)")
}
brosDir := filepath.Join(cwd, config.BrosDirName)
if _, err := os.Stat(filepath.Join(brosDir, "config.yaml")); err == nil {
return fmt.Errorf(".bros/config.yaml already exists — checkout already initialized")
}
patchesRepo, err := filepath.Abs(initPatchesRepo)
if err != nil {
return fmt.Errorf("resolving patches repo path: %w", err)
}
patchesDir := filepath.Join(patchesRepo, "chromium_patches")
if _, err := os.Stat(patchesDir); err != nil {
return fmt.Errorf("chromium_patches/ not found in %s", patchesRepo)
}
baseCommit, err := config.ReadBaseCommit(patchesRepo)
if err != nil {
return err
}
if !git.CommitExists(cwd, baseCommit) {
return fmt.Errorf("BASE_COMMIT %s not found in this checkout's git history", baseCommit)
}
name := initName
if name == "" {
name = filepath.Base(cwd)
}
cfg := &config.Config{
Name: name,
PatchesRepo: patchesRepo,
}
if err := config.WriteConfig(brosDir, cfg); err != nil {
return err
}
// Create logs directory
if err := os.MkdirAll(filepath.Join(brosDir, "logs"), 0o755); err != nil {
return fmt.Errorf("creating logs directory: %w", err)
}
chromiumVersion, _ := config.ReadChromiumVersion(patchesRepo)
// Count existing patches
patchCount := 0
_ = filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
patchCount++
}
return nil
})
fmt.Println(ui.TitleStyle.Render("bdev init"))
fmt.Println()
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Checkout:"), ui.ValueStyle.Render(name))
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Directory:"), cwd)
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Patches repo:"), patchesRepo)
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Base commit:"), baseCommit[:min(12, len(baseCommit))])
if chromiumVersion != "" {
fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Chromium:"), chromiumVersion)
}
fmt.Printf(" %s %d files\n", ui.LabelStyle.Render("Patches:"), patchCount)
fmt.Println()
fmt.Println(ui.SuccessStyle.Render("Initialized .bros/config.yaml"))
fmt.Println(ui.MutedStyle.Render("Run 'bdev pull' to apply patches, or 'bdev push' to extract."))
return nil
}

View File

@@ -0,0 +1,49 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
func init() {
command := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Annotations: map[string]string{"group": "Workspace:"},
Short: "List registered workspaces and their sync state",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(appState.Registry.Workspaces) == 0 {
return renderResult(map[string]any{"workspaces": []any{}}, func() {
fmt.Println("No workspaces registered. Run `bdev add <name> <path>`.")
})
}
info, err := repoInfo()
if err != nil {
return err
}
rows := make([][]string, 0, len(appState.Registry.Workspaces))
statuses := make([]*engine.WorkspaceStatus, 0, len(appState.Registry.Workspaces))
for _, ws := range appState.Registry.Workspaces {
status, err := engine.InspectWorkspace(cmd.Context(), ws, info)
if err != nil {
return err
}
statuses = append(statuses, status)
rows = append(rows, []string{
ws.Name,
status.SyncState,
fmt.Sprintf("%d/%d/%d", len(status.UpToDate), len(status.NeedsUpdate), len(status.Orphaned)),
ws.Path,
})
}
return renderResult(map[string]any{"workspaces": statuses}, func() {
fmt.Println(ui.RenderTable([]string{"NAME", "STATE", "PATCHES", "PATH"}, rows))
})
},
}
rootCmd.AddCommand(command)
}

View File

@@ -0,0 +1,41 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
func init() {
var message string
command := &cobra.Command{
Use: "publish [remote]",
Annotations: map[string]string{"group": "Remote:"},
Short: "Commit and push chromium_patches to a remote",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
info, err := repoInfo()
if err != nil {
return err
}
remote := "origin"
if len(args) == 1 {
remote = args[0]
}
result, err := engine.Publish(cmd.Context(), info, remote, message)
if err != nil {
return err
}
return renderResult(result, func() {
fmt.Println(ui.Success("Published chromium_patches"))
fmt.Printf("%s %s\n", ui.Muted("remote:"), result.Remote)
fmt.Printf("%s %s\n", ui.Muted("branch:"), result.Branch)
fmt.Printf("%s %s\n", ui.Muted("message:"), result.Message)
})
},
}
command.Flags().StringVarP(&message, "message", "m", "", "Commit message for the patch publish commit")
rootCmd.AddCommand(command)
}

View File

@@ -1,142 +0,0 @@
package cmd
import (
"fmt"
"time"
"bdev/internal/config"
"bdev/internal/engine"
"bdev/internal/git"
"bdev/internal/log"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var pullCmd = &cobra.Command{
Use: "pull [remote] [-- file1 file2 ...]",
Short: "Pull patches from repo to checkout",
Long: `Apply patches from the patches repository to the current Chromium
checkout. Use an optional remote (for example: 'bdev pull origin')
to fetch/rebase the patches repo before applying changes locally.`,
RunE: runPull,
}
var (
pullDryRun bool
pullRemote string
pullNoSync bool
pullRebase bool
pullKeepLocalOnly bool
)
func init() {
pullCmd.Flags().BoolVar(&pullDryRun, "dry-run", false, "show what would change")
pullCmd.Flags().StringVar(&pullRemote, "remote", "", "patches repo remote to sync before pull")
pullCmd.Flags().BoolVar(&pullNoSync, "no-sync", false, "skip syncing patches repo from remote")
pullCmd.Flags().BoolVar(&pullRebase, "rebase", true, "use git pull --rebase when syncing remote")
pullCmd.Flags().BoolVar(&pullKeepLocalOnly, "keep-local-only", true, "keep local-only checkout changes that are not in patches repo")
rootCmd.AddCommand(pullCmd)
}
func runPull(cmd *cobra.Command, args []string) error {
ctx, err := config.LoadContext()
if err != nil {
return err
}
activity := ui.NewActivity(verbose)
remote, files, err := resolveRemoteAndFiles(ctx.PatchesRepo, args, pullRemote)
if err != nil {
return err
}
shouldSync := remote != "" && !pullNoSync && !pullDryRun
if shouldSync {
dirty, err := git.IsDirty(ctx.PatchesRepo)
if err != nil {
return err
}
if dirty {
return fmt.Errorf("patches repo has local changes; commit/stash before syncing remote %q", remote)
}
activity.Step("syncing patches repo from remote %q", remote)
beforeRev, _ := git.HeadRev(ctx.PatchesRepo)
if err := git.Fetch(ctx.PatchesRepo, remote); err != nil {
return err
}
branch, detached, err := git.CurrentBranch(ctx.PatchesRepo)
if err != nil {
return err
}
if detached {
activity.Warn("patches repo is in detached HEAD; fetched remote but skipped pull/rebase")
} else {
if err := git.Pull(ctx.PatchesRepo, remote, branch, pullRebase); err != nil {
return err
}
}
afterRev, _ := git.HeadRev(ctx.PatchesRepo)
if beforeRev != "" && afterRev != "" && beforeRev != afterRev {
activity.Success("patches repo advanced %s -> %s", shortRev(beforeRev), shortRev(afterRev))
} else {
activity.Info("patches repo already up to date")
}
ctx, err = config.LoadContext()
if err != nil {
return err
}
} else if remote != "" && pullDryRun {
activity.Info("dry run enabled — skipping remote sync")
} else if remote != "" && pullNoSync {
activity.Info("remote %q provided, but sync is disabled via --no-sync", remote)
}
opts := engine.PullOpts{
DryRun: pullDryRun,
Files: files,
KeepLocalOnly: pullKeepLocalOnly,
}
if pullDryRun {
activity.Info("dry run enabled — no files will be modified")
activity.Divider()
}
activity.Step("computing patch delta and applying updates")
result, err := engine.Pull(ctx, opts)
if err != nil {
return err
}
fmt.Print(ui.RenderPullResult(result))
if len(result.Conflicts) > 0 {
fmt.Print(ui.RenderConflictReport(result.Conflicts))
}
if !pullDryRun {
repoRev, _ := git.HeadRev(ctx.PatchesRepo)
ctx.State.LastPull = &config.SyncEvent{
PatchesRepoRev: repoRev,
BaseCommit: ctx.BaseCommit,
Timestamp: time.Now(),
FileCount: len(result.Applied) + len(result.Deleted) + len(result.Reverted) + len(result.LocalOnly) + len(result.Skipped),
}
_ = config.WriteState(ctx.BrosDir, ctx.State)
logger := log.New(ctx.BrosDir)
_ = logger.LogPull(ctx.BaseCommit, repoRev, result)
}
if len(result.Conflicts) > 0 {
return fmt.Errorf("%d conflicts — see above for details", len(result.Conflicts))
}
return nil
}

View File

@@ -1,238 +0,0 @@
package cmd
import (
"fmt"
"time"
"bdev/internal/config"
"bdev/internal/engine"
"bdev/internal/git"
"bdev/internal/log"
"bdev/internal/patch"
"bdev/internal/ui"
"github.com/spf13/cobra"
)
var pushCmd = &cobra.Command{
Use: "push [remote] [-- file1 file2 ...]",
Short: "Push local changes to patches repo",
Long: `Extract diffs from the current Chromium checkout and write them
to the patches repository. When a remote is provided (for example:
'bdev push origin'), bdev commits patch changes and pushes upstream.`,
RunE: runPush,
}
var (
pushDryRun bool
pushRemote string
pushNoSync bool
pushRebase bool
pushMessage string
)
func init() {
pushCmd.Flags().BoolVar(&pushDryRun, "dry-run", false, "show what would be pushed")
pushCmd.Flags().StringVar(&pushRemote, "remote", "", "patches repo remote to publish to")
pushCmd.Flags().BoolVar(&pushNoSync, "no-sync", false, "skip syncing patches repo from remote before publish")
pushCmd.Flags().BoolVar(&pushRebase, "rebase", true, "use git pull --rebase when syncing before publish")
pushCmd.Flags().StringVarP(&pushMessage, "message", "m", "", "commit message when publishing to remote")
rootCmd.AddCommand(pushCmd)
}
func runPush(cmd *cobra.Command, args []string) error {
ctx, err := config.LoadContext()
if err != nil {
return err
}
activity := ui.NewActivity(verbose)
remote, files, err := resolveRemoteAndFiles(ctx.PatchesRepo, args, pushRemote)
if err != nil {
return err
}
shouldPublish := remote != "" && !pushDryRun
if shouldPublish {
dirty, err := git.IsDirty(ctx.PatchesRepo)
if err != nil {
return err
}
if dirty {
return fmt.Errorf("patches repo has local changes; commit/stash before publishing to remote %q", remote)
}
}
if shouldPublish && !pushNoSync {
if err := syncPatchesRepo(activity, ctx.PatchesRepo, remote, pushRebase); err != nil {
return err
}
}
if remote != "" && pushDryRun {
activity.Info("dry run enabled — skipping remote sync and publish")
}
opts := engine.PushOpts{
DryRun: pushDryRun,
Files: files,
}
if pushDryRun {
activity.Info("dry run enabled — no patch files will be written")
activity.Divider()
}
activity.Step("extracting checkout changes into patches")
result, err := engine.Push(ctx, opts)
if err != nil {
return err
}
renderPushResult(result, pushDryRun)
if !pushDryRun {
if remote != "" {
if err := publishPatchChanges(activity, ctx, remote, result, pushMessage); err != nil {
return err
}
}
// Update state
repoRev, _ := git.HeadRev(ctx.PatchesRepo)
ctx.State.LastPush = &config.SyncEvent{
PatchesRepoRev: repoRev,
Timestamp: time.Now(),
FileCount: result.Total() + len(result.Stale),
}
_ = config.WriteState(ctx.BrosDir, ctx.State)
// Activity log
logger := log.New(ctx.BrosDir)
_ = logger.LogPush(ctx.BaseCommit, result)
}
return nil
}
func syncPatchesRepo(activity *ui.Activity, patchesRepo, remote string, rebase bool) error {
activity.Step("syncing patches repo from remote %q", remote)
beforeRev, _ := git.HeadRev(patchesRepo)
if err := git.Fetch(patchesRepo, remote); err != nil {
return err
}
branch, detached, err := git.CurrentBranch(patchesRepo)
if err != nil {
return err
}
if detached {
return fmt.Errorf("patches repo is in detached HEAD; cannot sync for publish")
}
if err := git.Pull(patchesRepo, remote, branch, rebase); err != nil {
return err
}
afterRev, _ := git.HeadRev(patchesRepo)
if beforeRev != "" && afterRev != "" && beforeRev != afterRev {
activity.Success("patches repo advanced %s -> %s", shortRev(beforeRev), shortRev(afterRev))
} else {
activity.Info("patches repo already up to date")
}
return nil
}
func publishPatchChanges(
activity *ui.Activity,
ctx *config.Context,
remote string,
result *patch.PushResult,
commitMessage string,
) error {
dirty, err := git.IsDirty(ctx.PatchesRepo, "chromium_patches")
if err != nil {
return err
}
if !dirty {
activity.Info("no patch repository changes to commit")
return nil
}
branch, detached, err := git.CurrentBranch(ctx.PatchesRepo)
if err != nil {
return err
}
if detached {
return fmt.Errorf("patches repo is in detached HEAD; cannot publish")
}
message := commitMessage
if message == "" {
message = fmt.Sprintf(
"bdev push: %s (%d modified, %d added, %d deleted, %d stale)",
ctx.Config.Name,
len(result.Modified),
len(result.Added),
len(result.Deleted),
len(result.Stale),
)
}
activity.Step("committing patch changes to %s", branch)
if err := git.Add(ctx.PatchesRepo, "chromium_patches"); err != nil {
return err
}
if err := git.Commit(ctx.PatchesRepo, message); err != nil {
return err
}
activity.Success("created patch commit")
activity.Step("pushing patch commit to %s/%s", remote, branch)
if err := git.Push(ctx.PatchesRepo, remote, branch); err != nil {
return err
}
activity.Success("remote publish complete")
return nil
}
func renderPushResult(r *patch.PushResult, dryRun bool) {
if r.Total() == 0 && len(r.Stale) == 0 {
fmt.Println(ui.MutedStyle.Render("Nothing to push — checkout matches patches repo."))
return
}
verb := "Pushed"
if dryRun {
verb = "Would push"
}
fmt.Println(ui.TitleStyle.Render("bdev push"))
fmt.Println()
for _, f := range r.Added {
fmt.Printf(" %s %s\n", ui.AddedPrefix, f)
}
for _, f := range r.Modified {
fmt.Printf(" %s %s\n", ui.ModifiedPrefix, f)
}
for _, f := range r.Deleted {
fmt.Printf(" %s %s\n", ui.DeletedPrefix, f)
}
for _, f := range r.Stale {
fmt.Printf(" %s %s\n", ui.SkippedPrefix, ui.MutedStyle.Render(f+" (stale, removed)"))
}
fmt.Println()
summary := fmt.Sprintf("%s %d patches", verb, r.Total())
detail := fmt.Sprintf(" (%d modified, %d added, %d deleted)",
len(r.Modified), len(r.Added), len(r.Deleted))
fmt.Print(ui.SuccessStyle.Render(summary))
fmt.Println(ui.MutedStyle.Render(detail))
if len(r.Stale) > 0 {
fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("Cleaned %d stale patches", len(r.Stale))))
}
}

View File

@@ -0,0 +1,33 @@
package cmd
import (
"fmt"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
func init() {
command := &cobra.Command{
Use: "remove <name>",
Aliases: []string{"rm"},
Annotations: map[string]string{"group": "Workspace:"},
Short: "Unregister a workspace",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
entry, err := appState.Registry.Remove(args[0])
if err != nil {
return err
}
if err := appState.Save(); err != nil {
return err
}
return renderResult(map[string]any{"workspace": entry}, func() {
fmt.Println(ui.Success("Removed workspace"))
fmt.Printf("%s %s\n", ui.Muted("name:"), entry.Name)
fmt.Printf("%s %s\n", ui.Muted("path:"), entry.Path)
})
},
}
rootCmd.AddCommand(command)
}

View File

@@ -1,31 +1,129 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/app"
"github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui"
"github.com/spf13/cobra"
)
var Version = "dev"
var (
verbose bool
version string
jsonOut bool
verbose bool
appState *app.App
)
var groupOrder = []string{
"Workspace:",
"Core:",
"Conflict:",
"Remote:",
}
func helpHeader(s string) string { return ui.Header(s) }
func helpCmdCol(s string) string { return ui.Command(s) }
func helpHint(s string) string { return ui.Hint(s) }
func helpAliases(aliases []string) string {
return ui.Aliases(aliases)
}
func groupedHelp(cmd *cobra.Command) string {
groups := map[string][]*cobra.Command{}
for _, child := range cmd.Commands() {
if !child.IsAvailableCommand() && child.Name() != "help" {
continue
}
group := child.Annotations["group"]
if group == "" {
group = "Core:"
}
groups[group] = append(groups[group], child)
}
var builder strings.Builder
for _, group := range groupOrder {
commands, ok := groups[group]
if !ok {
continue
}
builder.WriteString("\n" + helpHeader(group) + "\n")
for _, child := range commands {
line := " " + helpCmdCol(fmt.Sprintf("%-14s", child.Name())) + " " + child.Short
if len(child.Aliases) > 0 {
line += " " + helpAliases(child.Aliases)
}
builder.WriteString(line + "\n")
}
}
return builder.String()
}
const usageTemplate = `{{helpHeader "Usage:"}}{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
{{helpHeader "Aliases:"}}
{{.NameAndAliases}}{{end}}{{if .HasExample}}
{{helpHeader "Examples:"}}
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
{{groupedHelp .}}{{end}}{{if .HasAvailableLocalFlags}}
{{helpHeader "Flags:"}}
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
{{helpHeader "Global Flags:"}}
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}}
{{helpHint (printf "Use \"%s [command] --help\" for more information." .CommandPath)}}{{end}}
`
var rootCmd = &cobra.Command{
Use: "bdev",
Short: "BrowserOS CLI — patch management, builds, and releases",
Long: "bdev manages BrowserOS patches across Chromium checkouts.\nUse push/pull to sync patches, clone for fresh applies.",
Short: "Workspace-centric BrowserOS patch tooling for Chromium checkouts",
Version: Version,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
var err error
appState, err = app.Load(jsonOut, verbose, "")
return err
},
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "increase output detail")
cobra.AddTemplateFunc("helpHeader", helpHeader)
cobra.AddTemplateFunc("helpCmdCol", helpCmdCol)
cobra.AddTemplateFunc("helpAliases", helpAliases)
cobra.AddTemplateFunc("helpHint", helpHint)
cobra.AddTemplateFunc("groupedHelp", groupedHelp)
rootCmd.SetUsageTemplate(usageTemplate)
rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "Emit JSON output")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
rootCmd.CompletionOptions.DisableDefaultCmd = true
}
func SetVersion(v string) {
version = v
rootCmd.Version = v
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func Execute() error {
return rootCmd.Execute()
func renderResult(data any, human func()) error {
if jsonOut {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(data)
}
human()
return nil
}

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