Compare commits

...

36 Commits

Author SHA1 Message Date
shivammittal274
d55b61cc60 feat(openclaw): WS streaming, device auth, MCP port fix (#687)
* feat(openclaw): WS streaming, device auth, MCP port fix

- Fix GatewayClient WS handshake: add Ed25519 device identity signing,
  Origin header, mode: cli (mode: ui requires device identity always)
- Add auto device pairing flow: generate client identity, attempt WS
  connect (triggers pending), approve via openclaw CLI, reconnect
- Replace HTTP /v1/chat/completions proxy with WS-based streaming that
  surfaces tool calls, thinking blocks, and text deltas
- Add chatStream() to GatewayClient returning ReadableStream of typed
  OpenClawStreamEvent (text-delta, thinking, tool-start/end, lifecycle)
- Update chat route to stream WS events as SSE to the extension
- Pass actual server port to OpenClaw config (fixes MCP bridge in dev)
- Rewrite AgentChat.tsx with turn-based model using Message/MessageContent
  components matching sidepanel pattern, with tool batching logic that
  groups consecutive tools and breaks on text/thinking (same as sidepanel)
- Add execInContainer() to ContainerRuntime for CLI commands
- Fix gateway response field mapping (id→agentId, agents.list/create)
- Skip creating main agent if gateway auto-creates it

* fix(openclaw): retry WS connect on signature expired (Podman clock skew)

Podman VM clock drifts when Mac sleeps, causing Ed25519 signature
validation to fail with "device signature expired" on auto-start.
Add connectGatewayWithRetry() that restarts the container (resyncs
clock) and re-approves the device if needed.

* fix(openclaw): address PR review — stream cleanup, error handling

- Fix silent catch in setup(): only swallow "pairing required" and
  "signature expired" errors, re-throw everything else
- Guard JSON.parse in approvePendingDevice(): check exit code and
  wrap parse in try/catch with descriptive error messages
- Add try/finally in chat SSE route: reader.cancel() on disconnect
- Add cancel callback to chatStream ReadableStream: restores
  ws.onmessage when stream is cancelled (prevents handler leak)
2026-04-13 08:50:58 -07:00
Nikhil Sonti
7ae85bb75c fix(openclaw): log service progress through server logger 2026-04-10 15:02:22 -07:00
Nikhil Sonti
7e43059c95 fix(openclaw): use agentId field in setup response mapping
Fix type error: GatewayAgentEntry uses agentId not id.
2026-04-10 14:55:48 -07:00
Nikhil Sonti
5c666aa4e0 refactor(openclaw): agent CRUD via WS RPC, per-agent chat targeting
Replace JSON mutation + restart with GatewayClient WS RPC calls for
agents.create, agents.delete, agents.list. Chat proxy now uses
model: "openclaw/<agentId>" for per-agent targeting. Setup writes
bootstrap config once then creates "main" agent via WS after gateway
starts. Container restarts only when a new provider env var is added.
2026-04-10 14:55:09 -07:00
Nikhil Sonti
8438325dcb refactor(openclaw): simplify config to bootstrap-only, add /readyz health
Config no longer contains agents.list — agent CRUD is handled via WS RPC.
buildOpenClawConfig → buildBootstrapConfig, removed makeAgentEntry and
AgentEntry (agents managed by OpenClaw runtime). Added isReady() and
waitForReady() using /readyz for gateway readiness checks.
2026-04-10 14:53:03 -07:00
Nikhil Sonti
928cd46579 feat(openclaw): add GatewayClient WebSocket RPC client
Persistent WS client for the OpenClaw Gateway protocol. Handles the
challenge → connect → hello-ok handshake (as openclaw-control-ui with
operator.admin scope), JSON-RPC with pending map + timeouts, and
auto-reconnect. Exposes typed methods for agents.list, agents.create,
agents.delete, and health.
2026-04-10 14:52:09 -07:00
Nikhil Sonti
4e7cfb5998 fix(openclaw): write gateway auth token to openclaw.json
The gateway was returning 401 because auth.mode was set to "token"
without providing the actual token value. Now the token is written
to gateway.auth.token in openclaw.json so the gateway and our chat
proxy agree on the same token.
2026-04-10 14:17:00 -07:00
Nikhil Sonti
95df5734c6 feat(openclaw): per-agent provider selection
Each agent can now have its own LLM provider. The Create Agent dialog
includes a provider selector that passes providerType/apiKey/modelId
to the backend. The service writes per-agent model config to
openclaw.json and merges the API key into the container's .env file.
2026-04-10 14:08:54 -07:00
Nikhil Sonti
2de20344a0 feat(openclaw): add provider selector to setup flow
Add LLM provider selector using useLlmProviders hook. Filters out
OAuth-only providers, pre-selects the user's default, and passes
providerType/apiKey/modelId to the setup endpoint so OpenClaw gets
a working LLM configuration on first setup.
2026-04-10 14:03:01 -07:00
Nikhil Sonti
4a571bc750 feat(openclaw): add agents page UI with chat, create, and lifecycle controls
Add /agents route with AgentsPage showing OpenClaw status, agent list,
create dialog, and per-agent chat. Includes useOpenClaw hook for
server communication, AgentChat component with SSE streaming, and
sidebar navigation entry.
2026-04-10 11:47:28 -07:00
Nikhil Sonti
71ba53e528 fix(openclaw): resolve type errors in service and podman runtime
Fix TIMEOUTS.TOOL_EXECUTION → TIMEOUTS.TOOL_CALL to match shared
constants. Fix ReadableStream undefined/null type mismatch in
PodmanRuntime.runCommand stream draining.
2026-04-10 11:39:41 -07:00
Nikhil Sonti
9a77da8d4e feat(openclaw): add API routes and server wiring
Add /api/claw/* routes for container lifecycle (setup/start/stop/restart),
agent CRUD (list/create/delete), chat proxy with SSE streaming, provider
key management, and log retrieval. Register routes in server.ts, add
OpenClaw auto-start on BrowserOS boot and graceful shutdown in main.ts.
2026-04-10 11:38:22 -07:00
Nikhil Sonti
b1fd3bdd31 feat(openclaw): add OpenClawService orchestrator
Main service managing the single OpenClaw container. Handles full
lifecycle (setup/start/stop/restart/shutdown), agent CRUD with config
rewrites and gateway restarts, chat proxy to /v1/chat/completions,
provider key updates, auto-start on BrowserOS boot, and status reporting.
2026-04-10 11:36:24 -07:00
Nikhil Sonti
92560cb369 feat(openclaw): add config builder and container runtime
openclaw-config.ts: pure functions to build openclaw.json and .env files
from BrowserOS settings. Maps provider keys, sets permissive defaults
(full exec, cron, web search, MCP bridge to BrowserOS).

container-runtime.ts: compose-level abstraction over PodmanRuntime for
the browseros-openclaw project. Handles up/down/restart/pull, health
checks, .env file writes, and safe machine shutdown.
2026-04-10 11:34:49 -07:00
Nikhil Sonti
78b797400e feat(openclaw): add PodmanRuntime container engine abstraction
Manages Podman CLI interactions: machine lifecycle (init/start/stop),
availability checks, command execution with streaming output, and
running container enumeration. Linux skips machine ops since Podman
runs natively.
2026-04-10 11:33:42 -07:00
Nikhil Sonti
3852660b52 feat(openclaw): add foundation — paths constant, browseros-dir helper, static compose file
Add OPENCLAW_DIR_NAME to shared paths constant, getOpenClawDir() to
browseros-dir.ts, and a static docker-compose.yml resource file that
uses native .env variable substitution instead of YAML template strings.
2026-04-10 11:32:57 -07:00
github-actions[bot]
6b18ebb1d8 docs: update agent extension changelog for v0.0.99 (#660)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-10 09:53:44 -07:00
shivammittal274
1f2e783ab9 fix: enable agent interaction with elements inside iframes (#667)
* fix: enable agent interaction with elements inside iframes

Fetch accessibility trees from all frames via Page.getFrameTree() +
per-frame Accessibility.getFullAXTree(frameId), so iframe elements
appear in snapshots with valid backendNodeIds. Pages without iframes
take the original single-call path with zero overhead.

Update snapshot tree builders to walk multiple RootWebArea roots from
merged multi-frame trees. Extract same-origin iframe content in the
markdown walker; show [iframe: url] placeholder for cross-origin.

* fix: namespace AX nodeIds by frameId to prevent cross-frame collisions

CDP AXNodeId values are frame-scoped — each frame's accessibility tree
starts its own counter from 1. Prefix nodeId and childIds with frameId
before merging so the nodeMap in snapshot builders never overwrites
nodes from a different frame.
2026-04-09 23:14:53 +05:30
Felarof
df7873562d Revert Kimi partnership UI, restore daily limit survey (#663)
* docs: add uBlock Origin install info to getting started and ad-blocking pages

Chrome dropped support for the full uBlock Origin extension — highlight
that BrowserOS brings it back and make it easy to install from both the
getting started guide and the dedicated ad-blocking page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: revert Kimi partnership UI, restore daily limit survey

Remove Kimi/Moonshot AI partnership branding from the rate limit
banner, provider card, provider templates, and LLM hub. Restore
the original survey CTA on daily limit errors. Moonshot AI remains
as a regular provider template without the "Recommended" badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address Greptile review comments

- Guard survey CTA with !isCreditsExhausted to avoid showing it for
  credits-exhausted users who already see "View Usage & Billing"
- Remove dead kimi-launch feature flag files (kimi-launch.ts,
  useKimiLaunch.ts)
- Remove unused KIMI_RATE_LIMIT analytics events
- Remove VITE_PUBLIC_KIMI_LAUNCH from env schema and .env.example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:39:00 -07:00
shivammittal274
412386b489 fix: ensure custom model entry is always visible in model selector (#662)
The merged PR (#661) injected custom entries into filteredModels, but
cmdk auto-scrolls to its first selected CommandItem, pushing the custom
entry out of view. Fix by using forceMount on a separate CommandGroup
and resetting scroll to top on every keystroke via requestAnimationFrame.
2026-04-09 02:40:38 +05:30
shivammittal274
33617ba9e7 feat: show custom model ID as first option in model selector (#661)
* feat: show custom model ID as first option in model selector

When typing in the model dropdown, the user's exact input now appears as the
first selectable row, followed by fuzzy search suggestions. This makes entering
custom model IDs intuitive — previously the option was hidden behind a
zero-results-only Enter shortcut that fuzzy search almost always prevented.

* fix: correct is_custom_model flag and prevent duplicate analytics events

- Use modelInfoList check instead of hardcoding is_custom_model: true in
  the Enter key handler
- Add stopPropagation to prevent cmdk's root keydown handler from also
  firing onSelect, which caused duplicate MODEL_SELECTED_EVENT emissions
2026-04-09 01:44:17 +05:30
Nikhil
6712e1d321 chore: bump server and extension version (#659) 2026-04-08 10:18:24 -07:00
Dani Akash
94540d9e87 chore(agent): remove workflows feature (#656) 2026-04-08 08:42:22 +05:30
Nikhil
bb62213e84 fix: install linux sysroot in configure, not via gclient hook (#653)
* fix: install linux sysroot in configure, not via gclient hook

`gn gen` was failing on the arm64 leg with `Missing sysroot
(//build/linux/debian_bullseye_arm64-sysroot)`. The previous design
relied on `git_setup` writing `target_cpus` to `.gclient` so that
`gclient sync`'s DEPS hook would download the cross-arch sysroot. That
chain breaks for any chromium_src that was synced before cross-arch
support landed (the hook is gated on .gclient state at sync time) and
for partial pipeline runs that skip git_setup entirely. Nothing in
configure declared or verified its sysroot precondition.

Make configure self-healing: on Linux, invoke
`build/linux/sysroot_scripts/install-sysroot.py --arch=<target>`
directly before `gn gen`. install-sysroot.py is idempotent (stamp file
+ SHA check), fast when already installed, and decoupled from .gclient
— it's exactly what the failing assertion's error message recommends.
The script accepts our arch names directly: `x64` translates to `amd64`
internally via ARCH_TRANSLATIONS, and `arm64` is a valid pass-through.

Also temporarily pin release.linux.yaml to x64 only while we validate
the sysroot bootstrap end-to-end. Flip back to `[x64, arm64]` once
arm64 is green.

* chore: pin release.linux.yaml to arm64-only for sysroot bootstrap test

x64 already builds cleanly — the failing leg is arm64 cross-compile from
an x64 host. Pin the config to arm64 to exercise the new
install-sysroot.py path in configure without burning time on x64.
Flip back to [x64, arm64] once arm64 is green.
2026-04-07 11:12:21 -07:00
Nikhil
dee3086a48 feat(server): cache klavis createStrata to unblock /chat hot path (#654)
* feat(server): cache klavis createStrata to unblock /chat hot path

Conversation creation in /chat was blocking on a Worker-proxied
klavisClient.createStrata round-trip every time the user had any
managed Klavis app connected. The 5s KLAVIS_TIMEOUT_MS in the
ai-worker proxy existed specifically to bound this latency, but
the same cap also caused user-visible 504s on /klavis/servers/remove
since Strata DELETE operations routinely take >5s. Without caching
we couldn't raise the timeout without regressing chat creation.

This adds an in-process cache for Strata createStrata responses,
keyed by (browserosId, hashed sorted-server-set) and gated by a 1h
TTL. The cache stores only immutable JSON metadata (strataServerUrl,
strataId, addedServers); per-session MCP clients continue to be
opened and disposed by AiSdkAgent exactly as before, which keeps
the cache concurrency-safe by construction.

Cache invalidation has two layers: (a) the cache key embeds the
server set, so adding/removing apps naturally produces a different
key; (b) POST /klavis/servers/add and DELETE /klavis/servers/remove
explicitly call invalidate(browserosId) after their underlying
Klavis API call succeeds, as defense-in-depth.

Other changes:
- Consolidates klavis-related services into a new
  apps/server/src/api/services/klavis/ directory; moves
  register-klavis-mcp.ts -> strata-proxy.ts and adds strata-cache.ts
  there. lib/clients/klavis/ stays unchanged.
- Refactors KlavisClient.removeServer into a low-level
  deleteServersFromStrata(strataId, servers) primitive. The
  cache-lookup + delete + invalidate orchestration moves up into
  routes/klavis.ts where it belongs, eliminating the lib->api
  layering inversion the original removeServer would have introduced.
- Uses Bun.hash (xxhash64) for fixed-width 16-hex-char keys, with
  serverKey verified on read to make collision risk strictly zero.
- Dedupes concurrent fetches via in-flight Promise sharing, with
  identity-checks before delete to avoid races between invalidate()
  and a racing replacement insert.

Follow-up (separate PR): bump KLAVIS_TIMEOUT_MS to 30000 in
ai-worker/wrangler.toml so /klavis/servers/remove stops 504-ing.

* fix: address greptile review comments for klavis strata cache

- Drop dead `invalidated` field on InflightEntry. It was added to
  support a "discard post-resolution if invalidated" check that I
  later replaced with identity-checked deletes during self-review,
  but I forgot to remove the field and the misleading comment
  referencing it. Simplify Map<string, InflightEntry> to plain
  Map<string, Promise<CacheEntry>>.
- Lower cache miss log from info to debug. Misses fire on every new
  conversation; matching the existing debug-level for hits.
- Stop routing the /klavis/servers/remove handler through
  klavisStrataCache.getOrFetch. The chat hot path keys its cache by
  the user's full enabled-server set (e.g. hash('Gmail,Linear')),
  so a single-server lookup here (hash('Gmail')) is guaranteed to
  miss, write a spurious entry, and then have it immediately
  cleared by invalidate() on the next line. Call createStrata
  directly to recover the strataId, mirroring the original
  removeServer flow.
2026-04-07 11:11:41 -07:00
Nikhil
8de2bf984f feat: build linux x64 + arm64 in a single invocation (#652)
`release.linux.yaml` now declares `architecture: [x64, arm64]` and the
runner loops the entire pipeline once per architecture. depot_tools
fetches both Linux sysroots automatically — `git_setup` idempotently
ensures `target_cpus = ['x64', 'arm64']` is in `.gclient` before
`gclient sync`, so cross-compiling arm64 from an x64 host just works.

The resolver returns `List[Context]` (single-element for the common
single-arch case), and `build/cli/build.py` loops `execute_pipeline` over
the per-arch contexts. Modules stay 100% arch-agnostic — no new
orchestration module, no new YAML schema beyond the list form.

Also fix a cross-compile bug in `build/modules/package/linux.py`: the
appimagetool binary must match the BUILD machine's arch (it executes
locally), not the target arch. Split into a host-keyed
`LINUX_HOST_APPIMAGETOOL` lookup vs the existing target-keyed
`LINUX_ARCHITECTURE_CONFIG`. Target arch is still passed to appimagetool
via the `ARCH` env var.

- build/common/resolver.py: scalar OR list `architecture` -> List[Context]
- build/cli/build.py: loop pipeline per arch, log multi-arch headers
- build/config/release.linux.yaml: `architecture: [x64, arm64]`
- build/modules/setup/git.py: idempotent `target_cpus` edit on Linux
- build/modules/package/linux.py: host vs target appimagetool split
- build/modules/package/linux_test.py: cover the host/target split
2026-04-06 13:08:06 -07:00
Nikhil
1b8720740c feat: add linux arm64 release support (#651)
* feat: support linux arm64 release artifacts

* fix: address PR review comments for 0406-linux_arm64_support
2026-04-06 10:20:38 -07:00
Nikhil
91be726381 refactor: remove --compile-only flag, consolidate into --ci (#646)
The --compile-only and --ci flags served overlapping purposes for CI
builds. Remove --compile-only entirely since --ci already handles the
CI use case (skip R2, skip prod env validation, local zip packaging)
and --no-upload covers the upload-skipping use case for full builds.
2026-04-03 14:58:52 -07:00
Nikhil
ff5386a24a fix: agent storage issue on update (#643)
* fix: agent storage erase issue fix

* fix: remove the guard against remote
2026-04-03 14:50:14 -07:00
Nikhil
a5f3c4da65 fix: skip windows exe patching in ci mode to avoid wine dependency (#645)
The server release CI workflow fails on ubuntu-latest because
patch-windows-exe.ts requires Wine to run rcedit. Thread the existing
--ci flag through compileServerBinaries so Windows PE metadata patching
is skipped in CI mode with a warning log.
2026-04-03 14:46:33 -07:00
Nikhil
e5a852dd3d chore: update server version (#644) 2026-04-03 14:29:07 -07:00
Felarof
aee30ce8e1 Update README.md (#638) 2026-04-02 13:00:11 -07:00
Nikhil
0833c8d42d fix: windows app-data location fix (#637) 2026-04-02 08:53:04 -07:00
Nikhil
036c7f280b fix: tab-grouping cdp crash (#635)
* fix: tab group crash + history fix

* fix: tab group crash + history fix
2026-04-01 15:06:41 -07:00
Nikhil
000429277d fix: isolate server release packaging to ci mode (#629)
* fix: relax compile-only release env requirements

* refactor: add ci mode for server release builds
2026-03-31 20:57:44 -07:00
Nikhil
f8535fd96d fix: exclude eval framework from language stats via gitattributes (#630) 2026-03-31 20:44:06 -07:00
107 changed files with 4550 additions and 3914 deletions

2
.gitattributes vendored
View File

@@ -9,4 +9,6 @@ packages/browseros/chromium_patches/**/*.py linguist-generated
scripts/*.py linguist-generated
# Mark build directories as generated
build/* linguist-generated
# Mark eval/test framework as vendored so it's excluded from language stats
packages/browseros-agent/apps/eval/** linguist-vendored
docs/videos/** filter=lfs diff=lfs merge=lfs -text

View File

@@ -53,11 +53,6 @@ jobs:
fi
- name: Build release artifacts
env:
BROWSEROS_CONFIG_URL: ${{ secrets.BROWSEROS_CONFIG_URL }}
CODEGEN_SERVICE_URL: ${{ secrets.CODEGEN_SERVICE_URL }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: bun run build:server:ci
- name: Verify release artifacts

View File

@@ -192,7 +192,7 @@ We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRI
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
Copyright &copy; 2025 Felafax, Inc.
Copyright &copy; 2026 Felafax, Inc.
## Stargazers

View File

@@ -3,13 +3,17 @@ title: "Ad Blocking"
description: "BrowserOS supports full ad blocking with uBlock Origin"
---
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most effective open-source ad blocker available.
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most powerful open-source ad blocker available — the full extension, not the watered-down "Lite" version.
## How It Works
## Why BrowserOS?
Chrome has been [phasing out support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for Manifest V2 extensions, which uBlock Origin relies on for its full blocking capabilities. We re-enabled Manifest V2 support in BrowserOS so uBlock Origin can run at full power.
Chrome [killed support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for uBlock Origin by phasing out Manifest V2 extensions. The only option left on Chrome is "uBlock Origin Lite," a significantly weaker version that can't use advanced filtering rules.
Install it from the Chrome Web Store: [uBlock Origin](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm)
**BrowserOS re-enabled full Manifest V2 support**, so you can install and run the original uBlock Origin at full power — the same extension Chrome no longer allows.
<Card title="Install uBlock Origin" icon="shield-check" href="https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">
Install the full uBlock Origin extension from the Chrome Web Store. Works on BrowserOS out of the box.
</Card>
## BrowserOS vs Chrome

View File

@@ -42,6 +42,10 @@ Welcome to BrowserOS! Let's get you set up.
## You're all set!
<Tip>
**Block ads with uBlock Origin** — Chrome dropped support for the full uBlock Origin extension, but BrowserOS brought it back. [Install it from the Chrome Web Store](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm) and browse ad-free. [Learn more →](/features/ad-blocking)
</Tip>
Explore what BrowserOS can do:
<Columns cols={2}>

View File

@@ -15,9 +15,6 @@ VITE_PUBLIC_SENTRY_DSN=
# BrowserOS API URL
VITE_PUBLIC_BROWSEROS_API=https://api.browseros.com
# Launch feature flags
VITE_PUBLIC_KIMI_LAUNCH=false
# GraphQL Schema Path (optional — falls back to schema/schema.graphql)
GRAPHQL_SCHEMA_PATH=

View File

@@ -1,5 +1,16 @@
# BrowserOS Agent Extension
## v0.0.99 (2026-04-08)
## What's Changed
- chore: bump server and extension version (#659)
- chore(agent): remove workflows feature (#656)
- feat: replace model picker with shadcn Combobox + fuse.js fuzzy search (#617)
- feat: clean-up - remove obsolete controller extension (#610)
- docs: update agent extension changelog for v0.0.98 (#609)
## v0.0.98 (2026-03-27)
## What's Changed

View File

@@ -12,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|------|------------|---------|
| Folders | kebab-case | `ai-settings/`, `jtbd-popup/`, `llm-hub/` |
| React components (.tsx) | PascalCase | `AISettingsPage.tsx`, `SurveyHeader.tsx` |
| Hooks (.ts) | camelCase with `use` prefix | `useRunWorkflow.ts`, `useVoiceInput.ts` |
| Hooks (.ts) | camelCase with `use` prefix | `useVoiceInput.ts`, `useMessageTree.ts` |
| Non-component files (.ts) | lowercase | `types.ts`, `models.ts`, `storage.ts` |
## Project Overview

View File

@@ -4,7 +4,6 @@ import {
Bot,
Compass,
CreditCard,
GitBranch,
MessageSquare,
Palette,
RotateCcw,
@@ -86,12 +85,6 @@ const primarySettingsSections: NavSection[] = [
icon: CreditCard,
feature: Feature.CREDITS_SUPPORT,
},
{
name: 'Workflows',
to: '/workflows',
icon: GitBranch,
feature: Feature.WORKFLOW_SUPPORT,
},
],
},
]

View File

@@ -1,6 +1,7 @@
import {
Brain,
CalendarClock,
Cpu,
Home,
PlugZap,
Settings,
@@ -39,6 +40,7 @@ const primaryNavItems: NavItem[] = [
feature: Feature.MANAGED_MCP_SUPPORT,
},
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
{ name: 'Agents', to: '/agents', icon: Cpu },
{
name: 'Skills',
to: '/home/skills',

View File

@@ -9,9 +9,9 @@ import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
import { FeaturesPage } from '../onboarding/features/Features'
import { Onboarding } from '../onboarding/index/Onboarding'
import { StepsLayout } from '../onboarding/steps/StepsLayout'
import { AgentsPage } from './agents/AgentsPage'
import { AISettingsPage } from './ai-settings/AISettingsPage'
import { ConnectMCP } from './connect-mcp/ConnectMCP'
import { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
import { CustomizationPage } from './customization/CustomizationPage'
import { SurveyPage } from './jtbd-agent/SurveyPage'
import { AuthLayout } from './layout/AuthLayout'
@@ -29,7 +29,6 @@ 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 } {
const params = new URLSearchParams(window.location.search)
@@ -53,9 +52,7 @@ const OptionsRedirect: FC = () => {
soul: '/home/soul',
skills: '/home/skills',
'jtbd-agent': '/settings/survey',
workflows: '/workflows',
scheduled: '/scheduled',
'create-graph': '/workflows/create-graph',
}
const newPath = routeMap[path] || '/settings/ai'
@@ -90,8 +87,8 @@ export const App: FC = () => {
{/* Primary nav routes */}
<Route path="connect-apps" element={<ConnectMCP />} />
<Route path="workflows" element={<WorkflowsPageWrapper />} />
<Route path="scheduled" element={<ScheduledTasksPage />} />
<Route path="agents" element={<AgentsPage />} />
</Route>
{/* Settings with dedicated sidebar */}
@@ -108,9 +105,6 @@ export const App: FC = () => {
</Route>
</Route>
{/* Full-screen without sidebar */}
<Route path="workflows/create-graph" element={<CreateGraphWrapper />} />
{/* Onboarding routes - no sidebar, no auth required */}
<Route path="onboarding">
<Route index element={<Onboarding />} />

View File

@@ -0,0 +1,413 @@
import {
ArrowLeft,
Bot,
CheckCircle2,
Loader2,
Send,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
interface ToolEntry {
id: string
name: string
status: 'running' | 'completed' | 'error'
durationMs?: number
}
type AssistantPart =
| { kind: 'thinking'; text: string; done: boolean }
| { kind: 'tool-batch'; tools: ToolEntry[] }
| { kind: 'text'; text: string }
interface ChatTurn {
id: string
userText: string
parts: AssistantPart[]
done: boolean
}
interface AgentChatProps {
agentId: string
agentName: string
onBack: () => void
}
function parseSSELines(buffer: string): {
events: OpenClawStreamEvent[]
remainder: string
} {
const lines = buffer.split('\n')
const remainder = lines.pop() ?? ''
const events: OpenClawStreamEvent[] = []
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (payload === '[DONE]') continue
try {
events.push(JSON.parse(payload) as OpenClawStreamEvent)
} catch {
// skip
}
}
return { events, remainder }
}
export const AgentChat: FC<AgentChatProps> = ({
agentId,
agentName,
onBack,
}) => {
const [turns, setTurns] = useState<ChatTurn[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sessionKeyRef = useRef(crypto.randomUUID())
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const scrollToBottom = () => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
useEffect(() => {
scrollToBottom()
}, [turns])
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
})
}
const processStream = async (response: Response) => {
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const { events, remainder } = parseSSELines(buffer)
buffer = remainder
for (const event of events) {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex(
(p) => p.kind === 'thinking' && !p.done,
)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool: ToolEntry = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running',
}
updateCurrentTurnParts((parts) => {
// Append to last batch if it's consecutive, otherwise start new batch
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const status =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
// Find the batch containing this tool (search from end)
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId
? {
...t,
status: status as ToolEntry['status'],
durationMs,
}
: t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((p) =>
p.kind === 'thinking' ? { ...p, done: true } : p,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
}
}
}
const handleSend = async () => {
const text = input.trim()
if (!text || streaming) return
const turn: ChatTurn = {
id: crypto.randomUUID(),
userText: text,
parts: [],
done: false,
}
setTurns((prev) => [...prev, turn])
setInput('')
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
try {
const response = await chatWithAgent(agentId, text, sessionKeyRef.current)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
return
}
await processStream(response)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
} finally {
setStreaming(false)
}
}
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<h2 className="font-semibold text-lg">{agentName}</h2>
</div>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.map((turn) => (
<div key={turn.id} className="space-y-3">
{/* User message */}
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{/* Assistant response — all parts grouped */}
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
case 'tool-batch':
return (
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">
{tool.name}
</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
))}
</div>
)
case 'text':
return (
<MessageResponse key={key}>
{part.text}
</MessageResponse>
)
default:
return null
}
})}
</MessageContent>
</Message>
)}
{/* Streaming indicator when waiting for first part */}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
</div>
))}
</div>
<div className="border-t p-4">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder="Send a message..."
className="min-h-[44px] resize-none"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || streaming}
size="icon"
>
{streaming ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,499 @@
import {
AlertCircle,
Cpu,
Loader2,
MessageSquare,
Plus,
RefreshCw,
Square,
Trash2,
} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { AgentChat } from './AgentChat'
import {
type AgentEntry,
createAgent,
deleteAgent,
restartOpenClaw,
setupOpenClaw,
stopOpenClaw,
useOpenClawAgents,
useOpenClawStatus,
} from './useOpenClaw'
const OAUTH_ONLY_TYPES = new Set(['chatgpt-pro', 'github-copilot', 'qwen-code'])
const StatusBadge: FC<{ status: string }> = ({ status }) => {
const variants: Record<
string,
{
variant: 'default' | 'secondary' | 'outline' | 'destructive'
label: string
}
> = {
running: { variant: 'default', label: 'Running' },
starting: { variant: 'secondary', label: 'Starting...' },
stopped: { variant: 'outline', label: 'Stopped' },
error: { variant: 'destructive', label: 'Error' },
uninitialized: { variant: 'outline', label: 'Not Set Up' },
}
const v = variants[status] ?? { variant: 'outline' as const, label: status }
return <Badge variant={v.variant}>{v.label}</Badge>
}
export const AgentsPage: FC = () => {
const { status, loading: statusLoading } = useOpenClawStatus()
const { providers, defaultProviderId } = useLlmProviders()
const [refreshKey, setRefreshKey] = useState(0)
const { agents, loading: agentsLoading } = useOpenClawAgents(refreshKey)
const [setupOpen, setSetupOpen] = useState(false)
const [setupProviderId, setSetupProviderId] = useState('')
const [settingUp, setSettingUp] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [newName, setNewName] = useState('')
const [createProviderId, setCreateProviderId] = useState('')
const [creating, setCreating] = useState(false)
const [actionInProgress, setActionInProgress] = useState(false)
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
const [error, setError] = useState<string | null>(null)
const compatibleProviders = providers.filter(
(p) => p.apiKey && !OAUTH_ONLY_TYPES.has(p.type),
)
// Pre-select default provider when dialogs open
useEffect(() => {
if (compatibleProviders.length === 0) return
const fallbackId =
compatibleProviders.find((p) => p.id === defaultProviderId)?.id ??
compatibleProviders[0].id
if (setupOpen) setSetupProviderId(fallbackId)
if (createOpen) setCreateProviderId(fallbackId)
}, [setupOpen, createOpen, compatibleProviders, defaultProviderId])
const refresh = () => setRefreshKey((k) => k + 1)
const handleSetup = async () => {
const provider = compatibleProviders.find((p) => p.id === setupProviderId)
setSettingUp(true)
setError(null)
try {
await setupOpenClaw({
providerType: provider?.type,
apiKey: provider?.apiKey,
modelId: provider?.modelId,
})
setSetupOpen(false)
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setSettingUp(false)
}
}
const handleCreate = async () => {
if (!newName.trim()) return
const provider = compatibleProviders.find((p) => p.id === createProviderId)
setCreating(true)
setError(null)
try {
await createAgent({
name: newName.trim().toLowerCase().replace(/\s+/g, '-'),
providerType: provider?.type,
apiKey: provider?.apiKey,
modelId: provider?.modelId,
})
setCreateOpen(false)
setNewName('')
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setCreating(false)
}
}
const handleDelete = async (id: string) => {
setActionInProgress(true)
try {
await deleteAgent(id)
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setActionInProgress(false)
}
}
const handleStop = async () => {
setActionInProgress(true)
try {
await stopOpenClaw()
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setActionInProgress(false)
}
}
const handleRestart = async () => {
setActionInProgress(true)
try {
await restartOpenClaw()
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setActionInProgress(false)
}
}
if (chatAgent) {
return (
<AgentChat
agentId={chatAgent.agentId}
agentName={chatAgent.name}
onBack={() => setChatAgent(null)}
/>
)
}
if (statusLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="font-bold text-2xl">Agents</h1>
<p className="text-muted-foreground text-sm">
OpenClaw agents running in a local container
</p>
</div>
<div className="flex items-center gap-2">
{status?.status === 'running' && (
<>
<StatusBadge status="running" />
<Button
variant="ghost"
size="icon"
onClick={handleRestart}
disabled={actionInProgress}
title="Restart gateway"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleStop}
disabled={actionInProgress}
title="Stop gateway"
>
<Square className="size-4" />
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 size-4" />
New Agent
</Button>
</>
)}
</div>
</div>
{/* Error banner */}
{error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-3">
<AlertCircle className="size-4 text-destructive" />
<p className="text-destructive text-sm">{error}</p>
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => setError(null)}
>
Dismiss
</Button>
</CardContent>
</Card>
)}
{/* Uninitialized state */}
{status?.status === 'uninitialized' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local container to run autonomous agents with full tool access.'
: 'Podman is required to run OpenClaw agents. Install Podman first.'}
</p>
</div>
{status.podmanAvailable && (
<Button onClick={() => setSetupOpen(true)}>Set Up Now</Button>
)}
</CardContent>
</Card>
)}
{/* Stopped state */}
{status?.status === 'stopped' && (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
<p className="text-muted-foreground text-sm">
The OpenClaw gateway is not running.
</p>
</div>
<Button onClick={() => setSetupOpen(true)}>Start Gateway</Button>
</CardContent>
</Card>
)}
{/* Error state */}
{status?.status === 'error' && (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="size-12 text-destructive" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Error</h3>
<p className="text-muted-foreground text-sm">{status.error}</p>
</div>
<Button onClick={handleRestart} disabled={actionInProgress}>
Retry
</Button>
</CardContent>
</Card>
)}
{/* Agent list */}
{status?.status === 'running' && (
<div className="space-y-3">
{agentsLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : agents.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8">
<p className="text-muted-foreground text-sm">
No agents yet. Create one to get started.
</p>
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 size-4" />
Create Agent
</Button>
</CardContent>
</Card>
) : (
agents.map((agent) => (
<Card key={agent.agentId}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="flex items-center gap-3">
<Cpu className="size-5 text-muted-foreground" />
<div>
<CardTitle className="text-base">{agent.name}</CardTitle>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setChatAgent(agent)}
>
<MessageSquare className="mr-1 size-4" />
Chat
</Button>
{agent.agentId !== 'main' && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(agent.agentId)}
disabled={actionInProgress}
>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
</div>
</CardHeader>
</Card>
))
)}
</div>
)}
{/* Setup Dialog (with provider selector) */}
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Up OpenClaw</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<ProviderSelector
providers={compatibleProviders}
defaultProviderId={defaultProviderId}
selectedId={setupProviderId}
onSelect={setSetupProviderId}
/>
<Button
onClick={handleSetup}
disabled={settingUp}
className="w-full"
>
{settingUp ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Setting up...
</>
) : (
'Set Up & Start'
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Create Agent Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<label
htmlFor="agent-name"
className="mb-1 block font-medium text-sm"
>
Agent Name
</label>
<Input
id="agent-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="research-agent"
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate()
}}
/>
<p className="mt-1 text-muted-foreground text-xs">
Lowercase letters, numbers, and hyphens only.
</p>
</div>
<ProviderSelector
providers={compatibleProviders}
defaultProviderId={defaultProviderId}
selectedId={createProviderId}
onSelect={setCreateProviderId}
/>
<Button
onClick={handleCreate}
disabled={!newName.trim() || creating}
className="w-full"
>
{creating ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Creating...
</>
) : (
'Create Agent'
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
interface ProviderSelectorProps {
providers: Array<{ id: string; type: string; name: string; modelId: string }>
defaultProviderId: string
selectedId: string
onSelect: (id: string) => void
}
const ProviderSelector: FC<ProviderSelectorProps> = ({
providers,
defaultProviderId,
selectedId,
onSelect,
}) => {
if (providers.length === 0) {
return (
<div className="space-y-2">
<p className="font-medium text-sm">LLM Provider</p>
<p className="text-muted-foreground text-sm">
No compatible LLM providers configured.{' '}
<a href="#/settings/ai" className="underline">
Add one in AI settings
</a>{' '}
first.
</p>
</div>
)
}
return (
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="provider-select">
LLM Provider
</label>
<Select value={selectedId} onValueChange={onSelect}>
<SelectTrigger id="provider-select">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} {p.modelId}
{p.id === defaultProviderId ? ' (default)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Uses your existing API key from BrowserOS settings. The key is passed to
the container and never leaves your machine.
</p>
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
export interface AgentEntry {
agentId: string
name: string
workspace: string
model?: string
}
export interface OpenClawStatus {
status: 'uninitialized' | 'starting' | 'running' | 'stopped' | 'error'
podmanAvailable: boolean
machineReady: boolean
port: number | null
agentCount: number
error: string | null
}
async function clawFetch<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = await getAgentServerUrl()
const res = await fetch(`${baseUrl}/claw${path}`, init)
return res.json() as Promise<T>
}
export function useOpenClawStatus(pollMs = 5000) {
const [status, setStatus] = useState<OpenClawStatus | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
const poll = async () => {
try {
const s = await clawFetch<OpenClawStatus>('/status')
if (active) setStatus(s)
} catch {
// Server may not be running
} finally {
if (active) setLoading(false)
}
}
poll()
const id = setInterval(poll, pollMs)
return () => {
active = false
clearInterval(id)
}
}, [pollMs])
return { status, loading }
}
export function useOpenClawAgents(refreshKey: number) {
const [agents, setAgents] = useState<AgentEntry[]>([])
const [loading, setLoading] = useState(true)
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional refetch trigger
useEffect(() => {
let active = true
clawFetch<{ agents: AgentEntry[] }>('/agents')
.then((data) => {
if (active) setAgents(data.agents ?? [])
})
.catch(() => {})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [refreshKey])
return { agents, loading }
}
export async function setupOpenClaw(input: {
providerType?: string
apiKey?: string
modelId?: string
}) {
return clawFetch<{ status: string; agents: AgentEntry[] }>('/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
}
export async function createAgent(input: {
name: string
providerType?: string
apiKey?: string
modelId?: string
}) {
return clawFetch<{ agent: AgentEntry }>('/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
}
export async function deleteAgent(id: string) {
return clawFetch<{ success: boolean }>(`/agents/${id}`, {
method: 'DELETE',
})
}
export async function startOpenClaw() {
return clawFetch<{ status: string }>('/start', { method: 'POST' })
}
export async function stopOpenClaw() {
return clawFetch<{ status: string }>('/stop', { method: 'POST' })
}
export async function restartOpenClaw() {
return clawFetch<{ status: string }>('/restart', { method: 'POST' })
}
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}
export async function chatWithAgent(
agentId: string,
message: string,
sessionKey?: string,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, sessionKey }),
})
}

View File

@@ -8,7 +8,7 @@ import {
Loader2,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod/v3'
import { Button } from '@/components/ui/button'
@@ -61,7 +61,6 @@ import {
KIMI_API_KEY_GUIDE_CLICKED_EVENT,
MODEL_SELECTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import {
getDefaultBaseUrlForProviders,
getProviderTemplate,
@@ -223,9 +222,9 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [modelPickerOpen, setModelPickerOpen] = useState(false)
const [modelSearch, setModelSearch] = useState('')
const modelListRef = useRef<HTMLDivElement>(null)
const { supports } = useCapabilities()
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const kimiLaunch = useKimiLaunch()
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
if (opt.value === 'chatgpt-pro')
@@ -233,8 +232,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
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') {
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
}
@@ -309,6 +306,9 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
? modelFuse.search(modelSearch).map((r) => r.item)
: modelInfoList
const showCustomEntry =
modelSearch && !filteredModels.some((m) => m.modelId === modelSearch)
// Handle provider type change (user-initiated via Select)
const handleTypeChange = (newType: ProviderType) => {
form.setValue('type', newType)
@@ -894,59 +894,96 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={setModelSearch}
onValueChange={(v) => {
setModelSearch(v)
requestAnimationFrame(() => {
modelListRef.current?.scrollTo(0, 0)
})
}}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
modelSearch &&
filteredModels.length === 0
) {
if (e.key === 'Enter' && modelSearch) {
e.preventDefault()
e.stopPropagation()
form.setValue('modelId', modelSearch)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: modelSearch,
is_custom_model: true,
is_custom_model: !modelInfoList.some(
(m) => m.modelId === modelSearch,
),
})
setModelPickerOpen(false)
setModelSearch('')
}
}}
/>
<CommandList>
<CommandList ref={modelListRef}>
<CommandEmpty>
No models found. Press Enter to use &quot;
{modelSearch}&quot;
</CommandEmpty>
<CommandGroup>
{filteredModels.map((model) => (
{showCustomEntry && (
<CommandGroup forceMount>
<CommandItem
key={model.modelId}
value={model.modelId}
forceMount
value={`custom:${modelSearch}`}
onSelect={() => {
form.setValue('modelId', model.modelId)
form.setValue('modelId', modelSearch)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: model.modelId,
context_window: model.contextLength,
is_custom_model: false,
model_id: modelSearch,
is_custom_model: true,
})
setModelPickerOpen(false)
setModelSearch('')
}}
>
<span className="flex-1 truncate">
{model.modelId}
{modelSearch}
</span>
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{formatContextWindow(model.contextLength)}
</span>
{field.value === model.modelId && (
{field.value === modelSearch && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandGroup>
)}
{filteredModels.length > 0 && (
<CommandGroup>
{filteredModels.map((model) => (
<CommandItem
key={model.modelId}
value={model.modelId}
onSelect={() => {
form.setValue('modelId', model.modelId)
track(MODEL_SELECTED_EVENT, {
provider_type: watchedType,
model_id: model.modelId,
context_window: model.contextLength,
is_custom_model: !modelInfoList.some(
(m) => m.modelId === model.modelId,
),
})
setModelPickerOpen(false)
setModelSearch('')
}}
>
<span className="flex-1 truncate">
{model.modelId}
</span>
{model.contextLength > 0 && (
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{formatContextWindow(
model.contextLength,
)}
</span>
)}
{field.value === model.modelId && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@@ -2,7 +2,6 @@ import { Check, Loader2, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { cn } from '@/lib/utils'
@@ -30,7 +29,6 @@ export const ProviderCard: FC<ProviderCardProps> = ({
isTesting = false,
}) => {
const inputId = `provider-${provider.id}`
const kimiLaunch = useKimiLaunch()
return (
<label
@@ -79,30 +77,21 @@ export const ProviderCard: FC<ProviderCardProps> = ({
</Badge>
)}
</div>
{isBuiltIn && provider.type === 'browseros' && kimiLaunch && (
<span className="mb-1 inline-block rounded-full border border-orange-300/60 bg-orange-100/70 px-3 py-0.5 font-semibold text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
In partnership with Moonshot AI
</span>
)}
<p className="truncate text-muted-foreground text-sm">
{isBuiltIn ? (
kimiLaunch ? (
'Extended usage limits for the next 2 weeks!'
) : (
<>
BrowserOS-hosted model with strict rate limits.{' '}
<a
href="https://docs.browseros.com/features/bring-your-own-llm"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
Bring your own key
</a>{' '}
for better performance.
</>
)
<>
BrowserOS-hosted model with strict rate limits.{' '}
<a
href="https://docs.browseros.com/features/bring-your-own-llm"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
Bring your own key
</a>{' '}
for better performance.
</>
) : provider.baseUrl ? (
`${provider.modelId}${provider.baseUrl}`
) : (

View File

@@ -7,7 +7,6 @@ import {
} from '@/components/ui/collapsible'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import {
type ProviderTemplate,
providerTemplates,
@@ -23,7 +22,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
onUseTemplate,
}) => {
const { supports } = useCapabilities()
const kimiLaunch = useKimiLaunch()
const filteredTemplates = providerTemplates.filter((template) => {
if (template.id === 'chatgpt-pro')
@@ -31,7 +29,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
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)
}
@@ -67,7 +64,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
<ProviderTemplateCard
key={template.id}
template={template}
highlighted={template.id === 'moonshot'}
isNew={isNew}
onUseTemplate={onUseTemplate}
/>

View File

@@ -1,484 +0,0 @@
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { compact } from 'es-toolkit/array'
import type { FC, FormEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import type { Provider } from '@/components/chat/chatComponentTypes'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable'
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import {
GRAPH_SAVED_EVENT,
GRAPH_UPDATED_EVENT,
NEW_GRAPH_CREATED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { track } from '@/lib/metrics/track'
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
import { sentry } from '@/lib/sentry/sentry'
import { useWorkflows } from '@/lib/workflows/workflowStorage'
import { GraphCanvas } from './GraphCanvas'
import { GraphChat } from './GraphChat'
import { WorkflowsChatHeader } from './WorkflowsChatHeader'
type MessageType = 'create-graph' | 'update-graph' | 'run-graph'
type GraphMessageMetadata = {
messageType?: MessageType
codeId?: string
graph?: GraphData
window?: chrome.windows.Window
}
export type GraphData = {
nodes: {
id: string
type: string
data: {
label: string
}
}[]
edges: {
id: string
source: string
target: string
}[]
}
const getLastMessageText = (messages: UIMessage[]) => {
const lastMessage = messages[messages.length - 1]
if (!lastMessage) return ''
return lastMessage.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
}
export const CreateGraph: FC = () => {
const [searchParams] = useSearchParams()
const workflowIdParam = searchParams.get('workflowId')
const [graphName, setGraphName] = useState('')
const [codeId, setCodeId] = useState<string | undefined>(undefined)
const [graphData, setGraphData] = useState<GraphData | undefined>(undefined)
const [savedWorkflowId, setSavedWorkflowId] = useState<string | undefined>(
undefined,
)
const [savedCodeId, setSavedCodeId] = useState<string | undefined>(undefined)
const [isInitialized, setIsInitialized] = useState(!workflowIdParam)
const [canvasPanelSize, setCanvasPanelSize] = useState<
{ asPercentage: number; inPixels: number } | undefined
>(undefined)
const [query, setQuery] = useState('')
const [showDiscardDialog, setShowDiscardDialog] = useState(false)
const { workflows, addWorkflow, editWorkflow } = useWorkflows()
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
const rpcClient = useRpcClient()
// Initialize edit mode when workflowId is provided
useDeepCompareEffect(() => {
if (!workflowIdParam || isInitialized) return
const workflow = workflows.find((w) => w.id === workflowIdParam)
if (!workflow) return
const initializeEditMode = async () => {
setGraphName(workflow.workflowName)
setCodeId(workflow.codeId)
setSavedWorkflowId(workflow.id)
setSavedCodeId(workflow.codeId)
try {
const response = await rpcClient.graph[':id'].$get({
param: { id: workflow.codeId },
})
if (response.ok) {
const data = await response.json()
if ('graph' in data && data.graph) {
setGraphData(data.graph as GraphData)
}
}
} catch (error) {
sentry.captureException(error, {
extra: {
message: 'Failed to fetch graph data from the server',
codeId: workflow.codeId,
},
})
}
setIsInitialized(true)
}
initializeEditMode()
}, [workflowIdParam, workflows, isInitialized, rpcClient])
const updateQuery = (newQuery: string) => {
setQuery(newQuery)
}
const onSubmit = (e: FormEvent) => {
e.preventDefault()
if (codeId) {
sendMessage({
text: query,
metadata: {
messageType: 'update-graph' as MessageType,
codeId,
},
})
track(GRAPH_UPDATED_EVENT)
} else {
sendMessage({
text: query,
metadata: {
messageType: 'create-graph' as MessageType,
},
})
track(NEW_GRAPH_CREATED_EVENT)
}
setQuery('')
}
const {
baseUrl: agentServerUrl,
isLoading: _isLoadingAgentUrl,
error: agentUrlError,
} = useAgentServerUrl()
const {
selectedLlmProviderRef,
enabledMcpServersRef,
enabledCustomServersRef,
personalizationRef,
selectedLlmProvider,
isLoadingProviders,
} = useChatRefs()
const agentUrlRef = useRef(agentServerUrl)
const codeIdRef = useRef(codeId)
useEffect(() => {
agentUrlRef.current = agentServerUrl
codeIdRef.current = codeId
}, [agentServerUrl, codeId])
const { sendMessage, stop, status, messages, error, setMessages } = useChat({
transport: new DefaultChatTransport({
prepareSendMessagesRequest: async ({ messages }) => {
const lastMessage = messages[messages.length - 1]
const lastMessageText = getLastMessageText(messages)
const metadata = lastMessage.metadata as
| GraphMessageMetadata
| undefined
if (metadata?.messageType === 'create-graph') {
return {
api: `${agentUrlRef.current}/graph`,
body: {
query: lastMessageText,
},
}
}
if (metadata?.messageType === 'update-graph' && codeIdRef.current) {
return {
api: `${agentUrlRef.current}/graph/${codeIdRef.current}`,
body: {
query: lastMessageText,
},
}
}
if (metadata?.messageType === 'run-graph' && codeIdRef.current) {
const provider = selectedLlmProviderRef.current
const enabledMcpServers = enabledMcpServersRef.current
const customMcpServers = enabledCustomServersRef.current
return {
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
body: {
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
model: provider?.modelId ?? 'browseros',
contextWindowSize: provider?.contextWindow,
temperature: provider?.temperature,
resourceName: provider?.resourceName,
// Bedrock-specific
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
browserContext: {
windowId: metadata?.window?.id,
activeTab: metadata?.window?.tabs?.[0],
enabledMcpServers: compact(enabledMcpServers),
customMcpServers,
},
userSystemPrompt: personalizationRef.current,
},
}
}
return {
api: `${agentUrlRef.current}/graph`,
body: {
query: lastMessageText,
},
}
},
}),
})
const lastAssistantMessageWithGraph = messages.findLast((m) => {
if (m.role !== 'assistant') return false
const metadata = m.metadata as GraphMessageMetadata | undefined
return metadata?.graph !== undefined
})
const onClickTest = async () => {
let backgroundWindow: chrome.windows.Window | undefined
try {
backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: true,
type: 'normal',
})
} catch {
// Fallback when no window context is available (e.g. all windows closed)
const tab = await chrome.tabs.create({
url: 'chrome://newtab',
active: true,
})
if (tab.windowId) {
backgroundWindow = await chrome.windows.get(tab.windowId)
}
}
sendMessage({
text: 'Run a test of the graph you just created.',
metadata: {
messageType: 'run-graph' as MessageType,
codeId,
window: backgroundWindow,
},
})
}
const hasUnsavedChanges = savedWorkflowId ? codeId !== savedCodeId : true
const shouldBlockNavigation = !!codeId && hasUnsavedChanges
// Handle browser refresh/close
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (shouldBlockNavigation) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [shouldBlockNavigation])
const onClickSave = async () => {
if (!graphName || !codeId) return
if (savedWorkflowId) {
await editWorkflow(savedWorkflowId, {
workflowName: graphName,
codeId,
})
setSavedCodeId(codeId)
} else {
const newWorkflow = await addWorkflow({
workflowName: graphName,
codeId,
})
setSavedWorkflowId(newWorkflow.id)
setSavedCodeId(codeId)
}
track(GRAPH_SAVED_EVENT)
}
// Provider data for header
const providers: Provider[] = llmProviders.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
}))
const selectedProviderForHeader: Provider | undefined = selectedLlmProvider
? {
id: selectedLlmProvider.id,
name: selectedLlmProvider.name,
type: selectedLlmProvider.type,
}
: providers[0]
// Has generated code but can't auto-save (no name)
const hasUnsavedWork = codeId && !graphName
const resetToNewWorkflow = () => {
setCodeId(undefined)
setGraphData(undefined)
setGraphName('')
setSavedWorkflowId(undefined)
setSavedCodeId(undefined)
setMessages([])
}
const handleSelectProvider = (provider: Provider) => {
setDefaultProvider(provider.id)
}
const handleNewWorkflow = async () => {
// Can auto-save: has name AND code
if (graphName && codeId) {
await onClickSave()
resetToNewWorkflow()
return
}
// Has unsaved work that can't be auto-saved: show confirmation
if (hasUnsavedWork) {
setShowDiscardDialog(true)
return
}
// Nothing to save, just reset
resetToNewWorkflow()
}
const handleConfirmDiscard = () => {
setShowDiscardDialog(false)
resetToNewWorkflow()
}
const handleSuggestionClick = (prompt: string) => {
sendMessage({
text: prompt,
metadata: {
messageType: 'create-graph' as MessageType,
},
})
}
useDeepCompareEffect(() => {
if (status === 'ready' && lastAssistantMessageWithGraph) {
const metadata = lastAssistantMessageWithGraph.metadata as
| GraphMessageMetadata
| undefined
setCodeId(metadata?.codeId)
setGraphData(metadata?.graph)
}
}, [status, lastAssistantMessageWithGraph ?? {}])
if (!isInitialized || isLoadingProviders || !selectedProviderForHeader) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background text-foreground">
<div className="fade-in animate-in text-muted-foreground duration-200 [animation-delay:300ms] [animation-fill-mode:backwards]">
Loading...
</div>
</div>
)
}
return (
<div className="h-screen w-screen bg-background text-foreground">
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
id="graph-canvas"
defaultSize={'70%'}
minSize={'30%'}
maxSize={'70%'}
onResize={(size) => setCanvasPanelSize(size)}
>
<GraphCanvas
graphName={graphName}
onGraphNameChange={(val) => setGraphName(val)}
graphData={graphData}
codeId={codeId}
onClickTest={onClickTest}
onClickSave={onClickSave}
isSaved={!!savedWorkflowId}
hasUnsavedChanges={hasUnsavedChanges}
shouldBlockNavigation={shouldBlockNavigation}
panelSize={canvasPanelSize}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
id="graph-chat"
defaultSize={'30%'}
maxSize={'70%'}
minSize={'30%'}
>
<div className="flex h-full flex-col">
<WorkflowsChatHeader
selectedProvider={selectedProviderForHeader}
providers={providers}
onSelectProvider={handleSelectProvider}
onNewWorkflow={handleNewWorkflow}
hasMessages={messages.length > 0}
/>
<div className="min-h-0 flex-1">
<GraphChat
messages={messages}
onSubmit={onSubmit}
onInputChange={updateQuery}
onStop={stop}
input={query}
status={status}
agentUrlError={agentUrlError}
chatError={error}
onSuggestionClick={handleSuggestionClick}
/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
<AlertDialog open={showDiscardDialog} onOpenChange={setShowDiscardDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard unsaved workflow?</AlertDialogTitle>
<AlertDialogDescription>
You have an unsaved workflow. Creating a new one will discard your
current changes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDiscard}>
Discard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,13 +0,0 @@
import { type FC, Suspense } from 'react'
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
import { CreateGraph } from './CreateGraph'
export const CreateGraphWrapper: FC = () => {
return (
<RpcClientProvider>
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
<CreateGraph />
</Suspense>
</RpcClientProvider>
)
}

View File

@@ -1,140 +0,0 @@
import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'
import {
CheckCircle,
Download,
GitBranch,
GitMerge,
MousePointer,
Navigation,
Play,
RotateCw,
Split,
Square,
} from 'lucide-react'
import type React from 'react'
import { memo } from 'react'
import { cn } from '@/lib/utils'
const nodeConfig: Record<
NodeType,
{ color: string; icon: React.ElementType; label: string }
> = {
start: {
color: 'text-green-600 dark:text-green-400',
icon: Play,
label: 'Start',
},
end: {
color: 'text-red-600 dark:text-red-400',
icon: Square,
label: 'End',
},
nav: {
color: 'text-blue-600 dark:text-blue-400',
icon: Navigation,
label: 'Navigate',
},
act: {
color: 'text-purple-600 dark:text-purple-400',
icon: MousePointer,
label: 'Action',
},
extract: {
color: 'text-amber-600 dark:text-amber-400',
icon: Download,
label: 'Extract',
},
verify: {
color: 'text-emerald-600 dark:text-emerald-400',
icon: CheckCircle,
label: 'Verify',
},
decision: {
color: 'text-pink-600 dark:text-pink-400',
icon: GitBranch,
label: 'Decision',
},
loop: {
color: 'text-cyan-600 dark:text-cyan-400',
icon: RotateCw,
label: 'Loop',
},
fork: {
color: 'text-indigo-600 dark:text-indigo-400',
icon: Split,
label: 'Fork',
},
join: {
color: 'text-lime-600 dark:text-lime-400',
icon: GitMerge,
label: 'Join',
},
}
export type NodeType =
| 'start'
| 'end'
| 'nav'
| 'act'
| 'extract'
| 'verify'
| 'decision'
| 'loop'
| 'fork'
| 'join'
type CustomNodeData = Node<{
type: NodeType
label: string
}>
export const CustomNode = memo(
({ data: { label, type } }: NodeProps<CustomNodeData>) => {
const config = nodeConfig[type || 'start']
const Icon = config.icon
const showSourceHandle = type !== 'end'
const showTargetHandle = type !== 'start'
return (
<div className="min-w-45 rounded-lg border border-border bg-card px-4 py-3 shadow-md transition-all">
{showTargetHandle && (
<Handle
type="target"
position={Position.Top}
className="h-2 w-2 bg-accent-orange!"
/>
)}
<div className="flex items-center gap-2">
<div className={cn('shrink-0', config.color)}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div
className={cn(
'mb-0.5 font-semibold text-xs uppercase tracking-wide',
config.color,
)}
>
{config.label}
</div>
<div className="wrap-break-word font-medium text-foreground text-sm">
{label}
</div>
</div>
</div>
{showSourceHandle && (
<Handle
type="source"
position={Position.Bottom}
className="h-2 w-2 bg-accent-orange!"
/>
)}
</div>
)
},
)
CustomNode.displayName = 'CustomNode'

View File

@@ -1,514 +0,0 @@
import cytoscape from 'cytoscape'
import dagre from 'cytoscape-dagre'
// @ts-expect-error no types available
import nodeHtmlLabel from 'cytoscape-node-html-label'
import DOMPurify from 'dompurify'
import {
ArrowLeft,
Maximize,
Minus,
Pencil,
Play,
Plus,
Save,
} from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import ProductLogo from '@/assets/product_logo.svg'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import type { GraphData } from './CreateGraph'
import type { NodeType } from './CustomNode'
cytoscape.use(dagre)
nodeHtmlLabel(cytoscape)
const NODE_CONFIG: Record<
NodeType,
{ color: string; bgColor: string; icon: string; label: string }
> = {
start: {
color: '#22c55e',
bgColor: 'rgba(34, 197, 94, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>`,
label: 'START',
},
end: {
color: '#ef4444',
bgColor: 'rgba(239, 68, 68, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>`,
label: 'END',
},
nav: {
color: '#3b82f6',
bgColor: 'rgba(59, 130, 246, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"></polygon></svg>`,
label: 'NAVIGATE',
},
act: {
color: '#8b5cf6',
bgColor: 'rgba(139, 92, 246, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 4 7.07 17 2.51-7.39L21 11.07z"></path></svg>`,
label: 'ACTION',
},
extract: {
color: '#f59e0b',
bgColor: 'rgba(245, 158, 11, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>`,
label: 'EXTRACT',
},
verify: {
color: '#10b981',
bgColor: 'rgba(16, 185, 129, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
label: 'VERIFY',
},
decision: {
color: '#ec4899',
bgColor: 'rgba(236, 72, 153, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>`,
label: 'DECISION',
},
loop: {
color: '#06b6d4',
bgColor: 'rgba(6, 182, 212, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`,
label: 'LOOP',
},
fork: {
color: '#6366f1',
bgColor: 'rgba(99, 102, 241, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"></path><path d="M8 3H3v5"></path><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"></path><path d="m15 9 6-6"></path></svg>`,
label: 'FORK',
},
join: {
color: '#84cc16',
bgColor: 'rgba(132, 204, 22, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M6 21V9a9 9 0 0 0 9 9"></path></svg>`,
label: 'JOIN',
},
}
const initialData: GraphData = {
nodes: [
{
id: 'start',
type: 'start',
data: { label: 'Use the Chat to build your workflow!' },
},
],
edges: [],
}
const MIN_NODE_WIDTH = 180
const MAX_NODE_WIDTH = 240
const BASE_NODE_HEIGHT = 70
const CHAR_WIDTH = 7
const ICON_AND_PADDING = 62
const MAX_ZOOM = 1.2
const calculateNodeDimensions = (
label: string,
): { width: number; height: number } => {
const textWidth = label.length * CHAR_WIDTH + ICON_AND_PADDING
const width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, textWidth))
const maxCharsPerLine = Math.floor((width - ICON_AND_PADDING) / CHAR_WIDTH)
const lines = Math.ceil(label.length / maxCharsPerLine)
const extraHeight = Math.max(0, lines - 1) * 18
const height = BASE_NODE_HEIGHT + extraHeight
return { width, height }
}
const createNodeHtml = (type: NodeType, label: string): string => {
const config = NODE_CONFIG[type] || NODE_CONFIG.start
const sanitizedLabel = DOMPurify.sanitize(label, { ALLOWED_TAGS: [] })
return `
<div class="graph-node" style="
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 160px;
max-width: 220px;
padding: 12px 16px;
background-color: var(--graph-node-bg);
border: 1px solid var(--graph-node-border);
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: system-ui, -apple-system, sans-serif;
">
<div style="
flex-shrink: 0;
color: ${config.color};
margin-top: 2px;
">
${config.icon}
</div>
<div style="flex: 1; min-width: 0;">
<div style="
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
color: ${config.color};
margin-bottom: 4px;
">${config.label}</div>
<div style="
font-size: 13px;
font-weight: 500;
color: var(--graph-node-text);
line-height: 1.4;
word-wrap: break-word;
">${sanitizedLabel}</div>
</div>
</div>
`
}
type GraphCanvasProps = {
graphName: string
onGraphNameChange: (name: string) => void
graphData?: GraphData
codeId?: string
onClickTest: () => unknown
onClickSave: () => unknown
isSaved: boolean
hasUnsavedChanges: boolean
shouldBlockNavigation: boolean
panelSize?: { asPercentage: number; inPixels: number }
}
export const GraphCanvas: FC<GraphCanvasProps> = ({
graphName,
onGraphNameChange,
graphData = initialData,
codeId,
onClickTest,
onClickSave,
isSaved,
hasUnsavedChanges,
shouldBlockNavigation,
panelSize,
}) => {
const [isEditingName, setIsEditingName] = useState(false)
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
const cyRef = useRef<cytoscape.Core | null>(null)
const handleBack = () => {
if (shouldBlockNavigation) {
const confirmed = window.confirm(
'You have unsaved changes. Are you sure you want to leave?',
)
if (!confirmed) return
}
navigate(-1)
}
const canTest = !!codeId
const canSave = !!graphName && !!codeId && hasUnsavedChanges
const getTestTooltip = () => {
if (!codeId) return 'Create a workflow using the chat first'
return 'Run a test of this workflow'
}
const getSaveTooltip = () => {
if (!codeId) return 'Create a workflow using the chat first'
if (!graphName) return 'Provide a name for the workflow'
if (isSaved && !hasUnsavedChanges) return 'Workflow already saved'
return isSaved ? 'Save changes to this workflow' : 'Save this workflow'
}
const getSaveButtonLabel = () => {
return isSaved ? 'Save Changes' : 'Save Workflow'
}
const zoomIn = useCallback(() => {
cyRef.current?.zoom(cyRef.current.zoom() * 1.2)
cyRef.current?.center()
}, [])
const zoomOut = useCallback(() => {
cyRef.current?.zoom(cyRef.current.zoom() / 1.2)
cyRef.current?.center()
}, [])
const fitView = useCallback(() => {
cyRef.current?.fit(undefined, 50)
cyRef.current?.center()
}, [])
useEffect(() => {
if (!containerRef.current) return
const cy = cytoscape({
container: containerRef.current,
elements: [],
style: [
{
selector: 'node',
style: {
width: 'data(nodeWidth)',
height: 'data(nodeHeight)',
'background-opacity': 0,
'border-width': 0,
},
},
{
selector: 'edge',
style: {
width: 2,
'line-color': '#f97316',
'target-arrow-color': '#f97316',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 1.2,
},
},
{
selector: 'edge.back-edge',
style: {
'line-style': 'dashed',
'line-dash-pattern': [6, 3],
'curve-style': 'unbundled-bezier',
'control-point-distances': [100],
'control-point-weights': [0.5],
},
},
],
layout: { name: 'preset' },
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
selectionType: 'single',
autoungrabify: true,
autounselectify: true,
maxZoom: MAX_ZOOM,
minZoom: 0.2,
})
// @ts-expect-error nodeHtmlLabel extension
cy.nodeHtmlLabel([
{
query: 'node',
halign: 'center',
valign: 'center',
halignBox: 'center',
valignBox: 'center',
tpl: (data: { type: NodeType; label: string }) => {
return createNodeHtml(data.type, data.label)
},
},
])
cyRef.current = cy
return () => {
cy.destroy()
}
}, [])
const updateGraph = useCallback((data: GraphData) => {
const cy = cyRef.current
if (!cy) return
cy.elements().remove()
const nodes = data.nodes.map((node) => {
const dimensions = calculateNodeDimensions(node.data.label)
return {
data: {
id: node.id,
label: node.data.label,
type: node.type as NodeType,
nodeWidth: dimensions.width,
nodeHeight: dimensions.height,
},
}
})
const edges = data.edges.map((edge) => ({
data: {
id: edge.id,
source: edge.source,
target: edge.target,
},
}))
cy.add([...nodes, ...edges])
cy.layout({
name: 'dagre',
rankDir: 'TB',
nodeSep: 80,
rankSep: 100,
padding: 50,
animate: true,
animationDuration: 300,
fit: true,
} as cytoscape.LayoutOptions).run()
setTimeout(() => {
cy.edges().forEach((edge) => {
const sourceNode = edge.source()
const targetNode = edge.target()
const sourceY = sourceNode.position('y')
const targetY = targetNode.position('y')
if (sourceY > targetY) {
edge.addClass('back-edge')
}
})
}, 350)
}, [])
useDeepCompareEffect(() => {
updateGraph(graphData)
}, [graphData])
useEffect(() => {
if (panelSize?.inPixels !== undefined) {
cyRef.current?.resize()
setTimeout(() => fitView(), 100)
}
}, [panelSize?.inPixels, fitView])
return (
<div className="flex h-full flex-col [--graph-node-bg:rgba(255,255,255,1)] [--graph-node-border:rgba(228,228,231,1)] [--graph-node-text:rgba(24,24,27,1)] dark:[--graph-node-bg:rgba(24,24,27,1)] dark:[--graph-node-border:rgba(63,63,70,1)] dark:[--graph-node-text:rgba(250,250,250,1)]">
{/* Graph Header */}
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<img src={ProductLogo} alt="BrowserOS" className="h-8 w-8 shrink-0" />
{isEditingName ? (
<input
type="text"
value={graphName}
onChange={(e) => onGraphNameChange(e.target.value)}
onBlur={() => setIsEditingName(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') setIsEditingName(false)
}}
// biome-ignore lint/a11y/noAutofocus: needed to autofocus field when edit mode is toggled
autoFocus
placeholder="Enter workflow name..."
className="max-w-64 border-[var(--accent-orange)] border-b bg-transparent font-semibold text-sm outline-none placeholder:font-normal placeholder:text-muted-foreground/60"
/>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditingName(true)}
className="group min-w-0 gap-2 px-2 py-1"
>
{graphName ? (
<span className="truncate font-semibold text-sm">
{graphName}
</span>
) : (
<span className="text-muted-foreground/60 text-sm italic">
Untitled workflow
</span>
)}
<Pencil className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Button>
)}
</div>
{/* Control Buttons */}
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
size="sm"
onClick={onClickTest}
disabled={!canTest}
>
<Play className="mr-1.5 h-4 w-4" />
Test Workflow
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{getTestTooltip()}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
onClick={onClickSave}
disabled={!canSave}
className="bg-[var(--accent-orange)] shadow-lg shadow-orange-500/20 hover:bg-[var(--accent-orange-bright)] disabled:bg-[var(--accent-orange)]/50"
>
<Save className="mr-1.5 h-4 w-4" />
{getSaveButtonLabel()}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{getSaveTooltip()}</TooltipContent>
</Tooltip>
</div>
</header>
{/* Graph Canvas */}
<div className="relative min-h-0 flex-1 overflow-hidden [--dot-color:rgba(0,0,0,0.2)] dark:[--dot-color:rgba(255,255,255,0.15)]">
<div
ref={containerRef}
className="h-full w-full bg-zinc-50 dark:bg-zinc-900"
style={{
backgroundImage:
'radial-gradient(circle, var(--dot-color) 1.5px, transparent 1.5px)',
backgroundSize: '20px 20px',
}}
/>
{/* Zoom Controls */}
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-1 rounded-lg border-2 border-border bg-card p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomIn}
title="Zoom in"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomOut}
title="Zoom out"
>
<Minus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={fitView}
title="Fit view"
>
<Maximize className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,194 +0,0 @@
import type { UIMessage } from 'ai'
import { Send, SquareStop } from 'lucide-react'
import type { FC, FormEventHandler, KeyboardEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
import { getResponseAndQueryFromMessageId } from '@/entrypoints/sidepanel/index/useChatSession'
import {
GRAPH_MESSAGE_DISLIKE_EVENT,
GRAPH_MESSAGE_LIKE_EVENT,
} from '@/lib/constants/analyticsEvents'
import { useJtbdPopup } from '@/lib/jtbd-popup/useJtbdPopup'
import { track } from '@/lib/metrics/track'
import { cn } from '@/lib/utils'
import { GraphEmptyState } from './GraphEmptyState'
import { getWorkflowDisplayMessages } from './workflow-tidbit-messages'
interface GraphChatProps {
onSubmit: FormEventHandler<HTMLFormElement>
onInputChange: (value: string) => void
onStop: () => void
input: string
status: 'streaming' | 'submitted' | 'ready' | 'error'
messages: UIMessage[]
chatError?: Error
agentUrlError?: Error | null
onSuggestionClick: (prompt: string) => void
}
export const GraphChat: FC<GraphChatProps> = ({
onSubmit,
onInputChange,
onStop,
input,
status,
messages,
chatError,
agentUrlError,
onSuggestionClick,
}) => {
const [liked, setLiked] = useState<Record<string, boolean>>({})
const [disliked, setDisliked] = useState<Record<string, boolean>>({})
const [mounted, setMounted] = useState(false)
const displayMessages = getWorkflowDisplayMessages(messages)
useEffect(() => {
setMounted(true)
}, [])
const {
popupVisible,
recordMessageSent,
triggerIfEligible,
onTakeSurvey: onTakeSurveyBase,
onDismiss: onDismissJtbdPopup,
} = useJtbdPopup()
const onTakeSurvey = () =>
onTakeSurveyBase({ experimentId: 'workflow_survey' })
// Trigger JTBD popup when AI finishes responding
const previousChatStatus = useRef(status)
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only trigger on status change
useEffect(() => {
const aiWasProcessing =
previousChatStatus.current === 'streaming' ||
previousChatStatus.current === 'submitted'
const aiJustFinished = aiWasProcessing && status === 'ready'
if (aiJustFinished && messages.length > 0) {
triggerIfEligible()
}
previousChatStatus.current = status
}, [status])
const onClickLike = (messageId: string) => {
const { responseText, queryText } = getResponseAndQueryFromMessageId(
messages,
messageId,
)
track(GRAPH_MESSAGE_LIKE_EVENT, { responseText, queryText, messageId })
setLiked((prev) => ({
...prev,
[messageId]: !prev[messageId],
}))
}
const onClickDislike = (messageId: string, comment?: string) => {
const { responseText, queryText } = getResponseAndQueryFromMessageId(
messages,
messageId,
)
track(GRAPH_MESSAGE_DISLIKE_EVENT, {
responseText,
queryText,
messageId,
comment,
})
setDisliked((prev) => ({
...prev,
[messageId]: !prev[messageId],
}))
}
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
recordMessageSent()
onSubmit(e)
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault()
if (input.trim()) {
e.currentTarget.form?.requestSubmit()
}
}
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="styled-scrollbar min-h-0 flex-1 overflow-y-auto pb-2">
{displayMessages.length === 0 ? (
<GraphEmptyState
mounted={mounted}
onSuggestionClick={onSuggestionClick}
/>
) : (
<ChatMessages
liked={liked}
disliked={disliked}
onClickDislike={onClickDislike}
onClickLike={onClickLike}
messages={displayMessages}
status={status}
showJtbdPopup={popupVisible}
showDontShowAgain={false}
onTakeSurvey={onTakeSurvey}
onDismissJtbdPopup={onDismissJtbdPopup}
/>
)}
</div>
{agentUrlError && <ChatError error={agentUrlError} />}
{chatError && <ChatError error={chatError} />}
<div className="shrink-0 border-border/40 border-t bg-background/80 p-2 backdrop-blur-md">
<form
onSubmit={handleSubmit}
className="relative flex w-full items-end gap-2"
>
<textarea
className={cn(
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 pr-11 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
)}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
'Visit Amazon and add sensodyne toothpaste to the cart.'
}
rows={1}
/>
{status === 'streaming' ? (
<button
type="button"
onClick={onStop}
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
>
<SquareStop className="h-3.5 w-3.5" />
<span className="sr-only">Stop</span>
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
<span className="sr-only">Send</span>
</button>
)}
</form>
</div>
</div>
)
}

View File

@@ -1,77 +0,0 @@
import { Workflow } from 'lucide-react'
import type { FC } from 'react'
import { cn } from '@/lib/utils'
interface Suggestion {
display: string
prompt: string
icon: string
}
const WORKFLOW_SUGGESTIONS: Suggestion[] = [
{
display: 'Search Amazon and add toothpaste to cart',
prompt:
'Go to Amazon, search for toothpaste, select 1 pack filter and add the first result to cart',
icon: '🛒',
},
{
display: 'Accept LinkedIn connection requests',
prompt:
'Open LinkedIn and go to my connection requests, accept one by one in a loop for 25 times',
icon: '🤝',
},
{
display: 'Unsubscribe from Gmail subscriptions',
prompt:
'Go to Gmail, navigate to manage subscriptions and unsubscribe from all',
icon: '📧',
},
]
interface GraphEmptyStateProps {
mounted: boolean
onSuggestionClick: (prompt: string) => void
}
export const GraphEmptyState: FC<GraphEmptyStateProps> = ({
mounted,
onSuggestionClick,
}) => {
return (
<div
className={cn(
'm-0! flex h-full flex-col items-center justify-center space-y-4 text-center opacity-0 transition-all duration-700',
mounted ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0',
)}
>
<div className="mb-2 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50">
<Workflow className="h-7 w-7 text-[var(--accent-orange)]" />
</div>
<div>
<h2 className="mb-1 font-semibold text-lg">
Create reliable workflows
</h2>
<p className="max-w-[240px] text-muted-foreground text-xs">
Chat with the agent to create and refine browser automation
</p>
</div>
<div className="mt-6 grid w-full max-w-[300px] grid-cols-1 gap-2">
{WORKFLOW_SUGGESTIONS.map((suggestion) => (
<button
type="button"
key={suggestion.display}
onClick={() => onSuggestionClick(suggestion.prompt)}
className="group flex items-center justify-between rounded-lg border border-border/50 bg-card px-3 py-2.5 text-left text-xs transition-all duration-200 hover:border-[var(--accent-orange)]/50 hover:bg-[var(--accent-orange)]/5"
>
{suggestion.display}
<span className="opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{suggestion.icon}
</span>
</button>
))}
</div>
</div>
)
}

View File

@@ -1,92 +0,0 @@
import { Github, Plus, SettingsIcon } from 'lucide-react'
import type { FC } from 'react'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { productRepositoryUrl } from '@/lib/constants/productUrls'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
interface WorkflowsChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
onSelectProvider: (provider: Provider) => void
onNewWorkflow: () => void
hasMessages: boolean
}
export const WorkflowsChatHeader: FC<WorkflowsChatHeaderProps> = ({
selectedProvider,
providers,
onSelectProvider,
onNewWorkflow,
hasMessages,
}) => {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
<div className="flex items-center gap-2">
<ChatProviderSelector
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={onSelectProvider}
>
<button
type="button"
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
title="Change AI Provider"
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={18} />
) : (
<ProviderIcon
type={selectedProvider.type as ProviderType}
size={18}
/>
)}
<span className="font-semibold text-base">
{selectedProvider.name}
</span>
</button>
</ChatProviderSelector>
</div>
<div className="flex items-center gap-1">
{hasMessages && (
<button
type="button"
onClick={onNewWorkflow}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New workflow"
>
<Plus className="h-4 w-4" />
</button>
)}
<a
href={productRepositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Star on Github"
>
<Github className="h-4 w-4" />
</a>
<a
href="/app.html#/settings"
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Settings"
>
<SettingsIcon className="h-4 w-4" />
</a>
<ThemeToggle
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
iconClassName="h-4 w-4"
/>
</div>
</header>
)
}

View File

@@ -1,111 +0,0 @@
import type { UIMessage } from 'ai'
type MessagePart = UIMessage['parts'][number]
const TIDBIT_SUFFIXES = ['...', '\u2026'] as const
const isTextPart = (
part: MessagePart,
): part is MessagePart & { type: 'text' } => part.type === 'text'
const isTidbitLine = (line: string): boolean => {
const trimmed = line.trim()
if (trimmed.length === 0) return false
return TIDBIT_SUFFIXES.some((suffix) => trimmed.endsWith(suffix))
}
const getNonEmptyLines = (text: string): string[] =>
text.split('\n').filter((line) => line.trim().length > 0)
const isAllTidbitText = (text: string): boolean => {
const lines = getNonEmptyLines(text)
return lines.length > 0 && lines.every((line) => isTidbitLine(line))
}
export const isWorkflowTidbitMessage = (message: UIMessage): boolean => {
if (message.role !== 'assistant') return false
if (message.parts.length === 0) return false
if (message.parts.some((part) => !isTextPart(part))) return false
const fullText = message.parts
.filter((part) => isTextPart(part))
.map((part) => part.text)
.join('')
return isAllTidbitText(fullText)
}
// within a text part that has multiple tidbit lines, keep only the last line
const compactTidbitLinesInPart = (part: MessagePart): MessagePart => {
if (!isTextPart(part)) return part
const lines = getNonEmptyLines(part.text)
if (lines.length <= 1) return part
if (!lines.every((line) => isTidbitLine(line))) return part
return { ...part, text: lines[lines.length - 1] }
}
// collapse consecutive tidbit text parts within a single message
const compactTidbitPartsInMessage = (message: UIMessage): UIMessage => {
if (message.role !== 'assistant') return message
// first compact multi-line tidbit text within each part
const lineCompactedParts = message.parts.map(compactTidbitLinesInPart)
// then collapse consecutive tidbit parts to just the last one
const compactedParts: UIMessage['parts'] = []
let pendingTidbitPart: (MessagePart & { type: 'text' }) | null = null
const flushPendingTidbitPart = () => {
if (!pendingTidbitPart) return
compactedParts.push(pendingTidbitPart)
pendingTidbitPart = null
}
for (const part of lineCompactedParts) {
if (isTextPart(part) && isAllTidbitText(part.text)) {
pendingTidbitPart = part
continue
}
flushPendingTidbitPart()
compactedParts.push(part)
}
flushPendingTidbitPart()
const partsChanged =
compactedParts.length !== message.parts.length ||
compactedParts.some((p, i) => p !== message.parts[i])
if (!partsChanged) return message
return { ...message, parts: compactedParts }
}
export const getWorkflowDisplayMessages = (
messages: UIMessage[],
): UIMessage[] => {
// first compact tidbit parts within each message
const normalizedMessages = messages.map(compactTidbitPartsInMessage)
const compactedMessages: UIMessage[] = []
// then collapse consecutive tidbit-only messages
for (const message of normalizedMessages) {
const previousMessage = compactedMessages[compactedMessages.length - 1]
const shouldReplacePreviousTidbit =
previousMessage &&
isWorkflowTidbitMessage(previousMessage) &&
isWorkflowTidbitMessage(message)
if (shouldReplacePreviousTidbit) {
compactedMessages[compactedMessages.length - 1] = message
continue
}
compactedMessages.push(message)
}
return compactedMessages
}

View File

@@ -2,8 +2,6 @@ import { Globe2, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import { cn } from '@/lib/utils'
import { getFaviconUrl, type LlmHubProvider } from './models'
interface HubProviderRowProps {
@@ -20,20 +18,9 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
onDelete,
}) => {
const iconUrl = useMemo(() => getFaviconUrl(provider.url), [provider.url])
const kimiLaunch = useKimiLaunch()
const normalizedName = provider.name.trim().toLowerCase()
const normalizedUrl = provider.url.trim().toLowerCase()
const isKimi = normalizedName === 'kimi' || normalizedUrl.includes('kimi.com')
const showKimiFlare = isKimi && kimiLaunch
return (
<div
className={cn(
'group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
showKimiFlare &&
'border-orange-300/80 bg-orange-50/20 shadow-sm ring-1 ring-orange-300/45 dark:bg-orange-500/5',
)}
>
<div className="group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{iconUrl ? (
<img
@@ -49,16 +36,6 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-center gap-2">
<span className="block truncate font-semibold">{provider.name}</span>
{showKimiFlare && (
<div className="flex flex-wrap items-center gap-1">
<span className="rounded-full border border-orange-300/60 bg-orange-100/70 px-2 py-0.5 font-semibold text-[11px] text-orange-700 dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
Recommended
</span>
<span className="rounded-full border border-orange-300/60 bg-orange-100/60 px-2.5 py-0.5 font-medium text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
Powered by Moonshot AI
</span>
</div>
)}
</div>
<p className="truncate text-muted-foreground/70 text-xs">
{provider.url}

View File

@@ -28,7 +28,7 @@ export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="rounded-lg border border-border border-dashed py-8 text-center">
<p className="text-muted-foreground text-sm">
No scheduled tasks yet. Create one to automate recurring workflows.
No scheduled tasks yet. Create one to automate recurring tasks.
</p>
</div>
</div>

View File

@@ -238,7 +238,7 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
<h3 className="mb-1 font-medium text-lg">No skills yet</h3>
<p className="mb-5 max-w-sm text-muted-foreground text-sm leading-6">
Skills teach your agent how to handle repeatable tasks like research,
extraction, and structured workflows.
extraction, and repeatable browser tasks.
</p>
<Button onClick={onCreateClick} size="sm">
<Plus className="mr-1.5 size-4" />

View File

@@ -1,123 +0,0 @@
import type { UIMessage } from 'ai'
import { Loader2, RotateCcw, Square, X } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface RunWorkflowDialogProps {
open: boolean
workflowName: string
messages: UIMessage[]
status: 'streaming' | 'submitted' | 'ready' | 'error'
wasCancelled: boolean
error: Error | undefined
onStop: () => void
onRetry: () => void
onClose: () => void
}
export const RunWorkflowDialog: FC<RunWorkflowDialogProps> = ({
open,
workflowName,
messages,
status,
wasCancelled,
error,
onStop,
onRetry,
onClose,
}) => {
const isProcessing = status === 'streaming' || status === 'submitted'
const _isComplete = !isProcessing
const getStatusText = () => {
if (status === 'submitted') return 'Starting workflow...'
if (status === 'streaming') return 'Running...'
if (wasCancelled) return 'Execution cancelled'
if (status === 'error') return 'Error occurred'
return 'Completed'
}
const getMessageContent = (message: UIMessage) => {
return message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
}
const assistantMessages = messages.filter((m) => m.role === 'assistant')
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="max-h-[80vh] max-w-2xl overflow-hidden [&>button]:hidden"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader className="flex-row items-center justify-between space-y-0">
<DialogTitle className="flex items-center gap-2">
{isProcessing && (
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent-orange)]" />
)}
Running: {workflowName}
</DialogTitle>
<div className="flex items-center gap-2">
{isProcessing ? (
<Button variant="destructive" size="sm" onClick={onStop}>
<Square className="mr-1.5 h-3 w-3" />
Stop
</Button>
) : (
<>
<Button variant="secondary" size="sm" onClick={onRetry}>
<RotateCcw className="mr-1.5 h-3 w-3" />
Retry
</Button>
<Button variant="outline" size="sm" onClick={onClose}>
<X className="mr-1.5 h-3 w-3" />
Close
</Button>
</>
)}
</div>
</DialogHeader>
<div className="flex flex-col gap-2">
<div className="text-muted-foreground text-sm">{getStatusText()}</div>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
<div className="font-medium">Error Details</div>
<div className="mt-1 whitespace-pre-wrap font-mono text-xs">
{error.message}
</div>
</div>
)}
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border bg-muted/30 p-4">
{assistantMessages.length === 0 ? (
<div className="text-muted-foreground text-sm">
{isProcessing
? 'Waiting for response...'
: 'No output available.'}
</div>
) : (
<div className="space-y-4">
{assistantMessages.map((message) => (
<div key={message.id} className="whitespace-pre-wrap text-sm">
{getMessageContent(message)}
</div>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,51 +0,0 @@
import { Pencil, Play, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { NavLink } from 'react-router'
import { Button } from '@/components/ui/button'
import type { Workflow } from '@/lib/workflows/workflowStorage'
interface WorkflowCardProps {
workflow: Workflow
onDelete: () => void
onRun: () => void
}
export const WorkflowCard: FC<WorkflowCardProps> = ({
workflow,
onDelete,
onRun,
}) => {
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:border-[var(--accent-orange)]/50 hover:shadow-sm">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<span className="truncate font-semibold">
{workflow.workflowName}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={onRun}>
<Play className="mr-1.5 h-3 w-3" />
Run
</Button>
<Button asChild variant="outline" size="sm">
<NavLink to={`/workflows/create-graph?workflowId=${workflow.id}`}>
<Pencil className="mr-1.5 h-3 w-3" />
Edit
</NavLink>
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onDelete}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={`Delete ${workflow.workflowName}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,56 +0,0 @@
import { HelpCircle, Plus, Workflow } from 'lucide-react'
import type { FC } from 'react'
import { NavLink } from 'react-router'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { workflowsHelpUrl } from '@/lib/constants/productUrls'
export const WorkflowsHeader: FC = () => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Workflow className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
<h2 className="font-semibold text-xl">Workflows</h2>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<a
href={workflowsHelpUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</a>
</TooltipTrigger>
<TooltipContent>Learn more about workflows</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-muted-foreground text-sm">
Create and manage browser automation workflows
</p>
</div>
<Button
asChild
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
variant="outline"
>
<NavLink to="/workflows/create-graph">
<Plus className="mr-1.5 h-4 w-4" />
New Workflow
</NavLink>
</Button>
</div>
</div>
)
}

View File

@@ -1,40 +0,0 @@
import type { FC } from 'react'
import type { Workflow } from '@/lib/workflows/workflowStorage'
import { WorkflowCard } from './WorkflowCard'
interface WorkflowsListProps {
workflows: Workflow[]
onDelete: (workflowId: string) => void
onRun: (workflowId: string) => void
}
export const WorkflowsList: FC<WorkflowsListProps> = ({
workflows,
onDelete,
onRun,
}) => {
if (workflows.length === 0) {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="rounded-lg border border-border border-dashed py-8 text-center">
<p className="text-muted-foreground text-sm">
No workflows yet. Create one to automate browser tasks.
</p>
</div>
</div>
)
}
return (
<div className="space-y-3">
{workflows.map((workflow) => (
<WorkflowCard
key={workflow.id}
workflow={workflow}
onDelete={() => onDelete(workflow.id)}
onRun={() => onRun(workflow.id)}
/>
))}
</div>
)
}

View File

@@ -1,127 +0,0 @@
import { type FC, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
WORKFLOW_DELETED_EVENT,
WORKFLOW_RUN_STARTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
import { sentry } from '@/lib/sentry/sentry'
import { useWorkflows } from '@/lib/workflows/workflowStorage'
import { RunWorkflowDialog } from './RunWorkflowDialog'
import { useRunWorkflow } from './useRunWorkflow'
import { WorkflowsHeader } from './WorkflowsHeader'
import { WorkflowsList } from './WorkflowsList'
export const WorkflowsPage: FC = () => {
const { workflows, removeWorkflow } = useWorkflows()
const rpcClient = useRpcClient()
const [deleteWorkflowId, setDeleteWorkflowId] = useState<string | null>(null)
const {
isRunning,
runningWorkflowName,
messages,
status,
wasCancelled,
error,
runWorkflow,
stopRun,
retry,
closeDialog,
} = useRunWorkflow()
const handleDelete = (workflowId: string) => {
setDeleteWorkflowId(workflowId)
}
const confirmDelete = async () => {
if (!deleteWorkflowId) return
const workflow = workflows.find((w) => w.id === deleteWorkflowId)
if (!workflow) return
try {
await rpcClient.graph[':id'].$delete({ param: { id: workflow.codeId } })
} catch (error) {
sentry.captureException(error, {
extra: {
message: 'Failed to delete graph from server',
codeId: workflow.codeId,
workflowId: deleteWorkflowId,
},
})
}
await removeWorkflow(deleteWorkflowId)
setDeleteWorkflowId(null)
track(WORKFLOW_DELETED_EVENT)
}
const handleRun = (workflowId: string) => {
const workflow = workflows.find((w) => w.id === workflowId)
if (workflow) {
track(WORKFLOW_RUN_STARTED_EVENT)
runWorkflow(workflow.codeId, workflow.workflowName)
}
}
const workflowToDelete = deleteWorkflowId
? workflows.find((w) => w.id === deleteWorkflowId)
: null
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<WorkflowsHeader />
<WorkflowsList
workflows={workflows}
onDelete={handleDelete}
onRun={handleRun}
/>
<AlertDialog
open={deleteWorkflowId !== null}
onOpenChange={(open) => !open && setDeleteWorkflowId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
<AlertDialogDescription>
Delete "{workflowToDelete?.workflowName}"? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<RunWorkflowDialog
open={isRunning}
workflowName={runningWorkflowName}
messages={messages}
status={status}
wasCancelled={wasCancelled}
error={error}
onStop={stopRun}
onRetry={retry}
onClose={closeDialog}
/>
</div>
)
}

View File

@@ -1,10 +0,0 @@
import { type FC, Suspense } from 'react'
import { WorkflowsPage } from './WorkflowsPage'
export const WorkflowsPageWrapper: FC = () => {
return (
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
<WorkflowsPage />
</Suspense>
)
}

View File

@@ -1,167 +0,0 @@
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { compact } from 'es-toolkit/array'
import { useEffect, useRef, useState } from 'react'
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import {
WORKFLOW_RUN_COMPLETED_EVENT,
WORKFLOW_RUN_RETRIED_EVENT,
WORKFLOW_RUN_STOPPED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
type WorkflowMessageMetadata = {
window?: chrome.windows.Window
}
export const useRunWorkflow = () => {
const [isRunning, setIsRunning] = useState(false)
const [runningWorkflowName, setRunningWorkflowName] = useState<string>('')
const [wasCancelled, setWasCancelled] = useState(false)
const codeIdRef = useRef<string | undefined>(undefined)
const { baseUrl: agentServerUrl } = useAgentServerUrl()
const {
selectedLlmProviderRef,
enabledMcpServersRef,
enabledCustomServersRef,
personalizationRef,
} = useChatRefs()
const agentUrlRef = useRef(agentServerUrl)
useEffect(() => {
agentUrlRef.current = agentServerUrl
}, [agentServerUrl])
const { sendMessage, stop, status, messages, setMessages, error } = useChat({
transport: new DefaultChatTransport({
prepareSendMessagesRequest: async ({ messages }) => {
const lastMessage = messages[messages.length - 1]
const metadata = lastMessage.metadata as
| WorkflowMessageMetadata
| undefined
const provider = selectedLlmProviderRef.current
const enabledMcpServers = enabledMcpServersRef.current
const customMcpServers = enabledCustomServersRef.current
return {
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
body: {
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
model: provider?.modelId ?? 'browseros',
contextWindowSize: provider?.contextWindow,
temperature: provider?.temperature,
resourceName: provider?.resourceName,
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
browserContext: {
windowId: metadata?.window?.id,
activeTab: metadata?.window?.tabs?.[0],
enabledMcpServers: compact(enabledMcpServers),
customMcpServers,
},
userSystemPrompt: personalizationRef.current,
supportsImages: provider?.supportsImages,
},
}
},
}),
})
const previousStatus = useRef(status)
useEffect(() => {
const wasProcessing =
previousStatus.current === 'streaming' ||
previousStatus.current === 'submitted'
const justFinished =
wasProcessing && (status === 'ready' || status === 'error')
if (justFinished && isRunning) {
track(WORKFLOW_RUN_COMPLETED_EVENT, {
status: wasCancelled
? 'cancelled'
: status === 'error'
? 'failed'
: 'completed',
})
}
previousStatus.current = status
}, [status, isRunning, wasCancelled])
const startWorkflowRun = async () => {
setMessages([])
setWasCancelled(false)
let backgroundWindow: chrome.windows.Window | undefined
try {
backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: true,
type: 'normal',
})
} catch {
// Fallback when no window context is available (e.g. all windows closed)
const tab = await chrome.tabs.create({
url: 'chrome://newtab',
active: true,
})
if (tab.windowId) {
backgroundWindow = await chrome.windows.get(tab.windowId)
}
}
sendMessage({
text: 'Run the workflow.',
metadata: {
window: backgroundWindow,
},
})
}
const runWorkflow = async (codeId: string, workflowName: string) => {
codeIdRef.current = codeId
setRunningWorkflowName(workflowName)
setIsRunning(true)
await startWorkflowRun()
}
const stopRun = () => {
track(WORKFLOW_RUN_STOPPED_EVENT)
setWasCancelled(true)
stop()
}
const retry = async () => {
track(WORKFLOW_RUN_RETRIED_EVENT)
await startWorkflowRun()
}
const closeDialog = () => {
setIsRunning(false)
setRunningWorkflowName('')
setWasCancelled(false)
setMessages([])
}
return {
isRunning,
runningWorkflowName,
messages,
status,
wasCancelled,
error,
runWorkflow,
stopRun,
retry,
closeDialog,
}
}

View File

@@ -45,7 +45,7 @@ export const TIPS: Tip[] = [
},
{
id: 'mcp-servers',
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to build multi-service workflows.',
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to power multi-service automations.',
},
{
id: 'skills',
@@ -75,10 +75,6 @@ export const TIPS: Tip[] = [
id: 'at-mention-tabs',
text: 'Type @ in the search bar to mention and attach open tabs as context for your AI queries.',
},
{
id: 'workflows',
text: 'For complex repeatable tasks, build visual Workflows instead of one-off prompts for consistent results.',
},
{
id: 'mode-selection',
text: 'Use Chat mode for read-only operations like questions and summaries, and Agent mode for multi-step browser tasks.',

View File

@@ -5,7 +5,6 @@ import {
Bot,
Code2,
FolderOpen,
GitBranch,
LinkIcon,
Plug,
SplitSquareHorizontal,
@@ -23,7 +22,6 @@ import {
COWORK_DEMO_URL,
MCP_SERVER_DEMO_URL,
SPLIT_VIEW_GIF_URL,
WORKFLOWS_DEMO_URL,
} from '@/lib/constants/mediaUrls'
import {
discordUrl,
@@ -44,7 +42,7 @@ const features: Feature[] = [
description:
'Describe any task and watch BrowserOS execute it—clicking, typing, and navigating for you.',
detailedDescription:
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step workflows automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step browser tasks automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
highlights: [
'Multi-tab execution — run agents in multiple tabs simultaneously',
'Smart navigation — automatically finds and interacts with page elements',
@@ -75,24 +73,6 @@ const features: Feature[] = [
gridClass: 'md:col-span-1',
videoUrl: MCP_SERVER_DEMO_URL,
},
{
id: 'workflows',
Icon: GitBranch,
tag: 'AUTOMATION',
title: 'Visual Workflows',
description:
'Build reliable, repeatable automations with a visual graph builder.',
detailedDescription:
'Workflows turn complex browser tasks into reliable, reusable automations. Instead of hoping the agent figures out the right steps each time, you define the exact sequence in a visual graph. Describe what you want in chat, and the workflow agent generates the graph. Add loops, conditionals, and parallel branches. Save workflows and run them on-demand whenever you need.',
highlights: [
'Chat-to-graph — describe your automation and get a visual workflow',
'Parallel execution — run multiple branches simultaneously',
'Loops & conditionals — handle complex logic with flow control',
'Save & reuse — run saved workflows on-demand, daily, or weekly',
],
gridClass: 'md:col-span-1',
videoUrl: WORKFLOWS_DEMO_URL || undefined,
},
{
id: 'cowork',
Icon: FolderOpen,

View File

@@ -1,20 +1,18 @@
import { AlertCircle, RefreshCw } from 'lucide-react'
import type { FC } from 'react'
// import { useMemo } from 'react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
// --- Commented out for Kimi partnership launch (restore after) ---
// const SURVEY_DIRECTIONS = [
// 'competitor',
// 'switching',
// 'workflow',
// 'activation',
// ] as const
//
// function pickRandomDirection(): string {
// return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
// }
// --- End commented out survey code ---
const SURVEY_DIRECTIONS = [
'competitor',
'switching',
'workflow',
'activation',
] as const
function pickRandomDirection(): string {
return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
}
interface ChatErrorProps {
error: Error
@@ -95,13 +93,11 @@ export const ChatError: FC<ChatErrorProps> = ({
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
parseErrorMessage(error.message, providerType)
// --- Commented out for Kimi partnership launch (restore after) ---
// const surveyUrl = useMemo(
// () =>
// `/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
// [],
// )
// --- End commented out survey code ---
const surveyUrl = useMemo(
() =>
`/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
[],
)
const getTitle = () => {
if (isRateLimit) return 'Daily limit reached'
@@ -126,8 +122,17 @@ export const ChatError: FC<ChatErrorProps> = ({
View troubleshooting guide
</a>
)}
{/* --- Commented out for Kimi partnership launch (restore after) ---
{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 && (
<p className="text-muted-foreground text-xs">
<a
href={url}
@@ -148,27 +153,6 @@ export const ChatError: FC<ChatErrorProps> = ({
</a>
</p>
)}
--- End commented out survey code --- */}
{isCreditsExhausted && url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground text-xs underline hover:text-foreground"
>
View Usage & Billing
</a>
)}
{isRateLimit && providerType === 'browseros' && (
<a
href="/app.html#/settings/ai"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 px-3 py-1.5 font-medium text-[var(--accent-orange)] text-xs transition-colors hover:bg-[var(--accent-orange)]/20"
>
Add your own provider for unlimited usage
</a>
)}
{onRetry && (
<Button
variant="outline"

View File

@@ -31,8 +31,6 @@ export enum Feature {
WORKSPACE_FOLDER_SUPPORT = 'WORKSPACE_FOLDER_SUPPORT',
// Proxy server support
PROXY_SUPPORT = 'PROXY_SUPPORT',
// Workflows feature
WORKFLOW_SUPPORT = 'WORKFLOW_SUPPORT',
// previousConversation as structured array (older servers only accept string)
PREVIOUS_CONVERSATION_ARRAY = 'PREVIOUS_CONVERSATION_ARRAY',
// Soul page: agent personality viewer and editor
@@ -73,7 +71,6 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
[Feature.CUSTOMIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
[Feature.WORKSPACE_FOLDER_SUPPORT]: { minBrowserOSVersion: '0.36.4.0' },
[Feature.PROXY_SUPPORT]: { minBrowserOSVersion: '0.39.0.1' },
[Feature.WORKFLOW_SUPPORT]: { minServerVersion: '0.0.41' },
[Feature.PREVIOUS_CONVERSATION_ARRAY]: { minServerVersion: '0.0.64' },
[Feature.SOUL_SUPPORT]: { minServerVersion: '0.0.67' },
[Feature.NEWTAB_CHAT_SUPPORT]: { minBrowserOSVersion: '0.40.0.0' },

View File

@@ -1,19 +1,6 @@
/** @public */
export const MESSAGE_LIKE_EVENT = 'ui.message.like'
export const GRAPH_MESSAGE_LIKE_EVENT = 'settings.graph.message.like'
export const GRAPH_MESSAGE_DISLIKE_EVENT = 'settings.graph.message.dislike'
/** @public */
export const NEW_GRAPH_CREATED_EVENT = 'settings.graph.created'
/** @public */
export const GRAPH_SAVED_EVENT = 'settings.graph.saved'
/** @public */
export const GRAPH_UPDATED_EVENT = 'settings.graph.updated'
/** @public */
export const MESSAGE_DISLIKE_EVENT = 'ui.message.dislike'
@@ -178,21 +165,6 @@ export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
/** @public */
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
/** @public */
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
/** @public */
export const WORKFLOW_RUN_STARTED_EVENT = 'settings.workflow.run_started'
/** @public */
export const WORKFLOW_RUN_STOPPED_EVENT = 'settings.workflow.run_stopped'
/** @public */
export const WORKFLOW_RUN_RETRIED_EVENT = 'settings.workflow.run_retried'
/** @public */
export const WORKFLOW_RUN_COMPLETED_EVENT = 'settings.workflow.run_completed'
/** @public */
export const SIDEPANEL_AI_TRIGGERED_EVENT = 'sidepanel.ai.triggered'
@@ -308,14 +280,6 @@ export const KIMI_API_KEY_CONFIGURED_EVENT = 'settings.kimi.api_key_configured'
export const KIMI_API_KEY_GUIDE_CLICKED_EVENT =
'settings.kimi.api_key_guide_clicked'
/** @public */
export const KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT =
'ui.rate_limit.kimi_docs_clicked'
/** @public */
export const KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT =
'ui.rate_limit.moonshot_platform_clicked'
/** @public */
export const SIDEPANEL_VOICE_RECORDING_STARTED_EVENT =
'sidepanel.voice.recording_started'

View File

@@ -49,11 +49,6 @@ export const productVideoUrl = 'https://youtu.be/J-lFhTP-7is'
*/
export const productRepositoryShortUrl = 'https://git.new/browseros'
/**
* @public
*/
export const workflowsHelpUrl = 'https://docs.browseros.com/features/workflows'
/**
* @public
*/

View File

@@ -6,7 +6,6 @@ const EnvSchema = z.object({
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
VITE_PUBLIC_KIMI_LAUNCH: z.string().optional(),
PROD: z.boolean(),
})

View File

@@ -1,14 +0,0 @@
import { env } from '@/lib/env'
const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on'])
function parseKimiLaunchFlag(value: string | undefined): boolean {
if (!value) return false
return ENABLED_VALUES.has(value.trim().toLowerCase())
}
const kimiLaunchEnabled = parseKimiLaunchFlag(env.VITE_PUBLIC_KIMI_LAUNCH)
export function isKimiLaunchEnabled(): boolean {
return kimiLaunchEnabled
}

View File

@@ -1,5 +0,0 @@
import { isKimiLaunchEnabled } from './kimi-launch'
export function useKimiLaunch(): boolean {
return isKimiLaunchEnabled()
}

View File

@@ -1,6 +1,5 @@
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
/** @public */
export interface LlmHubProvider {
@@ -8,43 +7,15 @@ export interface LlmHubProvider {
url: string
}
const KIMI_PROVIDER: LlmHubProvider = {
name: 'Kimi',
url: 'https://www.kimi.com',
}
function ensureKimiFirst(providers: LlmHubProvider[]): LlmHubProvider[] {
if (!isKimiLaunchEnabled()) return providers
const hasKimi = providers.some(
(p) => p.name === 'Kimi' || p.url.includes('kimi.com'),
)
return hasKimi ? providers : [KIMI_PROVIDER, ...providers]
}
export async function loadProviders(): Promise<LlmHubProvider[]> {
try {
const adapter = getBrowserOSAdapter()
const providersPref = await adapter.getPref(
BROWSEROS_PREFS.THIRD_PARTY_LLM_PROVIDERS,
)
const providers = (providersPref?.value as LlmHubProvider[]) || []
if (providers.length === 0) {
if (isKimiLaunchEnabled()) {
const defaults = [KIMI_PROVIDER]
await saveProviders(defaults)
return defaults
}
return []
}
const normalized = ensureKimiFirst(providers)
if (normalized !== providers) {
await saveProviders(normalized)
}
return normalized
return (providersPref?.value as LlmHubProvider[]) || []
} catch {
return isKimiLaunchEnabled() ? [KIMI_PROVIDER] : []
return []
}
}

View File

@@ -2,14 +2,12 @@ import { storage } from '@wxt-dev/storage'
import { sessionStorage } from '@/lib/auth/sessionStorage'
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
/** Default provider ID constant */
export const DEFAULT_PROVIDER_ID = 'browseros'
const DEFAULT_PROVIDER_NAME = 'BrowserOS'
const KIMI_LAUNCH_PROVIDER_NAME = 'Kimi K2.5'
/** Storage key for LLM providers array */
export const providersStorage = storage.defineItem<LlmProviderConfig[]>(
@@ -91,7 +89,7 @@ export function setupLlmProvidersSyncToBackend(): () => void {
/** Load providers from storage */
export async function loadProviders(): Promise<LlmProviderConfig[]> {
const providers = (await providersStorage.getValue()) || []
const normalizedProviders = normalizeProvidersForLaunch(providers)
const normalizedProviders = normalizeProviderNames(providers)
// Keep storage consistent so every consumer sees the same provider name.
if (
@@ -109,7 +107,7 @@ export function createDefaultBrowserOSProvider(): LlmProviderConfig {
return {
id: DEFAULT_PROVIDER_ID,
type: 'browseros',
name: getBuiltInProviderName(),
name: DEFAULT_PROVIDER_NAME,
baseUrl: 'https://api.browseros.com/v1',
modelId: 'browseros-auto',
supportsImages: true,
@@ -125,26 +123,22 @@ export function createDefaultProvidersConfig(): LlmProviderConfig[] {
return [createDefaultBrowserOSProvider()]
}
function getBuiltInProviderName(): string {
return isKimiLaunchEnabled()
? KIMI_LAUNCH_PROVIDER_NAME
: DEFAULT_PROVIDER_NAME
}
function normalizeProvidersForLaunch(
/**
* Normalize built-in provider names back to "BrowserOS" (e.g. from "Kimi K2.5"
* which was set during a previous partnership launch).
*/
function normalizeProviderNames(
providers: LlmProviderConfig[],
): LlmProviderConfig[] {
const builtInProviderName = getBuiltInProviderName()
return providers.map((provider) => {
if (
provider.id === DEFAULT_PROVIDER_ID &&
provider.type === 'browseros' &&
provider.name !== builtInProviderName
provider.name !== DEFAULT_PROVIDER_NAME
) {
return {
...provider,
name: builtInProviderName,
name: DEFAULT_PROVIDER_NAME,
}
}
return provider

View File

@@ -1,54 +0,0 @@
import { storage } from '@wxt-dev/storage'
import { useEffect, useState } from 'react'
export interface Workflow {
id: string
codeId: string
workflowName: string
}
export const workflowStorage = storage.defineItem<Workflow[]>(
'local:workflows',
{
fallback: [],
},
)
export function useWorkflows() {
const [workflows, setWorkflows] = useState<Workflow[]>([])
useEffect(() => {
workflowStorage.getValue().then(setWorkflows)
const unwatch = workflowStorage.watch((newValue) => {
setWorkflows(newValue ?? [])
})
return unwatch
}, [])
const addWorkflow = async (workflow: Omit<Workflow, 'id'>) => {
const newWorkflow: Workflow = {
id: crypto.randomUUID(),
...workflow,
}
const current = (await workflowStorage.getValue()) ?? []
await workflowStorage.setValue([...current, newWorkflow])
return newWorkflow
}
const removeWorkflow = async (id: string) => {
const current = (await workflowStorage.getValue()) ?? []
await workflowStorage.setValue(current.filter((w) => w.id !== id))
}
const editWorkflow = async (
id: string,
updates: Partial<Omit<Workflow, 'id'>>,
) => {
const current = (await workflowStorage.getValue()) ?? []
await workflowStorage.setValue(
current.map((w) => (w.id === id ? { ...w, ...updates } : w)),
)
}
return { workflows, addWorkflow, removeWorkflow, editWorkflow }
}

View File

@@ -2,7 +2,7 @@
"name": "@browseros/agent",
"description": "manifest.json description",
"private": true,
"version": "0.0.98",
"version": "0.0.99",
"type": "module",
"scripts": {
"dev": "test -d generated/graphql || bun run codegen; mkdir -p /tmp/browseros-dev; bun --env-file=.env.development wxt",

View File

@@ -92,10 +92,6 @@ Skills are custom instruction sets that shape agent behavior:
- **Loader** (`src/skills/loader.ts`) — loads skills from local and remote sources
- **Remote sync** (`src/skills/remote-sync.ts`) — syncs skills from the BrowserOS cloud
## Graph Executor (Workflows)
The graph executor (`src/graph/executor.ts`) runs visual workflow graphs built in the BrowserOS workflow editor. Each node in the graph maps to agent actions, conditionals, or data transformations.
## Directory Structure
```
@@ -120,14 +116,12 @@ apps/server/
│ │ ├── filesystem/
│ │ └── ...
│ ├── skills/ # Skills system
│ ├── graph/ # Workflow graph executor
│ ├── lib/ # Shared utilities
│ └── rpc.ts # JSON-RPC type definitions
├── tests/
│ ├── tools/ # Tool-level tests
│ ├── sdk/ # SDK integration tests
│ └── server.integration.test.ts
├── graph/ # Workflow graph definitions
└── package.json
```

View File

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

View File

@@ -0,0 +1,37 @@
services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}
ports:
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
environment:
- HOME=/home/node
- NODE_ENV=production
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
- OPENCLAW_GATEWAY_BIND=lan
- TZ=${TZ}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
extra_hosts:
- "host.containers.internal:host-gateway"
command:
- node
- dist/index.js
- gateway
- --bind
- lan
- --port
- "18789"
- --allow-unconfigured
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped

View File

@@ -2,6 +2,7 @@ import { createMCPClient } from '@ai-sdk/mcp'
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
import type { ToolSet } from 'ai'
import { klavisStrataCache } from '../api/services/klavis/strata-cache'
import type { KlavisClient } from '../lib/clients/klavis/klavis-client'
import { logger } from '../lib/logger'
import {
@@ -40,7 +41,8 @@ export async function buildMcpServerSpecs(
deps.browserContext?.enabledMcpServers?.length
) {
try {
const result = await deps.klavisClient.createStrata(
const result = await klavisStrataCache.getOrFetch(
deps.klavisClient,
deps.browserosId,
deps.browserContext.enabledMcpServers,
)

View File

@@ -1,274 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { PATHS } from '@browseros/shared/constants/paths'
import { zValidator } from '@hono/zod-validator'
import type { Context } from 'hono'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { logger } from '../../lib/logger'
import { GraphService } from '../services/graph-service'
import {
CreateGraphRequestSchema,
RunGraphRequestSchema,
UpdateGraphRequestSchema,
} from '../types'
import {
formatUIMessageStreamDone,
formatUIMessageStreamEvent,
} from '../utils/ui-message-stream'
import { SessionIdParamSchema } from '../utils/validation'
interface SSEStreamOptions {
vercelAIStream?: boolean
logLabel: string
}
type SSEStreamCallback = (
stream: { write: (data: string) => Promise<unknown> },
signal: AbortSignal,
) => Promise<void>
function createSSEStream(
c: Context,
options: SSEStreamOptions,
callback: SSEStreamCallback,
) {
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
c.header('Connection', 'keep-alive')
if (options.vercelAIStream) {
c.header('x-vercel-ai-ui-message-stream', 'v1')
}
const abortController = new AbortController()
if (c.req.raw.signal) {
c.req.raw.signal.addEventListener('abort', () => abortController.abort(), {
once: true,
})
}
return stream(c, async (honoStream) => {
honoStream.onAbort(() => {
abortController.abort()
logger.debug(`${options.logLabel} stream aborted`)
})
await callback(honoStream, abortController.signal)
})
}
interface GraphRouteDeps {
port: number
tempDir?: string
codegenServiceUrl?: string
}
export function createGraphRoutes(deps: GraphRouteDeps) {
const { port, codegenServiceUrl } = deps
const serverUrl = `http://127.0.0.1:${port}`
const tempDir = deps.tempDir || PATHS.DEFAULT_EXECUTION_DIR
const graphService = codegenServiceUrl
? new GraphService({ codegenServiceUrl, serverUrl, tempDir })
: null
// Chain route definitions for proper Hono RPC type inference
return new Hono()
.post('/', zValidator('json', CreateGraphRequestSchema), async (c) => {
if (!graphService) {
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
}
const request = c.req.valid('json')
logger.info('Graph create request received', { query: request.query })
return createSSEStream(
c,
{ logLabel: 'Graph create', vercelAIStream: true },
async (s, signal) => {
try {
await graphService.createGraph(
request.query,
async (event) => {
await s.write(formatUIMessageStreamEvent(event))
},
signal,
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
await s.write(
formatUIMessageStreamEvent({
type: 'error',
errorText: errorMessage,
}),
)
await s.write(
formatUIMessageStreamEvent({
type: 'finish',
finishReason: 'error',
}),
)
} finally {
await s.write(formatUIMessageStreamDone())
}
},
)
})
.post(
'/:id',
zValidator('param', SessionIdParamSchema),
zValidator('json', UpdateGraphRequestSchema),
async (c) => {
if (!graphService) {
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
}
const { id: sessionId } = c.req.valid('param')
const request = c.req.valid('json')
logger.info('Graph update request received', {
sessionId,
query: request.query,
})
return createSSEStream(
c,
{ logLabel: 'Graph update', vercelAIStream: true },
async (s, signal) => {
try {
await graphService.updateGraph(
sessionId,
request.query,
async (event) => {
await s.write(formatUIMessageStreamEvent(event))
},
signal,
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
await s.write(
formatUIMessageStreamEvent({
type: 'error',
errorText: errorMessage,
}),
)
await s.write(
formatUIMessageStreamEvent({
type: 'finish',
finishReason: 'error',
}),
)
} finally {
await s.write(formatUIMessageStreamDone())
}
},
)
},
)
.get('/:id', zValidator('param', SessionIdParamSchema), async (c) => {
if (!graphService) {
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
}
const { id: sessionId } = c.req.valid('param')
logger.debug('Graph get request received', { sessionId })
const session = await graphService.getGraph(sessionId)
if (!session) {
return c.json({ error: 'Graph not found' }, 404)
}
return c.json(session)
})
.post(
'/:id/run',
zValidator('param', SessionIdParamSchema),
zValidator('json', RunGraphRequestSchema),
async (c) => {
if (!graphService) {
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
}
const { id: sessionId } = c.req.valid('param')
const request = c.req.valid('json')
logger.info('Graph run request received', {
sessionId,
provider: request.provider,
model: request.model,
})
return createSSEStream(
c,
{ logLabel: 'Graph run', vercelAIStream: true },
async (s, signal) => {
try {
// Emit start event at route level
await s.write(
formatUIMessageStreamEvent({
type: 'start',
messageId: sessionId,
}),
)
await graphService.runGraph(
sessionId,
request,
async (event) => {
// Agent SDK handles proper event formatting
// Skip start/finish (managed at route level), forward everything else
if (event.type === 'start' || event.type === 'finish') {
return
}
await s.write(formatUIMessageStreamEvent(event))
},
signal,
)
// Emit finish at route level
await s.write(
formatUIMessageStreamEvent({
type: 'finish',
finishReason: 'stop',
}),
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
await s.write(
formatUIMessageStreamEvent({
type: 'error',
errorText: errorMessage,
}),
)
await s.write(
formatUIMessageStreamEvent({
type: 'finish',
finishReason: 'error',
}),
)
} finally {
await s.write(formatUIMessageStreamDone())
}
},
)
},
)
.delete('/:id', zValidator('param', SessionIdParamSchema), async (c) => {
if (!graphService) {
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
}
const { id: sessionId } = c.req.valid('param')
logger.debug('Graph delete request received', { sessionId })
await graphService.deleteGraph(sessionId)
return c.json({ success: true, message: `Graph ${sessionId} deleted` })
})
}

View File

@@ -10,6 +10,7 @@ import { z } from 'zod'
import { KlavisClient } from '../../lib/clients/klavis/klavis-client'
import { OAUTH_MCP_SERVERS } from '../../lib/clients/klavis/oauth-mcp-servers'
import { logger } from '../../lib/logger'
import { klavisStrataCache } from '../services/klavis/strata-cache'
const ServerNameSchema = z.object({
serverName: z.string().min(1),
@@ -125,6 +126,7 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) {
logger.info('Adding server to strata', { serverName })
const result = await klavisClient.createStrata(browserosId, [serverName])
klavisStrataCache.invalidate(browserosId)
return c.json({
success: true,
@@ -184,7 +186,17 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) {
logger.info('Removing server from strata', { serverName })
await klavisClient.removeServer(browserosId, serverName)
// The chat hot path keys its cache by the user's full enabled set,
// so a single-server lookup here would always miss and immediately
// be cleared by invalidate() below — call createStrata directly
// to recover the strataId, mirroring the original removeServer flow.
const strata = await klavisClient.createStrata(browserosId, [
serverName,
])
await klavisClient.deleteServersFromStrata(strata.strataId, [
serverName,
])
klavisStrataCache.invalidate(browserosId)
return c.json({
success: true,

View File

@@ -11,8 +11,8 @@ import { logger } from '../../lib/logger'
import { metrics } from '../../lib/metrics'
import { Sentry } from '../../lib/sentry'
import type { ToolRegistry } from '../../tools/tool-registry'
import type { KlavisProxyHandle } from '../services/klavis/strata-proxy'
import { createMcpServer } from '../services/mcp/mcp-server'
import type { KlavisProxyHandle } from '../services/mcp/register-klavis-mcp'
import type { Env } from '../types'
interface McpRouteDeps {

View File

@@ -0,0 +1,227 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* HTTP routes for OpenClaw agent management.
* Thin layer delegating to OpenClawService.
*/
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { getOpenClawService } from '../../services/openclaw/openclaw-service'
export function createOpenClawRoutes() {
return new Hono()
.get('/status', async (c) => {
const status = await getOpenClawService().getStatus()
return c.json(status)
})
.post('/setup', async (c) => {
const body = await c.req.json<{
providerType?: string
apiKey?: string
modelId?: string
}>()
try {
const logs: string[] = []
await getOpenClawService().setup(body, (msg) => logs.push(msg))
const agents = await getOpenClawService().listAgents()
return c.json(
{
status: 'running',
port: 18789,
agents: agents.map((a) => ({
agentId: a.agentId,
name: a.name,
status: 'running',
})),
logs,
},
201,
)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('Podman is not available')) {
return c.json({ error: message }, 503)
}
return c.json({ error: message }, 500)
}
})
.post('/start', async (c) => {
try {
await getOpenClawService().start()
return c.json({ status: 'running' })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/stop', async (c) => {
try {
await getOpenClawService().stop()
return c.json({ status: 'stopped' })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/restart', async (c) => {
try {
await getOpenClawService().restart()
return c.json({ status: 'running' })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents', async (c) => {
try {
const agents = await getOpenClawService().listAgents()
return c.json({ agents })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents', async (c) => {
const body = await c.req.json<{
name: string
providerType?: string
apiKey?: string
modelId?: string
}>()
const name = body.name?.trim()
if (!name) {
return c.json({ error: 'Name is required' }, 400)
}
try {
const agent = await getOpenClawService().createAgent({
name,
providerType: body.providerType,
apiKey: body.apiKey,
modelId: body.modelId,
})
return c.json({ agent }, 201)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('already exists')) {
return c.json({ error: message }, 409)
}
if (message.includes('must start with')) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.delete('/agents/:id', async (c) => {
const { id } = c.req.param()
try {
await getOpenClawService().removeAgent(id)
return c.json({ success: true })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('not found')) {
return c.json({ error: message }, 404)
}
if (message.includes('Cannot delete')) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/chat', async (c) => {
const { id } = c.req.param()
const body = await c.req.json<{
message: string
sessionKey?: string
}>()
if (!body.message?.trim()) {
return c.json({ error: 'Message is required' }, 400)
}
const sessionKey = body.sessionKey ?? crypto.randomUUID()
try {
const eventStream = getOpenClawService().chatStream(
id,
sessionKey,
body.message,
)
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
c.header('X-Session-Key', sessionKey)
return stream(c, async (s) => {
const reader = eventStream.getReader()
const encoder = new TextEncoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
await s.write(
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
)
}
await s.write(encoder.encode('data: [DONE]\n\n'))
} finally {
await reader.cancel()
}
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/logs', async (c) => {
try {
const logs = await getOpenClawService().getLogs()
return c.json({ logs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/providers', async (c) => {
const body = await c.req.json<{
providerType: string
apiKey: string
modelId?: string
}>()
if (!body.providerType || !body.apiKey) {
return c.json({ error: 'providerType and apiKey are required' }, 400)
}
try {
await getOpenClawService().updateProviderKeys(
body.providerType,
body.apiKey,
)
return c.json({
status: 'restarting',
message: 'Provider updated, restarting gateway',
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
}

View File

@@ -22,12 +22,12 @@ import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
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'
import { createMcpRoutes } from './routes/mcp'
import { createMemoryRoutes } from './routes/memory'
import { createOAuthRoutes } from './routes/oauth'
import { createOpenClawRoutes } from './routes/openclaw'
import { createProviderRoutes } from './routes/provider'
import { createRefinePromptRoutes } from './routes/refine-prompt'
import { createSdkRoutes } from './routes/sdk'
@@ -38,7 +38,7 @@ import { createStatusRoute } from './routes/status'
import {
connectKlavisProxy,
type KlavisProxyHandle,
} from './services/mcp/register-klavis-mcp'
} from './services/klavis/strata-proxy'
import type { Env, HttpServerConfig } from './types'
import { defaultCorsConfig } from './utils/cors'
@@ -171,14 +171,7 @@ export async function createHttpServer(config: HttpServerConfig) {
browserosId,
}),
)
.route(
'/graph',
createGraphRoutes({
port,
tempDir: executionDir,
codegenServiceUrl: config.codegenServiceUrl,
}),
)
.route('/claw', createOpenClawRoutes())
// Error handler
app.onError((err, c) => {

View File

@@ -1,328 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { UIMessageStreamEventSchema } from '@browseros/shared/schemas/ui-stream'
import type { LLMConfig, UIMessageStreamEvent } from '@browseros-ai/agent-sdk'
import { createParser, type EventSourceMessage } from 'eventsource-parser'
import { cleanupExecution, executeGraph } from '../../graph/executor'
import { logger } from '../../lib/logger'
import {
CodegenFinishMetadataSchema,
CodegenGetResponseSchema,
type GraphSession,
type RunGraphRequest,
type WorkflowGraph,
} from '../types'
export interface GraphServiceDeps {
codegenServiceUrl: string
serverUrl: string
tempDir: string
}
interface SessionState {
codeId: string | null
code: string | null
graph: WorkflowGraph | null
}
export class GraphService {
constructor(private deps: GraphServiceDeps) {}
/**
* Create a new graph by proxying to codegen service.
* Streams UIMessageStreamEvent events back to caller.
*/
async createGraph(
query: string,
onEvent: (event: UIMessageStreamEvent) => Promise<void>,
signal?: AbortSignal,
): Promise<GraphSession | null> {
const url = `${this.deps.codegenServiceUrl}/api/code`
logger.debug('Creating graph via codegen service', { url, query })
return this.proxyCodegenRequest(url, 'POST', { query }, onEvent, signal)
}
/**
* Update an existing graph by proxying to codegen service.
*/
async updateGraph(
sessionId: string,
query: string,
onEvent: (event: UIMessageStreamEvent) => Promise<void>,
signal?: AbortSignal,
): Promise<GraphSession | null> {
const url = `${this.deps.codegenServiceUrl}/api/code/${sessionId}`
logger.debug('Updating graph via codegen service', {
url,
sessionId,
query,
})
return this.proxyCodegenRequest(url, 'PUT', { query }, onEvent, signal)
}
/**
* Get graph code and visualization from codegen service.
*/
async getGraph(sessionId: string): Promise<GraphSession | null> {
const url = `${this.deps.codegenServiceUrl}/api/code/${sessionId}`
logger.debug('Fetching graph from codegen service', { url, sessionId })
try {
const response = await fetch(url)
if (!response.ok) {
if (response.status === 404) {
return null
}
throw new Error(`Codegen service error: ${response.status}`)
}
const json = await response.json()
const result = CodegenGetResponseSchema.safeParse(json)
if (!result.success) {
logger.error('Invalid codegen response', {
issues: result.error.issues,
})
throw new Error('Invalid response from codegen service')
}
return {
id: sessionId,
code: result.data.code,
graph: result.data.graph,
createdAt: new Date(result.data.createdAt || Date.now()),
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logger.error('Failed to fetch graph', { sessionId, error: errorMessage })
throw error
}
}
/**
* Execute a graph by fetching code from codegen and running it.
*/
async runGraph(
sessionId: string,
request: RunGraphRequest,
onProgress: (event: UIMessageStreamEvent) => Promise<void>,
signal?: AbortSignal,
): Promise<void> {
// Fetch code from codegen service
const graph = await this.getGraph(sessionId)
if (!graph) {
throw new Error(`Graph not found: ${sessionId}`)
}
logger.debug('Executing graph', {
sessionId,
codeLength: graph.code.length,
})
// Build LLM config from request
const llmConfig: LLMConfig | undefined = request.provider
? {
provider: request.provider,
model: request.model,
apiKey: request.apiKey,
baseUrl: request.baseUrl,
resourceName: request.resourceName,
region: request.region,
accessKeyId: request.accessKeyId,
secretAccessKey: request.secretAccessKey,
sessionToken: request.sessionToken,
}
: undefined
const result = await executeGraph(
graph.code,
sessionId,
this.deps.tempDir,
{
serverUrl: this.deps.serverUrl,
llmConfig,
browserContext: request.browserContext,
onProgress: (event) => {
onProgress(event).catch((err) => {
logger.warn('Failed to send progress event', { error: String(err) })
})
},
signal,
},
)
if (!result.success) {
throw new Error(result.error || 'Graph execution failed')
}
}
/**
* Delete execution files for a graph.
*/
async deleteGraph(sessionId: string): Promise<void> {
await cleanupExecution(sessionId, this.deps.tempDir)
}
/**
* Proxy a request to codegen service and stream UIMessageStreamEvent events.
*/
private async proxyCodegenRequest(
url: string,
method: 'POST' | 'PUT',
body: { query: string },
onEvent: (event: UIMessageStreamEvent) => Promise<void>,
signal?: AbortSignal,
): Promise<GraphSession | null> {
try {
const response = await this.fetchCodegenService(url, method, body, signal)
return await this.parseUIMessageStream(response, onEvent)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logger.error('Codegen proxy request failed', { url, error: errorMessage })
throw error
}
}
private async fetchCodegenService(
url: string,
method: 'POST' | 'PUT',
body: { query: string },
signal?: AbortSignal,
): Promise<Response> {
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
throw new Error(`Codegen service error: ${response.status}`)
}
if (!response.body) {
throw new Error('No response body from codegen service')
}
return response
}
/**
* Parse UIMessageStreamEvent SSE stream from codegen service.
* Extracts codeId, code, graph from the finish event's messageMetadata.
*/
private async parseUIMessageStream(
response: Response,
onEvent: (event: UIMessageStreamEvent) => Promise<void>,
): Promise<GraphSession | null> {
if (!response.body) {
throw new Error('No response body')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
const state: SessionState = { codeId: null, code: null, graph: null }
const pendingEvents: UIMessageStreamEvent[] = []
const parser = createParser({
onEvent: (msg: EventSourceMessage) => {
if (msg.data === '[DONE]') return
try {
const json = JSON.parse(msg.data)
const result = UIMessageStreamEventSchema.safeParse(json)
if (!result.success) {
logger.warn('Invalid UIMessageStream event', {
data: msg.data,
issues: result.error.issues,
})
return
}
pendingEvents.push(result.data as UIMessageStreamEvent)
} catch {
logger.warn('Failed to parse UIMessageStream event', {
data: msg.data,
})
}
},
})
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
parser.feed(text)
// Process any events that were parsed
let event = pendingEvents.shift()
while (event) {
this.extractSessionData(event, state)
await onEvent(event)
event = pendingEvents.shift()
}
}
// Process any remaining events
let remaining = pendingEvents.shift()
while (remaining) {
this.extractSessionData(remaining, state)
await onEvent(remaining)
remaining = pendingEvents.shift()
}
if (state.codeId && state.code) {
return {
id: state.codeId,
code: state.code,
graph: state.graph,
createdAt: new Date(),
}
}
return null
} finally {
reader.releaseLock()
}
}
/**
* Extract session data (codeId, code, graph) from UIMessageStreamEvent.
*/
private extractSessionData(
event: UIMessageStreamEvent,
state: SessionState,
): void {
if (event.type === 'start' && event.messageId) {
state.codeId = event.messageId
} else if (event.type === 'finish' && event.messageMetadata) {
const result = CodegenFinishMetadataSchema.safeParse(
event.messageMetadata,
)
if (result.success) {
if (result.data.codeId) state.codeId = result.data.codeId
if (result.data.code) state.code = result.data.code
if (result.data.graph !== undefined) state.graph = result.data.graph
}
}
}
}

View File

@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* In-process cache for Klavis Strata `createStrata` responses.
*
* Conversation creation in `/chat` was blocking on a Worker-proxied
* `klavisClient.createStrata` round-trip every time the user had any
* managed Klavis app connected. This cache stores the (immutable) JSON
* metadata returned by `createStrata` so that subsequent chats with the
* same `(browserosId, enabled-server-set)` skip the round-trip entirely.
*
* It does NOT cache live MCP client connections — only URL/id metadata.
* Per-session MCP clients continue to be opened and closed by
* `AiSdkAgent.create` / `dispose` exactly as before, which makes the
* cache safe across concurrent chats by construction.
*/
import type {
KlavisClient,
StrataCreateResponse,
} from '../../../lib/clients/klavis/klavis-client'
import { logger } from '../../../lib/logger'
const DEFAULT_TTL_MS = 60 * 60 * 1000
interface CacheEntry {
strataServerUrl: string
strataId: string
addedServers: string[]
serverKey: string
expiresAt: number
}
function normalizeServers(servers: readonly string[]): string {
return [...new Set(servers)].sort().join(',')
}
function keyOf(browserosId: string, normalized: string): string {
// xxhash64 → 16 hex chars, fixed width. Birthday-bound collision risk
// for our scale (<10k entries) is ~5e-15; we additionally verify
// serverKey on read so collisions cannot affect correctness.
const hash = Bun.hash(normalized).toString(16).padStart(16, '0')
return `${browserosId}|${hash}`
}
export class KlavisStrataCache {
private entries = new Map<string, Promise<CacheEntry>>()
constructor(private ttlMs: number = DEFAULT_TTL_MS) {}
async getOrFetch(
client: KlavisClient,
browserosId: string,
servers: readonly string[],
): Promise<StrataCreateResponse> {
const normalized = normalizeServers(servers)
const key = keyOf(browserosId, normalized)
const existing = this.entries.get(key)
if (existing) {
const resolved = await existing.catch(() => null)
if (
resolved &&
resolved.serverKey === normalized &&
Date.now() < resolved.expiresAt
) {
logger.debug('Klavis strata cache hit', { key })
return this.toResponse(resolved)
}
// Stale/collision/rejected — evict, but only if we're the rightful
// evictor (a racing caller may have already replaced this slot).
if (this.entries.get(key) === existing) {
this.entries.delete(key)
}
}
logger.debug('Klavis strata cache miss', {
key,
serverCount: servers.length,
})
const inflight = this.fetch(client, browserosId, servers, normalized)
this.entries.set(key, inflight)
try {
return this.toResponse(await inflight)
} catch (err) {
// Identity-check: only drop OUR entry. A racing invalidate() may have
// already removed it, or a racing miss may have inserted a new one
// that we must not clobber.
if (this.entries.get(key) === inflight) {
this.entries.delete(key)
}
throw err
}
}
invalidate(browserosId: string): void {
const prefix = `${browserosId}|`
let dropped = 0
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
this.entries.delete(key)
dropped++
}
}
if (dropped > 0) {
logger.debug('Klavis strata cache invalidated', {
browserosId: browserosId.slice(0, 12),
dropped,
})
}
}
clear(): void {
this.entries.clear()
}
private async fetch(
client: KlavisClient,
browserosId: string,
servers: readonly string[],
normalized: string,
): Promise<CacheEntry> {
const result = await client.createStrata(browserosId, [...servers])
return {
strataServerUrl: result.strataServerUrl,
strataId: result.strataId,
addedServers: result.addedServers,
serverKey: normalized,
expiresAt: Date.now() + this.ttlMs,
}
}
private toResponse(entry: CacheEntry): StrataCreateResponse {
return {
strataServerUrl: entry.strataServerUrl,
strataId: entry.strataId,
addedServers: entry.addedServers,
}
}
}
export const klavisStrataCache = new KlavisStrataCache()

View File

@@ -14,6 +14,7 @@ import type { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
import { logger } from '../../../lib/logger'
import { metrics } from '../../../lib/metrics'
import { klavisStrataCache } from './strata-cache'
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
let timerId: ReturnType<typeof setTimeout> | undefined
@@ -49,7 +50,8 @@ export async function connectKlavisProxy(
// even unauthenticated ones (Klavis handles auth prompts on call)
const allServers = OAUTH_MCP_SERVERS.map((s) => s.name)
const strata = await deps.klavisClient.createStrata(
const strata = await klavisStrataCache.getOrFetch(
deps.klavisClient,
deps.browserosId,
allServers,
)

View File

@@ -8,11 +8,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import type { Browser } from '../../../browser/browser'
import type { ToolRegistry } from '../../../tools/tool-registry'
import { MCP_INSTRUCTIONS } from './mcp-prompt'
import {
type KlavisProxyHandle,
registerKlavisTools,
} from './register-klavis-mcp'
} from '../klavis/strata-proxy'
import { MCP_INSTRUCTIONS } from './mcp-prompt'
import { registerTools } from './register-mcp'
export interface McpServiceDeps {

View File

@@ -102,87 +102,3 @@ export interface HttpServerConfig {
onShutdown?: () => void
}
// Graph request schemas
export const CreateGraphRequestSchema = z.object({
query: z.string().min(1, 'Query cannot be empty'),
})
export type CreateGraphRequest = z.infer<typeof CreateGraphRequestSchema>
export const UpdateGraphRequestSchema = z.object({
query: z.string().min(1, 'Query cannot be empty'),
})
export type UpdateGraphRequest = z.infer<typeof UpdateGraphRequestSchema>
// Run graph request - similar to ChatRequest, needs provider config for Agent SDK
export const RunGraphRequestSchema = AgentLLMConfigSchema.extend({
browserContext: BrowserContextSchema.optional(),
})
export type RunGraphRequest = z.infer<typeof RunGraphRequestSchema>
// Workflow graph schemas (matching codegen-service)
export const WorkflowNodeTypeSchema = z.enum([
'start',
'end',
'nav',
'act',
'extract',
'verify',
'decision',
'loop',
'fork',
'join',
])
export type WorkflowNodeType = z.infer<typeof WorkflowNodeTypeSchema>
export const WorkflowNodeSchema = z.object({
id: z.string(),
type: WorkflowNodeTypeSchema,
data: z.object({ label: z.string() }),
})
export type WorkflowNode = z.infer<typeof WorkflowNodeSchema>
export const WorkflowEdgeSchema = z.object({
id: z.string(),
source: z.string(),
target: z.string(),
})
export type WorkflowEdge = z.infer<typeof WorkflowEdgeSchema>
export const WorkflowGraphSchema = z.object({
nodes: z.array(WorkflowNodeSchema),
edges: z.array(WorkflowEdgeSchema),
})
export type WorkflowGraph = z.infer<typeof WorkflowGraphSchema>
export interface GraphSession {
id: string
code: string
graph: WorkflowGraph | null
createdAt: Date
}
// Codegen service response schema for GET /api/code/:id
export const CodegenGetResponseSchema = z.object({
code: z.string(),
graph: WorkflowGraphSchema.nullable(),
createdAt: z.string().optional(),
})
export type CodegenGetResponse = z.infer<typeof CodegenGetResponseSchema>
// Metadata schema for finish events from codegen service
export const CodegenFinishMetadataSchema = z.object({
codeId: z.string().optional(),
code: z.string().optional(),
graph: WorkflowGraphSchema.nullable().optional(),
})
export type CodegenFinishMetadata = z.infer<typeof CodegenFinishMetadataSchema>

View File

@@ -392,9 +392,48 @@ export class Browser {
// --- Observation ---
private async getFrameIds(session: ProtocolApi): Promise<string[]> {
try {
const result = await session.Page.getFrameTree()
const ids: string[] = []
type Tree = { frame: { id: string }; childFrames?: Tree[] }
function collect(tree: Tree) {
ids.push(tree.frame.id)
if (tree.childFrames)
for (const child of tree.childFrames) collect(child)
}
collect(result.frameTree as Tree)
return ids
} catch {
return []
}
}
private async fetchAXTree(session: ProtocolApi): Promise<AXNode[]> {
const result = await session.Accessibility.getFullAXTree()
return (result.nodes as AXNode[]) ?? []
const frameIds = await this.getFrameIds(session)
if (frameIds.length <= 1) {
const result = await session.Accessibility.getFullAXTree()
return (result.nodes as AXNode[]) ?? []
}
const allNodes: AXNode[] = []
for (const frameId of frameIds) {
try {
const result = await session.Accessibility.getFullAXTree({ frameId })
const nodes = (result.nodes as AXNode[]) ?? []
for (const node of nodes) {
allNodes.push({
...node,
nodeId: `${frameId}:${node.nodeId}`,
childIds: node.childIds?.map((id) => `${frameId}:${id}`),
})
}
} catch {
// Cross-origin or detached frames may fail — skip
}
}
return allNodes
}
async snapshot(page: number): Promise<string> {

View File

@@ -20,7 +20,7 @@ export function buildContentMarkdownExpression(
// Uses var + ES5 style for consistency with other injected scripts.
// Context object: { pre: bool, ld: listDepth, lt: listType, td: tableDepth }
const DOM_WALKER_SCRIPT = `(function(o) {
var SKIP = {SCRIPT:1,STYLE:1,NOSCRIPT:1,SVG:1,TEMPLATE:1,IFRAME:1,CANVAS:1,VIDEO:1,AUDIO:1,OBJECT:1,EMBED:1};
var SKIP = {SCRIPT:1,STYLE:1,NOSCRIPT:1,SVG:1,TEMPLATE:1,CANVAS:1,VIDEO:1,AUDIO:1,OBJECT:1,EMBED:1};
var FORM = {INPUT:1,SELECT:1,TEXTAREA:1,BUTTON:1};
var vh = window.innerHeight, vw = window.innerWidth;
var root = o.selector ? document.querySelector(o.selector) : document.body;
@@ -219,6 +219,15 @@ function walk(node, ctx) {
t = kids(el, ctx).trim();
return t ? '\\n*' + t + '*\\n' : '';
case 'IFRAME':
try {
var idoc = el.contentDocument;
if (idoc && idoc.body) return walk(idoc.body, ctx);
} catch(e) {}
var isrc = el.src || el.getAttribute('src');
if (isrc) return '\\n\\n[iframe: ' + isrc + ']\\n\\n';
return '';
default:
return kids(el, ctx);
}

View File

@@ -100,11 +100,16 @@ export function buildInteractiveTree(nodes: AXNode[]): string[] {
if (node.childIds) for (const childId of node.childIds) walk(childId)
}
const root =
nodes.find(
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
) ?? nodes[0]
if (root?.childIds) for (const childId of root.childIds) walk(childId)
const roots = nodes.filter(
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
)
if (roots.length === 0 && nodes[0]?.childIds) {
for (const childId of nodes[0].childIds) walk(childId)
} else {
for (const root of roots) {
if (root.childIds) for (const childId of root.childIds) walk(childId)
}
}
return lines
}
@@ -160,11 +165,16 @@ export function buildEnhancedTree(nodes: AXNode[]): string[] {
for (const childId of node.childIds) walk(childId, depth + 1)
}
const root =
nodes.find(
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
) ?? nodes[0]
if (root?.childIds) for (const childId of root.childIds) walk(childId, 0)
const roots = nodes.filter(
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
)
if (roots.length === 0 && nodes[0]?.childIds) {
for (const childId of nodes[0].childIds) walk(childId, 0)
} else {
for (const root of roots) {
if (root.childIds) for (const childId of root.childIds) walk(childId, 0)
}
}
return lines
}
@@ -292,11 +302,16 @@ export function extractLinkNodes(nodes: AXNode[]): LinkNode[] {
if (node.childIds) for (const childId of node.childIds) walk(childId)
}
const root =
nodes.find(
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
) ?? nodes[0]
if (root?.childIds) for (const childId of root.childIds) walk(childId)
const roots = nodes.filter(
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
)
if (roots.length === 0 && nodes[0]?.childIds) {
for (const childId of nodes[0].childIds) walk(childId)
} else {
for (const root of roots) {
if (root.childIds) for (const childId of root.childIds) walk(childId)
}
}
return links
}

View File

@@ -1,145 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mkdir, rm } from 'node:fs/promises'
import path from 'node:path'
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
import type { LLMConfig, UIMessageStreamEvent } from '@browseros-ai/agent-sdk'
import { Agent } from '@browseros-ai/agent-sdk'
import { z } from 'zod'
import { logger } from '../lib/logger'
//TODO: nikhil - Fix this with new bun package logic
// Expose zod globally for generated graph code. The codegen service generates code
// that uses `z` for schema validation, but transformCodeForExecution strips all imports
// since dependencies can't be resolved in dynamically imported files (especially in
// compiled binaries where modules are bundled). By exposing `z` as a global, the
// generated code can reference it without an import statement.
;(globalThis as unknown as Record<string, unknown>).z = z
export interface ExecutorOptions {
serverUrl: string
llmConfig?: LLMConfig
browserContext?: BrowserContext
onProgress: (event: UIMessageStreamEvent) => void
signal?: AbortSignal
}
export interface ExecutorResult {
success: boolean
result?: unknown
error?: string
}
/**
* Executes generated graph code using the Agent SDK.
*
* @param code - Generated code from codegen service
* @param sessionId - Unique session ID for this execution
* @param tempDir - Base temp directory for execution files
* @param options - Execution options (serverUrl, llmConfig, onProgress, signal)
*/
export async function executeGraph(
code: string,
sessionId: string,
tempDir: string,
options: ExecutorOptions,
): Promise<ExecutorResult> {
const execDir = path.join(tempDir, 'graph', sessionId)
try {
// Check if aborted before starting
if (options.signal?.aborted) {
return { success: false, error: 'Execution aborted' }
}
// Create execution directory
await mkdir(execDir, { recursive: true })
// Transform code: remove import statements (Agent is passed directly)
const transformedCode = transformCodeForExecution(code)
// Write code to file
const codePath = path.join(execDir, 'graph.ts')
await Bun.write(codePath, transformedCode)
logger.debug(`Wrote graph code to ${codePath}`)
// Create Agent instance with progress callback (auto-disposed on scope exit)
await using agent = new Agent({
url: options.serverUrl,
llm: options.llmConfig,
onProgress: options.onProgress,
signal: options.signal,
browserContext: options.browserContext,
stateful: true,
})
// Dynamic import with cache-busting (Bun caches imports by path)
const module = await import(`${codePath}?t=${Date.now()}`)
if (typeof module.run !== 'function') {
throw new Error('Generated code must export a "run" function')
}
let abortHandler: (() => void) | undefined
try {
// Only use Promise.race if we have a signal to listen to
const result = options.signal
? await Promise.race([
module.run(agent),
new Promise<never>((_, reject) => {
abortHandler = () => reject(new Error('Execution aborted'))
options.signal?.addEventListener('abort', abortHandler, {
once: true,
})
}),
])
: await module.run(agent)
return { success: true, result }
} finally {
if (abortHandler && options.signal) {
options.signal.removeEventListener('abort', abortHandler)
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(`Graph execution failed: ${errorMessage}`)
return { success: false, error: errorMessage }
}
}
export function transformCodeForExecution(code: string): string {
// Remove multi-line imports: import { ... } from 'any-package'
let result = code.replace(
/^\s*import\s+(?:type\s+)?\{[\s\S]*?\}\s*from\s*['"][^'"\n]*['"].*$/gm,
'',
)
// Remove single-line imports: import X from '...', import 'side-effect', etc.
result = result.replace(/^\s*import\s+.*['"][^'"\n]*['"].*$/gm, '')
return result
}
/**
* Cleans up execution files for a session.
*/
export async function cleanupExecution(
sessionId: string,
tempDir: string,
): Promise<void> {
const execDir = path.join(tempDir, 'graph', sessionId)
try {
await rm(execDir, { recursive: true, force: true })
logger.debug(`Cleaned up execution directory: ${execDir}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.warn(`Failed to cleanup execution directory: ${errorMessage}`)
}
}

View File

@@ -34,6 +34,10 @@ export function getBuiltinSkillsDir(): string {
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
}
export function getOpenClawDir(): string {
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
}
export function getServerConfigPath(): string {
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
}

View File

@@ -145,16 +145,14 @@ export class KlavisClient {
})
}
/**
* Remove a server from a Strata instance
* Flow: createStrata(server) to get strataId → DELETE /strata/{strataId}/servers?servers=X
*/
async removeServer(userId: string, serverName: string): Promise<void> {
// createStrata to get strataId (passing same server ensures it exists)
const strata = await this.createStrata(userId, [serverName])
async deleteServersFromStrata(
strataId: string,
servers: string[],
): Promise<void> {
const query = servers.map(encodeURIComponent).join(',')
await this.request(
'DELETE',
`/mcp-server/strata/${strata.strataId}/servers?servers=${encodeURIComponent(serverName)}`,
`/mcp-server/strata/${strataId}/servers?servers=${query}`,
)
}
}

View File

@@ -30,6 +30,7 @@ import { metrics } from './lib/metrics'
import { isPortInUseError } from './lib/port-binding'
import { Sentry } from './lib/sentry'
import { seedSoulTemplate } from './lib/soul'
import { getOpenClawService } from './services/openclaw/openclaw-service'
import { migrateBuiltinSkills } from './skills/migrate'
import {
startSkillSync,
@@ -118,12 +119,23 @@ export class Application {
this.logStartupSummary()
startSkillSync()
getOpenClawService(this.config.serverPort)
.tryAutoStart()
.catch((err) =>
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
metrics.log('http_server.started', { version: VERSION })
}
stop(reason?: string): void {
logger.info('Shutting down server...', { reason })
stopSkillSync()
getOpenClawService()
.shutdown()
.catch(() => {})
removeServerConfigSync()
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,

View File

@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Compose-level abstraction over PodmanRuntime.
* Manages a single compose project for the OpenClaw gateway container.
*/
import { copyFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { LogFn, PodmanRuntime } from './podman-runtime'
const COMPOSE_PROJECT_NAME = 'browseros-openclaw'
const COMPOSE_FILE_NAME = 'docker-compose.yml'
const ENV_FILE_NAME = '.env'
export class ContainerRuntime {
constructor(
private podman: PodmanRuntime,
private projectDir: string,
) {}
async ensureReady(onLog?: LogFn): Promise<void> {
return this.podman.ensureReady(onLog)
}
async isPodmanAvailable(): Promise<boolean> {
return this.podman.isPodmanAvailable()
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
return this.podman.getMachineStatus()
}
async composeUp(onLog?: LogFn): Promise<void> {
const code = await this.compose(['up', '-d'], onLog)
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
}
async composeDown(onLog?: LogFn): Promise<void> {
const code = await this.compose(['down'], onLog)
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
}
async composeStop(onLog?: LogFn): Promise<void> {
const code = await this.compose(['stop'], onLog)
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
}
async composeRestart(onLog?: LogFn): Promise<void> {
const code = await this.compose(['restart'], onLog)
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
}
async composePull(onLog?: LogFn): Promise<void> {
const code = await this.compose(['pull', '--quiet'], onLog)
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
}
async composeLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
lines.push(line),
)
return lines
}
async isHealthy(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
return res.ok
} catch {
return false
}
}
async isReady(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
return res.ok
} catch {
return false
}
}
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.isReady(port)) return true
await Bun.sleep(1000)
}
return false
}
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
}
async writeEnvFile(content: string): Promise<void> {
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
mode: 0o600,
})
}
/**
* Stops the Podman machine only if no non-BrowserOS containers are running.
* Prevents killing the user's own Podman workloads.
*/
async stopMachineIfSafe(): Promise<void> {
const status = await this.podman.getMachineStatus()
if (!status.running) return
try {
const containers = await this.podman.listRunningContainers()
const allOurs = containers.every((name) =>
name.startsWith('browseros-openclaw'),
)
if (containers.length === 0 || allOurs) {
await this.podman.stopMachine()
}
} catch {
// Best effort — don't stop machine if we can't check
}
}
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
const containerName = `${COMPOSE_PROJECT_NAME}-openclaw-gateway-1`
return this.podman.runCommand(['exec', containerName, ...command], {
onOutput: onLog,
})
}
private async compose(args: string[], onLog?: LogFn): Promise<number> {
return this.podman.runCommand(['compose', ...args], {
cwd: this.projectDir,
env: { COMPOSE_PROJECT_NAME },
onOutput: onLog,
})
}
}

View File

@@ -0,0 +1,654 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* WebSocket client for the OpenClaw Gateway protocol.
* Handles handshake (challenge → connect → hello-ok) with Ed25519 device
* identity signing, JSON-RPC over WS, and auto-reconnect.
* Used for agent CRUD and health — chat uses HTTP.
*/
import crypto from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { logger } from '../../lib/logger'
const RPC_TIMEOUT_MS = 15_000
const RECONNECT_DELAY_MS = 2_000
const MAX_RECONNECT_RETRIES = 5
const CONTAINER_HOME = '/home/node/.openclaw'
const SCOPES = [
'operator.read',
'operator.write',
'operator.admin',
'operator.approvals',
'operator.pairing',
]
interface DeviceIdentity {
deviceId: string
publicKeyPem: string
privateKeyPem: string
}
interface PendingRequest {
resolve: (value: unknown) => void
reject: (reason: Error) => void
timer: ReturnType<typeof setTimeout>
}
interface WsFrame {
type: 'req' | 'res' | 'event'
id?: string
method?: string
params?: Record<string, unknown>
ok?: boolean
payload?: Record<string, unknown>
error?: { message: string; code?: string }
event?: string
}
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}
export interface GatewayAgentEntry {
agentId: string
name: string
workspace: string
model?: string
}
// ── Device Identity Helpers ─────────────────────────────────────────
function rawPublicKeyFromPem(pem: string): Buffer {
const der = Buffer.from(
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
'base64',
)
return der.subarray(12)
}
function signChallenge(
device: DeviceIdentity,
nonce: string,
token: string,
): { signature: string; signedAt: number; publicKey: string } {
const signedAt = Date.now()
const payload = `v3|${device.deviceId}|cli|cli|operator|${SCOPES.join(',')}|${signedAt}|${token}|${nonce}|${process.platform}|`
const privateKey = crypto.createPrivateKey(device.privateKeyPem)
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
return {
signature: sig.toString('base64url'),
signedAt,
publicKey: rawPublicKeyFromPem(device.publicKeyPem).toString('base64url'),
}
}
/**
* Generates a client Ed25519 identity and pre-seeds it into the gateway's
* paired devices file so the gateway trusts it on next boot.
* Must be called before compose up (or requires a restart after).
*/
export function ensureClientIdentity(openclawDir: string): DeviceIdentity {
const identityPath = join(openclawDir, 'client-identity.json')
try {
return JSON.parse(readFileSync(identityPath, 'utf-8'))
} catch {
// Generate new identity
}
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
const publicKeyPem = publicKey
.export({ type: 'spki', format: 'pem' })
.toString()
const privateKeyPem = privateKey
.export({ type: 'pkcs8', format: 'pem' })
.toString()
const rawPub = rawPublicKeyFromPem(publicKeyPem)
const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex')
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem }
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
mode: 0o600,
})
seedPairedDevice(openclawDir, identity)
logger.info('Generated client device identity and pre-seeded pairing')
return identity
}
function seedPairedDevice(openclawDir: string, identity: DeviceIdentity): void {
const devicesDir = join(openclawDir, 'devices')
mkdirSync(devicesDir, { recursive: true })
const pairedPath = join(devicesDir, 'paired.json')
let paired: Record<string, unknown> = {}
try {
paired = JSON.parse(readFileSync(pairedPath, 'utf-8'))
} catch {
// First time
}
const rawPub = rawPublicKeyFromPem(identity.publicKeyPem)
paired[identity.deviceId] = {
deviceId: identity.deviceId,
publicKey: rawPub.toString('base64url'),
platform: process.platform,
clientId: 'cli',
clientMode: 'cli',
role: 'operator',
roles: ['operator'],
scopes: SCOPES,
pairedAt: Date.now(),
label: 'browseros-server',
}
writeFileSync(pairedPath, JSON.stringify(paired, null, 2), { mode: 0o600 })
}
// ── Gateway Client ──────────────────────────────────────────────────
export class GatewayClient {
private ws: WebSocket | null = null
private _connected = false
private pendingRequests = new Map<string, PendingRequest>()
private reconnectAttempts = 0
private shouldReconnect = true
private version: string
private device: DeviceIdentity | null = null
constructor(
private port: number,
private token: string,
openclawDir: string,
version = '1.0.0',
) {
this.version = version
try {
const identityPath = join(openclawDir, 'client-identity.json')
this.device = JSON.parse(readFileSync(identityPath, 'utf-8'))
} catch {
logger.warn('Client device identity not found, WS auth may fail')
}
}
get isConnected(): boolean {
return this._connected
}
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
const url = `ws://127.0.0.1:${this.port}`
this.ws = new WebSocket(url, {
headers: { Origin: `http://127.0.0.1:${this.port}` },
} as unknown as string[])
let handshakeComplete = false
let connectReqId: string | null = null
this.ws.onmessage = (event) => {
let frame: WsFrame
try {
frame = JSON.parse(
typeof event.data === 'string'
? event.data
: new TextDecoder().decode(event.data as ArrayBuffer),
)
} catch {
return
}
if (!handshakeComplete) {
if (frame.type === 'event' && frame.event === 'connect.challenge') {
const nonce = (frame.payload as Record<string, unknown>)
?.nonce as string
connectReqId = globalThis.crypto.randomUUID()
const params: Record<string, unknown> = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'cli',
version: this.version,
platform: process.platform,
mode: 'cli',
},
role: 'operator',
scopes: SCOPES,
caps: [],
commands: [],
permissions: {},
auth: { token: this.token },
locale: 'en-US',
userAgent: `browseros-server/${this.version}`,
}
if (this.device && nonce) {
const signed = signChallenge(this.device, nonce, this.token)
params.device = {
id: this.device.deviceId,
publicKey: signed.publicKey,
signature: signed.signature,
signedAt: signed.signedAt,
nonce,
}
}
this.ws?.send(
JSON.stringify({
type: 'req',
id: connectReqId,
method: 'connect',
params,
}),
)
return
}
if (frame.type === 'res' && frame.id === connectReqId) {
if (frame.ok) {
handshakeComplete = true
this._connected = true
this.reconnectAttempts = 0
logger.info('Gateway WS connected')
resolve()
} else {
const msg = frame.error?.message ?? 'Handshake failed'
logger.error('Gateway WS handshake rejected', {
error: msg,
code: frame.error?.code,
})
reject(new Error(msg))
}
return
}
return
}
if (frame.type === 'res' && frame.id) {
const pending = this.pendingRequests.get(frame.id)
if (pending) {
this.pendingRequests.delete(frame.id)
clearTimeout(pending.timer)
if (frame.ok) {
pending.resolve(frame.payload)
} else {
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
}
}
}
}
this.ws.onerror = (err) => {
if (!handshakeComplete) {
reject(
new Error(
`WS connection error: ${err instanceof Error ? err.message : 'unknown'}`,
),
)
}
}
this.ws.onclose = () => {
this._connected = false
this.rejectAllPending('WebSocket closed')
if (handshakeComplete) {
logger.info('Gateway WS disconnected')
this.tryReconnect()
}
}
})
}
disconnect(): void {
this.shouldReconnect = false
this._connected = false
this.rejectAllPending('Client disconnecting')
if (this.ws) {
this.ws.onclose = null
this.ws.close()
this.ws = null
}
}
// ── RPC ──────────────────────────────────────────────────────────────
async rpc<T = Record<string, unknown>>(
method: string,
params: Record<string, unknown> = {},
): Promise<T> {
if (!this._connected || !this.ws) {
throw new Error('Gateway WS not connected')
}
const id = globalThis.crypto.randomUUID()
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id)
reject(new Error(`RPC timeout: ${method}`))
}, RPC_TIMEOUT_MS)
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timer,
})
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
})
}
// ── Agent Methods ────────────────────────────────────────────────────
async listAgents(): Promise<GatewayAgentEntry[]> {
const result = await this.rpc<{
agents: Array<{
id: string
name?: string
workspace: string
model?: string
}>
}>('agents.list')
return (result.agents ?? []).map((a) => ({
agentId: a.id,
name: a.name ?? a.id,
workspace: a.workspace,
model: a.model,
}))
}
async createAgent(input: {
name: string
workspace: string
model?: string
}): Promise<GatewayAgentEntry> {
const result = await this.rpc<{
agentId?: string
id?: string
name?: string
workspace?: string
model?: string
}>('agents.create', input)
return {
agentId: result.agentId ?? result.id ?? input.name,
name: result.name ?? input.name,
workspace: result.workspace ?? input.workspace,
model: result.model ?? input.model,
}
}
async deleteAgent(agentId: string): Promise<void> {
await this.rpc('agents.delete', { id: agentId })
}
// ── Health ───────────────────────────────────────────────────────────
async getHealth(): Promise<Record<string, unknown>> {
return this.rpc('health')
}
// ── Chat Stream ─────────────────────────────────────────────────────
chatStream(
agentId: string,
sessionKey: string,
message: string,
): ReadableStream<OpenClawStreamEvent> {
if (!this._connected || !this.ws) {
throw new Error('Gateway WS not connected')
}
const ws = this.ws
const pendingRequests = this.pendingRequests
const fullSessionKey = `agent:${agentId}:browseros-${sessionKey}`
const idempotencyKey = globalThis.crypto.randomUUID()
const originalOnMessage = ws.onmessage
const restore = () => {
ws.onmessage = originalOnMessage
}
return new ReadableStream<OpenClawStreamEvent>({
start: (controller) => {
const subscribeId = globalThis.crypto.randomUUID()
const agentReqId = globalThis.crypto.randomUUID()
ws.onmessage = (event) => {
let frame: WsFrame
try {
frame = JSON.parse(
typeof event.data === 'string'
? event.data
: new TextDecoder().decode(event.data as ArrayBuffer),
)
} catch {
return
}
if (frame.type === 'res' && frame.id) {
if (frame.id === subscribeId || frame.id === agentReqId) {
if (!frame.ok) {
controller.enqueue({
type: 'error',
data: {
message: frame.error?.message ?? 'RPC error',
code: frame.error?.code,
},
})
controller.close()
restore()
}
return
}
const pending = pendingRequests.get(frame.id)
if (pending) {
pendingRequests.delete(frame.id)
clearTimeout(pending.timer)
if (frame.ok) {
pending.resolve(frame.payload)
} else {
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
}
}
return
}
const anyFrame = frame as any
const eventName = anyFrame.event as string | undefined
const payload = anyFrame.payload as
| Record<string, unknown>
| undefined
if (!eventName || !payload) return
if (eventName === 'agent') {
const streamType = payload.stream as string | undefined
const data = payload.data as Record<string, unknown> | undefined
if (streamType === 'assistant' && data?.delta) {
controller.enqueue({
type: 'text-delta',
data: { text: data.delta },
})
return
}
if (streamType === 'item' && data) {
const phase = data.phase as string | undefined
if (phase === 'start') {
controller.enqueue({
type: 'tool-start',
data: {
toolCallId: data.toolCallId ?? data.id,
toolName: data.name ?? data.title,
kind: data.kind,
},
})
return
}
if (phase === 'end') {
controller.enqueue({
type: 'tool-end',
data: {
toolCallId: data.toolCallId ?? data.id,
status: data.status,
durationMs: data.durationMs,
},
})
return
}
}
if (streamType === 'lifecycle') {
controller.enqueue({
type: 'lifecycle',
data: { phase: data?.phase ?? payload.phase },
})
return
}
}
if (eventName === 'session.tool') {
const toolData =
(payload.data as Record<string, unknown>) ?? payload
const phase =
(toolData.phase as string) ?? (payload.phase as string)
if (phase === 'result') {
controller.enqueue({
type: 'tool-output',
data: {
toolCallId: toolData.toolCallId,
isError: toolData.isError ?? false,
meta: toolData.meta,
},
})
return
}
}
if (eventName === 'session.message') {
const msg = payload.message as Record<string, unknown> | undefined
if (msg?.role === 'assistant') {
const content = msg.content as
| Array<Record<string, unknown>>
| undefined
if (content) {
for (const block of content) {
if (block.type === 'thinking') {
const text =
(block.thinking as string) ??
(block.content as string) ??
(block.text as string) ??
''
if (text) {
controller.enqueue({
type: 'thinking',
data: { text },
})
}
}
}
}
}
}
if (eventName === 'chat') {
const state = payload.state as string | undefined
if (state === 'final') {
controller.enqueue({
type: 'done',
data: { text: (payload.text as string) ?? '' },
})
controller.close()
restore()
return
}
}
}
ws.send(
JSON.stringify({
type: 'req',
id: subscribeId,
method: 'sessions.subscribe',
params: { sessionKey: fullSessionKey },
}),
)
ws.send(
JSON.stringify({
type: 'req',
id: agentReqId,
method: 'agent',
params: {
message,
sessionKey: fullSessionKey,
idempotencyKey,
},
}),
)
},
cancel: () => {
restore()
},
})
}
// ── Helpers ──────────────────────────────────────────────────────────
static agentWorkspace(name: string): string {
return name === 'main'
? `${CONTAINER_HOME}/workspace`
: `${CONTAINER_HOME}/workspace-${name}`
}
private tryReconnect(): void {
if (!this.shouldReconnect) return
if (this.reconnectAttempts >= MAX_RECONNECT_RETRIES) {
logger.warn('Gateway WS max reconnect attempts reached')
return
}
this.reconnectAttempts++
logger.info('Gateway WS reconnecting...', {
attempt: this.reconnectAttempts,
})
setTimeout(() => {
this.connect().catch((err) => {
logger.warn('Gateway WS reconnect failed', {
error: err instanceof Error ? err.message : String(err),
attempt: this.reconnectAttempts,
})
})
}, RECONNECT_DELAY_MS)
}
private rejectAllPending(reason: string): void {
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new Error(reason))
this.pendingRequests.delete(id)
}
}
}

View File

@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Pure functions for building OpenClaw bootstrap configuration.
* Config is write-once at setup — agent CRUD uses WS RPC, not config edits.
*/
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
const OPENCLAW_GATEWAY_PORT = 18789
const CONTAINER_HOME = '/home/node/.openclaw'
export const PROVIDER_ENV_MAP: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
groq: 'GROQ_API_KEY',
mistral: 'MISTRAL_API_KEY',
}
export interface BootstrapConfigInput {
gatewayPort: number
gatewayToken: string
browserosServerPort?: number
providerType?: string
modelId?: string
}
export interface EnvFileInput {
image?: string
port?: number
token: string
configDir: string
timezone?: string
providerKeys?: Record<string, string>
}
export function buildBootstrapConfig(
input: BootstrapConfigInput,
): Record<string, unknown> {
const serverPort = input.browserosServerPort ?? DEFAULT_PORTS.server
const defaults: Record<string, unknown> = {
workspace: `${CONTAINER_HOME}/workspace`,
timeoutSeconds: 4200,
thinkingDefault: 'adaptive',
}
if (input.providerType && input.modelId) {
defaults.model = { primary: `${input.providerType}/${input.modelId}` }
}
return {
gateway: {
mode: 'local',
port: input.gatewayPort,
bind: 'lan',
auth: { mode: 'token', token: input.gatewayToken },
reload: { mode: 'restart' },
controlUi: {
allowInsecureAuth: true,
allowedOrigins: [
`http://127.0.0.1:${input.gatewayPort}`,
`http://localhost:${input.gatewayPort}`,
],
},
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
agents: { defaults },
tools: {
profile: 'full',
web: {
search: { provider: 'duckduckgo', enabled: true },
},
exec: {
host: 'gateway',
security: 'full',
ask: 'off',
},
},
cron: { enabled: true },
hooks: {
internal: {
enabled: true,
entries: {
'boot-md': { enabled: true },
'bootstrap-extra-files': { enabled: true },
'session-memory': { enabled: true },
},
},
},
mcp: {
servers: {
browseros: {
url: `http://host.containers.internal:${serverPort}/mcp`,
transport: 'streamable-http',
},
},
},
approvals: {
exec: { enabled: false },
},
skills: {
install: { nodeManager: 'bun' },
},
}
}
export function buildEnvFile(input: EnvFileInput): string {
const lines: string[] = [
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
`OPENCLAW_GATEWAY_TOKEN=${input.token}`,
`OPENCLAW_CONFIG_DIR=${input.configDir}`,
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
]
if (input.providerKeys) {
for (const [key, value] of Object.entries(input.providerKeys)) {
lines.push(`${key}=${value}`)
}
}
return `${lines.join('\n')}\n`
}
export function resolveProviderKeys(
providerType?: string,
apiKey?: string,
): Record<string, string> {
const keys: Record<string, string> = {}
if (!providerType || !apiKey) return keys
const envVar = PROVIDER_ENV_MAP[providerType]
if (envVar) {
keys[envVar] = apiKey
}
return keys
}

View File

@@ -0,0 +1,644 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Main orchestrator for OpenClaw integration.
* Container lifecycle via Podman, agent CRUD via Gateway WS RPC,
* chat via HTTP /v1/chat/completions proxy.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import { getOpenClawDir } from '../../lib/browseros-dir'
import { logger } from '../../lib/logger'
import { ContainerRuntime } from './container-runtime'
import {
ensureClientIdentity,
type GatewayAgentEntry,
GatewayClient,
type OpenClawStreamEvent,
} from './gateway-client'
import {
buildBootstrapConfig,
buildEnvFile,
resolveProviderKeys,
} from './openclaw-config'
import { getPodmanRuntime } from './podman-runtime'
const COMPOSE_RESOURCE = resolve(
import.meta.dir,
'../../../resources/openclaw-compose.yml',
)
const OPENCLAW_CONFIG_FILE = 'openclaw.json'
const GATEWAY_PORT = 18789
const READY_TIMEOUT_MS = 30_000
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
export type OpenClawStatus =
| 'uninitialized'
| 'starting'
| 'running'
| 'stopped'
| 'error'
export interface OpenClawStatusResponse {
status: OpenClawStatus
podmanAvailable: boolean
machineReady: boolean
port: number | null
agentCount: number
error: string | null
}
export interface SetupInput {
providerType?: string
apiKey?: string
modelId?: string
}
export class OpenClawService {
private runtime: ContainerRuntime
private gateway: GatewayClient | null = null
private openclawDir: string
private port = GATEWAY_PORT
private token: string
private lastError: string | null = null
private browserosServerPort: number
constructor(browserosServerPort?: number) {
this.openclawDir = getOpenClawDir()
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
this.token = crypto.randomUUID()
this.browserosServerPort = browserosServerPort ?? DEFAULT_PORTS.server
}
// ── Lifecycle ────────────────────────────────────────────────────────
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
logProgress('Checking container runtime...')
const available = await this.runtime.isPodmanAvailable()
if (!available) {
throw new Error(
'Podman is not available. Install Podman to use OpenClaw agents.',
)
}
await this.runtime.ensureReady(logProgress)
logProgress('Container runtime ready')
await mkdir(this.openclawDir, { recursive: true })
await mkdir(join(this.openclawDir, 'workspace'), { recursive: true })
logProgress('Copying compose file...')
await this.runtime.copyComposeFile(COMPOSE_RESOURCE)
this.token = crypto.randomUUID()
const providerKeys = resolveProviderKeys(input.providerType, input.apiKey)
const envContent = buildEnvFile({
token: this.token,
configDir: this.openclawDir,
providerKeys,
})
await this.runtime.writeEnvFile(envContent)
logProgress('Generated .env file')
const config = buildBootstrapConfig({
gatewayPort: this.port,
gatewayToken: this.token,
browserosServerPort: this.browserosServerPort,
providerType: input.providerType,
modelId: input.modelId,
})
await this.writeBootstrapConfig(config)
logProgress('Generated openclaw.json')
logProgress('Pulling OpenClaw image...')
await this.runtime.composePull(logProgress)
logProgress('Image ready')
logProgress('Starting OpenClaw gateway...')
await this.runtime.composeUp(logProgress)
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
if (!ready) {
this.lastError = 'Gateway did not become ready within 30 seconds'
const logs = await this.runtime.composeLogs()
logger.error('Gateway readiness check failed', { logs })
throw new Error(this.lastError)
}
// Generate client device identity for WS auth
logProgress('Generating client device identity...')
ensureClientIdentity(this.openclawDir)
// Attempt WS connect — this triggers a pending pair request
logProgress('Pairing client device...')
try {
await this.connectGateway()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (
!msg.includes('pairing required') &&
!msg.includes('signature expired')
) {
throw err
}
}
// Approve the pending device via the openclaw CLI inside the container
await this.approvePendingDevice(logProgress)
logProgress('Connecting to gateway...')
await this.connectGateway()
// Ensure main agent exists (gateway may auto-create it)
const existingAgents = await this.gateway!.listAgents()
const hasMain = existingAgents.some((a) => a.agentId === 'main')
if (!hasMain) {
logProgress('Creating main agent...')
const model =
input.providerType && input.modelId
? `${input.providerType}/${input.modelId}`
: undefined
await this.gateway!.createAgent({
name: 'main',
workspace: GatewayClient.agentWorkspace('main'),
model,
})
} else {
logProgress('Main agent already exists')
}
this.lastError = null
logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.port}`)
logger.info('OpenClaw setup complete', { port: this.port })
}
async start(onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
logProgress('Loading gateway auth token...')
await this.loadTokenFromEnv()
await this.runtime.ensureReady(logProgress)
logProgress('Starting OpenClaw gateway...')
await this.runtime.composeUp(logProgress)
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
if (!ready) {
this.lastError = 'Gateway did not become ready after start'
throw new Error(this.lastError)
}
logProgress('Connecting to gateway...')
await this.connectGateway()
this.lastError = null
logger.info('OpenClaw gateway started', { port: this.port })
}
async stop(): Promise<void> {
this.disconnectGateway()
await this.runtime.composeStop()
logger.info('OpenClaw container stopped')
}
async restart(onLog?: (msg: string) => void): Promise<void> {
const logProgress = this.createProgressLogger(onLog)
this.disconnectGateway()
logProgress('Loading gateway auth token...')
await this.loadTokenFromEnv()
logProgress('Restarting OpenClaw gateway...')
await this.runtime.composeRestart(logProgress)
logProgress('Waiting for gateway readiness...')
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
if (!ready) {
this.lastError = 'Gateway did not become ready after restart'
throw new Error(this.lastError)
}
logProgress('Connecting to gateway...')
await this.connectGateway()
this.lastError = null
logProgress('Gateway restarted successfully')
logger.info('OpenClaw gateway restarted', { port: this.port })
}
async shutdown(): Promise<void> {
this.disconnectGateway()
try {
await this.runtime.composeStop()
} catch {
// Best effort during shutdown
}
await this.runtime.stopMachineIfSafe()
logger.info('OpenClaw shutdown complete')
}
// ── Status ───────────────────────────────────────────────────────────
async getStatus(): Promise<OpenClawStatusResponse> {
const podmanAvailable = await this.runtime.isPodmanAvailable()
if (!podmanAvailable) {
return {
status: 'uninitialized',
podmanAvailable: false,
machineReady: false,
port: null,
agentCount: 0,
error: null,
}
}
const isSetUp = existsSync(join(this.openclawDir, OPENCLAW_CONFIG_FILE))
if (!isSetUp) {
const machineStatus = await this.runtime.getMachineStatus()
return {
status: 'uninitialized',
podmanAvailable: true,
machineReady: machineStatus.running,
port: null,
agentCount: 0,
error: null,
}
}
const machineStatus = await this.runtime.getMachineStatus()
const ready = machineStatus.running
? await this.runtime.isReady(this.port)
: false
let agentCount = 0
if (ready && this.gateway?.isConnected) {
try {
const agents = await this.gateway.listAgents()
agentCount = agents.length
} catch {
// WS may be momentarily unavailable
}
}
return {
status: ready ? 'running' : this.lastError ? 'error' : 'stopped',
podmanAvailable: true,
machineReady: machineStatus.running,
port: this.port,
agentCount,
error: this.lastError,
}
}
// ── Agent Management (via WS RPC) ───────────────────────────────────
async createAgent(input: {
name: string
providerType?: string
apiKey?: string
modelId?: string
}): Promise<GatewayAgentEntry> {
const { name } = input
if (!AGENT_NAME_PATTERN.test(name)) {
throw new Error(
'Agent name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens',
)
}
logger.debug('Creating OpenClaw agent', {
name,
providerType: input.providerType,
hasModel: !!input.modelId,
hasApiKey: !!input.apiKey,
})
this.ensureGatewayConnected()
let needsRestart = false
if (input.providerType && input.apiKey) {
needsRestart = await this.mergeProviderKeyIfNew(
input.providerType,
input.apiKey,
)
}
if (needsRestart) {
await this.restart()
}
const model =
input.providerType && input.modelId
? `${input.providerType}/${input.modelId}`
: undefined
const agent = await this.gateway!.createAgent({
name,
workspace: GatewayClient.agentWorkspace(name),
model,
})
logger.info('Agent created via WS RPC', {
agentId: agent.agentId,
providerType: input.providerType,
})
return agent
}
async removeAgent(agentId: string): Promise<void> {
if (agentId === 'main') {
throw new Error('Cannot delete the main agent')
}
this.ensureGatewayConnected()
await this.gateway!.deleteAgent(agentId)
logger.info('Agent removed via WS RPC', { agentId })
}
async listAgents(): Promise<GatewayAgentEntry[]> {
this.ensureGatewayConnected()
logger.debug('Listing OpenClaw agents')
return this.gateway!.listAgents()
}
// ── Chat Stream (WS) ─────────────────────────────────────────────────
chatStream(
agentId: string,
sessionKey: string,
message: string,
): ReadableStream<OpenClawStreamEvent> {
this.ensureGatewayConnected()
logger.debug('Starting OpenClaw chat stream', { agentId, sessionKey })
return this.gateway!.chatStream(agentId, sessionKey, message)
}
// ── Provider Keys ────────────────────────────────────────────────────
async updateProviderKeys(
providerType: string,
apiKey: string,
): Promise<void> {
await this.mergeProviderKeyIfNew(providerType, apiKey)
await this.restart()
logger.info('Provider keys updated', { providerType })
}
// ── Logs ─────────────────────────────────────────────────────────────
async getLogs(tail = 100): Promise<string[]> {
logger.debug('Fetching OpenClaw container logs', { tail })
return this.runtime.composeLogs(tail)
}
// ── Auto-start on BrowserOS boot ────────────────────────────────────
async tryAutoStart(): Promise<void> {
const isSetUp = existsSync(join(this.openclawDir, OPENCLAW_CONFIG_FILE))
if (!isSetUp) return
const available = await this.runtime.isPodmanAvailable()
if (!available) return
try {
await this.loadTokenFromEnv()
await this.runtime.ensureReady()
if (!(await this.runtime.isReady(this.port))) {
await this.runtime.composeUp()
const ready = await this.runtime.waitForReady(
this.port,
READY_TIMEOUT_MS,
)
if (!ready) {
logger.warn('OpenClaw gateway failed to become ready on auto-start')
return
}
}
await this.connectGatewayWithRetry()
logger.info('OpenClaw gateway auto-started')
} catch (err) {
logger.warn('OpenClaw auto-start failed', {
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Connects to the gateway, retrying once after a container restart
* if the signature is expired (clock skew from Podman VM sleep).
*/
private async connectGatewayWithRetry(): Promise<void> {
try {
await this.connectGateway()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (
msg.includes('signature expired') ||
msg.includes('pairing required')
) {
logger.info(
'Gateway WS auth failed, restarting container to resync clock...',
)
await this.runtime.composeRestart()
const ready = await this.runtime.waitForReady(
this.port,
READY_TIMEOUT_MS,
)
if (!ready)
throw new Error('Gateway not ready after clock resync restart')
// Re-approve device if needed (pairing may have been lost)
try {
await this.connectGateway()
} catch (retryErr) {
const retryMsg =
retryErr instanceof Error ? retryErr.message : String(retryErr)
if (retryMsg.includes('pairing required')) {
await this.approvePendingDevice((m) =>
logger.debug(`Auto-start: ${m}`),
)
await this.connectGateway()
} else {
throw retryErr
}
}
} else {
throw err
}
}
}
// ── Internal ─────────────────────────────────────────────────────────
/**
* Approves the latest pending device pair request via the openclaw CLI
* running inside the container. This is needed because the gateway requires
* Ed25519 device identity and approval before granting operator scopes.
*/
private async approvePendingDevice(
logProgress: (msg: string) => void,
): Promise<void> {
// List pending devices to get the request ID
const output: string[] = []
const listCode = await this.runtime.execInContainer(
[
'node',
'dist/index.js',
'devices',
'list',
'--json',
'--token',
this.token,
],
(line) => output.push(line),
)
if (listCode !== 0) {
throw new Error(`Failed to list pending devices (exit ${listCode})`)
}
const jsonStr = output.join('\n')
let data: { pending?: Array<{ requestId: string }> }
try {
data = JSON.parse(jsonStr)
} catch {
throw new Error(
`Failed to parse device list output: ${jsonStr.slice(0, 200)}`,
)
}
const pending = data.pending
if (!pending?.length) {
logger.warn('No pending device pair requests found')
throw new Error('No pending device pair requests to approve')
}
const requestId = pending[0].requestId
logProgress(`Approving device pair request ${requestId.slice(0, 8)}...`)
const code = await this.runtime.execInContainer([
'node',
'dist/index.js',
'devices',
'approve',
requestId,
'--token',
this.token,
'--json',
])
if (code !== 0) {
logger.warn('Device approval command exited with code', { code })
throw new Error('Failed to approve client device pairing')
}
logProgress('Client device approved')
}
private async connectGateway(): Promise<void> {
this.disconnectGateway()
logger.debug('Connecting OpenClaw gateway client', { port: this.port })
this.gateway = new GatewayClient(this.port, this.token, this.openclawDir)
await this.gateway.connect()
}
private disconnectGateway(): void {
if (this.gateway) {
this.gateway.disconnect()
this.gateway = null
}
}
private ensureGatewayConnected(): void {
if (!this.gateway?.isConnected) {
logger.debug('OpenClaw gateway client is not connected')
throw new Error('Gateway WS not connected')
}
}
private async writeBootstrapConfig(
config: Record<string, unknown>,
): Promise<void> {
const configPath = join(this.openclawDir, OPENCLAW_CONFIG_FILE)
await writeFile(configPath, JSON.stringify(config, null, 2))
}
/**
* Merges a provider API key into .env. Returns true if the key was NEW
* (not previously present), meaning a container restart is needed to
* pick up the new env var.
*/
private async mergeProviderKeyIfNew(
providerType: string,
apiKey: string,
): Promise<boolean> {
const newKeys = resolveProviderKeys(providerType, apiKey)
if (Object.keys(newKeys).length === 0) return false
const envPath = join(this.openclawDir, '.env')
let content = ''
try {
content = await readFile(envPath, 'utf-8')
} catch {
// .env may not exist yet
}
let addedNew = false
let updatedExisting = false
for (const [key, value] of Object.entries(newKeys)) {
const pattern = new RegExp(`^${key}=.*$`, 'm')
if (pattern.test(content)) {
content = content.replace(pattern, `${key}=${value}`)
updatedExisting = true
} else {
content = `${content.trimEnd()}\n${key}=${value}\n`
addedNew = true
}
}
await writeFile(envPath, content, { mode: 0o600 })
logger.debug('Updated OpenClaw provider credentials', {
providerType,
addedNew,
updatedExisting,
})
return addedNew
}
private async loadTokenFromEnv(): Promise<void> {
const envPath = join(this.openclawDir, '.env')
try {
const content = await readFile(envPath, 'utf-8')
const match = content.match(/^OPENCLAW_GATEWAY_TOKEN=(.+)$/m)
if (match) {
this.token = match[1]
logger.debug('Loaded OpenClaw gateway token from env')
}
} catch {
logger.debug('OpenClaw env file not available while loading token')
}
}
private createProgressLogger(
onLog?: (msg: string) => void,
): (msg: string) => void {
return (msg) => {
logger.debug(`OpenClaw: ${msg}`)
onLog?.(msg)
}
}
}
let service: OpenClawService | null = null
export function getOpenClawService(
browserosServerPort?: number,
): OpenClawService {
if (!service) service = new OpenClawService(browserosServerPort)
return service
}

View File

@@ -0,0 +1,223 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Abstraction over the Podman CLI for container lifecycle management.
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
* On Linux, machine operations are no-ops since Podman runs natively.
*/
const isLinux = process.platform === 'linux'
export type LogFn = (msg: string) => void
export class PodmanRuntime {
private podmanPath: string
private machineReady = false
constructor(config?: { podmanPath?: string }) {
this.podmanPath = config?.podmanPath ?? 'podman'
}
getPodmanPath(): string {
return this.podmanPath
}
async isPodmanAvailable(): Promise<boolean> {
try {
const proc = Bun.spawn([this.podmanPath, '--version'], {
stdout: 'ignore',
stderr: 'ignore',
})
return (await proc.exited) === 0
} catch {
return false
}
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
if (isLinux) return { initialized: true, running: true }
try {
const proc = Bun.spawn(
[this.podmanPath, 'machine', 'list', '--format', 'json'],
{ stdout: 'pipe', stderr: 'ignore' },
)
const output = await new Response(proc.stdout).text()
await proc.exited
const machines = JSON.parse(output) as Array<{
Running?: boolean
LastUp?: string
}>
if (!machines.length) return { initialized: false, running: false }
const machine = machines[0]
const running =
machine.Running === true || machine.LastUp === 'Currently running'
return { initialized: true, running }
} catch {
return { initialized: false, running: false }
}
}
async initMachine(onLog?: LogFn): Promise<void> {
if (isLinux) return
const proc = Bun.spawn(
[
this.podmanPath,
'machine',
'init',
'--cpus',
'2',
'--memory',
'2048',
'--disk-size',
'10',
],
{ stdout: 'ignore', stderr: 'pipe' },
)
await this.drainStderr(proc, onLog)
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine init failed with code ${code}`)
}
async startMachine(onLog?: LogFn): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
stdout: 'ignore',
stderr: 'pipe',
})
await this.drainStderr(proc, onLog)
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine start failed with code ${code}`)
}
async stopMachine(): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
stdout: 'ignore',
stderr: 'ignore',
})
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine stop failed with code ${code}`)
this.machineReady = false
}
async ensureReady(onLog?: LogFn): Promise<void> {
if (this.machineReady) return
const status = await this.getMachineStatus()
if (!status.initialized) {
onLog?.('Initializing Podman machine...')
await this.initMachine(onLog)
}
if (!status.running) {
onLog?.('Starting Podman machine...')
await this.startMachine(onLog)
}
this.machineReady = true
}
async runCommand(
args: string[],
options?: {
cwd?: string
env?: Record<string, string>
onOutput?: (line: string) => void
},
): Promise<number> {
const useStreaming = !!options?.onOutput
const proc = Bun.spawn([this.podmanPath, ...args], {
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : undefined,
stdout: useStreaming ? 'pipe' : 'ignore',
stderr: useStreaming ? 'pipe' : 'ignore',
})
if (options?.onOutput) {
await Promise.all([
this.drainStream(proc.stdout ?? null, options.onOutput),
this.drainStream(proc.stderr ?? null, options.onOutput),
])
}
return proc.exited
}
/**
* Lists running container names. Used to check whether non-BrowserOS
* containers are running before stopping the Podman machine.
*/
async listRunningContainers(): Promise<string[]> {
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
stdout: 'pipe',
stderr: 'ignore',
})
const output = await new Response(proc.stdout).text()
await proc.exited
return output
.trim()
.split('\n')
.filter((name) => name.trim())
}
private async drainStderr(
proc: {
stderr: ReadableStream<Uint8Array> | null
exited: Promise<number>
},
onLog?: LogFn,
): Promise<void> {
if (!onLog || !proc.stderr) return
await this.drainStream(proc.stderr, onLog)
}
private async drainStream(
stream: ReadableStream<Uint8Array> | null,
onLine: (line: string) => void,
): Promise<void> {
if (!stream) return
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed) onLine(trimmed)
}
}
if (buffer.trim()) onLine(buffer.trim())
}
}
let runtime: PodmanRuntime | null = null
export function getPodmanRuntime(): PodmanRuntime {
if (!runtime) runtime = new PodmanRuntime()
return runtime
}

View File

@@ -12,8 +12,7 @@ BrowserOS is an AI-native browser built on Chromium that turns plain English int
## Modes
- **Chat Mode** — Ask questions about any webpage: summarize articles, extract data, translate content. Activate with Option+K. Works with any LLM, including local models.
- **Agent Mode** — Describe a task and the agent executes it: clicking, typing, navigating, filling forms, extracting data, multi-step workflows. Best with Claude Opus 4.5 or Kimi K2.5.
- **Graph Mode (Workflows)** — Build visual workflow graphs for repeatable, reliable automations with parallel execution, loops, and conditionals.
- **Agent Mode** — Describe a task and the agent executes it: clicking, typing, navigating, filling forms, extracting data, and multi-step browser tasks. Best with Claude Opus 4.5 or Kimi K2.5.
---
@@ -23,16 +22,12 @@ BrowserOS is an AI-native browser built on Chromium that turns plain English int
Connect your preferred AI provider or run models locally. Supported providers: Gemini (free tier), Claude/Anthropic, OpenAI, OpenRouter (500+ models). Local options: Ollama, LM Studio. Configure at chrome://browseros/settings.
Learn more: https://docs.browseros.com/features/bring-your-own-llm
### Workflows
Convert complex browser tasks into repeatable visual automations. Describe the task, the agent generates a workflow graph, refine it through conversation, then run it on demand. Ideal for data entry, outreach, price monitoring, bulk operations.
Learn more: https://docs.browseros.com/features/workflows
### Scheduled Tasks
Automate tasks on a schedule — daily, hourly, or every few minutes. Runs in a background window without interrupting your work. Use cases: morning briefings, LinkedIn automation, price monitoring. Requires BrowserOS to be open.
Learn more: https://docs.browseros.com/features/scheduled-tasks
### Filesystem Access
Grant the agent controlled access to a local folder to read files, write reports, and run shell commands. Sandboxed — cannot access parent directories. Combine web research with local file creation in a single workflow.
Grant the agent controlled access to a local folder to read files, write reports, and run shell commands. Sandboxed — cannot access parent directories. Combine web research with local file creation in a single task.
Learn more: https://docs.browseros.com/features/cowork
### Connect Apps (MCPs)
@@ -54,7 +49,6 @@ Learn more: https://docs.browseros.com/features/ad-blocking`
const VALID_TOPICS = [
'overview',
'bring-your-own-llm',
'workflows',
'scheduled-tasks',
'filesystem-access',
'connect-apps',
@@ -67,9 +61,8 @@ const TOPIC_SECTIONS: Record<string, { start: string; end?: string }> = {
overview: { start: '# BrowserOS', end: '## Core Features' },
'bring-your-own-llm': {
start: '### Bring Your Own LLM',
end: '### Workflows',
end: '### Scheduled Tasks',
},
workflows: { start: '### Workflows', end: '### Scheduled Tasks' },
'scheduled-tasks': {
start: '### Scheduled Tasks',
end: '### Filesystem Access',

View File

@@ -3,12 +3,17 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, it } from 'bun:test'
import { afterEach, beforeEach, describe, it } from 'bun:test'
import assert from 'node:assert'
import { createKlavisRoutes } from '../../../src/api/routes/klavis'
import { klavisStrataCache } from '../../../src/api/services/klavis/strata-cache'
const originalFetch = globalThis.fetch
beforeEach(() => {
klavisStrataCache.clear()
})
afterEach(() => {
globalThis.fetch = originalFetch
})

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { KlavisStrataCache } from '../../../../src/api/services/klavis/strata-cache'
import type {
KlavisClient,
StrataCreateResponse,
} from '../../../../src/lib/clients/klavis/klavis-client'
class StubKlavisClient {
callCount = 0
delayMs = 0
shouldThrowOnce = false
lastServers: string[] | null = null
async createStrata(
userId: string,
servers: string[],
): Promise<StrataCreateResponse> {
this.callCount++
this.lastServers = servers
if (this.shouldThrowOnce) {
this.shouldThrowOnce = false
throw new Error('boom')
}
if (this.delayMs > 0) {
await new Promise((r) => setTimeout(r, this.delayMs))
}
return {
strataServerUrl: `https://strata.test/${userId}/${servers.join('-')}`,
strataId: `strata_${userId}`,
addedServers: servers,
}
}
}
const asClient = (stub: StubKlavisClient): KlavisClient =>
stub as unknown as KlavisClient
describe('KlavisStrataCache', () => {
it('cache hit returns the same value without re-calling the client', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
const a = await cache.getOrFetch(asClient(client), 'user1', ['Gmail'])
const b = await cache.getOrFetch(asClient(client), 'user1', ['Gmail'])
expect(client.callCount).toBe(1)
expect(a.strataServerUrl).toBe(b.strataServerUrl)
expect(a.strataId).toBe(b.strataId)
})
it('normalizes server order — [Gmail, Linear] === [Linear, Gmail]', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
await cache.getOrFetch(asClient(client), 'u', ['Gmail', 'Linear'])
await cache.getOrFetch(asClient(client), 'u', ['Linear', 'Gmail'])
expect(client.callCount).toBe(1)
})
it('dedupes duplicate server names within one call', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
await cache.getOrFetch(asClient(client), 'u', ['Gmail', 'Gmail'])
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
expect(client.callCount).toBe(1)
})
it('different user gets a separate cache entry', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
await cache.getOrFetch(asClient(client), 'userB', ['Gmail'])
expect(client.callCount).toBe(2)
})
it('different server set (same user) gets a separate cache entry', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
await cache.getOrFetch(asClient(client), 'u', ['Gmail', 'Linear'])
expect(client.callCount).toBe(2)
})
it('concurrent misses share a single in-flight Promise', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
client.delayMs = 30
const [a, b, c] = await Promise.all([
cache.getOrFetch(asClient(client), 'u', ['Gmail']),
cache.getOrFetch(asClient(client), 'u', ['Gmail']),
cache.getOrFetch(asClient(client), 'u', ['Gmail']),
])
expect(client.callCount).toBe(1)
expect(a.strataId).toBe(b.strataId)
expect(b.strataId).toBe(c.strataId)
})
it('TTL expiry triggers a fresh fetch', async () => {
const cache = new KlavisStrataCache(10) // 10 ms TTL
const client = new StubKlavisClient()
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
await new Promise((r) => setTimeout(r, 25))
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
expect(client.callCount).toBe(2)
})
it('invalidate(userA) drops only userA entries', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
await cache.getOrFetch(asClient(client), 'userB', ['Gmail'])
cache.invalidate('userA')
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
await cache.getOrFetch(asClient(client), 'userB', ['Gmail'])
expect(client.callCount).toBe(3) // userA: cold + cold, userB: cold + hit
})
it('invalidate while a fetch is in flight does not store the result', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
client.delayMs = 30
const inflight = cache.getOrFetch(asClient(client), 'u', ['Gmail'])
cache.invalidate('u')
const result = await inflight
expect(result.strataId).toBe('strata_u')
// Next call should not see the post-invalidate write — must re-fetch.
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
expect(client.callCount).toBe(2)
})
it('rejected fetches do not poison the cache', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
client.shouldThrowOnce = true
await expect(
cache.getOrFetch(asClient(client), 'u', ['Gmail']),
).rejects.toThrow('boom')
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
expect(client.callCount).toBe(2)
})
it('clear() drops all entries', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
await cache.getOrFetch(asClient(client), 'userB', ['Linear'])
cache.clear()
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
await cache.getOrFetch(asClient(client), 'userB', ['Linear'])
expect(client.callCount).toBe(4)
})
it('passes a defensive copy of the servers array to the client', async () => {
const cache = new KlavisStrataCache()
const client = new StubKlavisClient()
const input: readonly string[] = ['Gmail', 'Linear']
await cache.getOrFetch(asClient(client), 'u', input)
expect(client.lastServers).not.toBe(input)
expect(client.lastServers).toEqual(['Gmail', 'Linear'])
})
})

View File

@@ -18,7 +18,6 @@ import {
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'
@@ -30,7 +29,22 @@ function getNativeTarget(): { id: string; ext: string } {
return { id: `${os}-${cpu}`, ext: process.platform === 'win32' ? '.exe' : '' }
}
// Stub values so the build config validation passes without real secrets
const REQUIRED_INLINE_ENV_KEYS = [
'BROWSEROS_CONFIG_URL',
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'SENTRY_DSN',
] as const
const R2_ENV_KEYS = [
'R2_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY',
'R2_BUCKET',
] as const
const PROD_SECRET_KEYS = [...REQUIRED_INLINE_ENV_KEYS, ...R2_ENV_KEYS]
const INLINE_ENV_STUBS: Record<string, string> = {
BROWSEROS_CONFIG_URL: 'https://stub.test/config',
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
@@ -53,6 +67,10 @@ describe('server build', () => {
rootDir,
'apps/server/.env.production.example',
)
const originalProdEnv = existsSync(prodEnvPath)
? readFileSync(prodEnvPath, 'utf-8')
: null
const prodEnvTemplate = readFileSync(prodEnvTemplatePath, 'utf-8')
const buildScript = resolve(rootDir, 'scripts/build/server.ts')
const target = getNativeTarget()
const binaryPath = resolve(
@@ -63,23 +81,16 @@ describe('server build', () => {
rootDir,
`dist/prod/server/browseros-server-resources-${target.id}.zip`,
)
const createdProdEnv = !existsSync(prodEnvPath)
// 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: [] }))
if (createdProdEnv) {
writeFileSync(prodEnvPath, readFileSync(prodEnvTemplatePath, 'utf-8'))
}
function buildEnv(
extraEnv: Record<string, string>,
omitKeys: string[] = [],
omitKeys: readonly string[] = [],
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
...INLINE_ENV_STUBS,
...extraEnv,
}
for (const key of omitKeys) {
@@ -88,14 +99,21 @@ describe('server build', () => {
return env
}
function resetProdEnvToTemplate(): void {
writeFileSync(prodEnvPath, prodEnvTemplate)
}
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true })
if (createdProdEnv) {
if (originalProdEnv === null) {
rmSync(prodEnvPath, { force: true })
return
}
writeFileSync(prodEnvPath, originalProdEnv)
})
it('compiles and --version outputs correct version', async () => {
resetProdEnvToTemplate()
const pkg = await Bun.file(serverPkgPath).json()
const expectedVersion: string = pkg.version
@@ -111,7 +129,7 @@ describe('server build', () => {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv(R2_ENV_STUBS),
env: buildEnv({ ...INLINE_ENV_STUBS, ...R2_ENV_STUBS }),
},
)
const buildExit = await build.exited
@@ -138,33 +156,23 @@ describe('server build', () => {
assert.strictEqual(versionOutput.trim(), expectedVersion)
}, 300_000)
it('archives compile-only builds without R2 config', async () => {
it('archives CI builds without R2 config or production env secrets', async () => {
resetProdEnvToTemplate()
rmSync(zipPath, { force: true })
const build = Bun.spawn(
[
'bun',
buildScript,
`--target=${target.id}`,
'--compile-only',
'--archive-compiled',
],
['bun', buildScript, `--target=${target.id}`, '--ci'],
{
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv({}, [
'R2_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY',
'R2_BUCKET',
]),
env: buildEnv({}, PROD_SECRET_KEYS),
},
)
const buildExit = await build.exited
if (buildExit !== 0) {
const stderr = await new Response(build.stderr).text()
assert.fail(`Compile-only archive failed (exit ${buildExit}):\n${stderr}`)
assert.fail(`CI build failed (exit ${buildExit}):\n${stderr}`)
}
assert.ok(existsSync(zipPath), `Expected archive at ${zipPath}`)

View File

@@ -1,285 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, it } from 'bun:test'
import assert from 'node:assert'
import { transformCodeForExecution } from '../../src/graph/executor'
describe('transformCodeForExecution', () => {
describe('single-line imports', () => {
it('removes default import', () => {
const code = `import foo from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes named import', () => {
const code = `import { foo } from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes multiple named imports', () => {
const code = `import { foo, bar, baz } from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes namespace import', () => {
const code = `import * as pkg from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes side-effect import', () => {
const code = `import 'side-effect'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes default + named import', () => {
const code = `import foo, { bar } from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes import with alias', () => {
const code = `import { foo as f } from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
describe('type imports', () => {
it('removes type import', () => {
const code = `import type { Foo } from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes type default import', () => {
const code = `import type Foo from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes inline type specifier', () => {
const code = `import { type Foo, bar } from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
describe('multi-line imports', () => {
it('removes multi-line named imports', () => {
const code = `import {
foo,
bar,
} from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes multi-line type imports', () => {
const code = `import type {
Foo,
Bar,
} from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes multi-line imports with aliases', () => {
const code = `import {
foo as f,
bar as b,
} from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes deeply nested multi-line imports', () => {
const code = `import {
foo,
bar,
baz,
qux,
} from '@scoped/package-name'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
describe('quote styles', () => {
it('handles single quotes', () => {
const code = `import foo from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('handles double quotes', () => {
const code = `import foo from "pkg"
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
describe('multiple imports', () => {
it('removes all imports from different packages', () => {
const code = `import { z } from 'zod'
import { Agent } from '@browseros-ai/agent-sdk'
import type { Config } from './types'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes mixed single and multi-line imports', () => {
const code = `import foo from 'foo'
import {
bar,
baz,
} from 'bar'
import qux from 'qux'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
describe('indentation', () => {
it('removes indented imports', () => {
const code = ` import foo from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes tab-indented imports', () => {
const code = `\timport foo from 'pkg'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
describe('preserves non-import code', () => {
it('preserves all code after imports', () => {
const code = `import foo from 'pkg'
export async function run(agent) {
await agent.navigate('https://example.com')
return 'done'
}`
const result = transformCodeForExecution(code)
assert.ok(result.includes('export async function run(agent)'))
assert.ok(result.includes("await agent.navigate('https://example.com')"))
assert.ok(result.includes("return 'done'"))
assert.ok(!result.includes('import'))
})
it('preserves code with import-like strings', () => {
const code = `import foo from 'pkg'
const str = "import { x } from 'y'"
const x = 1`
const result = transformCodeForExecution(code)
assert.ok(result.includes(`const str = "import { x } from 'y'"`))
assert.ok(result.includes('const x = 1'))
})
it('preserves dynamic imports', () => {
const code = `import foo from 'pkg'
const mod = await import('./dynamic')
const x = 1`
const result = transformCodeForExecution(code)
assert.ok(result.includes("const mod = await import('./dynamic')"))
assert.ok(result.includes('const x = 1'))
})
})
describe('scoped packages', () => {
it('removes @scoped/package imports', () => {
const code = `import { Agent } from '@browseros-ai/agent-sdk'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes deeply scoped package imports', () => {
const code = `import { foo } from '@org/pkg/sub/path'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
describe('relative imports', () => {
it('removes relative imports', () => {
const code = `import foo from './foo'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('removes parent directory imports', () => {
const code = `import foo from '../foo'
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
describe('edge cases', () => {
it('handles empty code', () => {
const result = transformCodeForExecution('')
assert.strictEqual(result, '')
})
it('handles code with no imports', () => {
const code = `const x = 1
const y = 2`
const result = transformCodeForExecution(code)
assert.strictEqual(result, code)
})
it('handles code with only imports', () => {
const code = `import foo from 'foo'
import bar from 'bar'`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), '')
})
it('handles imports with trailing semicolons', () => {
const code = `import foo from 'pkg';
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
it('handles imports with trailing comments', () => {
const code = `import foo from 'pkg' // comment
const x = 1`
const result = transformCodeForExecution(code)
assert.strictEqual(result.trim(), 'const x = 1')
})
})
})

View File

@@ -23,7 +23,7 @@
},
"apps/agent": {
"name": "@browseros/agent",
"version": "0.0.98",
"version": "0.0.99",
"dependencies": {
"@ai-sdk/react": "^3.0.96",
"@browseros/server": "workspace:*",
@@ -152,7 +152,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.80",
"version": "0.0.82",
"bin": {
"browseros-server": "./src/index.ts",
},

View File

@@ -141,21 +141,6 @@ Prefix: `browseros.native.extension.`
| `settings.scheduled_task.cancelled` | — | Running task was cancelled |
| `settings.scheduled_task.retried` | — | Task run was retried |
### Settings — Workflows
| Event | Properties | Description |
|-------|-----------|-------------|
| `settings.graph.created` | — | New workflow graph created |
| `settings.graph.saved` | — | Workflow graph saved |
| `settings.graph.updated` | — | Workflow graph updated |
| `settings.graph.message.like` | — | Workflow message liked |
| `settings.graph.message.dislike` | — | Workflow message disliked |
| `settings.workflow.deleted` | — | Workflow deleted |
| `settings.workflow.run_started` | — | Workflow run started |
| `settings.workflow.run_stopped` | — | Workflow run stopped |
| `settings.workflow.run_retried` | — | Workflow run retried |
| `settings.workflow.run_completed` | — | Workflow run completed |
### Onboarding
| Event | Properties | Description |

View File

@@ -19,7 +19,7 @@
"start:agent": "bun run --filter @browseros/agent dev",
"build": "bun run build:server && bun run build:agent",
"build:server": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all",
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --compile-only --archive-compiled",
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --ci",
"build:server:test": "FORCE_COLOR=1 bun scripts/build/server.ts --target=darwin-arm64 --no-upload",
"upload:cli-installers": "bun scripts/build/cli.ts",
"start:server:test": "bun run build:server:test && set -a && . apps/server/.env.development && set +a && dist/prod/server/.tmp/binaries/browseros-server-darwin-arm64",

View File

@@ -17,6 +17,7 @@ export const PATHS = {
SKILLS_DIR_NAME: 'skills',
BUILTIN_DIR_NAME: 'builtin',
SERVER_CONFIG_FILE_NAME: 'server.json',
OPENCLAW_DIR_NAME: 'openclaw',
SOUL_MAX_LINES: 150,
MEMORY_RETENTION_DAYS: 30,
SESSION_RETENTION_DAYS: 30,

View File

@@ -22,33 +22,26 @@ export function parseBuildArgs(argv: string[]): BuildArgs {
.option('--upload', 'Upload artifact zips to R2')
.option('--no-upload', 'Skip zip upload to R2')
.option(
'--compile-only',
'Compile binaries only (skip R2 staging and upload)',
)
.option(
'--archive-compiled',
'Archive compile-only binaries into local zip files without R2 resources',
'--ci',
'Build local release zip artifacts for CI without R2 and without requiring production env secrets',
)
program.parse(argv, { from: 'user' })
const options = program.opts<{
target: string
manifest: string
upload: boolean
compileOnly: boolean
archiveCompiled: boolean
ci: boolean
}>()
const compileOnly = options.compileOnly ?? false
const archiveCompiled = options.archiveCompiled ?? false
if (archiveCompiled && !compileOnly) {
throw new Error('--archive-compiled requires --compile-only')
const ci = options.ci ?? false
if (ci && options.upload) {
throw new Error('--ci cannot be combined with --upload')
}
return {
targets: resolveTargets(options.target),
manifestPath: options.manifest,
upload: compileOnly ? false : (options.upload ?? true),
compileOnly,
archiveCompiled,
upload: ci ? false : (options.upload ?? true),
ci,
}
}

View File

@@ -1,6 +1,7 @@
import { mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { log } from '../log'
import { wasmBinaryPlugin } from '../plugins/wasm-binary'
import { runCommand } from './command'
import type { BuildTarget, CompiledServerBinary } from './types'
@@ -52,6 +53,7 @@ async function bundleServer(
async function compileTarget(
target: BuildTarget,
env: NodeJS.ProcessEnv,
ci: boolean,
): Promise<string> {
const binaryPath = compiledBinaryPath(target)
const args = [
@@ -66,11 +68,15 @@ async function compileTarget(
await runCommand('bun', args, env)
if (target.os === 'windows') {
await runCommand(
'bun',
['scripts/patch-windows-exe.ts', binaryPath],
process.env,
)
if (ci) {
log.warn('Skipping Windows exe metadata patching in CI mode')
} else {
await runCommand(
'bun',
['scripts/patch-windows-exe.ts', binaryPath],
process.env,
)
}
}
return binaryPath
@@ -81,14 +87,16 @@ export async function compileServerBinaries(
envVars: Record<string, string>,
processEnv: NodeJS.ProcessEnv,
version: string,
options?: { ci?: boolean },
): Promise<CompiledServerBinary[]> {
const ci = options?.ci ?? false
rmSync(TMP_ROOT, { recursive: true, force: true })
mkdirSync(BINARIES_DIR, { recursive: true })
await bundleServer(envVars, version)
const compiled: CompiledServerBinary[] = []
for (const target of targets) {
const binaryPath = await compileTarget(target, processEnv)
const binaryPath = await compileTarget(target, processEnv, ci)
compiled.push({ target, binaryPath })
}

View File

@@ -75,7 +75,7 @@ function validateProductionEnv(envVars: Record<string, string>): void {
}
export interface LoadBuildConfigOptions {
compileOnly?: boolean
ci?: boolean
}
export function loadBuildConfig(
@@ -84,7 +84,9 @@ export function loadBuildConfig(
): BuildConfig {
const fileEnv = loadProdEnv(rootDir)
const envVars = buildInlineEnv(fileEnv)
validateProductionEnv(envVars)
if (!options.ci) {
validateProductionEnv(envVars)
}
const processEnv: NodeJS.ProcessEnv = {
PATH: process.env.PATH ?? '',
@@ -92,7 +94,7 @@ export function loadBuildConfig(
...process.env,
}
if (options.compileOnly) {
if (options.ci) {
return { version: readServerVersion(rootDir), envVars, processEnv }
}

View File

@@ -10,14 +10,8 @@ import { getTargetRules, loadManifest } from './manifest'
import { createR2Client } from './r2'
import { stageCompiledArtifact, stageTargetArtifact } from './stage'
function buildModeLabel(argv: {
compileOnly: boolean
archiveCompiled: boolean
}): string {
if (argv.compileOnly && argv.archiveCompiled) {
return 'compile-only+archive'
}
return argv.compileOnly ? 'compile-only' : 'full'
function buildModeLabel(ci: boolean): string {
return ci ? 'ci' : 'full'
}
export async function runProdResourceBuild(argv: string[]): Promise<void> {
@@ -26,49 +20,40 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
const args = parseBuildArgs(argv)
const buildConfig = loadBuildConfig(rootDir, {
compileOnly: args.compileOnly,
})
const buildConfig = loadBuildConfig(rootDir, { ci: args.ci })
log.header(`Building BrowserOS server artifacts v${buildConfig.version}`)
log.info(`Targets: ${args.targets.map((target) => target.id).join(', ')}`)
log.info(`Mode: ${buildModeLabel(args)}`)
log.info(`Mode: ${buildModeLabel(args.ci)}`)
const compiled = await compileServerBinaries(
args.targets,
buildConfig.envVars,
buildConfig.processEnv,
buildConfig.version,
{ ci: args.ci },
)
if (args.compileOnly) {
if (args.archiveCompiled) {
const distRoot = getDistProdRoot()
const localArtifacts = []
if (args.ci) {
const distRoot = getDistProdRoot()
const localArtifacts = []
for (const binary of compiled) {
log.step(`Packaging ${binary.target.name}`)
const staged = await stageCompiledArtifact(
distRoot,
binary.binaryPath,
binary.target,
buildConfig.version,
)
localArtifacts.push(staged)
log.success(`Packaged ${binary.target.id}`)
}
const archiveResults = await archiveArtifacts(localArtifacts)
log.done('Compile-only archive build completed')
for (const result of archiveResults) {
log.info(`${result.targetId}: ${result.zipPath}`)
}
return
for (const binary of compiled) {
log.step(`Packaging ${binary.target.name}`)
const staged = await stageCompiledArtifact(
distRoot,
binary.binaryPath,
binary.target,
buildConfig.version,
)
localArtifacts.push(staged)
log.success(`Packaged ${binary.target.id}`)
}
log.done('Compile-only build completed')
for (const binary of compiled) {
log.info(`${binary.target.id}: ${binary.binaryPath}`)
const archiveResults = await archiveArtifacts(localArtifacts)
log.done('CI build completed')
for (const result of archiveResults) {
log.info(`${result.targetId}: ${result.zipPath}`)
}
return
}

View File

@@ -21,8 +21,7 @@ export interface BuildArgs {
targets: BuildTarget[]
manifestPath: string
upload: boolean
compileOnly: boolean
archiveCompiled: boolean
ci: boolean
}
export interface R2Config {

View File

@@ -402,9 +402,11 @@ def main(
"upload": upload,
}
# Resolve build context (CONFIG mode or DIRECT mode)
# Resolve build context (CONFIG mode or DIRECT mode).
# Returns one Context per architecture — single-element for normal
# builds, multi-element when YAML declares `architecture: [x64, arm64]`.
try:
ctx = resolve_config(cli_args, config_data)
arch_ctxs = resolve_config(cli_args, config_data)
except ValueError as e:
log_error(str(e))
raise typer.Exit(1)
@@ -459,20 +461,40 @@ def main(
os.environ["DEPOT_TOOLS_WIN_TOOLCHAIN"] = "0"
log_info("Set DEPOT_TOOLS_WIN_TOOLCHAIN=0 for Windows build")
# Print build summary using the first context — versions and paths
# are identical across per-arch contexts. Architecture is logged again
# inside the loop below for multi-arch runs.
summary_ctx = arch_ctxs[0]
log_info(f"📍 Root: {root_dir}")
log_info(f"📍 Chromium: {ctx.chromium_src}")
log_info(f"📍 Architecture: {ctx.architecture}")
log_info(f"📍 Build type: {ctx.build_type}")
log_info(f"📍 Output: {ctx.out_dir}")
log_info(f"📍 Semantic version: {ctx.semantic_version}")
log_info(f"📍 Chromium version: {ctx.chromium_version}")
log_info(f"📍 Build offset: {ctx.browseros_build_offset}")
log_info(f"📍 Chromium: {summary_ctx.chromium_src}")
if len(arch_ctxs) > 1:
log_info(
f"📍 Architectures: {[c.architecture for c in arch_ctxs]} (multi-arch loop)"
)
else:
log_info(f"📍 Architecture: {summary_ctx.architecture}")
log_info(f"📍 Build type: {summary_ctx.build_type}")
log_info(f"📍 Semantic version: {summary_ctx.semantic_version}")
log_info(f"📍 Chromium version: {summary_ctx.chromium_version}")
log_info(f"📍 Build offset: {summary_ctx.browseros_build_offset}")
log_info(f"📍 Pipeline: {''.join(pipeline)}")
log_info("=" * 70)
# Set notification context for OS and architecture
os_name = "macOS" if IS_MACOS() else "Windows" if IS_WINDOWS() else "Linux"
set_build_context(os_name, ctx.architecture)
# Execute pipeline
execute_pipeline(ctx, pipeline, AVAILABLE_MODULES, pipeline_name="build")
# Execute the pipeline once per architecture. Modules see a normal
# single-arch ctx; the runner is the only thing that knows about the
# multi-arch loop.
for i, arch_ctx in enumerate(arch_ctxs, start=1):
if len(arch_ctxs) > 1:
log_info("\n" + "#" * 70)
log_info(
f"# Architecture {i}/{len(arch_ctxs)}: {arch_ctx.architecture}"
)
log_info(f"# Output: {arch_ctx.out_dir}")
log_info("#" * 70)
set_build_context(os_name, arch_ctx.architecture)
execute_pipeline(
arch_ctx, pipeline, AVAILABLE_MODULES, pipeline_name="build"
)

View File

@@ -26,11 +26,13 @@ from .context import Context
from .env import EnvConfig
from .utils import get_platform_arch, log_info
VALID_ARCHITECTURES = {"x64", "arm64", "universal"}
def resolve_config(
cli_args: Dict[str, Any],
yaml_config: Optional[Dict[str, Any]] = None,
) -> Context:
) -> List[Context]:
"""Resolve build configuration - single entry point.
Args:
@@ -38,7 +40,9 @@ def resolve_config(
yaml_config: Optional YAML configuration (triggers CONFIG mode)
Returns:
Fully resolved Context object
List of fully resolved Context objects. Single-element for the
common single-arch case; multi-element when YAML declares
`architecture: [x64, arm64]` (Linux multi-arch).
Raises:
ValueError: If required fields missing or invalid
@@ -59,7 +63,7 @@ def resolve_config(
def _resolve_config_mode(
yaml_config: Dict[str, Any], cli_args: Dict[str, Any]
) -> Context:
) -> List[Context]:
"""CONFIG MODE: YAML is base, CLI can override.
Args:
@@ -67,7 +71,7 @@ def _resolve_config_mode(
cli_args: CLI arguments (can override YAML values)
Returns:
Context with values from YAML, optionally overridden by CLI
List of Contexts. One per architecture when YAML provides a list.
Raises:
ValueError: If required fields missing from both YAML and CLI
@@ -94,41 +98,66 @@ def _resolve_config_mode(
f"Expected directory with Chromium source code"
)
# architecture: CLI override > YAML > platform default
architecture = (
cli_args.get("arch")
or build_section.get("architecture")
or build_section.get("arch")
)
arch_source = "cli" if cli_args.get("arch") else "yaml"
if not architecture:
architecture = get_platform_arch()
# architecture: CLI override > YAML > platform default.
# YAML may be a string OR a list (e.g. [x64, arm64]) — list form runs
# the entire pipeline once per arch.
cli_arch = cli_args.get("arch")
yaml_arch = build_section.get("architecture") or build_section.get("arch")
if cli_arch:
architectures = [cli_arch]
arch_source = "cli"
elif yaml_arch is not None:
architectures = yaml_arch if isinstance(yaml_arch, list) else [yaml_arch]
arch_source = "yaml"
else:
architectures = [get_platform_arch()]
arch_source = "default"
log_info(f"CONFIG MODE: Using platform default architecture: {architecture}")
log_info(
f"CONFIG MODE: Using platform default architecture: {architectures[0]}"
)
for arch in architectures:
if arch not in VALID_ARCHITECTURES:
raise ValueError(
f"CONFIG MODE: invalid architecture '{arch}'. "
f"Valid: {sorted(VALID_ARCHITECTURES)}"
)
# build_type: CLI override > YAML > debug
build_type = cli_args.get("build_type") or build_section.get("type", "debug")
build_type_source = "cli" if cli_args.get("build_type") else "yaml"
log_info(f"✓ CONFIG MODE: chromium_src={chromium_src} ({chromium_src_source})")
log_info(f"✓ CONFIG MODE: architecture={architecture} ({arch_source})")
if len(architectures) > 1:
log_info(
f"✓ CONFIG MODE: architectures={architectures} ({arch_source}, multi-arch loop)"
)
else:
log_info(
f"✓ CONFIG MODE: architecture={architectures[0]} ({arch_source})"
)
log_info(f"✓ CONFIG MODE: build_type={build_type} ({build_type_source})")
return Context(
chromium_src=chromium_src,
architecture=architecture,
build_type=build_type,
)
return [
Context(
chromium_src=chromium_src,
architecture=arch,
build_type=build_type,
)
for arch in architectures
]
def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context:
def _resolve_direct_mode(cli_args: Dict[str, Any]) -> List[Context]:
"""DIRECT MODE: CLI > Env > Defaults.
Args:
cli_args: CLI arguments (None if not provided by user)
Returns:
Context with resolved values
Single-element list with the resolved Context. DIRECT mode is
always single-arch (CLI --arch is a scalar).
Raises:
ValueError: If chromium_src not provided
@@ -160,6 +189,12 @@ def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context:
architecture = get_platform_arch()
log_info(f"DIRECT MODE: Using platform default architecture: {architecture}")
if architecture not in VALID_ARCHITECTURES:
raise ValueError(
f"DIRECT MODE: invalid architecture '{architecture}'. "
f"Valid: {sorted(VALID_ARCHITECTURES)}"
)
# build_type: CLI > Default
build_type = cli_args.get("build_type") or "debug"
@@ -167,11 +202,13 @@ def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context:
log_info(f"✓ DIRECT MODE: architecture={architecture} (cli/env/default)")
log_info(f"✓ DIRECT MODE: build_type={build_type} (cli/default)")
return Context(
chromium_src=chromium_src,
architecture=architecture,
build_type=build_type,
)
return [
Context(
chromium_src=chromium_src,
architecture=architecture,
build_type=build_type,
)
]
def resolve_pipeline(

View File

@@ -3,7 +3,10 @@
# This config packages an already-built Linux application.
# Use this when you have a pre-built app and only need to package it.
#
# Expects: out/Default/chrome (Linux binary)
# Expects: out/Default_<arch>/browseros
# Invoke with:
# browseros build --config build/config/package.linux.yaml --arch x64
# browseros build --config build/config/package.linux.yaml --arch arm64
#
# Environment Variables:
# Use !env tag to reference environment variables:
@@ -11,7 +14,6 @@
build:
type: release
architecture: x64 # Linux x64
gn_flags:
file: build/config/gn/flags.linux.release.gn

View File

@@ -1,17 +1,24 @@
# BrowserOS Linux Release Build Configuration
#
# Pinned to arm64-only to validate the cross-compile sysroot bootstrap
# end-to-end on a Linux x64 host. Flip back to `[x64, arm64]` once arm64
# is green.
#
# Run:
# browseros build --config build/config/release.linux.yaml
#
# Environment Variables:
# Use !env tag to reference environment variables:
# Example: chromium_src: !env CHROMIUM_SRC
build:
type: release
architecture: x64 # Linux x64
architecture: arm64
gn_flags:
file: build/config/gn/flags.linux.release.gn
# Explicit module execution order
# Explicit module execution order. Runs once per architecture above.
modules:
# Phase 1: Setup
- clean

View File

@@ -17,10 +17,64 @@ from ...common.utils import (
run_command,
safe_rmtree,
join_paths,
get_platform_arch,
IS_LINUX,
)
from ...common.notify import get_notifier, COLOR_GREEN
# Target-arch packaging metadata. These describe the artifact we're
# producing, not the build machine. `appimage_arch` is passed to
# appimagetool via the ARCH env var; `deb_arch` is written into the
# .deb control file.
LINUX_ARCHITECTURE_CONFIG = {
"x64": {
"appimage_arch": "x86_64",
"deb_arch": "amd64",
},
"arm64": {
"appimage_arch": "aarch64",
"deb_arch": "arm64",
},
}
# Host-arch tool selection. appimagetool is a normal binary that runs on
# the build machine — when cross-compiling arm64 from an x64 host, we
# still need the x86_64 tool to actually execute. Keyed on
# get_platform_arch() (BUILD machine arch), NOT ctx.architecture.
LINUX_HOST_APPIMAGETOOL = {
"x64": (
"appimagetool-x86_64.AppImage",
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage",
),
"arm64": (
"appimagetool-aarch64.AppImage",
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage",
),
}
def get_linux_architecture_config(architecture: str) -> dict[str, str]:
config = LINUX_ARCHITECTURE_CONFIG.get(architecture)
if not config:
supported = ", ".join(sorted(LINUX_ARCHITECTURE_CONFIG))
raise ValueError(
f"Unsupported Linux architecture: {architecture}. Supported: {supported}"
)
return config
def get_host_appimagetool() -> tuple[str, str]:
"""Return (filename, url) for the appimagetool binary that runs on
the current build machine. Critical for cross-compile correctness."""
host_arch = get_platform_arch()
tool = LINUX_HOST_APPIMAGETOOL.get(host_arch)
if not tool:
supported = ", ".join(sorted(LINUX_HOST_APPIMAGETOOL))
raise ValueError(
f"No appimagetool binary for host arch '{host_arch}'. Supported: {supported}"
)
return tool
class LinuxPackageModule(CommandModule):
produces = ["appimage", "deb"]
@@ -30,6 +84,10 @@ class LinuxPackageModule(CommandModule):
def validate(self, ctx: Context) -> None:
if not IS_LINUX():
raise ValidationError("Linux packaging requires Linux")
try:
get_linux_architecture_config(ctx.architecture)
except ValueError as exc:
raise ValidationError(str(exc)) from exc
out_dir = join_paths(ctx.chromium_src, ctx.out_dir)
chrome_binary = join_paths(out_dir, ctx.BROWSEROS_APP_NAME)
@@ -73,7 +131,7 @@ class LinuxPackageModule(CommandModule):
artifacts.append(deb_path.name)
notifier.notify(
"📦 Package Created",
f"Linux packages created successfully",
"Linux packages created successfully",
{
"Artifacts": ", ".join(artifacts),
"Version": ctx.semantic_version,
@@ -284,25 +342,30 @@ export CHROME_WRAPPER="${{THIS}}"
def download_appimagetool(ctx: Context) -> Optional[Path]:
"""Download appimagetool if not available"""
"""Download the appimagetool binary that runs on the build machine.
Note: this is keyed on the HOST arch, not ctx.architecture. When
cross-compiling arm64 packages from an x64 host, we still need the
x86_64 appimagetool because the tool executes locally; the target
arch is communicated via the ARCH env var in create_appimage().
"""
tool_dir = Path(join_paths(ctx.root_dir, "build", "tools"))
tool_dir.mkdir(exist_ok=True)
tool_path = Path(join_paths(tool_dir, "appimagetool-x86_64.AppImage"))
tool_filename, url = get_host_appimagetool()
tool_path = Path(join_paths(tool_dir, tool_filename))
if tool_path.exists():
log_info("✓ appimagetool already available")
log_info(f"✓ appimagetool already available ({tool_filename})")
return tool_path
log_info("📥 Downloading appimagetool...")
url = "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
log_info(f"📥 Downloading {tool_filename}...")
cmd = ["wget", "-O", str(tool_path), url]
result = run_command(cmd, check=False)
if result.returncode == 0:
tool_path.chmod(0o755)
log_success("✓ Downloaded appimagetool")
log_success(f"✓ Downloaded {tool_filename}")
return tool_path
else:
log_error("Failed to download appimagetool")
@@ -312,6 +375,7 @@ def download_appimagetool(ctx: Context) -> Optional[Path]:
def create_appimage(ctx: Context, appdir: Path, output_path: Path) -> bool:
"""Create AppImage from AppDir"""
log_info("📦 Creating AppImage...")
arch_config = get_linux_architecture_config(ctx.architecture)
# Download appimagetool if needed
appimagetool = download_appimagetool(ctx)
@@ -319,7 +383,7 @@ def create_appimage(ctx: Context, appdir: Path, output_path: Path) -> bool:
return False
# Set architecture environment variable (required by appimagetool)
arch = "x86_64" if ctx.architecture == "x64" else "aarch64"
arch = arch_config["appimage_arch"]
# Create AppImage with ARCH env var set for this command only
cmd = [
@@ -384,7 +448,7 @@ def create_control_file(ctx: Context, debian_dir: Path) -> None:
version = version.lstrip("v").replace(" ", "").replace("_", ".")
# Architecture mapping
deb_arch = "amd64" if ctx.architecture == "x64" else "arm64"
deb_arch = get_linux_architecture_config(ctx.architecture)["deb_arch"]
control_content = f"""Package: browseros
Version: {version}
@@ -653,7 +717,9 @@ def package_appimage(ctx: Context, package_dir: Path) -> Optional[Path]:
"""
log_info("🖼️ Building AppImage...")
appdir = Path(join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}.AppDir"))
appdir = Path(
join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}-{ctx.architecture}.AppDir")
)
if appdir.exists():
safe_rmtree(appdir)
@@ -683,7 +749,9 @@ def package_deb(ctx: Context, package_dir: Path) -> Optional[Path]:
"""
log_info("📦 Building .deb package...")
debdir = Path(join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}_deb"))
debdir = Path(
join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}_{ctx.architecture}_deb")
)
if debdir.exists():
safe_rmtree(debdir)
@@ -703,6 +771,8 @@ def package_deb(ctx: Context, package_dir: Path) -> Optional[Path]:
return output_path
return None
def package_universal(contexts: List[Context]) -> bool:
"""Linux doesn't support universal binaries"""
log_warning("Universal binaries are not supported on Linux")

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Tests for Linux packaging architecture helpers."""
import unittest
from unittest.mock import patch
from build.modules.package.linux import (
LINUX_HOST_APPIMAGETOOL,
get_host_appimagetool,
get_linux_architecture_config,
)
class LinuxArchitectureConfigTest(unittest.TestCase):
def test_returns_x64_packaging_config(self) -> None:
config = get_linux_architecture_config("x64")
self.assertEqual(config["appimage_arch"], "x86_64")
self.assertEqual(config["deb_arch"], "amd64")
def test_returns_arm64_packaging_config(self) -> None:
config = get_linux_architecture_config("arm64")
self.assertEqual(config["appimage_arch"], "aarch64")
self.assertEqual(config["deb_arch"], "arm64")
def test_rejects_unsupported_architecture(self) -> None:
with self.assertRaisesRegex(ValueError, "Unsupported Linux architecture"):
get_linux_architecture_config("universal")
class HostAppImageToolTest(unittest.TestCase):
"""The appimagetool binary must match the BUILD machine's arch, not
the target arch — otherwise cross-compiling arm64 packages from an x64
host fails because the aarch64 tool can't execute on x64."""
def test_x64_host_picks_x86_64_tool(self) -> None:
with patch(
"build.modules.package.linux.get_platform_arch", return_value="x64"
):
filename, url = get_host_appimagetool()
self.assertEqual(filename, "appimagetool-x86_64.AppImage")
self.assertIn("x86_64", url)
def test_arm64_host_picks_aarch64_tool(self) -> None:
with patch(
"build.modules.package.linux.get_platform_arch", return_value="arm64"
):
filename, url = get_host_appimagetool()
self.assertEqual(filename, "appimagetool-aarch64.AppImage")
self.assertIn("aarch64", url)
def test_host_lookup_independent_of_target(self) -> None:
# Both architectures must be present in the host lookup so cross
# builds work in either direction.
self.assertIn("x64", LINUX_HOST_APPIMAGETOOL)
self.assertIn("arm64", LINUX_HOST_APPIMAGETOOL)
if __name__ == "__main__":
unittest.main()

View File

@@ -6,7 +6,6 @@ from datetime import datetime
from typing import Dict, List, Optional
from ...common.env import EnvConfig
from ...common.utils import log_warning
from ..storage import get_release_json, get_r2_client, BOTO3_AVAILABLE
PLATFORMS = ["macos", "win", "linux"]
@@ -24,6 +23,8 @@ DOWNLOAD_PATH_MAPPING = {
"linux": {
"x64_appimage": "download/BrowserOS.AppImage",
"x64_deb": "download/BrowserOS.deb",
"arm64_appimage": "download/BrowserOS-arm64.AppImage",
"arm64_deb": "download/BrowserOS-arm64.deb",
},
}

View File

@@ -1,9 +1,19 @@
#!/usr/bin/env python3
"""Build configuration module for BrowserOS build system"""
import sys
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
from ...common.utils import run_command, log_info, log_success, join_paths, IS_WINDOWS
from ...common.utils import (
run_command,
log_info,
log_warning,
log_success,
join_paths,
IS_LINUX,
IS_WINDOWS,
)
class ConfigureModule(CommandModule):
@@ -25,6 +35,16 @@ class ConfigureModule(CommandModule):
def execute(self, ctx: Context) -> None:
log_info(f"\n⚙️ Configuring {ctx.build_type} build for {ctx.architecture}...")
# Linux: ensure the target-arch Debian sysroot is installed before
# `gn gen`. sysroot.gni asserts on missing sysroots, and relying on
# `gclient sync` DEPS hooks is fragile — the hook only fires when
# .gclient declared the right `target_cpus` *before* sync, which
# isn't guaranteed for chromium_src checkouts that predate
# cross-arch support. install-sysroot.py is idempotent and fast,
# so call it unconditionally for the target arch.
if IS_LINUX():
self._ensure_linux_sysroot(ctx)
out_path = join_paths(ctx.chromium_src, ctx.out_dir)
out_path.mkdir(parents=True, exist_ok=True)
@@ -43,3 +63,26 @@ class ConfigureModule(CommandModule):
run_command(gn_args, cwd=ctx.chromium_src)
log_success("Build configured")
def _ensure_linux_sysroot(self, ctx: Context) -> None:
install_script = (
ctx.chromium_src / "build" / "linux" / "sysroot_scripts" / "install-sysroot.py"
)
if not install_script.exists():
log_warning(
f"⚠️ install-sysroot.py not found at {install_script}; "
f"skipping sysroot bootstrap. gn gen will fail if the "
f"{ctx.architecture} sysroot is missing."
)
return
# install-sysroot.py accepts our arch names directly: it translates
# `x64`→`amd64` internally via ARCH_TRANSLATIONS, and `arm64` is a
# valid pass-through value.
log_info(
f"📦 Ensuring Linux sysroot for {ctx.architecture} (idempotent)..."
)
run_command(
[sys.executable, str(install_script), f"--arch={ctx.architecture}"],
cwd=ctx.chromium_src,
)

View File

@@ -1,12 +1,24 @@
#!/usr/bin/env python3
"""Git operations module for BrowserOS build system"""
import re
import subprocess
import tarfile
import urllib.request
from typing import List
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
from ...common.utils import run_command, log_info, log_error, log_success, IS_WINDOWS, safe_rmtree
from ...common.utils import (
run_command,
log_info,
log_warning,
log_error,
log_success,
IS_LINUX,
IS_WINDOWS,
safe_rmtree,
)
class GitSetupModule(CommandModule):
@@ -32,6 +44,12 @@ class GitSetupModule(CommandModule):
log_info(f"🔀 Checking out tag: {ctx.chromium_version}")
run_command(["git", "checkout", f"tags/{ctx.chromium_version}"], cwd=ctx.chromium_src)
# On Linux, depot_tools fetches per-arch sysroots automatically when
# `.gclient` declares `target_cpus`. Ensure both x64 and arm64 are
# listed before sync so cross-compilation just works on x64 hosts.
if IS_LINUX():
self._ensure_gclient_target_cpus(ctx, ["x64", "arm64"])
log_info("📥 Syncing dependencies (this may take a while)...")
if IS_WINDOWS():
run_command(["gclient.bat", "sync", "-D", "--no-history", "--shallow"], cwd=ctx.chromium_src)
@@ -40,6 +58,49 @@ class GitSetupModule(CommandModule):
log_success("Git setup complete")
def _ensure_gclient_target_cpus(self, ctx: Context, required: List[str]) -> None:
"""Idempotently add `target_cpus` to .gclient so depot_tools fetches
the matching Linux sysroots for cross-compilation.
depot_tools convention: .gclient lives one directory above
chromium_src (i.e. ../.gclient). It is a Python file with a list
of solution dicts followed by optional top-level assignments.
We append a `target_cpus = [...]` line if missing or merge in any
archs that aren't already present.
"""
gclient_path = ctx.chromium_src.parent / ".gclient"
if not gclient_path.exists():
log_warning(
f"⚠️ .gclient not found at {gclient_path}; "
f"skipping target_cpus bootstrap. "
f"Cross-arch builds may fail until you run `fetch chromium`."
)
return
content = gclient_path.read_text()
match = re.search(r"^\s*target_cpus\s*=\s*\[([^\]]*)\]", content, re.MULTILINE)
if match:
existing = re.findall(r"['\"]([^'\"]+)['\"]", match.group(1))
missing = [arch for arch in required if arch not in existing]
if not missing:
log_info(f"✓ .gclient target_cpus already includes {required}")
return
merged = sorted(set(existing) | set(required))
new_line = f"target_cpus = {merged!r}"
content = (
content[: match.start()] + new_line + content[match.end() :]
)
log_info(
f"📝 Updating .gclient target_cpus: {existing}{merged}"
)
else:
new_line = f"\ntarget_cpus = {required!r}\n"
content = content.rstrip() + "\n" + new_line
log_info(f"📝 Adding target_cpus = {required} to .gclient")
gclient_path.write_text(content)
def _verify_tag_exists(self, ctx: Context) -> None:
result = subprocess.run(
["git", "tag", "-l", ctx.chromium_version],

View File

@@ -4,7 +4,7 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, cast
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
@@ -21,6 +21,7 @@ from ...common.notify import get_notifier, COLOR_GREEN
from .r2 import (
BOTO3_AVAILABLE,
get_r2_client,
get_release_json,
upload_file_to_r2,
)
@@ -58,7 +59,10 @@ class UploadModule(CommandModule):
log_info("\nUploading package artifacts to R2...")
extra_metadata = {}
sparkle_signatures = ctx.artifacts.get("sparkle_signatures")
sparkle_signatures = cast(
Optional[dict[str, tuple[str, int]]],
ctx.artifacts.get("sparkle_signatures"),
)
if sparkle_signatures:
for filename, (sig, length) in sparkle_signatures.items():
extra_metadata[filename] = {
@@ -120,6 +124,36 @@ def generate_release_json(
return release_data
def merge_release_metadata(existing: Optional[Dict], new: Dict) -> Dict:
if not existing:
return new
merged = dict(existing)
merged.update({key: value for key, value in new.items() if key != "artifacts"})
artifacts = dict(existing.get("artifacts", {}))
artifacts.update(new.get("artifacts", {}))
merged["artifacts"] = artifacts
return merged
def _get_linux_artifact_key(filename: str) -> Optional[str]:
lower = filename.lower()
if ".appimage" in lower:
if "arm64" in lower or "aarch64" in lower:
return "arm64_appimage"
if "x64" in lower or "x86_64" in lower:
return "x64_appimage"
elif ".deb" in lower:
if "arm64" in lower or "aarch64" in lower:
return "arm64_deb"
if "amd64" in lower or "x64" in lower or "x86_64" in lower:
return "x64_deb"
return None
def _get_artifact_key(filename: str, platform: str) -> str:
"""Get artifact key name from filename
@@ -147,10 +181,10 @@ def _get_artifact_key(filename: str, platform: str) -> str:
return "x64_zip"
elif platform == "linux":
if ".appimage" in lower:
return "x64_appimage"
elif ".deb" in lower:
return "x64_deb"
artifact_key = _get_linux_artifact_key(filename)
if artifact_key:
return artifact_key
log_warning(f"Unrecognized Linux artifact name: {filename}; using stem key")
return Path(filename).stem
@@ -181,7 +215,7 @@ def detect_artifacts(ctx: Context) -> List[Path]:
def upload_release_artifacts(
ctx: Context,
extra_metadata: Optional[Dict[str, Dict[str, any]]] = None,
extra_metadata: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Tuple[bool, Optional[Dict]]:
"""Upload release artifacts to R2 and generate release.json
@@ -240,6 +274,13 @@ def upload_release_artifacts(
artifact_metadata.append(metadata)
release_data = generate_release_json(ctx, artifact_metadata, platform)
if platform == "linux":
# Linux x64 and arm64 release jobs must be sequenced. A parallel
# fetch-merge-upload flow can still race and drop one architecture.
existing_release_data = get_release_json(
ctx.get_semantic_version(), platform, env
)
release_data = merge_release_metadata(existing_release_data, release_data)
release_json_path = ctx.get_dist_dir() / "release.json"
release_json_path.write_text(json.dumps(release_data, indent=2))
@@ -248,7 +289,7 @@ def upload_release_artifacts(
return False, None
log_success(f"\nSuccessfully uploaded {len(artifacts)} artifact(s) to R2")
log_info(f"\nRelease metadata:")
log_info("\nRelease metadata:")
log_info(f" Version: {release_data['version']}")
if platform == "macos":
log_info(f" Sparkle version: {release_data.get('sparkle_version', 'N/A')}")

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Tests for release artifact upload metadata helpers."""
import unittest
from build.modules.storage.upload import _get_artifact_key, merge_release_metadata
class UploadMetadataTest(unittest.TestCase):
def test_linux_x64_artifacts_use_x64_keys(self) -> None:
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_x64.AppImage", "linux"),
"x64_appimage",
)
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_amd64.deb", "linux"),
"x64_deb",
)
def test_linux_arm64_artifacts_use_arm64_keys(self) -> None:
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_arm64.AppImage", "linux"),
"arm64_appimage",
)
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_arm64.deb", "linux"),
"arm64_deb",
)
self.assertEqual(
_get_artifact_key("BrowserOS_v1.2.3_aarch64.deb", "linux"),
"arm64_deb",
)
def test_merge_release_metadata_preserves_existing_artifacts(self) -> None:
existing = {
"platform": "linux",
"version": "1.2.3",
"build_date": "old",
"artifacts": {
"x64_appimage": {"filename": "BrowserOS_v1.2.3_x64.AppImage"},
"x64_deb": {"filename": "BrowserOS_v1.2.3_amd64.deb"},
},
}
new = {
"platform": "linux",
"version": "1.2.3",
"build_date": "new",
"artifacts": {
"arm64_appimage": {"filename": "BrowserOS_v1.2.3_arm64.AppImage"},
"arm64_deb": {"filename": "BrowserOS_v1.2.3_arm64.deb"},
},
}
merged = merge_release_metadata(existing, new)
self.assertEqual(merged["build_date"], "new")
self.assertEqual(
sorted(merged["artifacts"]),
["arm64_appimage", "arm64_deb", "x64_appimage", "x64_deb"],
)
def test_merge_release_metadata_overwrites_matching_artifact_keys(self) -> None:
existing = {
"platform": "linux",
"version": "1.2.3",
"artifacts": {
"x64_appimage": {"filename": "old.AppImage", "size": 1},
},
}
new = {
"platform": "linux",
"version": "1.2.3",
"artifacts": {
"x64_appimage": {"filename": "new.AppImage", "size": 2},
},
}
merged = merge_release_metadata(existing, new)
self.assertEqual(merged["artifacts"]["x64_appimage"]["filename"], "new.AppImage")
self.assertEqual(merged["artifacts"]["x64_appimage"]["size"], 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,9 +1,9 @@
diff --git a/chrome/browser/browseros/extensions/browseros_extension_loader.cc b/chrome/browser/browseros/extensions/browseros_extension_loader.cc
new file mode 100644
index 0000000000000..e61b45d08b7e2
index 0000000000000..fdb6be443f25b
--- /dev/null
+++ b/chrome/browser/browseros/extensions/browseros_extension_loader.cc
@@ -0,0 +1,226 @@
@@ -0,0 +1,269 @@
+// Copyright 2024 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
@@ -89,10 +89,53 @@ index 0000000000000..e61b45d08b7e2
+ extension_ids_.merge(result.extension_ids);
+ last_config_ = std::move(result.config);
+
+ LoadFinished(std::move(result.prefs));
+ base::DictValue prefs_to_load = std::move(result.prefs);
+
+ if (prefs_to_load.empty()) {
+ LOG(WARNING) << "browseros: Install returned empty prefs, "
+ << "reconstructing from installed extensions";
+ prefs_to_load = ReconstructPrefsFromInstalledExtensions();
+ LOG(INFO) << "browseros: Reconstructed prefs for "
+ << prefs_to_load.size() << " installed extensions";
+ }
+
+ LoadFinished(std::move(prefs_to_load));
+ OnStartupComplete(result.from_bundled);
+}
+
+base::DictValue
+BrowserOSExtensionLoader::ReconstructPrefsFromInstalledExtensions() {
+ base::DictValue prefs;
+
+ extensions::ExtensionRegistry* registry =
+ extensions::ExtensionRegistry::Get(profile_);
+ if (!registry) {
+ return prefs;
+ }
+
+ const std::string update_url =
+ base::FeatureList::IsEnabled(features::kBrowserOsAlphaFeatures)
+ ? kBrowserOSAlphaUpdateUrl
+ : kBrowserOSUpdateUrl;
+
+ for (const std::string& id : GetBrowserOSExtensionIds()) {
+ const extensions::Extension* ext = registry->GetInstalledExtension(id);
+ if (!ext) {
+ continue;
+ }
+
+ base::DictValue ext_pref;
+ ext_pref.Set(extensions::ExternalProviderImpl::kExternalUpdateUrl,
+ update_url);
+ prefs.Set(id, std::move(ext_pref));
+
+ LOG(INFO) << "browseros: Reconstructed pref for installed extension "
+ << id << " v" << ext->version().GetString();
+ }
+
+ return prefs;
+}
+
+const base::FilePath BrowserOSExtensionLoader::GetBaseCrxFilePath() {
+ return bundled_crx_base_path_;
+}

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