Compare commits

...

20 Commits

Author SHA1 Message Date
Nikhil Sonti
9550229033 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>
2026-03-21 09:44:29 -07:00
Nikhil Sonti
55e31c1e34 chore: add CI artifacts to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:38:16 -07:00
Nikhil Sonti
60660c5c17 refactor: rework 0320-github_action_for_tests based on feedback 2026-03-20 18:18:57 -07:00
Nikhil Sonti
45775f3e8d refactor: rework 0320-github_action_for_tests based on feedback 2026-03-20 12:34:59 -07:00
Nikhil Sonti
f0c9aa6f7b ci: run browseros tests on pull requests 2026-03-20 12:18:57 -07:00
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
79 changed files with 3122 additions and 314 deletions

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,91 @@ 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"
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}"
}

View File

@@ -187,6 +187,10 @@ log.txt
# Testing iteration temp files
tmp/
# CI artifacts
.ci/
test-results/
# Coding agent artifacts
.agent/
.llm/

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

@@ -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 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,19 +17,25 @@ 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 {
@@ -42,6 +48,47 @@ import { LlmProvidersHeader } from './LlmProvidersHeader'
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 +135,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 +156,47 @@ 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)
// 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 +204,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 +223,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 +233,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) => {

View File

@@ -62,6 +62,8 @@ const providerTypeEnum = z.enum([
'bedrock',
'browseros',
'chatgpt-pro',
'github-copilot',
'qwen-code',
])
/**
@@ -131,8 +133,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
@@ -190,6 +196,11 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
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') {
@@ -377,8 +388,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 +477,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 (

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)

View File

@@ -24,6 +24,8 @@ export interface ModelsData {
browseros: ModelInfo[]
moonshot: ModelInfo[]
'chatgpt-pro': ModelInfo[]
'github-copilot': ModelInfo[]
'qwen-code': ModelInfo[]
}
/**
@@ -101,6 +103,38 @@ export const MODELS_DATA: ModelsData = {
{ modelId: 'gpt-5.1-codex-mini', contextLength: 400000 },
{ modelId: 'gpt-5.1', contextLength: 200000 },
],
'github-copilot': [
// Free tier (unlimited with Pro)
{ modelId: 'gpt-5-mini', contextLength: 128000 },
{ modelId: 'claude-haiku-4.5', contextLength: 128000 },
{ modelId: 'gpt-4o', contextLength: 64000 },
{ modelId: 'gpt-4.1', contextLength: 64000 },
// Premium models (Pro: 300/mo, Pro+: 1500/mo)
{ modelId: 'claude-sonnet-4.6', contextLength: 128000 },
{ modelId: 'claude-sonnet-4.5', contextLength: 128000 },
{ modelId: 'claude-sonnet-4', contextLength: 128000 },
{ modelId: 'claude-opus-4.6', contextLength: 128000 },
{ modelId: 'claude-opus-4.5', contextLength: 128000 },
{ modelId: 'gemini-2.5-pro', contextLength: 128000 },
{ modelId: 'gemini-3-pro-preview', contextLength: 128000 },
{ modelId: 'gemini-3-flash-preview', contextLength: 128000 },
{ modelId: 'gemini-3.1-pro-preview', contextLength: 128000 },
{ modelId: 'gpt-5.4', contextLength: 272000 },
{ modelId: 'gpt-5.4-mini', contextLength: 128000 },
{ modelId: 'gpt-5.3-codex', contextLength: 272000 },
{ modelId: 'gpt-5.2-codex', contextLength: 272000 },
{ modelId: 'gpt-5.2', contextLength: 128000 },
{ modelId: 'gpt-5.1-codex', contextLength: 128000 },
{ modelId: 'gpt-5.1-codex-max', contextLength: 128000 },
{ modelId: 'gpt-5.1', contextLength: 128000 },
{ modelId: 'grok-code-fast-1', contextLength: 128000 },
],
'qwen-code': [
{ modelId: 'coder-model', contextLength: 1000000 },
{ modelId: 'qwen3-coder-plus', contextLength: 1000000 },
{ modelId: 'qwen3-coder-flash', contextLength: 1000000 },
{ modelId: 'qwen3.5-plus', contextLength: 1000000 },
],
}
/**

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

@@ -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,125 @@
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 justify-between">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-semibold text-sm">Need more credits?</p>
<p className="text-muted-foreground text-xs">
Additional credit packages coming soon
</p>
</div>
</div>
<Button variant="outline" size="sm" disabled className="opacity-50">
Add Credits
</Button>
</div>
</div>
</div>
)
}

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

@@ -30,6 +30,7 @@ function parseErrorMessage(message: string): {
text: string
url?: string
isRateLimit?: boolean
isCreditsExhausted?: boolean
isConnectionError?: boolean
} {
// Detect MCP server connection failures
@@ -44,6 +45,19 @@ function parseErrorMessage(message: string): {
}
}
// Detect credit exhaustion from gateway
if (
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 (unique pattern, no provider uses this)
if (message.includes('BrowserOS LLM daily limit reached')) {
return {
@@ -70,9 +84,8 @@ function parseErrorMessage(message: string): {
}
export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
const { text, url, isRateLimit, isConnectionError } = parseErrorMessage(
error.message,
)
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
parseErrorMessage(error.message)
// --- Commented out for Kimi partnership launch (restore after) ---
// const surveyUrl = useMemo(
@@ -128,7 +141,17 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
</p>
)}
--- End commented out survey code --- */}
{isRateLimit && (
{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 && !isCreditsExhausted && (
<div className="flex flex-col items-center gap-1">
<p className="text-muted-foreground text-xs">
{/* biome-ignore lint/a11y/useValidAnchor: link with click tracking */}

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'
@@ -86,6 +87,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
selectedLlmProvider,
isLoadingProviders,
} = useChatRefs()
const invalidateCredits = useInvalidateCredits()
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
@@ -481,8 +483,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 +510,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

@@ -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,6 +41,29 @@ 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'

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

@@ -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(/=+$/, '')
}

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

@@ -29,6 +29,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',
@@ -139,6 +157,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 +188,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,167 @@
import { useEffect, useRef } 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
}
interface OAuthProviderFlowReturn {
status: { authenticated: boolean; email?: string } | null
disconnect: () => Promise<void>
startOAuthFlow: (agentServerUrl: string | undefined) => Promise<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)
// 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,
})
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)
toast.info(`Enter code: ${deviceData.user_code}`, {
description: `Paste this code on the ${config.displayName} page that just opened.`,
duration: 60_000,
})
startTokenPolling(auth, deviceData, codeVerifier, async (token) => {
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)
toast.info(`Enter code: ${data.userCode}`, {
description: `Paste this code on the ${config.displayName} page that just opened.`,
duration: 60_000,
})
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,
}
}

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

@@ -55,6 +55,7 @@ export default defineConfig({
permissions: [
'topSites',
'tabs',
'tabGroups',
'storage',
'sidePanel',
'browserOS',

View File

@@ -1,8 +1,10 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -170,11 +172,44 @@ func defaultServerURL() string {
}
cfg, err := config.Load()
if err == nil {
if url := normalizeServerURL(cfg.ServerURL); url != "" {
return url
}
}
if url := loadBrowserosServerURL(); 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 {

View File

@@ -173,7 +173,9 @@ async function annotateScreenshot(
const image = sharp(inputPath)
const metadata = await image.metadata()
// biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images
const imgWidth = metadata.width!
// biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images
const imgHeight = metadata.height!
const sx = Math.round(action.cssX * dpr)

View File

@@ -49,10 +49,13 @@ async function callMcpTool(
const result = await Promise.race([toolPromise, timeoutPromise])
const duration = Date.now() - start
if ((result as any).isError) {
const res = result as Record<string, unknown>
if (res.isError) {
const content = res.content as
| Array<{ type: string; text?: string }>
| undefined
const errorText =
(result as any).content?.find((c: any) => c.type === 'text')?.text ||
'Unknown error'
content?.find((c) => c.type === 'text')?.text || 'Unknown error'
return { success: false, error: errorText, duration }
}
@@ -96,13 +99,19 @@ async function main() {
})
// Try structured content first
windowId = (result as any).structuredContent?.windowId
tabId = (result as any).structuredContent?.tabId
const createRes = result as Record<string, unknown>
const structured = createRes.structuredContent as
| Record<string, number>
| undefined
windowId = structured?.windowId ?? 0
tabId = structured?.tabId ?? 0
// Fall back to parsing text
if (!windowId || !tabId) {
const text =
(result as any).content?.find((c: any) => c.type === 'text')?.text || ''
const content = createRes.content as
| Array<{ type: string; text?: string }>
| undefined
const text = content?.find((c) => c.type === 'text')?.text || ''
const windowMatch = text.match(/window\s+(\d+)/i)
const tabMatch =
text.match(/Tab ID:\s*(\d+)/i) || text.match(/tab\s+(\d+)/i)

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.76",
"version": "0.0.79",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",
@@ -63,8 +63,8 @@
"@ai-sdk/amazon-bedrock": "^4.0.62",
"@ai-sdk/anthropic": "^3.0.46",
"@ai-sdk/azure": "^3.0.31",
"@ai-sdk/google": "^3.0.30",
"@ai-sdk/devtools": "^0.0.15",
"@ai-sdk/google": "^3.0.30",
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/openai": "^3.0.30",
"@ai-sdk/openai-compatible": "^2.0.30",
@@ -89,6 +89,7 @@
"fuse.js": "^7.1.0",
"gray-matter": "^4.0.3",
"hono": "^4.12.3",
"jimp": "^1.6.0",
"klavis": "^2.15.0",
"pino": "^9.6.0",
"posthog-node": "^4.17.0",

View File

@@ -203,7 +203,28 @@ function getExecution(
- Don't ask permission for routine steps. Act, then report.
- Do not refuse by default, attempt tasks even when outcomes are uncertain.
- For ambiguous/unclear requests, ask one targeted clarifying question.
- Stay on the current page. Only open new tabs when the user explicitly asks.
- Stay on the current page for single-page tasks. Use \`navigate_page\` to move within one tab.
### Multi-tab workflow
When a task requires working on multiple pages simultaneously:
1. **Inform the user** that you're creating background tabs for the task.
2. **Open new tabs in background** using \`new_page\` (opens in background by default) — never steal focus from the user's current tab.
3. **IMMEDIATELY create a tab group** using \`group_tabs\` with a descriptive title — do this right after opening the tabs, before any other work. Include the user's current tab in the group. Every multi-tab task MUST have a tab group.
4. **Work on background tabs** — all tools (click, fill, navigate, snapshot) work on background tabs via their page ID.
5. **Narrate progress in chat** — keep the user informed: "Checking Vercel pricing... Now checking Netlify..."
6. **Report results in chat** — summarize findings so the user doesn't need to switch tabs. Leave tabs open for the user to browse later.
7. **Never force-switch the user's active tab.** If you need user interaction on a background tab (e.g., login, CAPTCHA), tell the user which tab needs attention and let them switch manually.
8. **Never navigate the user's current tab** during a multi-tab task. The current tab is the user's anchor — use it only for reading (snapshots, content extraction). All navigation should happen on background tabs.
**Do NOT use \`create_hidden_window\` or \`new_hidden_page\` for user-requested tasks.** Hidden windows are invisible to the user and cannot be screenshotted. Use \`new_page\` (background mode) instead — tabs appear in the user's tab strip and can be inspected. Reserve hidden windows for automated/scheduled runs only.
For single-page lookups (e.g., "go to X and read Y"), use \`navigate_page\` on the current tab. Only create new tabs when the task requires multiple pages open simultaneously.
### Tab retry discipline
When a background tab fails (404, wrong content, unexpected redirect):
- **Navigate the existing tab** to the correct URL with \`navigate_page\` — do NOT open a new tab for retries.
- If you must abandon a tab, close it with \`close_page\` before opening a replacement.
- Never let orphan tabs accumulate — each task should end with only the tabs that contain useful content.
### Observe → Act → Verify
- **Before acting**: Take a snapshot to get interactive element IDs.
@@ -247,6 +268,14 @@ function getToolSelection(): string {
- Prefer \`fill\` over \`press_key\` for text input. Use \`press_key\` for keyboard shortcuts (Enter, Escape, Tab, Ctrl+A, etc.).
- Prefer clicking links over \`navigate_page\` when the link is visible. Use \`navigate_page\` for direct URL access, back/forward, or reload.
### Navigation: single-tab vs multi-tab
| Task | Approach |
|------|----------|
| Look up one page | \`navigate_page\` on current tab |
| Research across multiple sites | \`new_page\` (background) for each site + \`group_tabs\` |
| Compare two pages side by side | \`new_page\` (background) × 2 + \`group_tabs\` |
| User says "open a new tab" | \`new_page\` (background) — don't steal focus |
### Connected apps: Strata vs browser
When an app is Connected, prefer Strata tools over browser automation. Strata is faster, more reliable, and works without navigating away from the user's current page.
</tool_selection>`
@@ -351,7 +380,12 @@ function getErrorRecovery(
### Strata errors
- Authentication error → call \`suggest_app_connection\` for re-auth (STOP and wait)
- Action not found → try \`search_documentation\`, then fall back to browser automation
- Partial failure → report what succeeded and what didn't`
- Partial failure → report what succeeded and what didn't
### Retry budget
- If a site isn't cooperating after 3-4 attempts (form not filling, redirects, geo-blocks), stop trying.
- Report what you've found so far and explain what didn't work: "Kayak kept defaulting to your local city. Here are the Google Flights results instead."
- Don't exhaust 10+ tool calls on a single failing site — the user's time matters more than completeness.`
if (hasWorkspace) {
recovery += `
@@ -528,6 +562,12 @@ Default: do not narrate routine, low-risk tool calls (just call the tool).
Narrate only when it helps: multi-step plans, complex navigation, or when the user explicitly asked for explanation.
Keep narration brief. "Searching for flights..." then tool call — not "I will now search for flights by calling the search tool."
Execute independent tool calls in parallel when possible.
When working on background tabs, always narrate progress so the user knows what's happening:
- "Opening a background tab to check Yahoo News headlines..."
- "Found 5 headlines on Yahoo News. Now checking Reuters..."
- "Done! Here's what I found across all sources:"
This is essential because the user can't see the background tabs — chat is their only window into your work.
</tool_call_style>
- Be concise: 1-2 lines for status updates and action confirmations.

View File

@@ -4,10 +4,13 @@ import { createAzure } from '@ai-sdk/azure'
import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createOpenAI } from '@ai-sdk/openai'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { createBrowserOSFetch } from '../lib/browseros-fetch'
import { createCodexFetch } from '../lib/clients/oauth/codex-fetch'
import { createCopilotFetch } from '../lib/clients/oauth/copilot-fetch'
import { logger } from '../lib/logger'
import { createOpenRouterCompatibleFetch } from '../lib/openrouter-fetch'
import type { ResolvedAgentConfig } from './types'
@@ -102,26 +105,38 @@ function createBrowserOSFactory(
config: ResolvedAgentConfig,
): (modelId: string) => unknown {
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
const { baseUrl, apiKey, upstreamProvider } = config
const { baseUrl, apiKey, upstreamProvider, browserosId } = config
const browserosFetch = browserosId
? createBrowserOSFetch(browserosId)
: createOpenRouterCompatibleFetch()
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
return createOpenRouter({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: createOpenRouterCompatibleFetch(),
fetch: browserosFetch,
})
}
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
return createAnthropic({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
return createAzure({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}
logger.info('creating openai-compatible')
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
return createOpenAICompatible({
name: 'browseros',
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})
}
@@ -149,6 +164,30 @@ function createMoonshotFactory(
})
}
function createQwenCodeFactory(
config: ResolvedAgentConfig,
): (modelId: string) => unknown {
if (!config.apiKey) throw new Error('Qwen Code requires OAuth authentication')
return createOpenAICompatible({
name: 'qwen-code',
baseURL: EXTERNAL_URLS.QWEN_CODE_API,
apiKey: config.apiKey,
})
}
function createGitHubCopilotFactory(
config: ResolvedAgentConfig,
): (modelId: string) => unknown {
if (!config.apiKey)
throw new Error('GitHub Copilot requires OAuth authentication')
return createOpenAICompatible({
name: 'github-copilot',
baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API,
apiKey: config.apiKey,
fetch: createCopilotFetch() as typeof globalThis.fetch,
})
}
function createChatGPTProFactory(
config: ResolvedAgentConfig,
): (modelId: string) => unknown {
@@ -173,6 +212,8 @@ const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
[LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleFactory,
[LLM_PROVIDERS.MOONSHOT]: createMoonshotFactory,
[LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProFactory,
[LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotFactory,
[LLM_PROVIDERS.QWEN_CODE]: createQwenCodeFactory,
}
export function createLanguageModel(

View File

@@ -46,4 +46,6 @@ export interface ResolvedAgentConfig {
isScheduledTask?: boolean
/** Apps the user previously declined to connect via MCP (chose "do it manually"). */
declinedApps?: string[]
/** BrowserOS installation ID for credit-based tracking. */
browserosId?: string
}

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { Hono } from 'hono'
import { fetchCredits } from '../../lib/clients/gateway'
import { logger } from '../../lib/logger'
interface CreditsDeps {
browserosId?: string
gatewayBaseUrl?: string
}
export function createCreditsRoutes(deps: CreditsDeps) {
const { browserosId, gatewayBaseUrl } = deps
if (!browserosId || !gatewayBaseUrl) {
return new Hono().all('/*', (c) =>
c.json({ error: 'Credits not configured' }, 503),
)
}
return new Hono().get('/', async (c) => {
try {
const credits = await fetchCredits(gatewayBaseUrl, browserosId)
return c.json(credits)
} catch (error) {
logger.error('Failed to fetch credits', {
error: error instanceof Error ? error.message : String(error),
})
return c.json({ error: 'Failed to fetch credits' }, 502)
}
})
}

View File

@@ -29,6 +29,17 @@ export function createOAuthRoutes(deps: OAuthRouteDeps) {
}
try {
// Device Code flow: return JSON with user code for the extension to display
if (provider.authFlow === 'device-code') {
const result = await tokenManager.startDeviceCodeFlow(providerId)
return c.json({
userCode: result.userCode,
verificationUri: result.verificationUri,
expiresIn: result.expiresIn,
})
}
// PKCE flow: redirect to auth server
const authUrl = await tokenManager.generateAuthorizationUrl(
providerId,
redirectBackUrl,
@@ -39,7 +50,38 @@ export function createOAuthRoutes(deps: OAuthRouteDeps) {
provider: providerId,
error: error instanceof Error ? error.message : String(error),
})
return c.text('Failed to start authentication. Please try again.', 500)
const message =
error instanceof Error
? error.message
: 'Failed to start authentication. Please try again.'
return c.json({ error: message }, 500)
}
})
.post('/:provider/token', async (c) => {
const providerId = c.req.param('provider')
const provider = getOAuthProvider(providerId)
if (!provider) return c.text(`Unknown OAuth provider: ${providerId}`, 400)
try {
const body = await c.req.json()
if (!body.accessToken) return c.text('Missing accessToken', 400)
tokenManager.storeTokens(providerId, {
accessToken: body.accessToken,
refreshToken: body.refreshToken ?? '',
expiresIn: body.expiresIn ?? 0,
})
logger.info('OAuth tokens stored from client', {
provider: providerId,
})
return c.json({ ok: true })
} catch (error) {
logger.error('Failed to store OAuth token', {
provider: providerId,
error: error instanceof Error ? error.message : String(error),
})
return c.text('Failed to store token', 500)
}
})

View File

@@ -14,11 +14,13 @@ import { Hono } from 'hono'
import { cors } from 'hono/cors'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { HttpAgentError } from '../agent/errors'
import { INLINED_ENV } from '../env'
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
import { initializeOAuth } from '../lib/clients/oauth'
import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
import { createGraphRoutes } from './routes/graph'
import { createHealthRoute } from './routes/health'
import { createKlavisRoutes } from './routes/klavis'
@@ -132,6 +134,15 @@ export async function createHttpServer(config: HttpServerConfig) {
),
)
.route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' }))
.route(
'/credits',
createCreditsRoutes({
browserosId,
gatewayBaseUrl: INLINED_ENV.BROWSEROS_CONFIG_URL
? new URL(INLINED_ENV.BROWSEROS_CONFIG_URL).origin
: undefined,
}),
)
.route(
'/mcp',
createMcpRoutes({

View File

@@ -64,6 +64,7 @@ export class ChatService {
chatMode: request.mode === 'chat',
isScheduledTask: request.isScheduledTask,
declinedApps: request.declinedApps,
browserosId: this.deps.browserosId,
}
let session = sessionStore.get(request.conversationId)

View File

@@ -1,7 +1,9 @@
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
import { unlinkSync } from 'node:fs'
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
import { logger } from './logger'
export function getBrowserosDir(): string {
@@ -32,6 +34,24 @@ export function getBuiltinSkillsDir(): string {
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
}
export function getServerConfigPath(): string {
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
}
export async function writeServerConfig(
config: ServerDiscoveryConfig,
): Promise<void> {
await writeFile(getServerConfigPath(), `${JSON.stringify(config, null, 2)}\n`)
}
export function removeServerConfigSync(): void {
try {
unlinkSync(getServerConfigPath())
} catch {
// File may not exist or already be removed
}
}
export async function ensureBrowserosDir(): Promise<void> {
await mkdir(getMemoryDir(), { recursive: true })
await mkdir(getSkillsDir(), { recursive: true })

View File

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Custom fetch for BrowserOS gateway requests.
* Adds X-BrowserOS-ID header for credit tracking,
* handles CREDITS_EXHAUSTED (429), and extracts OpenRouter-style error details.
*/
import { APICallError } from '@ai-sdk/provider'
import { logger } from './logger'
function resolveUrl(url: RequestInfo | URL): string {
return typeof url === 'string' ? url : url.toString()
}
function parseErrorBody(
body: string,
): { message?: string; code?: string; metadata?: { raw?: unknown } } | null {
try {
const parsed = JSON.parse(body)
return parsed.error ?? null
} catch {
return null
}
}
function buildErrorMessage(
statusCode: number,
statusText: string,
error: NonNullable<ReturnType<typeof parseErrorBody>>,
): string {
if (!error.message) return `HTTP ${statusCode}: ${statusText}`
let msg = error.message
if (error.code) msg = `[${error.code}] ${msg}`
if (error.metadata?.raw) msg += ` (${JSON.stringify(error.metadata.raw)})`
return msg
}
export function createBrowserOSFetch(browserosId: string): typeof fetch {
return (async (url: RequestInfo | URL, options?: RequestInit) => {
const headers = new Headers(options?.headers)
headers.set('X-BrowserOS-ID', browserosId)
const response = await globalThis.fetch(url, { ...options, headers })
const creditsRemaining = response.headers.get('X-Credits-Remaining')
if (creditsRemaining !== null) {
logger.debug('Credits remaining', { creditsRemaining })
}
if (!response.ok) {
const statusCode = response.status
const responseBody = await response.text()
const error = parseErrorBody(responseBody)
if (statusCode === 429 && error?.code === 'CREDITS_EXHAUSTED') {
throw new APICallError({
message: error.message ?? 'Daily credits exhausted',
url: resolveUrl(url),
requestBodyValues: {},
statusCode,
responseBody,
isRetryable: false,
})
}
throw new APICallError({
message: error
? buildErrorMessage(statusCode, response.statusText, error)
: `HTTP ${statusCode}: ${response.statusText}`,
url: resolveUrl(url),
requestBodyValues: {},
statusCode,
responseBody,
})
}
return response
}) as typeof fetch
}

View File

@@ -11,9 +11,18 @@ export interface Provider {
apiKey: string
baseUrl?: string
dailyRateLimit?: number
dailyCredits?: number
creditCostPerRequest?: number
resetInterval?: string
providerType?: string // LLMProvider value from ai-gateway: "openrouter" | "azure" | "anthropic"
}
export interface CreditsInfo {
credits: number
dailyLimit: number
lastResetAt?: string
}
export interface BrowserOSConfig {
providers: Provider[]
}
@@ -109,3 +118,20 @@ export function getLLMConfigFromProvider(
providerType: provider.providerType,
}
}
export async function fetchCredits(
gatewayBaseUrl: string,
browserosId: string,
): Promise<CreditsInfo> {
const url = new URL(`/credits/${browserosId}`, gatewayBaseUrl).href
const response = await fetch(url)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`Failed to fetch credits: ${response.status} ${response.statusText} - ${errorText}`,
)
}
const result = (await response.json()) as CreditsInfo
logger.debug('Credits fetched', { credits: result.credits })
return result
}

View File

@@ -17,9 +17,34 @@ export async function resolveLLMConfig(
config: LLMConfig,
browserosId?: string,
): Promise<ResolvedLLMConfig> {
// ChatGPT Pro: resolve OAuth token from server-side storage
// OAuth providers: resolve token from server-side storage
if (config.provider === LLM_PROVIDERS.CHATGPT_PRO) {
return resolveChatGPTProConfig(config, browserosId)
return resolveOAuthConfig(config, browserosId, {
providerId: 'chatgpt-pro',
displayName: 'ChatGPT Plus/Pro',
defaultModel: 'gpt-5.3-codex',
useRefresh: true,
extraFields: (tokens) => ({
upstreamProvider: 'openai',
accountId: tokens.accountId,
}),
})
}
if (config.provider === LLM_PROVIDERS.GITHUB_COPILOT) {
return resolveOAuthConfig(config, browserosId, {
providerId: 'github-copilot',
displayName: 'GitHub Copilot',
defaultModel: 'gpt-5-mini',
useRefresh: false,
})
}
if (config.provider === LLM_PROVIDERS.QWEN_CODE) {
return resolveOAuthConfig(config, browserosId, {
providerId: 'qwen-code',
displayName: 'Qwen Code',
defaultModel: 'coder-model',
useRefresh: true,
})
}
// BrowserOS gateway: fetch config from remote service
@@ -34,30 +59,41 @@ export async function resolveLLMConfig(
return config as ResolvedLLMConfig
}
async function resolveChatGPTProConfig(
interface OAuthResolveOptions {
providerId: string
displayName: string
defaultModel: string
useRefresh: boolean
extraFields?: (tokens: { accountId?: string }) => Record<string, unknown>
}
async function resolveOAuthConfig(
config: LLMConfig,
browserosId?: string,
browserosId: string | undefined,
opts: OAuthResolveOptions,
): Promise<ResolvedLLMConfig> {
const tokenManager = getOAuthTokenManager()
if (!tokenManager || !browserosId) {
throw new Error(
'Not authenticated with ChatGPT Plus/Pro. Please login first.',
`Not authenticated with ${opts.displayName}. Please login first.`,
)
}
const tokens = await tokenManager.refreshIfExpired('chatgpt-pro')
const tokens = opts.useRefresh
? await tokenManager.refreshIfExpired(opts.providerId)
: tokenManager.getTokens(opts.providerId)
if (!tokens) {
throw new Error(
'Not authenticated with ChatGPT Plus/Pro. Please login first.',
`Not authenticated with ${opts.displayName}. Please login first.`,
)
}
return {
...config,
model: config.model || 'gpt-5.3-codex',
model: config.model || opts.defaultModel,
apiKey: tokens.accessToken,
upstreamProvider: 'openai',
accountId: tokens.accountId,
...opts.extraFields?.(tokens),
}
}
@@ -83,5 +119,6 @@ async function resolveBrowserOSConfig(
apiKey: llmConfig.apiKey,
baseUrl: llmConfig.baseUrl,
upstreamProvider: llmConfig.providerType,
browserosId,
}
}

View File

@@ -12,12 +12,15 @@ import { createAzure } from '@ai-sdk/azure'
import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createOpenAI } from '@ai-sdk/openai'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { LanguageModel } from 'ai'
import { createBrowserOSFetch } from '../../browseros-fetch'
import { logger } from '../../logger'
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
import { createCodexFetch } from '../oauth/codex-fetch'
import { createCopilotFetch } from '../oauth/copilot-fetch'
import type { ResolvedLLMConfig } from './types'
type ProviderFactory = (config: ResolvedLLMConfig) => LanguageModel
@@ -90,28 +93,38 @@ function createBedrockModel(config: ResolvedLLMConfig): LanguageModel {
function createBrowserOSModel(config: ResolvedLLMConfig): LanguageModel {
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
const { baseUrl, apiKey, model, upstreamProvider } = config
const { baseUrl, apiKey, model, upstreamProvider, browserosId } = config
const browserosFetch = browserosId
? createBrowserOSFetch(browserosId)
: createOpenRouterCompatibleFetch()
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
return createOpenRouter({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: createOpenRouterCompatibleFetch(),
fetch: browserosFetch,
})(model)
}
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(
model,
)
return createAnthropic({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(model)
return createAzure({
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
return createOpenAICompatible({
name: 'browseros',
baseURL: baseUrl,
...(apiKey && { apiKey }),
fetch: browserosFetch,
})(model)
}
@@ -135,6 +148,26 @@ function createMoonshotModel(config: ResolvedLLMConfig): LanguageModel {
})(config.model)
}
function createQwenCodeModel(config: ResolvedLLMConfig): LanguageModel {
if (!config.apiKey) throw new Error('Qwen Code requires OAuth authentication')
return createOpenAICompatible({
name: 'qwen-code',
baseURL: EXTERNAL_URLS.QWEN_CODE_API,
apiKey: config.apiKey,
})(config.model)
}
function createGitHubCopilotModel(config: ResolvedLLMConfig): LanguageModel {
if (!config.apiKey)
throw new Error('GitHub Copilot requires OAuth authentication')
return createOpenAICompatible({
name: 'github-copilot',
baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API,
apiKey: config.apiKey,
fetch: createCopilotFetch() as typeof globalThis.fetch,
})(config.model)
}
function createChatGPTProModel(config: ResolvedLLMConfig): LanguageModel {
if (!config.apiKey)
throw new Error('ChatGPT Plus/Pro requires OAuth authentication')
@@ -157,6 +190,8 @@ const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
[LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleModel,
[LLM_PROVIDERS.MOONSHOT]: createMoonshotModel,
[LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProModel,
[LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotModel,
[LLM_PROVIDERS.QWEN_CODE]: createQwenCodeModel,
}
export function createLLMProvider(config: ResolvedLLMConfig): LanguageModel {

View File

@@ -11,5 +11,6 @@ import type { LLMConfig } from '@browseros/shared/schemas/llm'
export interface ResolvedLLMConfig extends LLMConfig {
model: string
upstreamProvider?: string
browserosId?: string
accountId?: string
}

View File

@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Custom fetch wrapper for GitHub Copilot API requests.
* Injects required Copilot headers and resizes images following
* VS Code's algorithm (max 2048px longest side, 768px shortest side).
*/
import { Jimp } from 'jimp'
import { logger } from '../../logger'
const MAX_LONG_SIDE = 2048
const MAX_SHORT_SIDE = 768
export function createCopilotFetch() {
return async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers as HeadersInit)
headers.set('Openai-Intent', 'conversation-edits')
headers.set('x-initiator', 'user')
let body = init?.body
if (body && typeof body === 'string') {
try {
const json = JSON.parse(body)
if (hasImageContent(json)) {
headers.set('Copilot-Vision-Request', 'true')
await shrinkImages(json)
body = JSON.stringify(json)
}
} catch {
// Not JSON or resize failed, send as-is
}
}
return fetch(input, { ...init, headers, body })
}
}
function hasImageContent(body: Record<string, unknown>): boolean {
if (!Array.isArray(body.messages)) return false
for (const msg of body.messages) {
if (!Array.isArray(msg?.content)) continue
for (const part of msg.content) {
if (part?.type === 'image_url') return true
}
}
return false
}
// Resize images following VS Code's algorithm for OpenAI vision token optimization
async function shrinkImages(body: Record<string, unknown>): Promise<void> {
if (!Array.isArray(body.messages)) return
for (const msg of body.messages) {
if (!Array.isArray(msg?.content)) continue
for (const part of msg.content) {
if (part?.type !== 'image_url' || !part.image_url) continue
const url = part.image_url.url as string
if (!url?.startsWith('data:')) continue
try {
const resized = await resizeDataUrl(url)
if (resized) part.image_url.url = resized
} catch (err) {
logger.warn('Failed to resize image for Copilot', {
error: err instanceof Error ? err.message : String(err),
})
}
}
}
}
async function resizeDataUrl(dataUrl: string): Promise<string | null> {
const commaIdx = dataUrl.indexOf(',')
if (commaIdx === -1) return null
const base64Data = dataUrl.substring(commaIdx + 1)
const buffer = Buffer.from(base64Data, 'base64')
const image = await Jimp.fromBuffer(buffer)
const origWidth = image.width
const origHeight = image.height
if (!origWidth || !origHeight) return null
let width = origWidth
let height = origHeight
// Skip if already within both limits (no resize step will fire)
if (
Math.max(width, height) <= MAX_LONG_SIDE &&
Math.min(width, height) <= MAX_SHORT_SIDE
) {
return null
}
// Step 1: scale longest side to 2048
if (width > MAX_LONG_SIDE || height > MAX_LONG_SIDE) {
const scale = MAX_LONG_SIDE / Math.max(width, height)
width = Math.round(width * scale)
height = Math.round(height * scale)
}
// Step 2: scale shortest side to 768
const shortSide = Math.min(width, height)
if (shortSide > MAX_SHORT_SIDE) {
const scale = MAX_SHORT_SIDE / shortSide
width = Math.round(width * scale)
height = Math.round(height * scale)
}
image.resize({ w: width, h: height })
// Jimp always outputs with alpha; use PNG for alpha sources, JPEG otherwise
const hasAlpha = image.hasAlpha()
const mime = hasAlpha ? 'image/png' : 'image/jpeg'
const resizedBuffer = hasAlpha
? await image.getBuffer('image/png')
: await image.getBuffer('image/jpeg', { quality: 75 })
const originalKB = Math.round(base64Data.length / 1024)
const resizedB64 = resizedBuffer.toString('base64')
const resizedKB = Math.round(resizedB64.length / 1024)
logger.debug('Resized image for Copilot', {
original: `${origWidth}x${origHeight} (${originalKB}KB)`,
resized: `${width}x${height} (${resizedKB}KB)`,
})
return `data:${mime};base64,${resizedB64}`
}

View File

@@ -15,6 +15,11 @@ export interface OAuthProviderConfig {
scopes: string[]
extraAuthParams?: Record<string, string>
upstreamLLMProvider: string
authFlow?: 'pkce' | 'device-code'
/** Device code flow uses form-urlencoded instead of JSON */
deviceCodeContentType?: 'json' | 'form'
/** Device code flow requires PKCE code_challenge/code_verifier */
deviceCodeRequiresPKCE?: boolean
}
export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
@@ -32,6 +37,28 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
upstreamLLMProvider: 'openai',
},
'github-copilot': {
id: 'github-copilot',
name: 'GitHub Copilot',
clientId: 'Ov23li8tweQw6odWQebz',
authEndpoint: EXTERNAL_URLS.GITHUB_DEVICE_CODE,
tokenEndpoint: EXTERNAL_URLS.GITHUB_OAUTH_TOKEN,
scopes: ['read:user'],
upstreamLLMProvider: 'github-copilot',
authFlow: 'device-code',
},
'qwen-code': {
id: 'qwen-code',
name: 'Qwen Code',
clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
authEndpoint: EXTERNAL_URLS.QWEN_DEVICE_CODE,
tokenEndpoint: EXTERNAL_URLS.QWEN_OAUTH_TOKEN,
scopes: ['openid', 'profile', 'email', 'model.completion'],
upstreamLLMProvider: 'qwen-code',
authFlow: 'device-code',
deviceCodeContentType: 'form',
deviceCodeRequiresPKCE: true,
},
}
export function getOAuthProvider(

View File

@@ -7,7 +7,7 @@
import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
import { logger } from '../../logger'
import { getOAuthProvider } from './providers'
import { getOAuthProvider, type OAuthProviderConfig } from './providers'
import type { OAuthTokenStore, StoredOAuthTokens } from './token-store'
interface PendingOAuthFlow {
@@ -25,6 +25,29 @@ interface OAuthTokenResponse {
id_token?: string
}
export interface DeviceCodeResult {
userCode: string
verificationUri: string
expiresIn: number
}
interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete?: string
expires_in: number
interval: number
}
interface DeviceCodeTokenPollResponse {
access_token?: string
refresh_token?: string
expires_in?: number
error?: string
interval?: number
}
export class OAuthTokenManager {
private readonly pendingFlows = new Map<string, PendingOAuthFlow>()
private readonly refreshLocks = new Map<
@@ -37,6 +60,8 @@ export class OAuthTokenManager {
private readonly browserosId: string,
) {}
// --- PKCE flow (ChatGPT Plus/Pro) ---
async generateAuthorizationUrl(
providerId: string,
redirectBackUrl?: string,
@@ -138,16 +163,192 @@ export class OAuthTokenManager {
return { tokens, redirectBackUrl: flow.redirectBackUrl }
}
// Mutex-protected refresh: concurrent callers share one in-flight refresh
// --- Device Code flow (GitHub Copilot) ---
private readonly activeDeviceFlows = new Set<string>()
async startDeviceCodeFlow(providerId: string): Promise<DeviceCodeResult> {
const provider = getOAuthProvider(providerId)
if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`)
// Cancel any existing flow — user may be retrying
this.activeDeviceFlows.delete(providerId)
// PKCE: generate verifier/challenge if provider requires it
let codeVerifier: string | undefined
const params: Record<string, string> = {
client_id: provider.clientId,
scope: provider.scopes.join(' '),
}
if (provider.deviceCodeRequiresPKCE) {
codeVerifier = generateCodeVerifier()
params.code_challenge = await generateCodeChallenge(codeVerifier)
params.code_challenge_method = 'S256'
}
// Build request body (form-urlencoded or JSON based on provider)
const useForm = provider.deviceCodeContentType === 'form'
const response = await fetch(provider.authEndpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': useForm
? 'application/x-www-form-urlencoded'
: 'application/json',
},
body: useForm
? new URLSearchParams(params).toString()
: JSON.stringify(params),
})
// Detect WAF/captcha responses (HTML instead of JSON)
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw new Error(
'Authentication service temporarily unavailable. Please try again in a few minutes.',
)
}
if (!response.ok) {
throw new Error(`Failed to request device code: ${response.status}`)
}
const data = (await response.json()) as DeviceCodeResponse
// Some providers return 200 with an error payload
const dataObj = data as unknown as Record<string, unknown>
if ('error' in dataObj) {
throw new Error(`Device code error: ${dataObj.error}`)
}
if (!data.device_code || !data.user_code) {
throw new Error('Invalid device code response')
}
// Start background polling with error handling
this.activeDeviceFlows.add(providerId)
this.pollDeviceCode(
providerId,
provider,
data.device_code,
data.interval,
data.expires_in,
codeVerifier,
).finally(() => this.activeDeviceFlows.delete(providerId))
return {
userCode: data.user_code,
verificationUri: data.verification_uri_complete ?? data.verification_uri,
expiresIn: data.expires_in,
}
}
private async pollDeviceCode(
providerId: string,
provider: OAuthProviderConfig,
deviceCode: string,
initialInterval: number,
expiresIn: number,
codeVerifier?: string,
): Promise<void> {
let interval = initialInterval
const deadline = Date.now() + expiresIn * 1000
const useForm = provider.deviceCodeContentType === 'form'
while (Date.now() < deadline) {
await sleep(interval * 1000 + TIMEOUTS.DEVICE_CODE_POLL_SAFETY_MARGIN)
try {
const params: Record<string, string> = {
client_id: provider.clientId,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}
if (codeVerifier) params.code_verifier = codeVerifier
const response = await fetch(provider.tokenEndpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': useForm
? 'application/x-www-form-urlencoded'
: 'application/json',
},
body: useForm
? new URLSearchParams(params).toString()
: JSON.stringify(params),
})
// WAF returned HTML instead of JSON — retry later
const ct = response.headers.get('content-type') ?? ''
if (!ct.includes('application/json')) {
logger.warn('WAF blocked poll request, retrying', {
provider: providerId,
})
continue
}
const data = (await response.json()) as DeviceCodeTokenPollResponse
// Token received — store and return
if (data.access_token) {
const tokens: StoredOAuthTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? '',
expiresAt: data.expires_in
? Date.now() + data.expires_in * 1000
: 0,
email: undefined,
accountId: undefined,
}
this.store.upsertTokens(this.browserosId, providerId, tokens)
logger.info('Device code OAuth successful', { provider: providerId })
return
}
// Handle polling errors per RFC 8628
if (data.error === 'authorization_pending') continue
if (data.error === 'slow_down') {
interval = (data.interval ?? interval) + 5
continue
}
if (data.error === 'expired_token' || data.error === 'access_denied') {
logger.warn('Device code flow ended', {
provider: providerId,
error: data.error,
})
return
}
logger.warn('Unexpected device code poll response', {
provider: providerId,
error: data.error,
})
return
} catch (err) {
// Transient network error — loop continues to retry
logger.warn('Device code poll request failed, retrying', {
provider: providerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
logger.warn('Device code flow timed out', { provider: providerId })
}
// --- Token refresh ---
async refreshIfExpired(provider: string): Promise<StoredOAuthTokens | null> {
const tokens = this.store.getTokens(this.browserosId, provider)
if (!tokens) return null
// GitHub Copilot tokens never expire (expiresAt = 0)
if (tokens.expiresAt === 0) return tokens
if (Date.now() < tokens.expiresAt - TIMEOUTS.OAUTH_TOKEN_EXPIRY_BUFFER) {
return tokens
}
// If a refresh is already in progress, await it instead of starting another
const existing = this.refreshLocks.get(provider)
if (existing) return existing
@@ -214,6 +415,27 @@ export class OAuthTokenManager {
return refreshed
}
// --- Shared ---
// Store tokens provided by the extension (client-side auth flow)
storeTokens(
provider: string,
params: { accessToken: string; refreshToken: string; expiresIn: number },
): void {
const tokens: StoredOAuthTokens = {
accessToken: params.accessToken,
refreshToken: params.refreshToken,
expiresAt: params.expiresIn ? Date.now() + params.expiresIn * 1000 : 0,
email: undefined,
accountId: undefined,
}
this.store.upsertTokens(this.browserosId, provider, tokens)
}
getTokens(provider: string): StoredOAuthTokens | null {
return this.store.getTokens(this.browserosId, provider)
}
getStatus(provider: string) {
return this.store.getStatus(this.browserosId, provider)
}
@@ -257,6 +479,10 @@ function base64UrlEncode(bytes: Uint8Array): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// Extracts claims without signature verification — safe because the token
// comes directly from OpenAI's HTTPS token endpoint. Do not reuse for
// caller-supplied or externally-sourced tokens.

View File

@@ -18,7 +18,12 @@ import { ControllerBackend } from './browser/backends/controller'
import { Browser } from './browser/browser'
import type { ServerConfig } from './config'
import { INLINED_ENV } from './env'
import { cleanOldSessions, ensureBrowserosDir } from './lib/browseros-dir'
import {
cleanOldSessions,
ensureBrowserosDir,
removeServerConfigSync,
writeServerConfig,
} from './lib/browseros-dir'
import { initializeDb } from './lib/db'
import { identity } from './lib/identity'
import { logger } from './lib/logger'
@@ -109,6 +114,20 @@ export class Application {
this.handleStartupError('HTTP server', this.config.serverPort, error)
}
try {
await writeServerConfig({
server_port: this.config.serverPort,
url: `http://127.0.0.1:${this.config.serverPort}`,
server_version: VERSION,
browseros_version: this.config.instanceBrowserosVersion,
chromium_version: this.config.instanceChromiumVersion,
})
} catch (error) {
logger.warn('Failed to write server config for auto-discovery', {
error: error instanceof Error ? error.message : String(error),
})
}
logger.info(
`HTTP server listening on http://127.0.0.1:${this.config.serverPort}`,
)
@@ -125,6 +144,7 @@ export class Application {
stop(reason?: string): void {
logger.info('Shutting down server...', { reason })
stopSkillSync()
removeServerConfigSync()
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,
// and we need to free the port instantly so the HTTP port doesn't keep switching.

View File

@@ -121,14 +121,17 @@ export const navigate_page = defineTool({
export const new_page = defineTool({
name: 'new_page',
description: 'Open a new page (tab) and navigate to a URL',
description:
'Open a new page (tab) and navigate to a URL. Opens in background by default to keep the user on their current page. Use group_tabs to organize related tabs.',
input: z.object({
url: z.string().describe('URL to open'),
hidden: z.boolean().default(false).describe('Create as hidden tab'),
background: z
.boolean()
.default(false)
.describe('Open in background without activating'),
.default(true)
.describe(
'Open in background without stealing focus. Set to false only when user needs to see the tab immediately.',
),
windowId: z.number().optional().describe('Window ID to create tab in'),
}),
output: z.object({
@@ -140,8 +143,8 @@ export const new_page = defineTool({
}),
handler: async (args, ctx, response) => {
const pageId = await ctx.browser.newPage(args.url, {
hidden: args.hidden || undefined,
background: args.background || undefined,
hidden: args.hidden ? true : undefined,
background: args.background === false ? false : true,
windowId: args.windowId,
})
response.text(`Opened new page: ${args.url}\nPage ID: ${pageId}`)

View File

@@ -101,7 +101,13 @@ export const get_page_content = defineTool({
extension: 'md',
content: text,
})
response.text(`Saved page content to ${path}`)
// Return truncated content inline so the agent can work immediately,
// plus the file path for optional deep reading
const truncated = text.slice(0, TOOL_LIMITS.INLINE_PAGE_CONTENT_MAX_CHARS)
response.text(truncated)
response.text(
`\n\n[Content truncated at ${TOOL_LIMITS.INLINE_PAGE_CONTENT_MAX_CHARS} chars. Full content (${text.length} chars) saved to: ${path}]`,
)
response.data({
path,
contentLength: text.length,

View File

@@ -16,6 +16,7 @@ export interface BrowserConfig {
binaryPath: string
userDataDir: string
headless: boolean
extraArgs: string[]
}
interface BrowserState {
@@ -26,6 +27,12 @@ interface BrowserState {
let browserState: BrowserState | null = null
function shouldLogBrowserOutput(): boolean {
return (
process.env.CI === 'true' || process.env.BROWSEROS_TEST_DEBUG === 'true'
)
}
export async function isBrowserRunning(cdpPort: number): Promise<boolean> {
try {
const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
@@ -74,6 +81,7 @@ export async function spawnBrowser(
'--show-component-extension-options',
'--enable-logging=stderr',
...(config.headless ? ['--headless=new'] : []),
...config.extraArgs,
`--user-data-dir=${config.userDataDir}`,
// TODO: replace with --browseros-cdp-port once we fix the browseros bug
`--remote-debugging-port=${config.cdpPort}`,
@@ -86,14 +94,18 @@ export async function spawnBrowser(
},
)
browserProcess.stdout?.on('data', (_data) => {
// Uncomment for debugging
// console.log(`[BROWSER] ${_data.toString().trim()}`)
browserProcess.stdout?.on('data', (data) => {
if (!shouldLogBrowserOutput()) {
return
}
console.log(`[BROWSER] ${data.toString().trim()}`)
})
browserProcess.stderr?.on('data', (_data) => {
// Uncomment for debugging
// console.log(`[BROWSER] ${_data.toString().trim()}`)
browserProcess.stderr?.on('data', (data) => {
if (!shouldLogBrowserOutput()) {
return
}
console.error(`[BROWSER] ${data.toString().trim()}`)
})
browserProcess.on('error', (error) => {

View File

@@ -132,6 +132,7 @@ export async function ensureBrowserOS(
binaryPath: runtimePlan.binaryPath,
userDataDir: runtimePlan.userDataDir,
headless: runtimePlan.headless,
extraArgs: runtimePlan.extraArgs,
}
await spawnBrowser(browserConfig)

View File

@@ -20,9 +20,20 @@ export interface TestRuntimePlan {
userDataDir: string
binaryPath: string
headless: boolean
extraArgs: string[]
usesFixedPorts: boolean
}
function parseExtraArgs(value: string | undefined): string[] {
if (!value) {
return []
}
return value
.split(/\s+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
}
function parsePort(
value: string | undefined,
envName: string,
@@ -137,12 +148,14 @@ export async function createTestRuntimePlan(): Promise<TestRuntimePlan> {
const resolvedPorts = await resolveRuntimePorts()
const userDataDir = mkdtempSync(join(tmpdir(), 'browseros-test-'))
const headless = process.env.BROWSEROS_TEST_HEADLESS === 'true'
const extraArgs = parseExtraArgs(process.env.BROWSEROS_TEST_EXTRA_ARGS)
return {
ports: resolvedPorts.ports,
userDataDir,
binaryPath: DEFAULT_BINARY_PATH,
headless,
extraArgs,
usesFixedPorts: resolvedPorts.usesFixedPorts,
}
}

View File

@@ -46,6 +46,7 @@ async function getOrCreateBrowser(): Promise<Browser> {
binaryPath: runtimePlan.binaryPath,
userDataDir: runtimePlan.userDataDir,
headless: runtimePlan.headless,
extraArgs: runtimePlan.extraArgs,
}
await spawnBrowser(config)

View File

@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, it } from 'bun:test'
import assert from 'node:assert'
import { Jimp } from 'jimp'
import { createCopilotFetch } from '../../src/lib/clients/oauth/copilot-fetch'
function makeImageBody(dataUrls: string[]) {
return JSON.stringify({
messages: [
{
role: 'user',
content: dataUrls.map((url) => ({
type: 'image_url',
image_url: { url },
})),
},
],
})
}
async function createTestImage(
width: number,
height: number,
hasAlpha = false,
): Promise<string> {
const image = new Jimp({ width, height, color: 0xff0000ff })
if (hasAlpha) {
// Set some pixels to transparent so hasAlpha() returns true
for (let x = 0; x < Math.min(width, 10); x++) {
image.setPixelColor(0xff000080, x, 0)
}
}
const mime = hasAlpha ? 'image/png' : 'image/jpeg'
const buffer = await image.getBuffer(mime)
return `data:${mime};base64,${buffer.toString('base64')}`
}
function parseDataUrl(dataUrl: string) {
const [header, b64] = dataUrl.split(',')
const mime = header.match(/data:([^;]+)/)?.[1]
return { mime, buffer: Buffer.from(b64, 'base64') }
}
describe('createCopilotFetch', () => {
it('sets Copilot headers on every request', async () => {
const copilotFetch = createCopilotFetch()
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
const originalFetch = globalThis.fetch
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
calls.push({ input, init })
return new Response('ok')
}) as typeof fetch
try {
await copilotFetch('https://api.example.com', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }),
})
assert.strictEqual(calls.length, 1)
const headers = new Headers(calls[0].init?.headers as HeadersInit)
assert.strictEqual(headers.get('Openai-Intent'), 'conversation-edits')
assert.strictEqual(headers.get('x-initiator'), 'user')
} finally {
globalThis.fetch = originalFetch
}
})
it('sets Copilot-Vision-Request header when images present', async () => {
const copilotFetch = createCopilotFetch()
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
const originalFetch = globalThis.fetch
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
calls.push({ input, init })
return new Response('ok')
}) as typeof fetch
try {
const dataUrl = await createTestImage(100, 100)
await copilotFetch('https://api.example.com', {
body: makeImageBody([dataUrl]),
})
const headers = new Headers(calls[0].init?.headers as HeadersInit)
assert.strictEqual(headers.get('Copilot-Vision-Request'), 'true')
} finally {
globalThis.fetch = originalFetch
}
})
})
describe('image resizing', () => {
async function resizeViaFetch(dataUrl: string): Promise<string> {
let capturedBody = ''
const originalFetch = globalThis.fetch
globalThis.fetch = (async (_: RequestInfo | URL, init?: RequestInit) => {
capturedBody = init?.body as string
return new Response('ok')
}) as typeof fetch
try {
const copilotFetch = createCopilotFetch()
await copilotFetch('https://api.example.com', {
body: makeImageBody([dataUrl]),
})
const parsed = JSON.parse(capturedBody)
return parsed.messages[0].content[0].image_url.url
} finally {
globalThis.fetch = originalFetch
}
}
it('does not resize images already within limits', async () => {
const dataUrl = await createTestImage(800, 600)
const result = await resizeViaFetch(dataUrl)
assert.strictEqual(result, dataUrl)
})
it('scales down when longest side exceeds 2048', async () => {
const dataUrl = await createTestImage(4096, 2048)
const result = await resizeViaFetch(dataUrl)
assert.notStrictEqual(result, dataUrl)
const { buffer } = parseDataUrl(result)
const resized = await Jimp.fromBuffer(buffer)
assert.ok(resized.width <= 2048)
assert.ok(resized.height <= 1024)
})
it('scales down when shortest side exceeds 768', async () => {
const dataUrl = await createTestImage(2000, 1500)
const result = await resizeViaFetch(dataUrl)
assert.notStrictEqual(result, dataUrl)
const { buffer } = parseDataUrl(result)
const resized = await Jimp.fromBuffer(buffer)
assert.ok(Math.min(resized.width, resized.height) <= 768)
})
it('outputs JPEG for opaque images', async () => {
const dataUrl = await createTestImage(4096, 3072, false)
const result = await resizeViaFetch(dataUrl)
const { mime } = parseDataUrl(result)
assert.strictEqual(mime, 'image/jpeg')
})
it('outputs PNG for images with alpha', async () => {
const dataUrl = await createTestImage(4096, 3072, true)
const result = await resizeViaFetch(dataUrl)
const { mime } = parseDataUrl(result)
assert.strictEqual(mime, 'image/png')
})
it('applies both scaling steps (long side then short side)', async () => {
// 4000x3000: step 1 scales to 2048x1536, step 2 scales 1536→768 so 1024x768
const dataUrl = await createTestImage(4000, 3000)
const result = await resizeViaFetch(dataUrl)
const { buffer } = parseDataUrl(result)
const resized = await Jimp.fromBuffer(buffer)
assert.ok(resized.width <= 2048)
assert.ok(Math.min(resized.width, resized.height) <= 768)
})
})

View File

@@ -1032,9 +1032,56 @@ describe('execution section', () => {
expect(prompt).toContain('500')
})
it('includes new-tab restriction', () => {
it('includes multi-tab workflow guidance', () => {
// Why: The agent must know how to handle multi-tab tasks — open background
// tabs, create tab groups, narrate progress, and never steal user focus.
const prompt = buildRegular()
expect(prompt).toContain('Only open new tabs when the user explicitly asks')
expect(prompt).toContain('Multi-tab workflow')
expect(prompt).toContain('background')
expect(prompt).toContain('group_tabs')
expect(prompt).toContain('Never force-switch')
})
it('enforces mandatory tab group creation', () => {
// Why: Run 7 showed the agent opening background tabs without creating
// a tab group. The prompt must make tab groups mandatory, not optional.
const prompt = buildRegular()
expect(prompt).toContain('IMMEDIATELY create a tab group')
expect(prompt).toContain('MUST have a tab group')
})
it('prohibits navigating user current tab during multi-tab', () => {
// Why: Run 7 showed the agent clicking a link on the user's current tab,
// navigating away from their starting page. The current tab must be read-only.
const prompt = buildRegular()
expect(prompt).toContain('Never navigate the user')
expect(prompt).toContain('anchor')
})
it('prohibits hidden windows for user tasks', () => {
// Why: Run 2 used create_hidden_window instead of background tabs.
// Hidden windows are invisible to users and can't be screenshotted.
const prompt = buildRegular()
expect(prompt).toContain('Do NOT use')
expect(prompt).toContain('create_hidden_window')
expect(prompt).toContain('new_hidden_page')
})
it('includes tab retry discipline', () => {
// Why: Run 7 opened 7+ tabs for a 3-article task because retries
// created new tabs instead of navigating existing ones.
const prompt = buildRegular()
expect(prompt).toContain('Tab retry discipline')
expect(prompt).toContain('Navigate the existing tab')
expect(prompt).toContain('close_page')
})
it('includes retry budget for failing sites', () => {
// Why: Run 8 spent 15+ tool calls fighting Kayak's geo-detection.
// The agent should give up after 3-4 attempts and report partial results.
const prompt = buildRegular()
expect(prompt).toContain('Retry budget')
expect(prompt).toContain('3-4 attempts')
})
})

View File

@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2025 BrowserOS
*
* Build smoke test — compiles the server binary and verifies --version output.
* Catches compile failures, broken imports, and version injection bugs.
*/
import { afterAll, describe, it } from 'bun:test'
import assert from 'node:assert'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
// Derive the build target from the current platform so the test is portable
function getNativeTarget(): { id: string; ext: string } {
const os =
process.platform === 'darwin'
? 'darwin'
: process.platform === 'win32'
? 'windows'
: 'linux'
const cpu = process.arch === 'arm64' ? 'arm64' : 'x64'
return { id: `${os}-${cpu}`, ext: process.platform === 'win32' ? '.exe' : '' }
}
// Stub values so the build config validation passes without real secrets
const BUILD_ENV_STUBS: Record<string, string> = {
BROWSEROS_CONFIG_URL: 'https://stub.test/config',
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
POSTHOG_API_KEY: 'phc_test_stub',
SENTRY_DSN: 'https://stub@sentry.test/0',
R2_ACCOUNT_ID: 'test',
R2_ACCESS_KEY_ID: 'test',
R2_SECRET_ACCESS_KEY: 'test',
R2_BUCKET: 'test',
}
describe('server build', () => {
const rootDir = resolve(import.meta.dir, '../../..')
const serverPkgPath = resolve(rootDir, 'apps/server/package.json')
const buildScript = resolve(rootDir, 'scripts/build/server.ts')
const target = getNativeTarget()
const binaryPath = resolve(
rootDir,
`dist/prod/server/.tmp/binaries/browseros-server-${target.id}${target.ext}`,
)
// Empty manifest so the build skips R2 resource downloads
const tempDir = mkdtempSync(join(tmpdir(), 'browseros-build-test-'))
const emptyManifestPath = join(tempDir, 'empty-manifest.json')
writeFileSync(emptyManifestPath, JSON.stringify({ resources: [] }))
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true })
})
it('compiles and --version outputs correct version', async () => {
const pkg = await Bun.file(serverPkgPath).json()
const expectedVersion: string = pkg.version
const build = Bun.spawn(
[
'bun',
buildScript,
`--target=${target.id}`,
'--no-upload',
`--manifest=${emptyManifestPath}`,
],
{
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...BUILD_ENV_STUBS },
},
)
const buildExit = await build.exited
if (buildExit !== 0) {
const stderr = await new Response(build.stderr).text()
assert.fail(`Build failed (exit ${buildExit}):\n${stderr}`)
}
const proc = Bun.spawn([binaryPath, '--version'], {
stdout: 'pipe',
stderr: 'pipe',
})
const [versionOutput, versionStderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
const versionExit = await proc.exited
assert.strictEqual(
versionExit,
0,
`Binary --version exited non-zero:\n${versionStderr}`,
)
assert.strictEqual(versionOutput.trim(), expectedVersion)
}, 300_000)
})

View File

@@ -55,7 +55,7 @@ describe('filesystem_bash', () => {
})
it('times out long-running commands', async () => {
const result = await exec({ command: 'sleep 30', timeout: 1 })
const result = await exec({ command: 'exec sleep 30', timeout: 1 })
expect(result.isError).toBe(true)
expect(result.text).toContain('timed out')
}, 10_000)

View File

@@ -1,5 +1,7 @@
import { describe, it } from 'bun:test'
import assert from 'node:assert'
import type { Browser } from '../../src/browser/browser'
import { executeTool, type ToolContext } from '../../src/tools/framework'
import {
check,
click,
@@ -320,37 +322,48 @@ describe('input tools', () => {
}, 60_000)
it('scroll dispatches without error', async () => {
await withBrowser(async ({ execute }) => {
const newResult = await execute(new_page, {
url: FORM_PAGE,
})
const pageId = pageIdOf(newResult)
const calls: Array<{
page: number
direction: string
amount: number
element?: number
}> = []
const browser = {
getTabIdForPage: () => undefined,
scroll: async (
page: number,
direction: string,
amount: number,
element?: number,
) => {
calls.push({ page, direction, amount, element })
},
} as unknown as Browser
const ctx: ToolContext = {
browser,
directories: { workingDir: process.cwd() },
}
const before = await execute(evaluate_script, {
page: pageId,
expression: 'window.scrollY',
})
const result = await executeTool(
scroll,
{ page: 7, direction: 'down', amount: 5 },
ctx,
AbortSignal.timeout(1_000),
)
const scrollResult = await execute(scroll, {
page: pageId,
direction: 'down',
amount: 5,
})
assert.ok(!scrollResult.isError, textOf(scrollResult))
assert.ok(textOf(scrollResult).includes('Scrolled down'))
const after = await execute(evaluate_script, {
page: pageId,
expression: 'window.scrollY',
})
assert.ok(
Number(textOf(after)) > Number(textOf(before)),
`Expected scrollY to increase, before=${textOf(before)} after=${textOf(after)}`,
)
await execute(close_page, { page: pageId })
assert.ok(!result.isError, textOf(result))
assert.ok(textOf(result).includes('Scrolled down'))
assert.deepStrictEqual(calls, [
{ page: 7, direction: 'down', amount: 5, element: undefined },
])
assert.deepStrictEqual(structuredOf(result), {
action: 'scroll',
page: 7,
direction: 'down',
amount: 5,
element: undefined,
})
}, 60_000)
})
it('hover moves cursor over element', async () => {
await withBrowser(async ({ execute }) => {

View File

@@ -203,7 +203,8 @@ describe('observation tools', () => {
savedPath = data.path
assert.strictEqual(data.writtenToFile, true)
assert.ok(textOf(contentResult).includes('Saved page content'))
assert.ok(textOf(contentResult).includes('Content truncated'))
assert.ok(textOf(contentResult).includes(savedPath))
assert.ok(existsSync(savedPath), 'Saved page content file should exist')
assert.ok(
dirname(savedPath).startsWith(

View File

@@ -30,13 +30,13 @@ function structuredOf<T>(result: { structuredContent?: unknown }): T {
function createToolContext(
browser: Browser,
executionDir: string,
workingDir: string,
resourcesDir?: string,
): ToolContext {
return {
browser,
directories: {
executionDir,
workingDir,
resourcesDir,
},
}
@@ -50,10 +50,8 @@ function createBrowserStub(methods: Record<string, unknown>): Browser {
}
describe('page action tools', () => {
it('save_pdf resolves relative paths against the execution directory by default', async () => {
const executionDir = await mkdtemp(
join(tmpdir(), 'browseros-page-actions-'),
)
it('save_pdf resolves relative paths against the working directory by default', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
const browser = createBrowserStub({
printToPDF: async () => ({
data: Buffer.from('pdf-data').toString('base64'),
@@ -64,26 +62,24 @@ describe('page action tools', () => {
const result = await executeTool(
save_pdf,
{ page: 1, path: 'report.pdf' },
createToolContext(browser, executionDir),
createToolContext(browser, workingDir),
AbortSignal.timeout(1_000),
)
assert.ok(!result.isError, textOf(result))
const outputPath = join(executionDir, 'report.pdf')
const outputPath = join(workingDir, 'report.pdf')
assert.strictEqual(
structuredOf<{ path: string }>(result).path,
outputPath,
)
assert.ok(existsSync(outputPath), 'PDF file should exist in executionDir')
assert.ok(existsSync(outputPath), 'PDF file should exist in workingDir')
} finally {
await rm(executionDir, { recursive: true, force: true })
await rm(workingDir, { recursive: true, force: true })
}
})
it('save_screenshot still honors an explicit cwd override', async () => {
const executionDir = await mkdtemp(
join(tmpdir(), 'browseros-page-actions-'),
)
const workingDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
const overrideDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
const browser = createBrowserStub({
screenshot: async () => ({
@@ -95,7 +91,7 @@ describe('page action tools', () => {
const result = await executeTool(
save_screenshot,
{ page: 1, path: 'capture.png', cwd: overrideDir },
createToolContext(browser, executionDir),
createToolContext(browser, workingDir),
AbortSignal.timeout(1_000),
)
@@ -110,18 +106,18 @@ describe('page action tools', () => {
'Screenshot should exist in overrideDir',
)
assert.ok(
!existsSync(join(executionDir, 'capture.png')),
'Execution directory should not be used when cwd is provided',
!existsSync(join(workingDir, 'capture.png')),
'Working directory should not be used when cwd is provided',
)
} finally {
await rm(executionDir, { recursive: true, force: true })
await rm(workingDir, { recursive: true, force: true })
await rm(overrideDir, { recursive: true, force: true })
}
})
it('download_file resolves relative directories against the execution directory by default', async () => {
it('download_file resolves relative directories against the working directory by default', async () => {
const baseDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
const executionDir = join(baseDir, 'execution')
const workingDir = join(baseDir, 'working')
let stagingDir: string | undefined
const browser = createBrowserStub({
downloadViaClick: async (
@@ -143,23 +139,23 @@ describe('page action tools', () => {
const result = await executeTool(
download_file,
{ page: 1, element: 7, path: '.' },
createToolContext(browser, executionDir),
createToolContext(browser, workingDir),
AbortSignal.timeout(1_000),
)
assert.ok(!result.isError, textOf(result))
const outputPath = join(executionDir, 'download.txt')
const outputPath = join(workingDir, 'download.txt')
const structured = structuredOf<{
directory: string
destinationPath: string
}>(result)
assert.strictEqual(structured.directory, executionDir)
assert.strictEqual(structured.directory, workingDir)
assert.strictEqual(structured.destinationPath, outputPath)
assert.ok(existsSync(outputPath), 'Download should land in executionDir')
assert.ok(existsSync(outputPath), 'Download should land in workingDir')
assert.ok(stagingDir, 'Download should use a staging directory')
assert.ok(
stagingDir.startsWith(join(executionDir, 'browseros-dl-')),
'Staging directory should be created inside executionDir',
stagingDir.startsWith(join(workingDir, 'browseros-dl-')),
'Staging directory should be created inside workingDir',
)
assert.ok(
!existsSync(stagingDir),

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",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -6,7 +6,7 @@
"name": "browseros-monorepo",
"devDependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"@biomejs/biome": "2.4.5",
"@biomejs/biome": "2.4.8",
"@sentry/cli": "^2.42.2",
"@types/bun": "^1.3.5",
"@types/node": "^24.3.3",
@@ -169,7 +169,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.76",
"version": "0.0.79",
"bin": {
"browseros-server": "./src/index.ts",
},
@@ -203,6 +203,7 @@
"fuse.js": "^7.1.0",
"gray-matter": "^4.0.3",
"hono": "^4.12.3",
"jimp": "^1.6.0",
"klavis": "^2.15.0",
"pino": "^9.6.0",
"posthog-node": "^4.17.0",
@@ -450,23 +451,23 @@
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="],
"@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="],
@@ -894,6 +895,62 @@
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
"@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="],
"@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="],
"@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="],
"@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="],
"@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="],
"@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="],
"@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="],
"@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="],
"@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="],
"@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="],
"@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="],
"@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="],
"@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="],
"@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="],
"@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="],
"@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="],
"@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="],
"@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="],
"@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="],
"@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="],
"@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="],
"@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="],
"@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="],
"@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="],
"@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="],
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
"@joshua.litt/get-ripgrep": ["@joshua.litt/get-ripgrep@0.0.3", "", { "dependencies": { "@lvce-editor/verror": "^1.6.0", "execa": "^9.5.2", "extract-zip": "^2.0.1", "fs-extra": "^11.3.0", "got": "^14.4.5", "path-exists": "^5.0.0", "xdg-basedir": "^5.1.0" } }, "sha512-rycdieAKKqXi2bsM7G2ayDiNk5CAX8ZOzsTQsirfOqUKPef04Xw40BWGGyimaOOuvPgLWYt3tPnLLG3TvPXi5Q=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -1734,6 +1791,8 @@
"@theguild/federation-composition": ["@theguild/federation-composition@0.21.3", "", { "dependencies": { "constant-case": "^3.0.4", "debug": "4.4.3", "json5": "^2.2.3", "lodash.sortby": "^4.7.0" }, "peerDependencies": { "graphql": "^16.0.0" } }, "sha512-+LlHTa4UbRpZBog3ggAxjYIFvdfH3UMvvBUptur19TMWkqU4+n3GmN+mDjejU+dyBXIG27c25RsiQP1HyvM99g=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
"@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
@@ -2052,6 +2111,8 @@
"antd-style": ["antd-style@3.7.1", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.1", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=5.8.1", "react": ">=18" } }, "sha512-CQOfddVp4aOvBfCepa+Kj2e7ap+2XBINg1Kn2osdE3oQvrD7KJu/K0sfnLcFLkgCJygbxmuazYdWLKb+drPDYA=="],
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -2086,6 +2147,8 @@
"auto-bind": ["auto-bind@4.0.0", "", {}, "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ=="],
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
@@ -2126,6 +2189,8 @@
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -2214,7 +2279,7 @@
"chrome-devtools-frontend": ["chrome-devtools-frontend@1.0.1577886", "", {}, "sha512-B9hY3o/0RuVCDWNYh9YnkEbRrPUMCY+NaOgBxvZRzGvqbGSMNckkVSdO67SwWR8bm4fo/qplXbUj0cSr229V6w=="],
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.20.2", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-QYwRj8YJjvFHODiYVDJpHaUYLD8/wt0DP+HXQPn/IF+QbbAfr7Vn2JtACyrIPEzTX3XJXgDPZkr4gSYHRDgqvQ=="],
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.20.3", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-6MlNKlKa+J1FX9w4SUnFERF4MRGWLlrnZvIJGhhsuuMPM7qUG0F4SwheRyjwl0+tsTemxMCBHiib8mXkg5j6og=="],
"chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="],
@@ -2618,6 +2683,8 @@
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
@@ -2676,6 +2743,8 @@
"file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"filesize": ["filesize@11.0.13", "", {}, "sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -2754,6 +2823,8 @@
"get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="],
@@ -2892,6 +2963,8 @@
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
@@ -3016,12 +3089,16 @@
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
@@ -3466,6 +3543,8 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"on-change": ["on-change@4.0.2", "", {}, "sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
@@ -3520,6 +3599,12 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="],
"parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="],
"parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-filepath": ["parse-filepath@1.0.2", "", { "dependencies": { "is-absolute": "^1.0.0", "map-cache": "^0.2.0", "path-root": "^0.1.1" } }, "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q=="],
@@ -3560,6 +3645,8 @@
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
@@ -3582,12 +3669,16 @@
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
@@ -3808,6 +3899,8 @@
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
@@ -3996,6 +4089,8 @@
"simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="],
"simple-xml-to-json": ["simple-xml-to-json@1.2.4", "", {}, "sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg=="],
"sinon": ["sinon@21.0.1", "", { "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^15.1.0", "@sinonjs/samsam": "^8.0.3", "diff": "^8.0.2", "supports-color": "^7.2.0" } }, "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
@@ -4092,6 +4187,8 @@
"strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
"stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="],
"stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
@@ -4148,6 +4245,8 @@
"timeout-signal": ["timeout-signal@2.0.0", "", {}, "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA=="],
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -4164,6 +4263,8 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
"tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
@@ -4282,6 +4383,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
@@ -4376,6 +4479,8 @@
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@@ -4566,6 +4671,8 @@
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@lexical/react/react-error-boundary": ["react-error-boundary@3.1.4", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA=="],
"@lobehub/fluent-emoji/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -4984,6 +5091,8 @@
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
@@ -5048,12 +5157,16 @@
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],

View File

@@ -0,0 +1,58 @@
# Credits Tracking UI Design
## Overview
Surface credit balance to users across two locations: a compact badge in the side panel chat header, and a dedicated Usage & Billing settings page. Credits refresh after each completed message turn or on error.
## 1. Side Panel — Credit Badge
**Location:** Chat header, next to provider selector. Only visible when provider is `browseros`.
**Display:**
- Coin/credit icon + remaining count (e.g., "87")
- Color-coded by threshold:
- Green: >30 credits
- Yellow/orange: 130 credits
- Red: 0 credits
- Clicking the badge navigates to the Usage & Billing settings page
**Update triggers:**
- Message turn completes successfully (agent finishes all tool calls and responds)
- CREDITS_EXHAUSTED error mid-turn (badge syncs to 0, error shown in chat)
## 2. Settings — Usage & Billing Page
**Sidebar entry:** "Usage & Billing" in the "Other" section (icon: CreditCard or Coins).
**Route:** `/settings/usage`
**Content:**
- Credits card: large display of remaining credits (e.g., "87 / 100") with color-coded progress bar
- Reset info: "Resets daily at midnight UTC" with last reset date
- Credit cost: "1 credit per request"
- Placeholder section: "Need more credits?" with disabled "Add Credits" button (future payment/recharge)
## 3. Data Flow
**Hook:** `useCredits()` — React Query hook fetching `GET /credits` from the agent server.
**Refresh strategy:**
- Refetch after each completed message turn (`onFinish` callback in chat session)
- Refetch on CREDITS_EXHAUSTED error
- Refetch on window focus (React Query default)
- No aggressive polling
**State sharing:** Credits query is global (React Query cache). Both side panel badge and settings page read from the same cache key.
## 4. Error Handling (0 credits)
When credits are exhausted mid-conversation:
- Chat stream shows error via existing `ChatError.tsx` pattern: "Daily credits exhausted. Resets at midnight UTC." with link to Usage & Billing page
- Header badge turns red (0 credits)
- Chat input stays enabled — user can switch to a different provider
## 5. Future Hooks
- "Add Credits" button on Usage & Billing page (currently disabled placeholder)
- Payment integration will live entirely within the Usage & Billing page
- Credit badge could show a "+" icon when balance is low, linking to recharge

View File

@@ -0,0 +1,384 @@
# Credits Tracking UI Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Show credit balance in the side panel chat header and a dedicated Usage & Billing settings page, with live updates after each message turn.
**Architecture:** A `useCredits()` React Query hook fetches `GET /credits` from the agent server. The side panel header shows a color-coded badge (green >30, yellow 1-30, red 0). A new settings page at `/settings/usage` shows full details. Credits refresh after each completed chat turn or on CREDITS_EXHAUSTED error.
**Tech Stack:** React, React Query, Shadcn UI, Lucide icons, Hono (server already done)
---
### Task 1: Create useCredits() hook
**Files:**
- Create: `apps/agent/lib/credits/useCredits.ts`
**Step 1: Write the hook**
```typescript
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
interface CreditsInfo {
credits: 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 })
}
```
**Step 2: Commit**
```bash
git add apps/agent/lib/credits/useCredits.ts
git commit -m "feat: add useCredits React Query hook"
```
---
### Task 2: Create CreditBadge component
**Files:**
- Create: `apps/agent/components/credits/CreditBadge.tsx`
**Step 1: Write the component**
The badge shows a coin icon + credit count, color-coded by threshold. Only renders when credits data is available.
```tsx
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { cn } from '@/lib/utils'
interface CreditBadgeProps {
credits: number
onClick?: () => void
}
function getCreditColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= 30) return 'text-yellow-500'
return 'text-green-500'
}
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium transition-colors hover:bg-muted/50',
getCreditColor(credits),
)}
title={`${credits} credits remaining`}
>
<Coins className="h-3.5 w-3.5" />
<span>{credits}</span>
</button>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/components/credits/CreditBadge.tsx
git commit -m "feat: add CreditBadge component with color thresholds"
```
---
### Task 3: Add CreditBadge to ChatHeader
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx`
**Step 1: Update ChatHeader**
Add the credit badge after the provider selector, only when provider is `browseros`. The badge links to the Usage & Billing settings page.
Changes to `ChatHeader.tsx`:
1. Import `CreditBadge` and `useCredits`
2. After the `ChatProviderSelector` closing tag (line 61), add the badge conditionally
```tsx
// Add imports at top:
import { CreditBadge } from '@/components/credits/CreditBadge'
import { useCredits } from '@/lib/credits/useCredits'
// After line 61 (closing </ChatProviderSelector>), before closing </div>:
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
```
Create a small wrapper component inside the file to keep the hook call conditional:
```tsx
const CreditsBadgeWrapper: FC = () => {
const { data } = useCredits()
if (data === undefined) return null
return (
<CreditBadge
credits={data.credits}
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
/>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx
git commit -m "feat: show credit badge in chat header for BrowserOS provider"
```
---
### Task 4: Add credit refresh on message completion
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/useChatSession.ts`
**Step 1: Update useChatSession**
Import `useInvalidateCredits` and call it when a message turn completes (status transitions from streaming/submitted to ready) and when an error occurs.
```typescript
// Add import:
import { useInvalidateCredits } from '@/lib/credits/useCredits'
// Inside useChatSession(), near other hook calls:
const invalidateCredits = useInvalidateCredits()
```
Find the existing completion detection logic (where `saveLocalConversation` or `saveRemoteConversation` is called after status becomes 'ready'). Add `invalidateCredits()` call there.
Also, in the error handling path (where `chatError` is set), add `invalidateCredits()` to sync badge on CREDITS_EXHAUSTED.
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/useChatSession.ts
git commit -m "feat: refresh credits after chat message completion and on error"
```
---
### Task 5: Update ChatError for CREDITS_EXHAUSTED
**Files:**
- Modify: `apps/agent/entrypoints/sidepanel/index/ChatError.tsx`
**Step 1: Add CREDITS_EXHAUSTED detection to parseErrorMessage**
In `parseErrorMessage()` (line 29), add a new detection block after the existing rate limit check (line 48):
```typescript
// After the 'BrowserOS LLM daily limit reached' block, add:
if (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,
}
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/sidepanel/index/ChatError.tsx
git commit -m "feat: handle CREDITS_EXHAUSTED error in chat"
```
---
### Task 6: Create Usage & Billing settings page
**Files:**
- Create: `apps/agent/entrypoints/app/usage/UsagePage.tsx`
**Step 1: Write the page component**
Follow the same pattern as `AISettingsPage.tsx` — a standalone page component rendered inside the settings sidebar layout.
```tsx
import { Coins } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useCredits } from '@/lib/credits/useCredits'
import { cn } from '@/lib/utils'
function getCreditColor(credits: number): string {
if (credits <= 0) return 'text-red-500'
if (credits <= 30) return 'text-yellow-500'
return 'text-green-500'
}
function getProgressColor(credits: number): string {
if (credits <= 0) return 'bg-red-500'
if (credits <= 30) return 'bg-yellow-500'
return 'bg-green-500'
}
export const UsagePage: FC = () => {
const { data, isLoading } = useCredits()
if (isLoading) {
return (
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
Loading usage data...
</div>
)
}
const credits = data?.credits ?? 0
const total = 100
const percentage = Math.min((credits / total) * 100, 100)
return (
<div className="space-y-6 p-6">
<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>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Coins className="h-5 w-5" />
Credits
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-baseline gap-2">
<span className={cn('font-bold text-3xl', getCreditColor(credits))}>
{credits}
</span>
<span className="text-muted-foreground text-sm">/ {total} daily</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full transition-all', getProgressColor(credits))}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="space-y-1 text-muted-foreground text-sm">
<p>1 credit per request</p>
<p>Resets daily at midnight UTC</p>
{data?.lastResetAt && (
<p>Last reset: {new Date(data.lastResetAt).toLocaleDateString()}</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Need more credits?</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-muted-foreground text-sm">
Additional credit packages will be available soon.
</p>
<Button variant="outline" disabled>
Add Credits (Coming Soon)
</Button>
</CardContent>
</Card>
</div>
)
}
```
**Step 2: Commit**
```bash
git add apps/agent/entrypoints/app/usage/UsagePage.tsx
git commit -m "feat: add Usage & Billing settings page"
```
---
### Task 7: Register route and sidebar entry
**Files:**
- Modify: `apps/agent/entrypoints/app/App.tsx` — add route
- Modify: `apps/agent/components/sidebar/SettingsSidebar.tsx` — add sidebar entry
**Step 1: Add route to App.tsx**
Inside the `<Route path="settings">` block (after line 103, before closing `</Route>`):
```tsx
import { UsagePage } from './usage/UsagePage'
// Add as new route:
<Route path="usage" element={<UsagePage />} />
```
**Step 2: Add sidebar entry to SettingsSidebar.tsx**
Import `CreditCard` from lucide-react (line 1). Add entry to the "Other" section in `primarySettingsSections` array (after line 81):
```typescript
{ name: 'Usage & Billing', to: '/settings/usage', icon: CreditCard },
```
**Step 3: Commit**
```bash
git add apps/agent/entrypoints/app/App.tsx apps/agent/components/sidebar/SettingsSidebar.tsx
git commit -m "feat: register usage page route and sidebar entry"
```
---
### Task 8: Verify end-to-end
**Step 1: Start dev server**
```bash
bun run dev:watch -- --new
```
**Step 2: Visual verification checklist**
- [ ] Open side panel — credit badge shows next to BrowserOS provider name
- [ ] Badge color is green when credits > 30
- [ ] Send a chat message — after response completes, badge count decrements
- [ ] Click badge — opens settings/usage page
- [ ] Settings sidebar shows "Usage & Billing" under "Other"
- [ ] Usage page shows credit count, progress bar, reset info
- [ ] Exhaust credits — badge turns red, chat shows error message
**Step 3: Commit any fixes**

View File

@@ -45,7 +45,7 @@
"homepage": "https://github.com/browseros-ai/BrowserOS#readme",
"devDependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"@biomejs/biome": "2.4.5",
"@biomejs/biome": "2.4.8",
"@sentry/cli": "^2.42.2",
"@types/bun": "^1.3.5",
"@types/node": "^24.3.3",

View File

@@ -37,6 +37,10 @@
"types": "./src/types/logger.ts",
"default": "./src/types/logger.ts"
},
"./types/server-config": {
"types": "./src/types/server-config.ts",
"default": "./src/types/server-config.ts"
},
"./schemas/llm": {
"types": "./src/schemas/llm.ts",
"default": "./src/schemas/llm.ts"

View File

@@ -16,6 +16,7 @@ export const PATHS = {
CORE_MEMORY_FILE_NAME: 'CORE.md',
SKILLS_DIR_NAME: 'skills',
BUILTIN_DIR_NAME: 'builtin',
SERVER_CONFIG_FILE_NAME: 'server.json',
SOUL_MAX_LINES: 150,
MEMORY_RETENTION_DAYS: 30,
SESSION_RETENTION_DAYS: 30,

View File

@@ -54,6 +54,7 @@ export const TIMEOUTS = {
OAUTH_TOKEN_EXPIRY_BUFFER: 300_000,
OAUTH_POLL_INTERVAL: 2_000,
OAUTH_POLL_TIMEOUT: 300_000,
DEVICE_CODE_POLL_SAFETY_MARGIN: 3_000,
} as const
export type TimeoutKey = keyof typeof TIMEOUTS

View File

@@ -13,4 +13,10 @@ export const EXTERNAL_URLS = {
OPENAI_AUTH: 'https://auth.openai.com/oauth/authorize',
OPENAI_TOKEN: 'https://auth.openai.com/oauth/token',
SKILLS_CATALOG: 'https://cdn.browseros.com/skills/v1/catalog.json',
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
GITHUB_OAUTH_TOKEN: 'https://github.com/login/oauth/access_token',
GITHUB_COPILOT_API: 'https://api.githubcopilot.com',
QWEN_DEVICE_CODE: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
QWEN_OAUTH_TOKEN: 'https://chat.qwen.ai/api/v1/oauth2/token',
QWEN_CODE_API: 'https://portal.qwen.ai/v1',
} as const

View File

@@ -25,6 +25,8 @@ export const LLM_PROVIDERS = {
OPENAI_COMPATIBLE: 'openai-compatible',
MOONSHOT: 'moonshot',
CHATGPT_PRO: 'chatgpt-pro',
GITHUB_COPILOT: 'github-copilot',
QWEN_CODE: 'qwen-code',
} as const
/**
@@ -44,6 +46,8 @@ export const LLMProviderSchema: z.ZodEnum<
'openai-compatible',
'moonshot',
'chatgpt-pro',
'github-copilot',
'qwen-code',
]
> = z.enum([
LLM_PROVIDERS.ANTHROPIC,
@@ -58,6 +62,8 @@ export const LLMProviderSchema: z.ZodEnum<
LLM_PROVIDERS.OPENAI_COMPATIBLE,
LLM_PROVIDERS.MOONSHOT,
LLM_PROVIDERS.CHATGPT_PRO,
LLM_PROVIDERS.GITHUB_COPILOT,
LLM_PROVIDERS.QWEN_CODE,
])
export type LLMProvider = z.infer<typeof LLMProviderSchema>

View File

@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Shape of ~/.browseros/server.json written by the server on startup.
* The CLI reads this file for auto-discovery of the server URL.
*/
export interface ServerDiscoveryConfig {
server_port: number
url: string
server_version: string
browseros_version?: string
chromium_version?: string
}

View File

@@ -1,4 +1,4 @@
BROWSEROS_MAJOR=0
BROWSEROS_MINOR=43
BROWSEROS_MINOR=44
BROWSEROS_BUILD=0
BROWSEROS_PATCH=2
BROWSEROS_PATCH=1