Compare commits

...

80 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
Dani Akash
7f20319272 docs: add OAuth provider setup guides for ChatGPT Pro, GitHub Copilot, and Qwen Code (#545)
* docs: add setup guides for ChatGPT Pro, GitHub Copilot, and Qwen Code

Add individual OAuth setup guide pages with step-by-step screenshots
for each provider. Add "Use Your Existing Subscription" section to the
Bring Your Own LLM page with card links to each guide. Register pages
in docs navigation.

* docs: add ChatGPT Pro setup screenshots

* docs: use custom provider icons for OAuth setup cards

* docs: inline SVG icons in provider cards for dark mode support

* docs: place provider icons above card titles
2026-03-24 18:29:20 +05:30
shivammittal274
c8204efab6 feat: improve rate limit UX, usage page, and provider selector (#544)
* feat: improve rate limit UX, usage page, and provider selector

- Show "Add your own provider for unlimited usage" CTA when BrowserOS
  credits are exhausted or daily limit is reached
- Fix credit exhaustion detection to match actual error message
- Improve Usage page: remove disabled Add Credits button, add "Coming
  soon" badge, add "Want unlimited usage?" section linking to providers
- Add "+ Add Provider" button at bottom of chat provider selector dropdown

* fix: use asChild pattern for Button+anchor in usage page

Replace nested <a><Button> (invalid HTML) with Button asChild
pattern per shadcn/ui convention.
2026-03-24 18:01:42 +05:30
shivammittal274
fb5143b563 feat: UI improvements for OAuth dialog, provider badges, and events docs (#543)
* feat: UI improvements for OAuth dialog, provider badges, and events docs

- Replace OAuth device code toast with a proper Dialog showing the code
  prominently with a copy button (GitHub Copilot, Qwen Code, ChatGPT Pro)
- Add "New" badge on provider template cards for ChatGPT Plus/Pro,
  GitHub Copilot, and Qwen Code with orange border highlight
- Add events.md documenting all analytics events across the platform

* fix: add verificationUri to DeviceCodeDialog for popup-blocked fallback

Add verificationUri to PendingDeviceCode interface and pass it from
both handleClientAuth and handleServerAuth. Render a fallback "Open
verification page" link in DeviceCodeDialog so users can navigate
to the auth page if the popup was blocked.
2026-03-24 17:27:27 +05:30
Dani Akash
fe257cd8d1 feat: only parse browseros provider errors (#542) 2026-03-24 14:43:05 +05:30
shivammittal274
890d3406dd feat: promote BrowserOS as MCP with UI improvements (#541)
- Add MCP promo banner on AI providers page with "New" badge and
  "66+ tools" highlight, linking to /settings/mcp
- Add Quick Setup section on MCP settings page with copy-paste
  commands for Claude Code, Gemini CLI, Codex, Claude Desktop, OpenClaw
- Consolidate MCP settings: move restart button inline with server URL,
  remove separate MCP Server Settings card
- Add analytics event for promo banner clicks
2026-03-24 03:08:08 +05:30
shivammittal274
c316e09c11 feat: add source tag to tool_executed PostHog events (#538)
Add `source: 'mcp' | 'chat'` property to all `tool_executed` metrics
events so we can distinguish tool calls from external MCP clients
(Claude Code, Cursor) vs the built-in BrowserOS agent in PostHog.

- register-mcp.ts: source='mcp' (browser tools via MCP endpoint)
- register-klavis-mcp.ts: source='mcp' (Klavis tools via MCP endpoint)
- tool-adapter.ts: source='chat' (browser tools via chat agent)
- ai-sdk-agent.ts: source='chat' (Klavis/external MCP tools via chat agent, previously untracked)
- filesystem/utils.ts: source='chat' (filesystem tools via chat agent)
2026-03-24 02:03:18 +05:30
shivammittal274
65547c60c0 fix(eval): clean up eval configs and add test-clado-api script (#540)
Consolidate 13 configs down to 7 with uniform settings:
- 3 weekly (CI): browseros-agent, browseros-oe-agent, browseros-oe-clado
- 4 test (local): test_gemini-computer-use, test_yutori-navigator, test_webvoyager, test_mind2web
- All configs: headless=false, captcha block, full browseros ports, restart_server_per_task

Deleted: debug-test, mind2web-test, tool-loop-test, orchestrator-executor-test,
orchestrator-executor-clado-test, fireworks-minimax-m2, webvoyager-test

Added: test-clado-api.ts script, browseros-oe-agent-weekly.json (OE with AI SDK executor)
2026-03-24 01:28:05 +05:30
shivammittal274
0babc05077 feat(eval): NopeCHA CAPTCHA solver integration (#537)
* feat(eval): show mean score instead of pass/fail in report and viewer

* feat(eval): integrate NopeCHA CAPTCHA solver into eval pipeline

Add CAPTCHA detection and waiting so screenshots capture post-solve state.
Run headed with xvfb on CI since headless breaks extension content scripts.

- Add CaptchaWaiter module (detect reCAPTCHA/hCaptcha/Turnstile, poll until solved)
- Add optional `captcha` config block to EvalConfigSchema
- Wait for CAPTCHA solve before screenshot in single-agent and orchestrator-executor
- Patch NopeCHA manifest with API key before launching workers
- Fix CAPTCHA_EXT_DIR path (was pointing one level too high)
- Remove --incognito (extensions don't run in incognito; fresh user-data-dir isolates)
- CI: install xvfb, run headed via xvfb-run, pass NOPECHA_API_KEY secret
2026-03-24 00:14:16 +05:30
Nikhil
1270b5b55c feat: new manifest perms (#536)
* feat: new manifest perms

* fix: minor

* fix: minor
2026-03-23 09:31:07 -07:00
Nikhil
e97d8bc1cb fix: remove daily rate-limit middleware (#535)
* fix: remove daily rate-limit middleware

The daily conversation rate limit is no longer needed. Remove the
middleware, RateLimiter class, fetch-config, error type, shared
constants, DB schema table, and integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove unused getDb() method

No longer needed after rate-limiter removal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:31:20 -07:00
Dani Akash
5109ca4347 feat: added scope in server error logs (#533)
* feat: added scope in server error logs

* fix: prevent double capture on chat request
2026-03-23 20:47:28 +05:30
shivammittal274
f14942c6f9 feat(eval): show mean score instead of pass/fail in report and viewer (#534) 2026-03-23 20:28:34 +05:30
Dani Akash
86ec88ed80 feat: sentry improvements (#532)
* feat: process request record from sentry locally

* feat: added analytics for logged in users
2026-03-23 19:45:28 +05:30
Dani Akash
4928b7e84b fix: no current window and sentry context (#531)
* fix: error reporting and better breadcrumbs

* fix: lint issues
2026-03-23 18:46:39 +05:30
shivammittal274
94a1a701f6 fix(eval): include browser context in agent prompt (#530)
The eval's single-agent was passing raw task.query as the prompt,
without browser context (active tab URL, title). The agent didn't
know which page it was on, causing it to ask "which website?" instead
of browsing.

Use formatUserMessage() (same as chat-service.ts) to include browser
context in the prompt. Re-export formatUserMessage from agent/tool-loop.
2026-03-23 17:42:03 +05:30
Dani Akash
ecf2efa857 fix: add unlimited storage permission to agent (#529) 2026-03-23 17:36:26 +05:30
shivammittal274
026c6a03a3 feat(eval): auto-trigger eval on agent/tools changes pushed to main (#528) 2026-03-23 16:52:30 +05:30
Nikhil
2b53daf641 fix: prevent deleted scheduled tasks from reappearing after sync (#518)
* fix: prevent deleted scheduled tasks from reappearing after sync

When a scheduled task was deleted, the sync function would see the
remote job missing locally and re-add it, undoing the delete. Fix by
tracking pending deletions in storage so the sync function deletes
them from the backend instead of re-adding them locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use read-modify-write for pending deletions to prevent concurrent clobber

Re-read pendingDeletionStorage before write-back and only remove
resolved IDs, preserving any new entries added by concurrent
removeJob calls during the sync's network I/O.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:31:57 -07:00
Nikhil
3cc946ded8 fix(ci): report test pass/fail status on PRs (#520)
The test workflow captured exit codes but never failed the job, so PR
checks always showed green even when tests failed. Exit with the
captured code in the summarize step so each suite properly reports
pass/fail. Not a required check, so failures remain non-blocking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:31:23 -07:00
shivammittal274
70be5c5c21 fix(eval): log agent errors in task progress for CI visibility (#523) 2026-03-21 23:33:19 +05:30
shivammittal274
0f9d93058f chore(eval): remove unused env vars from workflow (OPENROUTER, OPENAI) (#522) 2026-03-21 23:22:03 +05:30
shivammittal274
cafed57832 fix(eval): use CLAUDE_CODE_OAUTH_TOKEN for performance grader auth (#521) 2026-03-21 23:14:23 +05:30
shivammittal274
f157436e7d feat(eval): switch to Linux GitHub-hosted runner (#519)
* feat(eval): switch to ubuntu-latest runner, add OE-Clado config

- Switch workflow from self-hosted Mac Studio to ubuntu-latest
- Install BrowserOS Linux .deb in CI (no self-hosted runner needed)
- Add browseros-oe-clado-weekly.json config for orchestrator-executor
- Fix report chart to show date+time (not just date)
- Make BROWSEROS_BINARY configurable via env var

* feat(eval): add NopeCHA captcha solver extension to eval runs

- Auto-load NopeCHA extension in eval Chrome instances
- Works in incognito + headless mode
- CI workflow downloads NopeCHA before eval
- extensions/ directory gitignored (downloaded at runtime)

* feat(eval): per-config concurrency — different configs run in parallel

* feat(eval): remove concurrency limit — all runs execute in parallel
2026-03-21 23:04:45 +05:30
Nikhil
ba7892322b ci: run BrowserOS test suites on PRs (#514)
* ci: run browseros tests on pull requests

* refactor: rework 0320-github_action_for_tests based on feedback

* refactor: rework 0320-github_action_for_tests based on feedback

* chore: add CI artifacts to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove mikepenz/action-junit-report to fix check suite misattribution

The JUnit report action creates check runs that GitHub associates with the
CLA check suite instead of the Tests check suite, causing test reports to
appear under "CLA Assistant" in the PR checks UI.

Remove the action and rely on job status + step summary + artifact upload
for test result visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:46:36 -07:00
shivammittal274
4e90b4561a feat(eval): weekly eval pipeline with R2 uploads and trend dashboard (#516)
* feat(eval): weekly eval pipeline with R2 uploads and trend dashboard

Add infrastructure for running weekly evaluations and tracking score
trends over time:

- Auto-generated output dirs: results/{config-name}/{timestamp}/
  Each eval run gets its own timestamped folder, nothing is overwritten.

- upload-run.ts: uploads eval results to Cloudflare R2. Supports
  uploading a specific run or all un-uploaded runs for a config.

- weekly-report.ts: generates an interactive HTML dashboard from R2
  data. Config dropdown, trend chart with hover tooltips, searchable
  runs table. Groups runs by config name.

- viewer.html: client-facing 3-column run viewer (task list,
  screenshots with autoplay, agent stream with messages.jsonl).
  Shows performance grader axis breakdown with per-axis scores.

- browseros-agent-weekly.json: weekly benchmark config (kimi-k2p5,
  webbench-2of4-50, 10 workers, performance grader, headless).

- eval-weekly.yml: GitHub Actions workflow with cron (Saturday 6am)
  and manual trigger. Runs on self-hosted Mac Studio runner.
  Concurrency group ensures only one eval runs at a time.

- Dashboard updates: load previous runs, messages.jsonl viewer,
  grade badges show percentages, async stream loading.

- Grader updates: timeout 30min, max turns 100, DOM content
  verification guidance for performance grader.

* fix(eval): address Greptile review — injection, nested dirs, escaping

- Fix script injection in eval-weekly.yml: pass github.event.inputs
  through env var instead of interpolating into shell
- Fix /api/runs to enumerate nested results/{config}/{timestamp}/ dirs
- Fix /api/load-run to allow single-slash run names (config/timestamp)
- Add HTML escaping for R2-sourced values in weekly-report.ts
- Escape axis names in viewer.html renderAxesBreakdown

* fix(eval): fix biome lint — non-null assertion, template literals

* fix(eval): fix biome errors — replace var with let, fix inner function declaration

* fix(eval): address Greptile P2 issues

- isRunDir: check all subdirs for metadata.json, not just first 3
- eval-runner: guard configPath for dashboard-driven runs (fallback to 'eval')
- load-run: default unknown termination_reason to 'failed' not 'completed'

* feat(eval): make BROWSEROS_BINARY configurable via env var
2026-03-21 22:12:52 +05:30
shivammittal274
86eed82350 fix: lazy OAuth callback server with cancel+retry (Codex CLI pattern) (#515)
The OAuth callback server on port 1455 was bound eagerly at startup,
crashing the server if another BrowserOS instance was already running.

Rewrite as a lazy class (OAuthCallbackServer) that:
- Only binds port 1455 when the user initiates a ChatGPT Pro login
- Sends GET /cancel to any existing server on the port first, then
  retries up to 5 times (follows Codex CLI's cancel+retry pattern)
- Exposes /cancel endpoint so other instances/tools can cancel us
- Releases the port after the OAuth callback arrives
- Device-code providers (GitHub Copilot, Qwen) never touch port 1455

This allows running eval, dev instances, and multiple BrowserOS
instances without port conflicts. OAuth login works on whichever
instance initiates it — the others continue without OAuth.
2026-03-21 16:44:03 +05:30
Nikhil
be6ed22af4 test: fix BrowserOS tool test harness regressions (#513)
* test: fix browseros tool test harness regressions

* test: align working directory naming in page action tests
2026-03-20 12:05:39 -07:00
Nikhil
149cde118d chore: bump server version, offset and patch for release (#512) 2026-03-20 11:45:12 -07:00
Nikhil
9bc5e666c4 feat: auto-discover server port via ~/.browseros/server.json (#504)
* feat: auto-discover server port via ~/.browseros/server.json

Server writes its port to ~/.browseros/server.json on startup so the CLI
can auto-discover the server URL without requiring `browseros-cli init`.

Discovery chain: BROWSEROS_URL env > config.yaml > server.json > error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback for PR #504

- Use synchronous unlinkSync in stop() since process.exit() fires
  immediately after, abandoning any pending async operations
- Wrap writeServerConfig in try/catch so a write failure doesn't crash
  a healthy server for a convenience feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: type server discovery config and add version metadata

Add ServerDiscoveryConfig interface to @browseros/shared and enrich
server.json with server_version, browseros_version, and chromium_version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: normalize URL from server.json for consistency

All other URL sources (env var, config.yaml) pass through
normalizeServerURL; apply the same to the server.json path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:37:00 -07:00
Nikhil
2271277b4d feat: add voice input to new tab search bar (#509)
* feat: add voice recording UI with waveform overlay to new tab search bar

Add a microphone button to the NewTab search bar that opens a fullscreen
recording overlay powered by react-voice-visualizer. The overlay shows a
real-time waveform visualization during recording, recording time, and a
stop button. On completion, the audio is transcribed via the existing
gateway endpoint and the transcript auto-navigates to inline chat.

Changes:
- Extract transcribeAudio() to shared lib/voice/transcribe-audio.ts
- Add VoiceRecordingOverlay component with react-voice-visualizer
- Add Mic button to NewTab search bar
- Track analytics via existing NEWTAB_VOICE_* events
- Handle cancel (backdrop click) vs submit (stop button) correctly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review comments for voice recording overlay

- Reset processingRef on transcription error to prevent stuck state
- Use stable callback refs to prevent useEffect re-runs from inline
  arrow function props (fixes timer reset and unnecessary re-processing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace voice overlay with inline sidepanel-style voice UI

Remove react-voice-visualizer dependency and VoiceRecordingOverlay.
Instead use the same inline voice pattern as the sidepanel ChatInput:
- Waveform bars replace the search input during recording
- Mic/stop/loading button states in the search bar
- Transcript populates the search input on completion
- Voice error shown inline below the search bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:33:01 -07:00
Nikhil
f865d301a2 test: add build smoke test to catch compile failures (#511)
* test: add build smoke test to catch compile failures

Compiles the server binary (darwin-arm64) and verifies --version outputs
the correct version from package.json. Uses an empty resource manifest
and stub env vars so the test runs without R2 access or real secrets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback for PR #511

- Derive build target from process.platform/arch for CI portability
- Include binary stderr in --version assertion for better diagnostics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:16:57 -07:00
Nikhil
6f398f0b36 fix: replace sharp with jimp to fix compiled binary crash (#510)
sharp is a native C module (libvips) whose .node binaries can't be
embedded in Bun compiled executables. It was imported at the top level
in copilot-fetch.ts, crashing the entire server at startup.

Replace with jimp (pure JavaScript, zero native deps) which bundles
cleanly into compiled binaries. Same resize algorithm preserved.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:06:05 -07:00
shivammittal274
8548bcf50a feat: credit-based tracking for BrowserOS provider (#489)
* feat: add credit-based tracking for BrowserOS provider

Send X-BrowserOS-ID header on all LLM requests through the BrowserOS
gateway for per-installation credit tracking. Handle 429 CREDITS_EXHAUSTED
as non-retryable. Add GET/PUT /credits endpoints to check and manage
credit balance.

* docs: add credits tracking UI design

Design for showing credit balance in side panel chat header (color-coded
badge) and a dedicated Usage & Billing settings page. Credits refresh
after each completed message turn or on exhaustion error.

* docs: add credits tracking UI implementation plan

8-task plan covering useCredits hook, CreditBadge component, ChatHeader
integration, message completion refresh, ChatError CREDITS_EXHAUSTED
handling, Usage & Billing settings page, and route/sidebar registration.

* feat: add useCredits React Query hook

* feat: add CreditBadge component with color thresholds

* feat: show credit badge in chat header for BrowserOS provider

* feat: refresh credits after chat message completion and on error

* feat: handle CREDITS_EXHAUSTED error in chat

* feat: add Usage & Billing settings page

* feat: register usage page route and sidebar entry

* fix: lint and formatting fixes for credit tracking UI

* fix: separate credits exhausted from Kimi rate limit in ChatError, redesign Usage page

* chore: remove PUT /credits endpoint and setCredits function

* fix: extract shared credit colors, add error state to UsagePage, use dailyLimit from gateway

* fix: make dailyLimit required in CreditsInfo (gateway always returns it)

* feat: gate credits UI behind CREDITS_SUPPORT feature flag (server >= 0.0.78)
2026-03-20 22:49:00 +05:30
shivammittal274
e3601bfdc1 feat: gate Qwen Code behind server version 0.0.77 (#508) 2026-03-20 20:07:39 +05:30
Dani Akash
2b4fdf1aad feat: improved multi tab agent workflow (#507)
* feat: updated multitab workflow

* fix: updated prompt with fix for test cases

* fix: active agent glow

* fix: review comments
2026-03-20 18:31:36 +05:30
shivammittal274
11d15d079f feat: alibaba qwen oauth (#506)
* feat: add Qwen Code as OAuth LLM provider with refactored OAuth hooks

Add Alibaba Qwen Code as a third OAuth provider using Device Code flow
with PKCE. Free tier: 2,000 requests/day, up to 1M token context.

Refactoring:
- Extract useOAuthProviderFlow hook (eliminates ~180 lines of duplicated
  OAuth logic from AISettingsPage for ChatGPT Pro + Copilot + Qwen)
- Extract resolveOAuthConfig in config.ts (shared resolver for all OAuth
  providers, parameterized by provider name, default model, refresh flag)
- Generalize token-manager device code flow to support PKCE
  (code_challenge/code_verifier) and form-urlencoded content type

New code:
- Qwen Code provider config with PKCE + form encoding flags
- Provider factories (both provider.ts and provider-factory.ts)
- Extension UI (template card, models, analytics, dialog)

* fix: use portal.qwen.ai as API base URL for OAuth tokens

DashScope (dashscope.aliyuncs.com) expects Alibaba Cloud API keys,
not OAuth tokens from chat.qwen.ai. The correct endpoint for OAuth
Bearer tokens is portal.qwen.ai/v1.

* fix: correct Qwen Code model IDs and context windows

- coder-model (1M context): virtual alias that routes to best model
- qwen3-coder-plus (1M): was incorrectly 131K
- qwen3-coder-flash (1M): new, speed-optimized variant
- qwen3.5-plus (1M): was incorrectly 1048576 (power-of-two vs decimal)
- Removed qwen3-coder-next (local/self-hosted, not available via OAuth)
- Default model changed to coder-model (auto-routes server-side)

* fix: move Qwen device code request to extension (bypasses WAF)

Alibaba WAF blocks server-side requests to chat.qwen.ai. Move the
initial device code request to the extension (browser context with
cookies), then hand off the deviceCode + codeVerifier to the server
for background polling via new POST /oauth/:provider/poll endpoint.

* fix: persist OAuth flow-started flag in sessionStorage

The flowStartedRef was lost when the component remounted (e.g. user
navigated to onboarding then back to settings). Use sessionStorage
to persist the flag so auto-create works after navigation.

* revert: remove sessionStorage for OAuth flow flag

Revert to simple useRef pattern matching the original ChatGPT Pro
implementation. The auto-create works when the user stays on the
AI settings page during auth.

* revert: move Qwen back to server-side device code flow

WAF block was temporary (rate-limiting), not permanent. Server-side
fetch to chat.qwen.ai now works. Reverted client-side device code
approach — Qwen now uses the same clean server-side flow as Copilot.

Removed: clientSideDeviceCode config, startClientSideDeviceCode(),
POST /oauth/:provider/poll endpoint, startDeviceCodePolling().

* feat: add WAF detection, rate-limit protection, and token storage endpoint

- Detect WAF captcha responses (HTML instead of JSON) in device code
  request and token polling, with user-friendly error messages
- Add 30s cooldown on "USE" button to prevent rapid clicks triggering WAF
- WAF-blocked poll requests silently retry instead of aborting
- Add POST /oauth/:provider/token endpoint for storing externally-provided
  tokens (useful for future fallback flows)
- Add storeTokens() method to OAuthTokenManager
- Pass server error messages through to extension toast notifications

* refactor: remove 30s cooldown, simplify OAuth hook

The hook is now identical for all providers — server handles retries
via activeDeviceFlows.delete(). Removed flowStartedAtRef cooldown
that was blocking legitimate retries.

* feat: client-side OAuth for Copilot and Qwen Code

Move device code OAuth flow to the extension for GitHub Copilot and
Qwen Code. The extension makes requests using Chrome's network stack,
which bypasses Alibaba WAF TLS fingerprint detection that blocks
server-side Bun/Node.js fetch.

New files:
- client-oauth.ts: Client-side device code + PKCE + token polling

Changes:
- useOAuthProviderFlow: handleClientAuth() for providers with clientAuth
  config, handleServerAuth() for others (ChatGPT Pro)
- AISettingsPage: clientAuth config for Copilot and Qwen Code
- WAF detection: opens provider site for captcha solving on block

Server-side device code flow preserved as fallback (token-manager.ts,
providers.ts). Token storage via POST /oauth/:provider/token endpoint.

* fix: export OAuthProviderFlowConfig type, fix typecheck errors

- Export OAuthProviderFlowConfig interface so AISettingsPage can use it
  instead of duplicating the type inline
- Fix string | null → string | undefined for agentServerUrl parameter
2026-03-20 17:46:48 +05:30
Nikhil
9257832acf feat: gate ChatGPT Pro and GitHub Copilot behind server version 0.0.77 (#503)
Add CHATGPT_PRO_SUPPORT and GITHUB_COPILOT_SUPPORT feature flags gated
on minServerVersion 0.0.77. Hide template cards and provider type
dropdown options when the server doesn't support the OAuth endpoints.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:43:09 -07:00
Nikhil
7bde0d59fa chore: bump chromium version (#502) 2026-03-19 16:22:13 -07:00
Nikhil
1c737b0f02 chore: bump server version (#501) 2026-03-19 16:17:50 -07:00
Nikhil
5d0a2b9bfe feat: add model selector to newtab search bar (#499)
* feat: add model selector to newtab search bar

Add AI provider/model selector button to the newtab homepage footer bar,
matching the existing button aesthetics (Workspace, Tabs, Apps). Reuses
ChatProviderSelector popover from sidepanel. Users can now see and change
their AI provider before starting a conversation from the newtab page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clean up newtab footer with icon-only buttons

Reduce visual clutter in the search bar footer by converting Provider,
Workspace, and Tabs buttons to compact icon-only buttons (8x8). Text
labels and chevron indicators are removed — native title tooltips
provide discoverability on hover. Apps button on the right keeps its
text label per user preference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add hover-expand labels to newtab footer icon buttons

Replace static title tooltips with smooth hover-expand animation —
buttons show icon-only by default, text label slides out on hover
via max-w transition. Gives a clean compact look while keeping
labels discoverable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert workspace/tabs to full text, keep provider hover-expand only

Restore full text labels for Workspace and Tabs buttons. Only the
provider selector uses the compact icon + hover-expand pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify provider selector to plain icon button

Remove hover-expand animation, use a simple icon-only button with
native title tooltip for the provider selector.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:14:15 -07:00
shivammittal274
720baaed3e feat: add GitHub Copilot as OAuth LLM provider (#500)
* feat: add GitHub Copilot as OAuth-based LLM provider

Add GitHub Copilot as a second OAuth provider using the Device Code flow
(RFC 8628). Users authenticate via github.com/login/device, and the server
polls for token completion. Supports 25+ models through a single Copilot
subscription.

Key changes:
- Device Code OAuth flow in token manager (poll with safety margin)
- Custom fetch wrapper injecting Copilot headers + vision detection
- Provider factory using createOpenAICompatible for Chat Completions API
- Extension UI with template card, auto-create on auth, and disconnect

* fix: address PR review comments for GitHub Copilot OAuth

- Validate device code response for error fields (GitHub can return 200
  with error payload)
- Store empty refreshToken instead of access token for GitHub tokens
- Add closeButton to Toaster for dismissing device code toast

* fix: add github-copilot to agent provider factory

The chat route uses a separate provider-factory.ts (agent layer) from the
test-provider route (llm/provider.ts). Added createGitHubCopilotFactory
to the agent factory so chat works with GitHub Copilot.

* fix: add github-copilot to provider icons, models, and dialog

- Add Github icon from lucide-react to providerIcons map
- Add 8 Copilot models (GPT-4o, Claude, Gemini, Grok) to models.ts
- Add github-copilot to NewProviderDialog zod enum, validation skip,
  canTest check, and OAuth credential message

* fix: reorder copilot models with free-tier models first

Put models available on Copilot Free at the top (gpt-4o, gpt-4.1,
gpt-5-mini, claude-haiku-4.5, grok-code-fast-1), followed by
premium models that require paid Copilot subscription.

* fix: set correct 64K context window for Copilot models

Copilot API enforces a 64K input token limit regardless of the
underlying model's native context window. Updated all model entries
and the default template to 64000 so compaction triggers correctly.

* fix: use actual per-model prompt limits from Copilot /models API

Queried api.githubcopilot.com/models for real max_prompt_tokens values.
GPT-4o/4.1 have 64K, Claude/gpt-5-mini have 128K, GPT-5.x have 272K.
Also updated model list to match what's actually available on the API
(e.g. claude-sonnet-4.6 instead of 4.5, added gpt-5.4/5.2-codex).

* feat: resize images for Copilot using VS Code's algorithm

Large screenshots cause 413 errors on Copilot's API. Resize images
following VS Code's approach: max 2048px longest side, 768px shortest
side, re-encode as JPEG at 75% quality. Uses sharp for server-side
image processing.

* fix: address all Greptile P1 review comments

- Add .catch() on fire-and-forget pollDeviceCode to prevent unhandled
  rejection crashes (Node 15+)
- Add deduplication guard (activeDeviceFlows Set) to prevent concurrent
  device code flows for the same provider
- Add runtime validation of server response in frontend before calling
  window.open() and showing toast
- Remove dead GITHUB_DEVICE_VERIFICATION constant from urls.ts

* fix: upgrade biome to 2.4.8, fix all lint errors, and address review bugs

- Upgrade biome from 2.4.5 to 2.4.8 (matches CI) and migrate configs
- Fix image resize: only re-encode when dimensions actually change
- Fix device code polling: retry on transient network errors instead of aborting
- Allow restarting device code flow (clear old flow instead of throwing 500)
- Fix pre-existing noNonNullAssertion and noExplicitAny lint errors globally

* fix: address Greptile P2 review — image resize and config guard

- Fix early-return guard: check max/min sides against their respective
  limits (MAX_LONG_SIDE/MAX_SHORT_SIDE) instead of both against SHORT
- Preserve PNG alpha: detect hasAlpha and keep PNG format instead of
  unconditionally converting to lossy JPEG
- Keep browserosId guard in resolveGitHubCopilotConfig consistent with
  ChatGPT Pro pattern (safety check that caller context is valid)

* feat: update Copilot models to full list from pricing page, default to gpt-5-mini

Added all 23 models from GitHub Copilot pricing page. Ordered with
free-tier models first (gpt-5-mini, claude-haiku-4.5), then premium.
Changed default from gpt-4o to gpt-5-mini since it's unlimited on
Pro plan and has 128K context (vs gpt-4o's 64K limit).
2026-03-20 02:33:09 +05:30
308 changed files with 21169 additions and 5714 deletions

98
.github/workflows/eval-weekly.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: Weekly Eval
on:
schedule:
# Every Saturday at 06:00 UTC
- cron: '0 6 * * 6'
push:
branches: [main]
paths:
- 'packages/browseros-agent/apps/server/src/agent/**'
- 'packages/browseros-agent/apps/server/src/tools/**'
workflow_dispatch:
inputs:
config:
description: 'Eval config file (relative to apps/eval/)'
required: false
default: 'configs/browseros-agent-weekly.json'
permissions:
contents: read
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 360
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install BrowserOS
run: |
wget -q https://github.com/browseros-ai/BrowserOS/releases/download/v0.44.0.1/BrowserOS_v0.44.0.1_amd64.deb
sudo dpkg -i BrowserOS_v0.44.0.1_amd64.deb
browseros --version || echo "BrowserOS installed at $(which browseros)"
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
working-directory: packages/browseros-agent
run: bun install --ignore-scripts && bun run build:agent-sdk
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Install captcha solver extension
working-directory: packages/browseros-agent/apps/eval
run: |
mkdir -p extensions
curl -sL -o /tmp/nopecha.zip https://github.com/NopeCHALLC/nopecha-extension/releases/latest/download/chromium_automation.zip
unzip -qo /tmp/nopecha.zip -d extensions/nopecha
- name: Run eval
working-directory: packages/browseros-agent/apps/eval
env:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
BROWSEROS_BINARY: /usr/bin/browseros
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
echo "Running eval with config: $EVAL_CONFIG"
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts -c "$EVAL_CONFIG"
- name: Upload runs to R2
if: success()
working-directory: packages/browseros-agent/apps/eval
env:
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
run: |
CONFIG_NAME=$(basename "$EVAL_CONFIG" .json)
bun scripts/upload-run.ts "results/$CONFIG_NAME"
- name: Generate trend report
if: success()
working-directory: packages/browseros-agent
env:
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
run: bun apps/eval/scripts/weekly-report.ts /tmp/eval-report.html
- name: Upload report as artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: eval-report-${{ github.run_id }}
path: /tmp/eval-report.html

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 }}

View File

@@ -1,15 +1,44 @@
name: Tests
on: []
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- .github/workflows/test.yml
- packages/browseros-agent/**
workflow_dispatch:
permissions:
contents: read
env:
BROWSEROS_APPIMAGE_URL: https://files.browseros.com/download/BrowserOS.AppImage
jobs:
test:
name: Run Tests
runs-on: macos-latest
timeout-minutes: 10
name: Tests / ${{ matrix.suite }}
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: packages/browseros-agent
strategy:
fail-fast: false
matrix:
include:
- suite: tools
test_path: tests/tools
junit_path: test-results/tools.xml
- suite: integration
test_path: tests/server.integration.test.ts
junit_path: test-results/integration.xml
- suite: sdk
test_path: tests/sdk
junit_path: test-results/sdk.xml
steps:
- name: Checkout code
@@ -21,7 +50,92 @@ jobs:
- name: Install dependencies
run: bun ci
- name: Run all tests
run: bun test:all
- name: Resolve BrowserOS cache key
id: browseros-cache-key
run: |
set -euo pipefail
headers="$(curl -fsSI "$BROWSEROS_APPIMAGE_URL")"
etag="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/\r$/, "", $2); gsub(/"/, "", $2); print $2; exit}')"
last_modified="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {$1=""; sub(/^ /, ""); sub(/\r$/, ""); print; exit}')"
raw_key="${etag:-$last_modified}"
if [ -z "$raw_key" ]; then
raw_key="$BROWSEROS_APPIMAGE_URL"
fi
cache_key="$(printf '%s' "$raw_key" | shasum -a 256 | awk '{print $1}')"
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
- name: Restore BrowserOS cache
id: browseros-cache
uses: actions/cache@v4
with:
path: packages/browseros-agent/.ci/bin/BrowserOS.AppImage
key: ${{ steps.browseros-cache-key.outputs.key }}
- name: Download BrowserOS
if: steps.browseros-cache.outputs.cache-hit != 'true'
run: |
mkdir -p .ci/bin
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
chmod +x .ci/bin/BrowserOS.AppImage
- name: Prepare BrowserOS wrapper
run: |
mkdir -p .ci/bin
cat > .ci/bin/browseros <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
export APPIMAGE_EXTRACT_AND_RUN=1
exec "$(dirname "$0")/BrowserOS.AppImage" "$@"
EOF
chmod +x .ci/bin/browseros
- name: Create server env file
working-directory: packages/browseros-agent/apps/server
run: cp .env.example .env.development
- name: Run ${{ matrix.suite }} tests
id: test
env:
PUPPETEER_EXECUTABLE_PATH: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
BROWSEROS_TEST_HEADLESS: "true"
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
run: |
set +e
mkdir -p test-results
cd apps/server
bun run test:cleanup
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
exit_code=$?
cd ../..
if [ ! -f "${{ matrix.junit_path }}" ]; then
cat > "${{ matrix.junit_path }}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="1" failures="1">
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
<testcase classname="workflow" name="${{ matrix.suite }} setup">
<failure message="Test run failed before JUnit output was written">See workflow logs for details.</failure>
</testcase>
</testsuite>
</testsuites>
EOF
fi
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
- name: Upload JUnit XML
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-${{ matrix.suite }}
path: packages/browseros-agent/${{ matrix.junit_path }}
- name: Summarize suite result
if: always()
run: |
if [ "${{ steps.test.outputs.exit_code }}" = "0" ]; then
echo "### :white_check_mark: ${{ matrix.suite }} suite passed" >> "$GITHUB_STEP_SUMMARY"
else
echo "### :x: ${{ matrix.suite }} suite failed (exit code ${{ steps.test.outputs.exit_code }})" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"terminal.integrated.tabs.title": "${sequence} ${process}",
"terminal.integrated.tabs.description": "${cwd}"
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

View File

@@ -187,7 +187,12 @@ log.txt
# Testing iteration temp files
tmp/
# CI artifacts
.ci/
test-results/
# Coding agent artifacts
.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

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
"root": false,
"extends": "//",
"vcs": {

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

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

View File

@@ -0,0 +1,26 @@
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { getCreditTextColor } from '@/lib/credits/credit-colors'
import { cn } from '@/lib/utils'
interface CreditBadgeProps {
credits: number
onClick?: () => void
}
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs transition-colors hover:bg-muted/50',
getCreditTextColor(credits),
)}
title={`${credits} credits remaining`}
>
<Coins className="h-3.5 w-3.5" />
<span>{credits}</span>
</button>
)
}

View File

@@ -3,6 +3,7 @@ import {
BookOpen,
Bot,
Compass,
CreditCard,
GitBranch,
MessageSquare,
Palette,
@@ -79,6 +80,12 @@ const primarySettingsSections: NavSection[] = [
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{
name: 'Usage & Billing',
to: '/settings/usage',
icon: CreditCard,
feature: Feature.CREDITS_SUPPORT,
},
{
name: 'Workflows',
to: '/workflows',

View File

@@ -18,6 +18,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
closeButton
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,

View File

@@ -28,6 +28,7 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SkillsPage } from './skills/SkillsPage'
import { SoulPage } from './soul/SoulPage'
import { UsagePage } from './usage/UsagePage'
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
@@ -103,6 +104,7 @@ export const App: FC = () => {
<Route path="customization" element={<CustomizationPage />} />
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
</Route>
</Route>

View File

@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { type FC, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -17,21 +17,28 @@ import {
CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
CHATGPT_PRO_OAUTH_STARTED_EVENT,
GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
GITHUB_COPILOT_OAUTH_STARTED_EVENT,
QWEN_CODE_OAUTH_COMPLETED_EVENT,
QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
QWEN_CODE_OAUTH_STARTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import {
getProviderTemplate,
type ProviderTemplate,
} from '@/lib/llm-providers/providerTemplates'
import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import { testProvider } from '@/lib/llm-providers/testProvider'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
import {
type OAuthProviderFlowConfig,
useOAuthProviderFlow,
} from '@/lib/llm-providers/useOAuthProviderFlow'
import { track } from '@/lib/metrics/track'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import { DeviceCodeDialog } from './DeviceCodeDialog'
import {
DeleteRemoteLlmProviderDocument,
GetRemoteLlmProvidersDocument,
@@ -39,9 +46,51 @@ import {
import type { IncompleteProvider } from './IncompleteProviderCard'
import { IncompleteProvidersList } from './IncompleteProvidersList'
import { LlmProvidersHeader } from './LlmProvidersHeader'
import { McpPromoBanner } from './McpPromoBanner'
import { NewProviderDialog } from './NewProviderDialog'
import { ProviderTemplatesSection } from './ProviderTemplatesSection'
// All OAuth providers share the same flow via useOAuthProviderFlow
const OAUTH_PROVIDERS_CONFIG: Record<string, OAuthProviderFlowConfig> = {
'chatgpt-pro': {
providerType: 'chatgpt-pro',
displayName: 'ChatGPT Plus/Pro',
startedEvent: CHATGPT_PRO_OAUTH_STARTED_EVENT,
completedEvent: CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
},
'github-copilot': {
providerType: 'github-copilot',
displayName: 'GitHub Copilot',
startedEvent: GITHUB_COPILOT_OAUTH_STARTED_EVENT,
completedEvent: GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
clientAuth: {
deviceCodeEndpoint: 'https://github.com/login/device/code',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
clientId: 'Ov23li8tweQw6odWQebz',
scopes: 'read:user',
requiresPKCE: false,
contentType: 'json',
},
},
'qwen-code': {
providerType: 'qwen-code',
displayName: 'Qwen Code',
startedEvent: QWEN_CODE_OAUTH_STARTED_EVENT,
completedEvent: QWEN_CODE_OAUTH_COMPLETED_EVENT,
disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
clientAuth: {
deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token',
clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
scopes: 'openid profile email model.completion',
requiresPKCE: true,
contentType: 'form',
},
},
}
/**
* AI Settings page for managing LLM providers
* @public
@@ -88,9 +137,7 @@ export const AISettingsPage: FC = () => {
const incompleteProviders = useMemo<IncompleteProvider[]>(() => {
if (!remoteProvidersData?.llmProviders?.nodes) return []
const localProviderIds = new Set(providers.map((p) => p.id))
return remoteProvidersData.llmProviders.nodes
.filter((node): node is NonNullable<typeof node> => node !== null)
.filter((node) => !localProviderIds.has(node.rowId))
@@ -111,56 +158,57 @@ export const AISettingsPage: FC = () => {
null,
)
// OAuth status for ChatGPT Plus/Pro
const {
status: chatgptProStatus,
startPolling: startChatGPTProPolling,
disconnect: disconnectChatGPTPro,
} = useOAuthStatus('chatgpt-pro')
// OAuth flows — shared hook eliminates per-provider duplication
const chatgptPro = useOAuthProviderFlow(
OAUTH_PROVIDERS_CONFIG['chatgpt-pro'],
providers,
saveProvider,
)
const copilot = useOAuthProviderFlow(
OAUTH_PROVIDERS_CONFIG['github-copilot'],
providers,
saveProvider,
)
const qwenCode = useOAuthProviderFlow(
OAUTH_PROVIDERS_CONFIG['qwen-code'],
providers,
saveProvider,
)
// Track whether user explicitly started an OAuth flow this session
const oauthFlowStartedRef = useRef(false)
const activeDeviceCode =
chatgptPro.pendingDeviceCode ??
copilot.pendingDeviceCode ??
qwenCode.pendingDeviceCode
const clearActiveDeviceCode = () => {
chatgptPro.clearDeviceCode()
copilot.clearDeviceCode()
qwenCode.clearDeviceCode()
}
// Auto-create provider only when user actively completed OAuth,
// not on passive page load when server has old tokens
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
useEffect(() => {
if (!chatgptProStatus?.authenticated) return
if (!oauthFlowStartedRef.current) return
const exists = providers.some((p) => p.type === 'chatgpt-pro')
if (exists) return
const now = Date.now()
try {
const template = getProviderTemplate('chatgpt-pro')
saveProvider({
id: `chatgpt-pro-${now}`,
type: 'chatgpt-pro',
name: `ChatGPT Plus/Pro${chatgptProStatus.email ? ` (${chatgptProStatus.email})` : ''}`,
modelId: template?.defaultModelId ?? 'gpt-5.3-codex',
supportsImages: template?.supportsImages ?? true,
contextWindow: template?.contextWindow ?? 400000,
temperature: 0.2,
createdAt: now,
updatedAt: now,
})
track(CHATGPT_PRO_OAUTH_COMPLETED_EVENT, {
email: chatgptProStatus.email,
})
toast.success('ChatGPT Plus/Pro Connected', {
description: chatgptProStatus.email
? `Authenticated as ${chatgptProStatus.email}`
: 'Successfully authenticated with ChatGPT Plus/Pro',
})
} catch (err) {
toast.error('Failed to create ChatGPT Plus/Pro provider', {
description: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
oauthFlowStartedRef.current = false
const oauthFlows: Record<
string,
{
startOAuthFlow: (url: string | undefined) => Promise<void>
disconnect: () => Promise<void>
disconnectedEvent: string
}
}, [chatgptProStatus?.authenticated])
> = {
'chatgpt-pro': {
startOAuthFlow: chatgptPro.startOAuthFlow,
disconnect: chatgptPro.disconnect,
disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
},
'github-copilot': {
startOAuthFlow: copilot.startOAuthFlow,
disconnect: copilot.disconnect,
disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
},
'qwen-code': {
startOAuthFlow: qwenCode.startOAuthFlow,
disconnect: qwenCode.disconnect,
disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
},
}
const handleAddProvider = () => {
setTemplateValues(undefined)
@@ -168,9 +216,10 @@ export const AISettingsPage: FC = () => {
}
const handleUseTemplate = (template: ProviderTemplate) => {
// OAuth providers: trigger OAuth flow instead of opening form dialog
if (template.id === 'chatgpt-pro') {
handleStartChatGPTProOAuth()
// OAuth providers: trigger OAuth flow
const oauthFlow = oauthFlows[template.id]
if (oauthFlow) {
oauthFlow.startOAuthFlow(agentServerUrl ?? undefined)
return
}
@@ -186,27 +235,6 @@ export const AISettingsPage: FC = () => {
setIsNewDialogOpen(true)
}
const handleStartChatGPTProOAuth = () => {
if (!agentServerUrl) {
toast.error('Server not available', {
description: 'Cannot start OAuth flow without server connection.',
})
return
}
oauthFlowStartedRef.current = true
const extensionSettingsUrl = chrome.runtime.getURL('app.html#/ai-settings')
const startUrl = `${agentServerUrl}/oauth/chatgpt-pro/start?redirect=${encodeURIComponent(extensionSettingsUrl)}`
window.open(startUrl, '_blank')
// Start polling for OAuth completion
startChatGPTProPolling()
track(CHATGPT_PRO_OAUTH_STARTED_EVENT)
toast.info('Authenticating with ChatGPT Plus/Pro', {
description: 'Complete the login in the opened tab.',
})
}
const handleEditProvider = (provider: LlmProviderConfig) => {
setEditingProvider(provider)
setIsEditDialogOpen(true)
@@ -217,16 +245,18 @@ export const AISettingsPage: FC = () => {
}
const confirmDeleteProvider = async () => {
if (providerToDelete) {
// Clear OAuth tokens on server for OAuth-based providers
if (providerToDelete.type === 'chatgpt-pro') {
await disconnectChatGPTPro()
track(CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT)
}
await deleteProvider(providerToDelete.id)
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
setProviderToDelete(null)
if (!providerToDelete) return
// Clear OAuth tokens on server for OAuth-based providers
const oauthFlow = oauthFlows[providerToDelete.type]
if (oauthFlow) {
await oauthFlow.disconnect()
track(oauthFlow.disconnectedEvent)
}
await deleteProvider(providerToDelete.id)
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
setProviderToDelete(null)
}
const handleAddKeysToIncomplete = (provider: IncompleteProvider) => {
@@ -329,6 +359,8 @@ export const AISettingsPage: FC = () => {
onAddProvider={handleAddProvider}
/>
<McpPromoBanner />
<ProviderTemplatesSection onUseTemplate={handleUseTemplate} />
<ConfiguredProvidersList
@@ -403,6 +435,11 @@ export const AISettingsPage: FC = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeviceCodeDialog
deviceCode={activeDeviceCode}
onClose={clearActiveDeviceCode}
/>
</div>
)
}

View File

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

View File

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

View File

@@ -1,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',
@@ -62,6 +74,8 @@ const providerTypeEnum = z.enum([
'bedrock',
'browseros',
'chatgpt-pro',
'github-copilot',
'qwen-code',
])
/**
@@ -131,8 +145,12 @@ export const providerFormSchema = z
})
}
}
// ChatGPT Pro: no credentials needed (server-managed OAuth)
else if (data.type === 'chatgpt-pro') {
// OAuth providers: no credentials needed (server-managed)
else if (
data.type === 'chatgpt-pro' ||
data.type === 'github-copilot' ||
data.type === 'qwen-code'
) {
// No validation needed — OAuth tokens are on the server
}
// Other providers: require baseUrl
@@ -157,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
@@ -182,14 +301,19 @@ 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()
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
if (opt.value === 'chatgpt-pro')
return supports(Feature.CHATGPT_PRO_SUPPORT)
if (opt.value === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
if (opt.value === 'moonshot')
return kimiLaunch || initialValues?.type === 'moonshot'
if (opt.value === 'openai-compatible') {
@@ -250,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) => {
@@ -261,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,
@@ -279,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) {
@@ -314,7 +425,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
reasoningEffort: initialValues.reasoningEffort || 'high',
reasoningSummary: initialValues.reasoningSummary || 'auto',
})
setIsCustomModel(false)
}
}, [initialValues, form])
@@ -341,7 +451,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
reasoningEffort: 'high',
reasoningSummary: 'auto',
})
setIsCustomModel(false)
}
// Clear test result when dialog opens/closes
setTestResult(null)
@@ -377,8 +486,13 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
const canTest = (): boolean => {
if (!watchedModelId) return false
// ChatGPT Pro: always testable (server has the OAuth token)
if (watchedType === 'chatgpt-pro') return true
// OAuth providers: always testable (server has the OAuth token)
if (
watchedType === 'chatgpt-pro' ||
watchedType === 'github-copilot' ||
watchedType === 'qwen-code'
)
return true
if (watchedType === 'azure') {
return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey
@@ -461,6 +575,15 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
}
const renderProviderSpecificFields = () => {
// OAuth-only providers (no API key needed)
if (watchedType === 'github-copilot' || watchedType === 'qwen-code') {
const name = watchedType === 'github-copilot' ? 'GitHub' : 'Qwen Code'
return (
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 text-sm dark:border-green-800 dark:bg-green-950 dark:text-green-300">
Credentials are managed via {name} OAuth. No API key needed.
</div>
)
}
// ChatGPT Pro: OAuth credentials + Codex reasoning settings
if (watchedType === 'chatgpt-pro') {
return (
@@ -786,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

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

View File

@@ -26,6 +26,11 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
const kimiLaunch = useKimiLaunch()
const filteredTemplates = providerTemplates.filter((template) => {
if (template.id === 'chatgpt-pro')
return supports(Feature.CHATGPT_PRO_SUPPORT)
if (template.id === 'github-copilot')
return supports(Feature.GITHUB_COPILOT_SUPPORT)
if (template.id === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
if (template.id === 'moonshot') return kimiLaunch
if (template.id === 'openai-compatible') {
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
@@ -53,14 +58,21 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
<CollapsibleContent>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredTemplates.map((template) => (
<ProviderTemplateCard
key={template.id}
template={template}
highlighted={template.id === 'moonshot'}
onUseTemplate={onUseTemplate}
/>
))}
{filteredTemplates.map((template) => {
const isNew =
template.id === 'chatgpt-pro' ||
template.id === 'github-copilot' ||
template.id === 'qwen-code'
return (
<ProviderTemplateCard
key={template.id}
template={template}
highlighted={template.id === 'moonshot'}
isNew={isNew}
onUseTemplate={onUseTemplate}
/>
)
})}
</div>
</CollapsibleContent>
</div>

View File

@@ -1,96 +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[]
}
/**
* 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 },
@@ -101,27 +26,31 @@ export const MODELS_DATA: ModelsData = {
{ modelId: 'gpt-5.1-codex-mini', contextLength: 400000 },
{ modelId: 'gpt-5.1', contextLength: 200000 },
],
'qwen-code': [
{ modelId: 'coder-model', contextLength: 1000000 },
{ modelId: 'qwen3-coder-plus', contextLength: 1000000 },
{ modelId: 'qwen3-coder-flash', contextLength: 1000000 },
{ modelId: 'qwen3.5-plus', contextLength: 1000000 },
],
}
function fromModelsDevModel(m: ModelsDevModel): ModelInfo {
return {
modelId: m.id,
contextLength: m.contextWindow,
supportsImages: m.supportsImages,
supportsReasoning: m.supportsReasoning,
supportsToolCall: m.supportsToolCall,
}
}
/**
* Get models for a specific provider type
*/
export function getModelsForProvider(providerType: ProviderType): ModelInfo[] {
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,
@@ -130,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

@@ -24,6 +24,7 @@ export const useGetUserMCPIntegrations = () => {
const query = useQuery({
queryKey: [INTEGRATIONS_QUERY_KEY, agentServerUrl],
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
queryFn: () => getUserMCPIntegrations(agentServerUrl!),
enabled: !!agentServerUrl,
refetchOnWindowFocus: true,

View File

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

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'
@@ -10,6 +10,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { resetIdentity } from '@/lib/analytics/identify'
import { signOut } from '@/lib/auth/auth-client'
import { providersStorage } from '@/lib/llm-providers/storage'
import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage'
@@ -24,8 +25,9 @@ export const LogoutPage: FC = () => {
await providersStorage.removeValue()
await scheduledJobStorage.removeValue()
queryClient.clear()
await localforage.clear()
await clear()
resetIdentity()
await signOut()
navigate('/home', { replace: true })
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { AlertCircle, Eye, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import Markdown from 'react-markdown'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -26,7 +27,6 @@ import { Label } from '@/components/ui/label'
import { MarkdownEditor } from '@/components/ui/MarkdownEditor'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import Markdown from 'react-markdown'
import { type SkillDetail, type SkillMeta, useSkills } from './useSkills'
const loadingSkillCards = [
@@ -330,9 +330,15 @@ const SkillCard: FC<{
className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
{skill.builtIn ? (
<><Eye className="size-3.5" />View</>
<>
<Eye className="size-3.5" />
View
</>
) : (
<><Pencil className="size-3.5" />Edit</>
<>
<Pencil className="size-3.5" />
Edit
</>
)}
</Button>
{!skill.builtIn ? (
@@ -408,7 +414,11 @@ const SkillDialog: FC<{
<DialogContent className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle>
{readOnly ? 'View Skill' : editingSkill ? 'Edit Skill' : 'Create Skill'}
{readOnly
? 'View Skill'
: editingSkill
? 'Edit Skill'
: 'Create Skill'}
</DialogTitle>
<DialogDescription>
{readOnly
@@ -472,7 +482,7 @@ const SkillDialog: FC<{
</div>
{readOnly ? (
<div className="prose prose-sm mt-4 min-h-[320px] max-w-none flex-1 overflow-y-auto rounded-md border p-4 text-sm dark:prose-invert">
<div className="prose prose-sm dark:prose-invert mt-4 min-h-[320px] max-w-none flex-1 overflow-y-auto rounded-md border p-4 text-sm">
<Markdown>{content}</Markdown>
</div>
) : (

View File

@@ -0,0 +1,147 @@
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
getCreditBarColor,
getCreditTextColor,
} from '@/lib/credits/credit-colors'
import { useCredits } from '@/lib/credits/useCredits'
import { BrowserOSIcon } from '@/lib/llm-providers/providerIcons'
import { cn } from '@/lib/utils'
export const UsagePage: FC = () => {
const { data, isLoading, error } = useCredits()
if (isLoading) {
return (
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
Loading usage data...
</div>
)
}
if (error) {
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 rounded-xl border p-5">
<BrowserOSIcon size={40} />
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage
</p>
</div>
</div>
<div className="flex flex-col items-center gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-8">
<AlertCircle className="h-6 w-6 text-muted-foreground" />
<p className="text-muted-foreground text-sm">
Unable to load credit information
</p>
</div>
</div>
)
}
const credits = data?.credits ?? 0
const total = data?.dailyLimit ?? 100
const percentage = Math.min((credits / total) * 100, 100)
return (
<div className="space-y-6 p-6">
<div className="flex items-center gap-4 rounded-xl border p-5">
<BrowserOSIcon size={40} />
<div>
<h2 className="font-semibold text-lg">Usage & Billing</h2>
<p className="text-muted-foreground text-sm">
Monitor your BrowserOS AI credit usage
</p>
</div>
</div>
<div className="rounded-xl border p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Coins className="h-5 w-5 text-muted-foreground" />
<span className="font-semibold text-sm">Daily Credits</span>
</div>
<span
className={cn('font-bold text-2xl', getCreditTextColor(credits))}
>
{credits}
<span className="ml-1 font-normal text-muted-foreground text-sm">
/ {total}
</span>
</span>
</div>
<div className="mb-5 h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
getCreditBarColor(credits),
)}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-xs">Resets daily</p>
<p className="text-muted-foreground text-xs">Midnight UTC</p>
</div>
</div>
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-xs">Credits used today</p>
<p className="text-muted-foreground text-xs">
{total - credits} of {total}
</p>
</div>
</div>
</div>
</div>
<div className="rounded-xl border p-5">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<p className="flex items-center gap-2 font-semibold text-sm">
Need more credits?
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground uppercase tracking-wide">
Coming soon
</span>
</p>
<p className="text-muted-foreground text-xs">
Additional credit packages will be available soon
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/5 p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="h-5 w-5 text-[var(--accent-orange)]" />
<div>
<p className="font-semibold text-sm">Want unlimited usage?</p>
<p className="text-muted-foreground text-xs">
Add your own LLM provider no credit limits
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20"
asChild
>
<a href="/app.html#/settings/ai">Add Provider</a>
</Button>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -5,13 +5,17 @@ import {
Folder,
Globe,
Layers,
Loader2,
Mic,
PlugZap,
Search,
Square,
X,
} from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import { AppSelector } from '@/components/elements/AppSelector'
import {
GlowingBorder,
@@ -27,6 +31,7 @@ import {
} from '@/components/ui/tooltip'
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import {
@@ -42,13 +47,20 @@ import {
NEWTAB_TAB_REMOVED_EVENT,
NEWTAB_TAB_TOGGLED_EVENT,
NEWTAB_TABS_OPENED_EVENT,
NEWTAB_VOICE_ERROR_EVENT,
NEWTAB_VOICE_RECORDING_STARTED_EVENT,
NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
NEWTAB_WORKSPACE_OPENED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { ImportDataHint } from './ImportDataHint'
import type { SuggestionItem } from './lib/suggestions/types'
@@ -90,10 +102,42 @@ export const NewTab = () => {
})
const { selectedFolder } = useWorkspace()
const { supports } = useCapabilities()
const { providers, selectedProvider, handleSelectProvider } =
useChatSessionContext()
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
useSyncRemoteIntegrations()
const voice = useVoiceInput()
// Voice transcript → populate search input
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
useEffect(() => {
if (voice.transcript && !voice.isTranscribing) {
setComboboxInputValue(voice.transcript)
track(NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT)
voice.clearTranscript()
}
}, [voice.transcript, voice.isTranscribing])
useEffect(() => {
if (voice.error) {
track(NEWTAB_VOICE_ERROR_EVENT, { error: voice.error })
}
}, [voice.error])
const handleStartRecording = async () => {
const started = await voice.startRecording()
if (started) {
track(NEWTAB_VOICE_RECORDING_STARTED_EVENT)
}
}
const handleStopRecording = async () => {
await voice.stopRecording()
track(NEWTAB_VOICE_RECORDING_STOPPED_EVENT)
}
const connectedManagedServers = mcpServers.filter((s) => {
if (s.type !== 'managed' || !s.managedServerName) return false
return userMCPIntegrations?.integrations?.find(
@@ -424,32 +468,89 @@ export const NewTab = () => {
anchorRef={inputRef}
side="bottom"
/>
<input
type="text"
placeholder={searchPlaceholder}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
{...getInputProps({
ref: inputRef,
onChange: (e) => handleInputChange(e.currentTarget.value),
onKeyDown: (e) => {
if (!mentionStateRef.current.isOpen) return
if (e.key === 'Tab') {
e.preventDefault()
closeMention()
}
},
})}
/>
{voice.isRecording ? (
<div className="flex min-h-[40px] flex-1 items-center justify-center gap-1.5">
{voice.audioLevels.map((level, i) => (
<div
key={i.toString()}
className="w-1.5 rounded-full bg-red-500 transition-all duration-75"
style={{
height: `${Math.max(6, Math.min(28, level * 0.7))}px`,
}}
/>
))}
</div>
) : (
<input
type="text"
placeholder={
voice.isTranscribing ? 'Transcribing...' : searchPlaceholder
}
disabled={voice.isTranscribing}
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
{...getInputProps({
ref: inputRef,
onChange: (e) => handleInputChange(e.currentTarget.value),
onKeyDown: (e) => {
if (!mentionStateRef.current.isOpen) return
if (e.key === 'Tab') {
e.preventDefault()
closeMention()
}
},
})}
/>
)}
<Button
onClick={handleSend}
size="icon"
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
<ArrowRight className="h-5 w-5" />
</Button>
<div className="flex items-center gap-1.5">
{voice.isRecording ? (
<Button
type="button"
size="icon"
onClick={handleStopRecording}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-red-600 text-white hover:bg-red-700"
>
<Square className="h-4 w-4" />
</Button>
) : voice.isTranscribing ? (
<Button
type="button"
variant="ghost"
size="icon"
disabled
className="h-10 w-10 flex-shrink-0 rounded-xl"
>
<Loader2 className="h-5 w-5 animate-spin" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleStartRecording}
className="h-10 w-10 flex-shrink-0 rounded-xl text-muted-foreground transition-colors hover:text-foreground"
title="Voice input"
>
<Mic className="h-5 w-5" />
</Button>
)}
<Button
onClick={handleSend}
size="icon"
disabled={voice.isRecording || voice.isTranscribing}
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
>
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</div>
{voice.error && (
<div className="px-5 pb-2 text-destructive text-xs">
{voice.error}
</div>
)}
<AnimatePresence>
{selectedTabs.length > 0 && (
<motion.div
@@ -523,6 +624,34 @@ export const NewTab = () => {
{mounted && (
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
<div className="flex items-center gap-1">
{selectedProvider && (
<ChatProviderSelector
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={handleSelectProvider}
>
<Button
variant="ghost"
size="icon"
title={selectedProvider.name}
className={cn(
'h-8 w-8 rounded-lg transition-all',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={16} />
) : (
<ProviderIcon
type={selectedProvider.type as ProviderType}
size={16}
/>
)}
</Button>
</ChatProviderSelector>
)}
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
<WorkspaceSelector>
<Button

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

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

View File

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

View File

@@ -3,11 +3,27 @@ import type { FC } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { CreditBadge } from '@/components/credits/CreditBadge'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { productRepositoryUrl } from '@/lib/constants/productUrls'
import { useCredits } from '@/lib/credits/useCredits'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
const CreditsBadgeWrapper: FC = () => {
const { supports } = useCapabilities()
const { data } = useCredits()
if (!supports(Feature.CREDITS_SUPPORT) || data === undefined) return null
return (
<CreditBadge
credits={data.credits}
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
/>
)
}
interface ChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
@@ -61,6 +77,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
</span>
</button>
</ChatProviderSelector>
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
</div>
<div className="flex items-center gap-1">

View File

@@ -21,6 +21,7 @@ import {
useConversations,
} from '@/lib/conversations/conversationStorage'
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
import { useInvalidateCredits } from '@/lib/credits/useCredits'
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
@@ -75,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,
@@ -86,6 +85,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
selectedLlmProvider,
isLoadingProviders,
} = useChatRefs()
const invalidateCredits = useInvalidateCredits()
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
@@ -342,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,
@@ -481,8 +477,14 @@ export const useChatSession = (options?: ChatSessionOptions) => {
} else {
saveLocalConversation(conversationIdRef.current, messagesToSave)
}
invalidateCredits()
}, [status])
useEffect(() => {
if (chatError) invalidateCredits()
}, [chatError, invalidateCredits])
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
const pendingMessageRef = useRef<{
@@ -502,6 +504,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
if (pending.action) {
setTextToAction((prev) => {
const next = new Map(prev)
// biome-ignore lint/style/noNonNullAssertion: guarded by if (pending.action) above
next.set(pending.text, pending.action!)
return next
})

View File

@@ -19,6 +19,10 @@ function extractTabId(toolPart: ToolUIPart | null): number | undefined {
return input?.tabId
}
function sendGlow(tabId: number, message: GlowMessage): void {
chrome.tabs.sendMessage(tabId, message).catch(() => {})
}
export const useNotifyActiveTab = ({
messages,
status,
@@ -28,7 +32,10 @@ export const useNotifyActiveTab = ({
status: ChatStatus
conversationId: string
}) => {
const lastTabIdRef = useRef<number | null>(null)
// Track the single tab currently glowing
const activeTabIdRef = useRef<number | null>(null)
// Track all tabs that have been glowed during this stream (for cleanup)
const allGlowedTabsRef = useRef<Set<number>>(new Set())
const lastMessage = messages?.[messages.length - 1]
@@ -41,27 +48,35 @@ export const useNotifyActiveTab = ({
useEffect(() => {
const isStreaming = status === 'streaming'
const previousTabId = lastTabIdRef.current
if (!isStreaming) {
if (previousTabId) {
// Deactivate ALL tabs that were glowed during this stream
const allGlowed = allGlowedTabsRef.current
if (allGlowed.size > 0) {
const deactivate = async () => {
// Capture tab IDs before any async work to avoid race with clear()
const tabIds = Array.from(allGlowed)
allGlowed.clear()
const alreadyShown = await firstRunConfettiShownStorage.getValue()
const deactivateMessage: GlowMessage = {
conversationId,
isActive: false,
showConfetti: !alreadyShown,
let showConfetti = !alreadyShown
for (const tabId of tabIds) {
sendGlow(tabId, {
conversationId,
isActive: false,
showConfetti,
})
showConfetti = false
}
chrome.tabs
.sendMessage(previousTabId, deactivateMessage)
.catch(() => {})
if (!alreadyShown) {
await firstRunConfettiShownStorage.setValue(true)
}
}
deactivate()
lastTabIdRef.current = null
}
activeTabIdRef.current = null
return
}
@@ -70,34 +85,41 @@ export const useNotifyActiveTab = ({
let cancelled = false
const activate = async () => {
let targetTabId = toolTabId ?? previousTabId ?? undefined
let targetTabId = toolTabId ?? undefined
if (!targetTabId) {
const tabs = await chrome.tabs.query({
active: true,
currentWindow: true,
})
targetTabId = tabs[0]?.id
// Fallback: use the currently active tab, or query browser
if (activeTabIdRef.current) {
targetTabId = activeTabIdRef.current
} else {
const tabs = await chrome.tabs.query({
active: true,
currentWindow: true,
})
targetTabId = tabs[0]?.id
}
}
if (cancelled || !targetTabId) return
const previousTabId = activeTabIdRef.current
// If the agent moved to a different tab, deactivate the previous one
if (previousTabId && previousTabId !== targetTabId) {
const deactivateMessage: GlowMessage = {
sendGlow(previousTabId, {
conversationId,
isActive: false,
}
chrome.tabs
.sendMessage(previousTabId, deactivateMessage)
.catch(() => {})
})
}
const activateMessage: GlowMessage = {
// Activate glow on the target tab
sendGlow(targetTabId, {
conversationId,
isActive: true,
}
chrome.tabs.sendMessage(targetTabId, activateMessage).catch(() => {})
lastTabIdRef.current = targetTabId
})
activeTabIdRef.current = targetTabId
allGlowedTabsRef.current.add(targetTabId)
}
activate()

View File

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

View File

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

View File

@@ -45,6 +45,14 @@ export enum Feature {
MEMORY_SUPPORT = 'MEMORY_SUPPORT',
// Skills page: agent skills viewer and editor
SKILLS_SUPPORT = 'SKILLS_SUPPORT',
// ChatGPT Pro OAuth LLM provider
CHATGPT_PRO_SUPPORT = 'CHATGPT_PRO_SUPPORT',
// GitHub Copilot OAuth LLM provider
GITHUB_COPILOT_SUPPORT = 'GITHUB_COPILOT_SUPPORT',
// Qwen Code OAuth LLM provider
QWEN_CODE_SUPPORT = 'QWEN_CODE_SUPPORT',
// Credit-based usage tracking
CREDITS_SUPPORT = 'CREDITS_SUPPORT',
}
/**
@@ -72,6 +80,10 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
[Feature.VERTICAL_TABS_SUPPORT]: { minBrowserOSVersion: '0.42.0.0' },
[Feature.MEMORY_SUPPORT]: { minServerVersion: '0.0.73' },
[Feature.SKILLS_SUPPORT]: { minBrowserOSVersion: '0.43.0.0' },
[Feature.CHATGPT_PRO_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.GITHUB_COPILOT_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.QWEN_CODE_SUPPORT]: { minServerVersion: '0.0.77' },
[Feature.CREDITS_SUPPORT]: { minServerVersion: '0.0.78' },
}
function parseVersion(version: string): number[] {

View File

@@ -41,9 +41,36 @@ export const CHATGPT_PRO_OAUTH_COMPLETED_EVENT =
export const CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT =
'settings.chatgpt_pro.oauth_disconnected'
/** @public */
export const GITHUB_COPILOT_OAUTH_STARTED_EVENT =
'settings.github_copilot.oauth_started'
/** @public */
export const GITHUB_COPILOT_OAUTH_COMPLETED_EVENT =
'settings.github_copilot.oauth_completed'
/** @public */
export const GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT =
'settings.github_copilot.oauth_disconnected'
/** @public */
export const QWEN_CODE_OAUTH_STARTED_EVENT = 'settings.qwen_code.oauth_started'
/** @public */
export const QWEN_CODE_OAUTH_COMPLETED_EVENT =
'settings.qwen_code.oauth_completed'
/** @public */
export const QWEN_CODE_OAUTH_DISCONNECTED_EVENT =
'settings.qwen_code.oauth_disconnected'
/** @public */
export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added'
/** @public */
export const MCP_PROMO_BANNER_CLICKED_EVENT =
'settings.mcp_promo_banner.clicked'
/** @public */
export const MCP_EXTERNAL_ACCESS_ENABLED_EVENT =
'settings.mcp_external_access.enabled'

View File

@@ -0,0 +1,13 @@
const LOW_THRESHOLD = 30
export function getCreditTextColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= LOW_THRESHOLD) return 'text-yellow-500'
return 'text-green-500'
}
export function getCreditBarColor(credits: number): string {
if (credits <= 0) return 'bg-red-500'
if (credits <= LOW_THRESHOLD) return 'bg-yellow-500'
return 'bg-green-500'
}

View File

@@ -0,0 +1,33 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
export interface CreditsInfo {
credits: number
dailyLimit: number
lastResetAt?: string
}
const CREDITS_QUERY_KEY = ['credits']
async function fetchCredits(): Promise<CreditsInfo> {
const baseUrl = await getAgentServerUrl()
const response = await fetch(`${baseUrl}/credits`)
if (!response.ok)
throw new Error(`Failed to fetch credits: ${response.status}`)
return response.json()
}
export function useCredits() {
return useQuery<CreditsInfo>({
queryKey: CREDITS_QUERY_KEY,
queryFn: fetchCredits,
refetchOnWindowFocus: true,
staleTime: 30_000,
retry: 1,
})
}
export function useInvalidateCredits() {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
}

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 }) => {

View File

@@ -0,0 +1,169 @@
/**
* Client-side OAuth Device Code flow.
* Used for providers where server-side fetch is blocked by WAF (e.g. Qwen).
* The extension makes requests using Chrome's network stack which bypasses
* TLS fingerprint-based WAF detection.
*/
export interface ClientAuthConfig {
deviceCodeEndpoint: string
tokenEndpoint: string
clientId: string
scopes: string
requiresPKCE: boolean
contentType: 'json' | 'form'
}
interface DeviceCodeData {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete?: string
expires_in: number
interval: number
}
export interface TokenResult {
accessToken: string
refreshToken: string
expiresIn: number
}
export async function requestDeviceCode(
auth: ClientAuthConfig,
): Promise<{ deviceData: DeviceCodeData; codeVerifier?: string }> {
let codeVerifier: string | undefined
const params: Record<string, string> = {
client_id: auth.clientId,
scope: auth.scopes,
}
if (auth.requiresPKCE) {
codeVerifier = generateCodeVerifier()
params.code_challenge = await generateCodeChallenge(codeVerifier)
params.code_challenge_method = 'S256'
}
const res = await authFetch(auth.deviceCodeEndpoint, params, auth.contentType)
// WAF captcha detected — open the site for user to solve, then retry
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('application/json')) {
const baseUrl = new URL(auth.deviceCodeEndpoint).origin
window.open(baseUrl, '_blank')
throw new Error(
'Please complete the verification in the opened tab, then click USE again.',
)
}
if (!res.ok) throw new Error(`Device code request failed: ${res.status}`)
const deviceData = (await res.json()) as DeviceCodeData
if (!deviceData.device_code || !deviceData.user_code) {
throw new Error('Invalid device code response')
}
return { deviceData, codeVerifier }
}
export function startTokenPolling(
auth: ClientAuthConfig,
deviceData: DeviceCodeData,
codeVerifier: string | undefined,
onToken: (token: TokenResult) => void,
): void {
let interval = deviceData.interval
const deadline = Date.now() + deviceData.expires_in * 1000
const safetyMargin = 3
const poll = async () => {
if (Date.now() > deadline) return
const params: Record<string, string> = {
client_id: auth.clientId,
device_code: deviceData.device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}
if (codeVerifier) params.code_verifier = codeVerifier
try {
const res = await authFetch(auth.tokenEndpoint, params, auth.contentType)
// WAF returned HTML — retry later
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('application/json')) {
setTimeout(poll, (interval + safetyMargin) * 1000)
return
}
const data = (await res.json()) as {
access_token?: string
refresh_token?: string
expires_in?: number
error?: string
interval?: number
}
if (data.access_token) {
onToken({
accessToken: data.access_token,
refreshToken: data.refresh_token ?? '',
expiresIn: data.expires_in ?? 0,
})
return
}
if (data.error === 'authorization_pending') {
setTimeout(poll, (interval + safetyMargin) * 1000)
return
}
if (data.error === 'slow_down') {
interval = (data.interval ?? interval) + 5
setTimeout(poll, (interval + safetyMargin) * 1000)
return
}
} catch {
setTimeout(poll, (interval + safetyMargin) * 1000)
}
}
setTimeout(poll, (interval + safetyMargin) * 1000)
}
function authFetch(
endpoint: string,
params: Record<string, string>,
contentType: 'json' | 'form',
): Promise<Response> {
return fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type':
contentType === 'form'
? 'application/x-www-form-urlencoded'
: 'application/json',
Accept: 'application/json',
},
body:
contentType === 'form'
? new URLSearchParams(params).toString()
: JSON.stringify(params),
})
}
function generateCodeVerifier(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return base64UrlEncode(bytes)
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier),
)
return base64UrlEncode(new Uint8Array(digest))
}
function base64UrlEncode(bytes: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...bytes))
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

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

@@ -8,8 +8,9 @@ import {
Ollama,
OpenAI,
OpenRouter,
Qwen,
} from '@lobehub/icons'
import { Bot } from 'lucide-react'
import { Bot, Github } from 'lucide-react'
import type { FC, SVGProps } from 'react'
import ProductLogoSvg from '@/assets/product_logo.svg'
import type { ProviderType } from './types'
@@ -33,6 +34,8 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
browseros: null,
moonshot: Kimi,
'chatgpt-pro': OpenAI,
'github-copilot': Github,
'qwen-code': Qwen,
}
interface ProviderIconProps {

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
@@ -29,6 +54,24 @@ export const providerTemplates: ProviderTemplate[] = [
contextWindow: 400000,
setupGuideUrl: 'https://docs.browseros.com/features/chatgpt-pro-oauth',
},
{
id: 'github-copilot',
name: 'GitHub Copilot',
defaultBaseUrl: 'https://api.githubcopilot.com',
defaultModelId: 'gpt-5-mini',
supportsImages: true,
contextWindow: 128000,
setupGuideUrl: 'https://docs.browseros.com/features/github-copilot-oauth',
},
{
id: 'qwen-code',
name: 'Qwen Code',
defaultBaseUrl: 'https://portal.qwen.ai/v1',
defaultModelId: 'coder-model',
supportsImages: true,
contextWindow: 1000000,
setupGuideUrl: 'https://docs.browseros.com/features/qwen-code-oauth',
},
{
id: 'moonshot',
name: 'Moonshot AI',
@@ -39,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',
@@ -58,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',
@@ -90,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',
},
}),
]
/**
@@ -139,6 +148,8 @@ export const providerTemplates: ProviderTemplate[] = [
*/
export const providerTypeOptions: { value: ProviderType; label: string }[] = [
{ value: 'chatgpt-pro', label: 'ChatGPT Plus/Pro' },
{ value: 'github-copilot', label: 'GitHub Copilot' },
{ value: 'qwen-code', label: 'Qwen Code' },
{ value: 'moonshot', label: 'Moonshot AI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
@@ -168,6 +179,8 @@ export const getProviderTemplate = (
*/
export const DEFAULT_BASE_URLS: Record<ProviderType, string> = {
'chatgpt-pro': 'https://chatgpt.com/backend-api',
'github-copilot': 'https://api.githubcopilot.com',
'qwen-code': 'https://portal.qwen.ai/v1',
moonshot: 'https://api.moonshot.ai/v1',
anthropic: 'https://api.anthropic.com/v1',
openai: 'https://api.openai.com/v1',

View File

@@ -15,6 +15,8 @@ export type ProviderType =
| 'browseros'
| 'moonshot'
| 'chatgpt-pro'
| 'github-copilot'
| 'qwen-code'
/**
* LLM Provider configuration

View File

@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { track } from '@/lib/metrics/track'
import {
type ClientAuthConfig,
requestDeviceCode,
startTokenPolling,
} from './client-oauth'
import { getProviderTemplate } from './providerTemplates'
import type { LlmProviderConfig, ProviderType } from './types'
import { useOAuthStatus } from './useOAuthStatus'
export interface OAuthProviderFlowConfig {
providerType: ProviderType
displayName: string
startedEvent: string
completedEvent: string
disconnectedEvent: string
/** Client-side auth for providers with WAF-protected endpoints */
clientAuth?: ClientAuthConfig
}
export interface PendingDeviceCode {
userCode: string
providerName: string
verificationUri: string
}
interface OAuthProviderFlowReturn {
status: { authenticated: boolean; email?: string } | null
disconnect: () => Promise<void>
startOAuthFlow: (agentServerUrl: string | undefined) => Promise<void>
pendingDeviceCode: PendingDeviceCode | null
clearDeviceCode: () => void
}
export function useOAuthProviderFlow(
config: OAuthProviderFlowConfig,
providers: LlmProviderConfig[],
saveProvider: (provider: LlmProviderConfig) => Promise<void> | void,
): OAuthProviderFlowReturn {
const { status, startPolling, disconnect } = useOAuthStatus(
config.providerType,
)
const flowStartedRef = useRef(false)
const [pendingDeviceCode, setPendingDeviceCode] =
useState<PendingDeviceCode | null>(null)
// Auto-create provider when OAuth completes
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
useEffect(() => {
if (!status?.authenticated) return
if (!flowStartedRef.current) return
if (providers.some((p) => p.type === config.providerType)) return
const now = Date.now()
try {
const template = getProviderTemplate(config.providerType)
saveProvider({
id: `${config.providerType}-${now}`,
type: config.providerType,
name: `${config.displayName}${status.email ? ` (${status.email})` : ''}`,
modelId: template?.defaultModelId ?? '',
supportsImages: template?.supportsImages ?? true,
contextWindow: template?.contextWindow ?? 128000,
temperature: 0.2,
createdAt: now,
updatedAt: now,
})
setPendingDeviceCode(null)
track(config.completedEvent, { email: status.email })
toast.success(`${config.displayName} Connected`, {
description: status.email
? `Authenticated as ${status.email}`
: `Successfully authenticated with ${config.displayName}`,
})
} catch (err) {
toast.error(`Failed to create ${config.displayName} provider`, {
description: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
flowStartedRef.current = false
}
}, [status?.authenticated])
async function startOAuthFlow(agentServerUrl: string | undefined) {
if (!agentServerUrl) {
toast.error('Server not available', {
description: 'Cannot start OAuth flow without server connection.',
})
return
}
flowStartedRef.current = true
try {
if (config.clientAuth) {
await handleClientAuth(config.clientAuth, agentServerUrl)
} else {
await handleServerAuth(agentServerUrl)
}
} catch (err) {
flowStartedRef.current = false
toast.error(`Failed to start ${config.displayName} authentication`, {
description: err instanceof Error ? err.message : 'Unknown error',
})
}
}
// Client-side: extension handles device code + polling, sends token to server
async function handleClientAuth(auth: ClientAuthConfig, serverUrl: string) {
const { deviceData, codeVerifier } = await requestDeviceCode(auth)
const verificationUri =
deviceData.verification_uri_complete ?? deviceData.verification_uri
window.open(verificationUri, '_blank')
track(config.startedEvent)
setPendingDeviceCode({
userCode: deviceData.user_code,
providerName: config.displayName,
verificationUri,
})
startTokenPolling(auth, deviceData, codeVerifier, async (token) => {
await fetch(`${serverUrl}/oauth/${config.providerType}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(token),
})
startPolling()
})
}
// Server-side: server handles device code + polling
async function handleServerAuth(agentServerUrl: string) {
const res = await fetch(
`${agentServerUrl}/oauth/${config.providerType}/start`,
)
if (res.headers.get('content-type')?.includes('application/json')) {
const data = (await res.json()) as {
userCode?: string
verificationUri?: string
error?: string
}
if (!res.ok || data.error) {
throw new Error(data.error || `Server returned ${res.status}`)
}
if (!data.userCode || !data.verificationUri) {
throw new Error('Invalid response from server')
}
window.open(data.verificationUri, '_blank')
startPolling()
track(config.startedEvent)
setPendingDeviceCode({
userCode: data.userCode,
providerName: config.displayName,
verificationUri: data.verificationUri,
})
return
}
// PKCE redirect flow
if (!res.ok) throw new Error(`Server returned ${res.status}`)
window.open(res.url, '_blank')
startPolling()
track(config.startedEvent)
toast.info(`Authenticating with ${config.displayName}`, {
description: 'Complete the login in the opened tab.',
})
}
return {
status,
disconnect,
startOAuthFlow,
pendingDeviceCode,
clearDeviceCode: () => setPendingDeviceCode(null),
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
const GATEWAY_URL = 'https://llm.browseros.com'
interface TranscribeResponse {
text: string
}
export async function transcribeAudio(audioBlob: Blob): Promise<string> {
const formData = new FormData()
formData.append('file', audioBlob, 'recording.webm')
formData.append('response_format', 'json')
const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
const errorBody: { error?: string } = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(
errorBody.error || `Transcription failed: ${response.status}`,
)
}
const result: TranscribeResponse = await response.json()
return result.text || ''
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { transcribeAudio } from './transcribe-audio'
const GATEWAY_URL = 'https://llm.browseros.com'
const WAVEFORM_BAND_COUNT = 5
export interface VoiceInputState {
@@ -26,34 +26,6 @@ export interface UseVoiceInputReturn {
const EMPTY_LEVELS = Array(WAVEFORM_BAND_COUNT).fill(0)
interface TranscribeResponse {
text: string
}
async function transcribeAudio(audioBlob: Blob): Promise<string> {
const formData = new FormData()
formData.append('file', audioBlob, 'recording.webm')
formData.append('response_format', 'json')
const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
const errorBody: { error?: string } = await response
.json()
.catch(() => ({ error: 'Transcription failed' }))
throw new Error(
errorBody.error || `Transcription failed: ${response.status}`,
)
}
const result: TranscribeResponse = await response.json()
return result.text || ''
}
export function useVoiceInput(): UseVoiceInputReturn {
const [isRecording, setIsRecording] = useState(false)
const [isTranscribing, setIsTranscribing] = useState(false)

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

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

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

@@ -1,12 +1,15 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"browseros-cli/analytics"
"browseros-cli/config"
"browseros-cli/mcp"
"browseros-cli/output"
@@ -111,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)
@@ -165,16 +184,52 @@ 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 != "" {
return url
}
}
return ""
}
type serverDiscoveryConfig struct {
ServerPort int `json:"server_port"`
URL string `json:"url"`
ServerVersion string `json:"server_version"`
BrowserOSVersion string `json:"browseros_version,omitempty"`
ChromiumVersion string `json:"chromium_version,omitempty"`
}
func loadBrowserosServerURL() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return normalizeServerURL(cfg.ServerURL)
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
if err != nil {
return ""
}
var sc serverDiscoveryConfig
if err := json.Unmarshal(data, &sc); err != nil {
return ""
}
return normalizeServerURL(sc.URL)
}
func normalizeServerURL(raw string) string {
@@ -190,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)
}
})
}
}

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