From 7ddc9ba861fc021f7817c0dd33adb11d5348887d Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Mon, 14 Sep 2026 07:47:29 +0530 Subject: [PATCH 1/2] Add Herdr plugin for persistent Browserbase context --- .changeset/tidy-pandas-browse.md | 5 + README.md | 1 + packages/herdr/LICENSE | 21 ++ packages/herdr/README.md | 104 ++++++++ packages/herdr/herdr-plugin.toml | 30 +++ packages/herdr/package.json | 49 ++++ packages/herdr/skills/herdr-browse/SKILL.md | 77 ++++++ packages/herdr/src/browser.test.ts | 282 ++++++++++++++++++++ packages/herdr/src/browser.ts | 267 ++++++++++++++++++ packages/herdr/src/cli.ts | 111 ++++++++ packages/herdr/src/identity.test.ts | 41 +++ packages/herdr/src/identity.ts | 69 +++++ packages/herdr/src/runner.ts | 45 ++++ packages/herdr/src/skill.ts | 44 +++ packages/herdr/src/state.test.ts | 72 +++++ packages/herdr/src/state.ts | 109 ++++++++ packages/herdr/src/types.ts | 28 ++ packages/herdr/tsconfig.json | 20 ++ 18 files changed, 1375 insertions(+) create mode 100644 .changeset/tidy-pandas-browse.md create mode 100644 packages/herdr/LICENSE create mode 100644 packages/herdr/README.md create mode 100644 packages/herdr/herdr-plugin.toml create mode 100644 packages/herdr/package.json create mode 100644 packages/herdr/skills/herdr-browse/SKILL.md create mode 100644 packages/herdr/src/browser.test.ts create mode 100644 packages/herdr/src/browser.ts create mode 100644 packages/herdr/src/cli.ts create mode 100644 packages/herdr/src/identity.test.ts create mode 100644 packages/herdr/src/identity.ts create mode 100644 packages/herdr/src/runner.ts create mode 100644 packages/herdr/src/skill.ts create mode 100644 packages/herdr/src/state.test.ts create mode 100644 packages/herdr/src/state.ts create mode 100644 packages/herdr/src/types.ts create mode 100644 packages/herdr/tsconfig.json diff --git a/.changeset/tidy-pandas-browse.md b/.changeset/tidy-pandas-browse.md new file mode 100644 index 0000000..7436d45 --- /dev/null +++ b/.changeset/tidy-pandas-browse.md @@ -0,0 +1,5 @@ +--- +'@browserbasehq/herdr': minor +--- + +Add a Herdr plugin and scoped CLI that give each workspace a persistent Browserbase context and each agent a separate live browser session. diff --git a/README.md b/README.md index 5f654f8..b096e5f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ integrations/ │ ├── trigger/ # Trigger.dev background jobs & automation │ └── vercel/ # Vercel integrations ├── packages/ # Published npm packages +│ └── herdr/ # Persistent workspace browsers for Herdr └── README.md ``` diff --git a/packages/herdr/LICENSE b/packages/herdr/LICENSE new file mode 100644 index 0000000..3b33da2 --- /dev/null +++ b/packages/herdr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Browserbase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/herdr/README.md b/packages/herdr/README.md new file mode 100644 index 0000000..b2d789c --- /dev/null +++ b/packages/herdr/README.md @@ -0,0 +1,104 @@ +# Herdr Browserbase plugin + +Give every Herdr workspace its own Browserbase context. Agents in that workspace get separate live sessions and share the context's cookies and local storage. + +## Install + +You need Node.js 20 or newer and Herdr 0.9 or newer. + +```bash +herdr plugin install browserbase/integrations/packages/herdr +``` + +The Herdr actions work immediately after plugin installation. Install the package command when you also want to control the browser directly from agent terminals: + +```bash +npm install --global @browserbasehq/herdr +``` + +This installs `herdr-browse` and its private copy of `browse`. You do not need a global `browse` installation. + +Install the bundled agent skill so Codex, Claude Code, and other supported agents automatically choose the workspace-aware command for browser tasks: + +```bash +herdr-browse skills install +``` + +Start a new agent session after installation so it discovers the skill. The skill tells agents to use `herdr-browse` instead of calling the underlying `browse` command directly and not to supply their own session name. + +For local development: + +```bash +herdr plugin link /path/to/integrations/packages/herdr +npm --prefix /path/to/integrations/packages/herdr install +npm --prefix /path/to/integrations/packages/herdr run build +npm link /path/to/integrations/packages/herdr +``` + +Cloud browsing reads credentials from the environment inherited by Herdr: + +```bash +export BROWSERBASE_API_KEY="bb_live_..." +export BROWSERBASE_PROJECT_ID="..." +herdr +``` + +The plugin never writes these values to its state file. It also disables `browse`'s legacy automatic `.env` loading, so a workspace `.env` cannot silently select a different Browserbase account. + +Herdr shows the manifest and both install-time build commands before it runs them. The npm install is scoped to this package and does not install the rest of the integrations monorepo. + +## Use + +Run commands inside a Herdr workspace: + +```bash +herdr-browse open http://localhost:3000 +herdr-browse snapshot +herdr-browse click @0-3 +herdr-browse screenshot page.png +herdr-browse status +herdr-browse stop +``` + +Localhost, loopback addresses, and `.localhost`, `.local`, or `.test` hosts use a clean local browser. Other URLs use Browserbase. Override routing when needed: + +```bash +herdr-browse open https://example.com --local +herdr-browse open http://localhost:3000 --cloud +``` + +`herdr-browse` passes commands and arguments to the bundled `browse` CLI. It adds a session name derived from the current Herdr workspace and agent or pane, which prevents agents from controlling one another's live sessions. + +## Persistence and concurrency + +The first cloud open creates a Browserbase Context for the workspace. Later sessions load it, and stopping a session saves cookies and local storage back to it. + +Agents have separate live browser sessions but share the workspace Context. If several sessions update it concurrently, the last released session wins. + +Local browser sessions are isolated and do not keep state after they stop. + +Resetting deletes the remote Context and its saved login state: + +```bash +herdr-browse reset +# Non-interactive: +herdr-browse reset --yes +``` + +## Herdr actions + +The plugin registers actions to start a blank cloud browser, inspect its status, and stop it. Use `herdr-browse open ` when you want automatic local or cloud routing. + +## State + +The plugin stores context IDs and session metadata in `HERDR_PLUGIN_STATE_DIR/browser-state.json`. It writes the file atomically under a lock. It does not store API keys, cookies, or local storage. Browserbase stores the persistent browser data in the workspace Context. + +When `herdr-browse` runs directly from an agent terminal, Herdr does not inject `HERDR_PLUGIN_STATE_DIR`. The command uses Herdr's standard per-plugin state location instead: + +- `$XDG_STATE_HOME/herdr/plugins/browserbase.browser` when `XDG_STATE_HOME` is set +- `$HOME/.local/state/herdr/plugins/browserbase.browser` on macOS and Linux +- `%LOCALAPPDATA%\\herdr\\plugins\\browserbase.browser` on Windows + +## Marketplace publishing + +Herdr discovers plugins from public GitHub repositories whose default branch contains a valid `herdr-plugin.toml` and whose repository has the `herdr-plugin` topic. After this package reaches the default branch, add that topic to `browserbase/integrations`. The marketplace refreshes automatically. diff --git a/packages/herdr/herdr-plugin.toml b/packages/herdr/herdr-plugin.toml new file mode 100644 index 0000000..c5b34a9 --- /dev/null +++ b/packages/herdr/herdr-plugin.toml @@ -0,0 +1,30 @@ +id = "browserbase.browser" +name = "Browserbase Browser" +version = "0.1.0" +min_herdr_version = "0.9.0" +description = "A workspace-scoped browser that keeps its cloud login state." +platforms = ["linux", "macos"] + +[[build]] +command = ["npm", "install", "--ignore-scripts", "--workspaces=false", "--package-lock=false"] + +[[build]] +command = ["npm", "run", "build"] + +[[actions]] +id = "start" +title = "Start workspace browser" +contexts = ["workspace"] +command = ["node", "dist/cli.js", "open", "about:blank", "--cloud"] + +[[actions]] +id = "status" +title = "Workspace browser status" +contexts = ["workspace"] +command = ["node", "dist/cli.js", "status"] + +[[actions]] +id = "stop" +title = "Stop workspace browser" +contexts = ["workspace"] +command = ["node", "dist/cli.js", "stop"] diff --git a/packages/herdr/package.json b/packages/herdr/package.json new file mode 100644 index 0000000..c5c5f62 --- /dev/null +++ b/packages/herdr/package.json @@ -0,0 +1,49 @@ +{ + "name": "@browserbasehq/herdr", + "version": "0.1.0", + "description": "Give every Herdr workspace a persistent Browserbase browser.", + "type": "module", + "bin": { + "herdr-browse": "dist/cli.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "check-types": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "files": [ + "dist", + "skills", + "herdr-plugin.toml", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/browserbase/integrations.git", + "directory": "packages/herdr" + }, + "keywords": [ + "herdr", + "browserbase", + "browse", + "browser-automation", + "persistent-context" + ], + "author": "Browserbase", + "license": "MIT", + "dependencies": { + "browse": "0.9.6" + }, + "devDependencies": { + "@types/node": "25.0.9", + "typescript": "6.0.2", + "vitest": "4.0.6" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/herdr/skills/herdr-browse/SKILL.md b/packages/herdr/skills/herdr-browse/SKILL.md new file mode 100644 index 0000000..ddf64ad --- /dev/null +++ b/packages/herdr/skills/herdr-browse/SKILL.md @@ -0,0 +1,77 @@ +--- +name: herdr-browse +description: Use herdr-browse for browser navigation, website interaction, screenshots, form filling, and web-app testing inside a Herdr workspace. Prefer it over browse or direct browser automation so Herdr can isolate each agent session and preserve workspace login state. +compatibility: 'Requires herdr-browse inside a Herdr workspace. Cloud browsing requires BROWSERBASE_API_KEY.' +license: MIT +allowed-tools: Bash +--- + +# Herdr Browse + +Use `herdr-browse` as the browser automation CLI inside a Herdr workspace. It delegates browser operations to the Browse CLI while assigning the current workspace and agent's session automatically. + +Do not call `browse` directly and do not pass `--session`. Doing either bypasses Herdr's session isolation and workspace context handling. + +## Browser routing + +Open the target URL first: + +```bash +herdr-browse open +``` + +Herdr uses a local browser for localhost, loopback addresses, and `.localhost`, `.local`, or `.test` hosts. Other URLs use Browserbase and the workspace's persistent context. Override this only when the task requires it: + +```bash +herdr-browse open --local +herdr-browse open --cloud +``` + +Cloud browsing requires `BROWSERBASE_API_KEY`. The workspace context preserves cookies and local storage across cloud sessions. Each agent gets a separate live session. + +## Interaction workflow + +Inspect the page before acting, then take a new snapshot after navigation or a UI update because element refs can change: + +```bash +herdr-browse snapshot +herdr-browse click @0-5 +herdr-browse fill @0-8 "search query" +herdr-browse snapshot +``` + +Useful delegated Browse commands include: + +```bash +herdr-browse get url +herdr-browse get title +herdr-browse get text body +herdr-browse screenshot --path page.png +herdr-browse tab list +herdr-browse wait load +herdr-browse doctor --json +``` + +Run `herdr-browse --help` before using unfamiliar Browse commands. + +## Lifecycle + +Check or stop only the current agent's session: + +```bash +herdr-browse status +herdr-browse stop +``` + +`herdr-browse reset` deletes the entire workspace's remote context and saved login state. Run it only when the user asks to remove that state or resetting it is necessary to complete the task. Use `--yes` only when that destructive action is already authorized. + +## Browse.sh skills + +Site-specific Browse.sh skill discovery remains available through the wrapper: + +```bash +herdr-browse skills find +herdr-browse skills add / +``` + +Use `herdr-browse skills install` to install or refresh this Herdr-specific skill. Do not run `browse skills install`, which installs instructions for the unwrapped `browse` command. diff --git a/packages/herdr/src/browser.test.ts b/packages/herdr/src/browser.test.ts new file mode 100644 index 0000000..9304d0d --- /dev/null +++ b/packages/herdr/src/browser.test.ts @@ -0,0 +1,282 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { BrowserController } from './browser.js'; +import type { CommandRunner } from './types.js'; + +const identity = { + workspaceId: 'workspace-1', + actorId: 'codex', + browseSession: 'herdr-workspace-1-codex', +}; + +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), 'herdr-browser-test-')); + return join(directory, 'state.json'); +} + +describe('BrowserController', () => { + it('opens localhost without cloud credentials', async () => { + const calls: string[][] = []; + const run: CommandRunner = async args => { + calls.push(args); + return { status: 0, stdout: '', stderr: '' }; + }; + const controller = new BrowserController({ + identity, + path: await fixture(), + run, + env: {}, + }); + await controller.open('http://localhost:3000'); + expect(calls[0]).toEqual([ + 'open', + 'http://localhost:3000', + '--local', + '--session', + identity.browseSession, + ]); + }); + + it('creates and reuses a workspace context', async () => { + const path = await fixture(); + const calls: string[][] = []; + let session = 0; + const run: CommandRunner = async args => { + calls.push(args); + if (args.slice(0, 3).join(' ') === 'cloud contexts create') { + return { status: 0, stdout: '{"id":"ctx_1"}', stderr: '' }; + } + if (args.slice(0, 3).join(' ') === 'cloud sessions create') { + session += 1; + return { + status: 0, + stdout: JSON.stringify({ + id: `sess_${session}`, + connectUrl: `wss://example/${session}`, + }), + stderr: '', + }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + const controller = new BrowserController({ + identity, + path, + run, + env: { BROWSERBASE_API_KEY: 'secret-value' }, + }); + await controller.open('https://example.com'); + await controller.stop(); + await controller.open('https://example.org'); + + expect( + calls.filter( + args => args.slice(0, 3).join(' ') === 'cloud contexts create' + ) + ).toHaveLength(1); + expect(await readFile(path, 'utf8')).not.toContain('secret-value'); + }); + + it('releases a cloud session even when daemon stop fails', async () => { + const path = await fixture(); + const calls: string[][] = []; + const run: CommandRunner = async args => { + calls.push(args); + if (args.slice(0, 3).join(' ') === 'cloud contexts create') { + return { status: 0, stdout: '{"id":"ctx_1"}', stderr: '' }; + } + if (args.slice(0, 3).join(' ') === 'cloud sessions create') { + return { + status: 0, + stdout: '{"id":"sess_1","connectUrl":"wss://example"}', + stderr: '', + }; + } + if (args[0] === 'stop') return { status: 2, stdout: '', stderr: '' }; + return { status: 0, stdout: '', stderr: '' }; + }; + const controller = new BrowserController({ + identity, + path, + run, + env: { BROWSERBASE_API_KEY: 'secret' }, + }); + await controller.open('https://example.com'); + expect(await controller.stop()).toBe(2); + expect(calls).toContainEqual([ + 'cloud', + 'sessions', + 'update', + 'sess_1', + '--status', + 'REQUEST_RELEASE', + ]); + }); + + it('rejects cloud mode without an API key', async () => { + const controller = new BrowserController({ + identity, + path: await fixture(), + run: async () => ({ status: 0, stdout: '', stderr: '' }), + env: {}, + }); + await expect(controller.open('https://example.com')).rejects.toThrow( + 'BROWSERBASE_API_KEY' + ); + }); + + it('keeps workspace contexts separate', async () => { + const path = await fixture(); + let contextNumber = 0; + const sessionContextIds: string[] = []; + const run: CommandRunner = async args => { + if (args.slice(0, 3).join(' ') === 'cloud contexts create') { + contextNumber += 1; + return { + status: 0, + stdout: JSON.stringify({ id: `ctx_${contextNumber}` }), + stderr: '', + }; + } + if (args.slice(0, 3).join(' ') === 'cloud sessions create') { + sessionContextIds.push(args[4] ?? ''); + return { + status: 0, + stdout: JSON.stringify({ + id: `sess_${sessionContextIds.length}`, + connectUrl: `wss://example/${sessionContextIds.length}`, + }), + stderr: '', + }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + const first = new BrowserController({ + identity, + path, + run, + env: { BROWSERBASE_API_KEY: 'secret' }, + }); + const second = new BrowserController({ + identity: { + workspaceId: 'workspace-2', + actorId: 'codex', + browseSession: 'herdr-workspace-2-codex', + }, + path, + run, + env: { BROWSERBASE_API_KEY: 'secret' }, + }); + await first.open('https://example.com'); + await second.open('https://example.com'); + expect(sessionContextIds).toEqual(['ctx_1', 'ctx_2']); + }); + + it('deletes all workspace sessions and its context on reset', async () => { + const path = await fixture(); + const calls: string[][] = []; + const run: CommandRunner = async args => { + calls.push(args); + if (args.slice(0, 3).join(' ') === 'cloud contexts create') { + return { status: 0, stdout: '{"id":"ctx_1"}', stderr: '' }; + } + if (args.slice(0, 3).join(' ') === 'cloud sessions create') { + return { + status: 0, + stdout: '{"id":"sess_1","connectUrl":"wss://example"}', + stderr: '', + }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + const controller = new BrowserController({ + identity, + path, + run, + env: { BROWSERBASE_API_KEY: 'secret' }, + }); + await controller.open('https://example.com'); + expect(await controller.reset()).toBe(0); + expect(calls).toContainEqual(['cloud', 'contexts', 'delete', 'ctx_1']); + expect(await readFile(path, 'utf8')).not.toContain('workspace-1'); + }); + + it('does not allow a new session to race with workspace reset', async () => { + const path = await fixture(); + let contextNumber = 0; + let sessionNumber = 0; + let releaseReset: (() => void) | undefined; + let markResetReachedRelease: (() => void) | undefined; + const resetReachedRelease = new Promise(resolve => { + markResetReachedRelease = resolve; + }); + const holdResetRelease = new Promise(resolve => { + releaseReset = resolve; + }); + const run: CommandRunner = async args => { + if (args.slice(0, 3).join(' ') === 'cloud contexts create') { + contextNumber += 1; + return { + status: 0, + stdout: JSON.stringify({ id: `ctx_${contextNumber}` }), + stderr: '', + }; + } + if (args.slice(0, 3).join(' ') === 'cloud sessions create') { + sessionNumber += 1; + return { + status: 0, + stdout: JSON.stringify({ + id: `sess_${sessionNumber}`, + connectUrl: `wss://example/${sessionNumber}`, + }), + stderr: '', + }; + } + if ( + args.slice(0, 3).join(' ') === 'cloud sessions update' && + args[3] === 'sess_1' + ) { + markResetReachedRelease?.(); + await holdResetRelease; + } + return { status: 0, stdout: '', stderr: '' }; + }; + const resetter = new BrowserController({ + identity, + path, + run, + env: { BROWSERBASE_API_KEY: 'secret' }, + }); + const concurrentAgent = new BrowserController({ + identity: { + ...identity, + actorId: 'claude', + browseSession: 'herdr-workspace-1-claude', + }, + path, + run, + env: { BROWSERBASE_API_KEY: 'secret' }, + }); + + await resetter.open('https://example.com'); + const resetting = resetter.reset(); + await resetReachedRelease; + const opening = concurrentAgent.open('https://example.org'); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(contextNumber).toBe(1); + expect(sessionNumber).toBe(1); + + releaseReset?.(); + await resetting; + await opening; + expect(contextNumber).toBe(2); + expect(sessionNumber).toBe(2); + const state = JSON.parse(await readFile(path, 'utf8')) as { + workspaces: Record }>; + }; + expect(state.workspaces['workspace-1']?.sessions).toHaveProperty('claude'); + }); +}); diff --git a/packages/herdr/src/browser.ts b/packages/herdr/src/browser.ts new file mode 100644 index 0000000..9bb1a30 --- /dev/null +++ b/packages/herdr/src/browser.ts @@ -0,0 +1,267 @@ +import { createHash } from 'node:crypto'; +import type { HerdrIdentity } from './identity.js'; +import { selectMode } from './identity.js'; +import { parseJsonOutput } from './runner.js'; +import { readState, withFileLock, withStateLock } from './state.js'; +import type { + BrowserMode, + CommandResult, + CommandRunner, + SessionState, +} from './types.js'; + +interface CloudContextResponse { + id: string; +} + +interface CloudSessionResponse { + id: string; + connectUrl: string; +} + +export interface BrowserControllerOptions { + identity: HerdrIdentity; + path: string; + run: CommandRunner; + env?: NodeJS.ProcessEnv; +} + +export class BrowserController { + private readonly identity: HerdrIdentity; + private readonly path: string; + private readonly run: CommandRunner; + private readonly env: NodeJS.ProcessEnv; + + constructor(options: BrowserControllerOptions) { + this.identity = options.identity; + this.path = options.path; + this.run = options.run; + this.env = options.env ?? process.env; + } + + async open(url: string, override?: BrowserMode): Promise { + return await this.withLifecycleLock(() => this.openUnlocked(url, override)); + } + + private async openUnlocked( + url: string, + override?: BrowserMode + ): Promise { + const mode = selectMode(url, override); + if (mode === 'local') { + const result = await this.run([ + 'open', + url, + '--local', + '--session', + this.identity.browseSession, + ]); + if (result.status === 0) { + await this.saveSession({ + browseSession: this.identity.browseSession, + mode, + }); + } + return result; + } + + if (!this.env.BROWSERBASE_API_KEY) { + throw new Error( + 'BROWSERBASE_API_KEY is required for cloud URLs. Set it in the environment or pass --local.' + ); + } + + const existing = await this.currentSession(); + if (existing?.mode === 'cloud' && existing.browserbaseSessionId) { + const resumed = await this.run([ + 'open', + url, + '--session', + this.identity.browseSession, + ]); + if (resumed.status === 0) return resumed; + await this.release(existing.browserbaseSessionId); + } + + const contextId = await this.contextId(); + const cloudSession = parseJsonOutput( + await this.run( + [ + 'cloud', + 'sessions', + 'create', + '--context-id', + contextId, + '--persist', + '--keep-alive', + ], + { capture: true } + ) + ); + if (!cloudSession.id || !cloudSession.connectUrl) { + throw new Error( + 'browse cloud session response is missing id or connectUrl' + ); + } + + const session: SessionState = { + browseSession: this.identity.browseSession, + browserbaseSessionId: cloudSession.id, + mode, + }; + await this.saveSession(session); + const opened = await this.run([ + 'open', + url, + '--cdp', + cloudSession.connectUrl, + '--session', + this.identity.browseSession, + ]); + if (opened.status !== 0) { + await this.release(cloudSession.id); + await this.removeSession(); + } + return opened; + } + + async delegate(args: string[]): Promise { + return await this.run([...args, '--session', this.identity.browseSession]); + } + + async status(): Promise<{ + workspaceId: string; + actorId: string; + contextId?: string; + session?: SessionState; + browse: CommandResult; + }> { + const state = await readState(this.path); + const workspace = state.workspaces[this.identity.workspaceId]; + return { + workspaceId: this.identity.workspaceId, + actorId: this.identity.actorId, + contextId: workspace?.contextId, + session: workspace?.sessions[this.identity.actorId], + browse: await this.run( + ['status', '--session', this.identity.browseSession], + { capture: true } + ), + }; + } + + async stop(): Promise { + return await this.withLifecycleLock(() => this.stopUnlocked()); + } + + private async stopUnlocked(): Promise { + const session = await this.currentSession(); + const stopped = await this.run([ + 'stop', + '--session', + this.identity.browseSession, + ]); + let releaseStatus = 0; + if (session?.browserbaseSessionId) { + releaseStatus = (await this.release(session.browserbaseSessionId)).status; + } + await this.removeSession(); + return stopped.status || releaseStatus; + } + + async reset(): Promise { + return await this.withLifecycleLock(() => this.resetUnlocked()); + } + + private async resetUnlocked(): Promise { + const state = await readState(this.path); + const workspace = state.workspaces[this.identity.workspaceId]; + let status = 0; + for (const session of Object.values(workspace?.sessions ?? {})) { + const stopped = await this.run([ + 'stop', + '--session', + session.browseSession, + ]); + status ||= stopped.status; + if (session.browserbaseSessionId) { + const released = await this.release(session.browserbaseSessionId); + status ||= released.status; + } + } + if (workspace?.contextId) { + const deleted = await this.run([ + 'cloud', + 'contexts', + 'delete', + workspace.contextId, + ]); + status ||= deleted.status; + } + await withStateLock(this.path, lockedState => { + delete lockedState.workspaces[this.identity.workspaceId]; + }); + return status; + } + + private async contextId(): Promise { + return await withStateLock(this.path, async state => { + const workspace = (state.workspaces[this.identity.workspaceId] ??= { + sessions: {}, + }); + if (workspace.contextId) return workspace.contextId; + const context = parseJsonOutput( + await this.run(['cloud', 'contexts', 'create'], { capture: true }) + ); + if (!context.id) throw new Error('browse context response is missing id'); + workspace.contextId = context.id; + return context.id; + }); + } + + private async currentSession(): Promise { + const state = await readState(this.path); + return state.workspaces[this.identity.workspaceId]?.sessions[ + this.identity.actorId + ]; + } + + private async saveSession(session: SessionState): Promise { + await withStateLock(this.path, state => { + const workspace = (state.workspaces[this.identity.workspaceId] ??= { + sessions: {}, + }); + workspace.sessions[this.identity.actorId] = session; + }); + } + + private async removeSession(): Promise { + await withStateLock(this.path, state => { + const workspace = state.workspaces[this.identity.workspaceId]; + if (workspace) delete workspace.sessions[this.identity.actorId]; + }); + } + + private async release(sessionId: string): Promise { + return await this.run([ + 'cloud', + 'sessions', + 'update', + sessionId, + '--status', + 'REQUEST_RELEASE', + ]); + } + + private async withLifecycleLock(operation: () => Promise): Promise { + const workspaceHash = createHash('sha256') + .update(this.identity.workspaceId) + .digest('hex') + .slice(0, 16); + return await withFileLock( + `${this.path}.workspace-${workspaceHash}.lock`, + operation, + { attempts: 2400, retryMs: 50 } + ); + } +} diff --git a/packages/herdr/src/cli.ts b/packages/herdr/src/cli.ts new file mode 100644 index 0000000..310b842 --- /dev/null +++ b/packages/herdr/src/cli.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import { createInterface } from 'node:readline/promises'; +import { stdin, stdout } from 'node:process'; +import { BrowserController } from './browser.js'; +import { deriveIdentity } from './identity.js'; +import { runBrowse } from './runner.js'; +import { installBundledSkill, showBundledSkill } from './skill.js'; +import { statePath } from './state.js'; +import type { BrowserMode } from './types.js'; + +function usage(): string { + return `Usage: + herdr-browse open [--local|--cloud] + herdr-browse [...args] + herdr-browse status [--json] + herdr-browse stop + herdr-browse reset [--yes] + herdr-browse skills install + herdr-browse skills show`; +} + +async function confirmReset(): Promise { + if (!stdin.isTTY) return false; + const prompt = createInterface({ input: stdin, output: stdout }); + try { + const answer = await prompt.question( + "Delete this workspace's Browserbase context and saved login state? [y/N] " + ); + return /^y(es)?$/i.test(answer.trim()); + } finally { + prompt.close(); + } +} + +export async function main(args = process.argv.slice(2)): Promise { + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + console.log(usage()); + return 0; + } + + if (args[0] === 'skills' && args[1] === 'install' && args.length === 2) { + return await installBundledSkill(); + } + if (args[0] === 'skills' && args[1] === 'show' && args.length === 2) { + return await showBundledSkill(); + } + + const identity = deriveIdentity(); + const controller = new BrowserController({ + identity, + path: statePath(), + run: runBrowse, + }); + const command = args[0]; + + if (command === 'open') { + const url = args[1]; + if (!url) throw new Error('open requires a URL'); + const hasLocal = args.includes('--local'); + const hasCloud = args.includes('--cloud'); + if (hasLocal && hasCloud) { + throw new Error('Choose either --local or --cloud, not both.'); + } + const override: BrowserMode | undefined = hasLocal + ? 'local' + : hasCloud + ? 'cloud' + : undefined; + return (await controller.open(url, override)).status; + } + + if (command === 'status') { + const status = await controller.status(); + if (args.includes('--json')) { + console.log(JSON.stringify(status, null, 2)); + } else { + console.log(`Workspace: ${status.workspaceId}`); + console.log(`Agent/pane: ${status.actorId}`); + console.log(`Context: ${status.contextId ?? 'not created'}`); + console.log(`Mode: ${status.session?.mode ?? 'stopped'}`); + console.log( + status.browse.stdout.trim() || + status.browse.stderr.trim() || + 'Browse daemon is stopped.' + ); + } + return status.browse.status; + } + + if (command === 'stop') return await controller.stop(); + + if (command === 'reset') { + if (!args.includes('--yes') && !(await confirmReset())) { + console.error('Reset cancelled. Pass --yes for non-interactive use.'); + return 1; + } + return await controller.reset(); + } + + return (await controller.delegate(args)).status; +} + +main().then( + code => { + process.exitCode = code; + }, + (error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +); diff --git a/packages/herdr/src/identity.test.ts b/packages/herdr/src/identity.test.ts new file mode 100644 index 0000000..8aec842 --- /dev/null +++ b/packages/herdr/src/identity.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + deriveIdentity, + isLocalUrl, + sanitizeIdentifier, + selectMode, +} from './identity.js'; + +describe('browser identity', () => { + it.each([ + 'http://localhost:3000', + 'https://app.localhost', + 'http://127.0.0.1:8787', + 'http://[::1]:4321', + 'https://project.local', + 'https://project.test', + ])('recognizes %s as local', url => expect(isLocalUrl(url)).toBe(true)); + + it('routes deployed URLs to cloud unless overridden', () => { + expect(selectMode('https://example.com')).toBe('cloud'); + expect(selectMode('https://example.com', 'local')).toBe('local'); + expect(selectMode('http://localhost:3000', 'cloud')).toBe('cloud'); + }); + + it('derives separate sessions for separate agents', () => { + const first = deriveIdentity({ + HERDR_WORKSPACE_ID: 'Workspace 1', + HERDR_PLUGIN_CONTEXT_JSON: JSON.stringify({ agent: { name: 'Claude' } }), + }); + const second = deriveIdentity({ + HERDR_WORKSPACE_ID: 'Workspace 1', + HERDR_PLUGIN_CONTEXT_JSON: JSON.stringify({ agent: { name: 'Codex' } }), + }); + expect(first.browseSession).toBe('herdr-workspace-1-claude'); + expect(second.browseSession).toBe('herdr-workspace-1-codex'); + }); + + it('sanitizes values used as browse session names', () => { + expect(sanitizeIdentifier('w1:p1 / Main')).toBe('w1-p1-main'); + }); +}); diff --git a/packages/herdr/src/identity.ts b/packages/herdr/src/identity.ts new file mode 100644 index 0000000..9883c90 --- /dev/null +++ b/packages/herdr/src/identity.ts @@ -0,0 +1,69 @@ +import type { BrowserMode } from './types.js'; + +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); + +export function sanitizeIdentifier(value: string): string { + const sanitized = value + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return sanitized || 'unknown'; +} + +export function isLocalUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + return ( + LOCAL_HOSTNAMES.has(hostname) || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') || + hostname.endsWith('.test') + ); +} + +export function selectMode(url: string, override?: BrowserMode): BrowserMode { + return override ?? (isLocalUrl(url) ? 'local' : 'cloud'); +} + +function agentFromContext(raw: string | undefined): string | undefined { + if (!raw) return undefined; + try { + const context = JSON.parse(raw) as { + agent?: { name?: string; label?: string }; + }; + return context.agent?.name ?? context.agent?.label; + } catch { + return undefined; + } +} + +export interface HerdrIdentity { + workspaceId: string; + actorId: string; + browseSession: string; +} + +export function deriveIdentity( + env: NodeJS.ProcessEnv = process.env +): HerdrIdentity { + const workspaceId = env.HERDR_WORKSPACE_ID; + if (!workspaceId) { + throw new Error( + 'HERDR_WORKSPACE_ID is missing. Run herdr-browse inside a Herdr workspace.' + ); + } + + const actorId = + agentFromContext(env.HERDR_PLUGIN_CONTEXT_JSON) ?? + env.HERDR_PANE_ID ?? + 'workspace'; + const browseSession = `herdr-${sanitizeIdentifier(workspaceId)}-${sanitizeIdentifier(actorId)}`; + return { workspaceId, actorId, browseSession }; +} diff --git a/packages/herdr/src/runner.ts b/packages/herdr/src/runner.ts new file mode 100644 index 0000000..6ab5e0a --- /dev/null +++ b/packages/herdr/src/runner.ts @@ -0,0 +1,45 @@ +import { spawn } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { CommandRunner } from './types.js'; + +export function browseExecutable(): string { + const extension = process.platform === 'win32' ? '.cmd' : ''; + return join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'node_modules', + '.bin', + `browse${extension}` + ); +} + +export const runBrowse: CommandRunner = async (args, options = {}) => + await new Promise((resolve, reject) => { + const capture = options.capture ?? false; + const child = spawn(browseExecutable(), args, { + env: { ...process.env, BROWSE_LOAD_DOTENV: '0' }, + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString())); + child.once('error', reject); + child.once('close', code => resolve({ status: code ?? 1, stdout, stderr })); + }); + +export function parseJsonOutput(result: { + status: number; + stdout: string; + stderr: string; +}): T { + if (result.status !== 0) { + throw new Error(result.stderr.trim() || 'browse command failed'); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error('browse returned invalid JSON'); + } +} diff --git a/packages/herdr/src/skill.ts b/packages/herdr/src/skill.ts new file mode 100644 index 0000000..c0c8f65 --- /dev/null +++ b/packages/herdr/src/skill.ts @@ -0,0 +1,44 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function bundledSkillPath(): string { + return join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'skills', + 'herdr-browse' + ); +} + +export async function showBundledSkill(): Promise { + process.stdout.write(await readFile(join(bundledSkillPath(), 'SKILL.md'))); + return 0; +} + +export async function installBundledSkill(): Promise { + const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + const args = [ + '--yes', + 'skills', + 'add', + bundledSkillPath(), + '--yes', + '--global', + '--agent', + '*', + ]; + + return await new Promise((resolve, reject) => { + const child = spawn(executable, args, { stdio: 'inherit' }); + child.once('error', error => { + reject( + new Error( + `Could not run npx to install the herdr-browse skill: ${error.message}` + ) + ); + }); + child.once('close', code => resolve(code ?? 1)); + }); +} diff --git a/packages/herdr/src/state.test.ts b/packages/herdr/src/state.test.ts new file mode 100644 index 0000000..7ce3963 --- /dev/null +++ b/packages/herdr/src/state.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + defaultStateDir, + readState, + statePath, + withStateLock, +} from './state.js'; + +describe('state locking', () => { + it('serializes concurrent updates', async () => { + const directory = await mkdtemp(join(tmpdir(), 'herdr-state-test-')); + const path = join(directory, 'state.json'); + await Promise.all( + Array.from({ length: 8 }, (_, index) => + withStateLock(path, state => { + state.workspaces[`workspace-${index}`] = { sessions: {} }; + }) + ) + ); + expect(Object.keys((await readState(path)).workspaces)).toHaveLength(8); + }); +}); + +describe('statePath', () => { + it('uses HERDR_PLUGIN_STATE_DIR when present', () => { + expect( + defaultStateDir({ HERDR_PLUGIN_STATE_DIR: '/custom/state/dir' }) + ).toBe('/custom/state/dir'); + expect(statePath({ HERDR_PLUGIN_STATE_DIR: '/custom/state/dir' })).toBe( + '/custom/state/dir/browser-state.json' + ); + }); + + it('defaults to XDG_STATE_HOME on unix platforms', () => { + expect( + defaultStateDir({ XDG_STATE_HOME: '/custom/xdg/state' }, 'linux') + ).toBe('/custom/xdg/state/herdr/plugins/browserbase.browser'); + }); + + it('defaults to ~/.local/state on unix platforms when XDG_STATE_HOME is unset', () => { + expect(defaultStateDir({ HOME: '/home/test' }, 'linux')).toBe( + join( + '/home/test', + '.local', + 'state', + 'herdr', + 'plugins', + 'browserbase.browser' + ) + ); + }); + + it('defaults to LOCALAPPDATA on win32', () => { + expect( + defaultStateDir( + { LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local' }, + 'win32' + ) + ).toBe( + 'C:\\Users\\test\\AppData\\Local\\herdr\\plugins\\browserbase.browser' + ); + }); + + it('falls back to USERPROFILE on win32', () => { + expect(defaultStateDir({ USERPROFILE: 'C:\\Users\\test' }, 'win32')).toBe( + 'C:\\Users\\test\\AppData\\Local\\herdr\\plugins\\browserbase.browser' + ); + }); +}); diff --git a/packages/herdr/src/state.ts b/packages/herdr/src/state.ts new file mode 100644 index 0000000..0782e6a --- /dev/null +++ b/packages/herdr/src/state.ts @@ -0,0 +1,109 @@ +import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join, win32 } from 'node:path'; +import type { PluginState } from './types.js'; + +export const PLUGIN_ID = 'browserbase.browser'; +const EMPTY_STATE: PluginState = { version: 1, workspaces: {} }; + +export function defaultStateDir( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string { + if (env.HERDR_PLUGIN_STATE_DIR) { + return env.HERDR_PLUGIN_STATE_DIR; + } + if (platform === 'win32') { + const localAppData = env.LOCALAPPDATA + ? env.LOCALAPPDATA + : win32.join( + env.USERPROFILE ?? env.HOME ?? homedir(), + 'AppData', + 'Local' + ); + return win32.join(localAppData, 'herdr', 'plugins', PLUGIN_ID); + } + const stateHome = env.XDG_STATE_HOME + ? env.XDG_STATE_HOME + : join(env.HOME ?? homedir(), '.local', 'state'); + return join(stateHome, 'herdr', 'plugins', PLUGIN_ID); +} + +export function statePath(env: NodeJS.ProcessEnv = process.env): string { + return join(defaultStateDir(env), 'browser-state.json'); +} + +export async function readState(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf8')) as PluginState; + if (parsed.version !== 1 || typeof parsed.workspaces !== 'object') { + throw new Error(`Unsupported state format in ${path}`); + } + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return structuredClone(EMPTY_STATE); + } + throw error; + } +} + +async function writeState(path: string, state: PluginState): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temporaryPath, path); +} + +async function delay(milliseconds: number): Promise { + await new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +export async function withStateLock( + path: string, + update: (state: PluginState) => Promise | T, + options: { attempts?: number; retryMs?: number } = {} +): Promise { + return await withFileLock( + `${path}.lock`, + async () => { + const state = await readState(path); + const result = await update(state); + await writeState(path, state); + return result; + }, + options + ); +} + +export async function withFileLock( + lockPath: string, + operation: () => Promise | T, + options: { attempts?: number; retryMs?: number } = {} +): Promise { + const attempts = options.attempts ?? 100; + const retryMs = options.retryMs ?? 50; + await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 }); + + let lock: Awaited> | undefined; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + lock = await open(lockPath, 'wx', 0o600); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + await delay(retryMs); + } + } + + if (!lock) throw new Error(`Timed out waiting for state lock ${lockPath}`); + + try { + return await operation(); + } finally { + await lock.close(); + await rm(lockPath, { force: true }); + } +} diff --git a/packages/herdr/src/types.ts b/packages/herdr/src/types.ts new file mode 100644 index 0000000..bfb7ed4 --- /dev/null +++ b/packages/herdr/src/types.ts @@ -0,0 +1,28 @@ +export type BrowserMode = 'local' | 'cloud'; + +export interface SessionState { + browseSession: string; + browserbaseSessionId?: string; + mode: BrowserMode; +} + +export interface WorkspaceState { + contextId?: string; + sessions: Record; +} + +export interface PluginState { + version: 1; + workspaces: Record; +} + +export interface CommandResult { + status: number; + stdout: string; + stderr: string; +} + +export type CommandRunner = ( + args: string[], + options?: { capture?: boolean } +) => Promise; diff --git a/packages/herdr/tsconfig.json b/packages/herdr/tsconfig.json new file mode 100644 index 0000000..5c91ce3 --- /dev/null +++ b/packages/herdr/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"], + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} From 3f4ee203778a7fb19d74d9955df89b2d922dc5b2 Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Mon, 14 Sep 2026 16:01:14 +0530 Subject: [PATCH 2/2] Upgrade TypeScript version to 7.0.2 in Herdr package.json --- packages/herdr/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/herdr/package.json b/packages/herdr/package.json index c5c5f62..1be9a53 100644 --- a/packages/herdr/package.json +++ b/packages/herdr/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@types/node": "25.0.9", - "typescript": "6.0.2", + "typescript": "7.0.2", "vitest": "4.0.6" }, "engines": {