diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..973b75f --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", + "commit": false, + "fixed": [], + "linked": [], + "baseBranch": "main", + "updateInternalDependencies": "patch", + "access": "public", + "changelog": [ + "@changesets/changelog-github", + { + "repo": "browserbase/integrations" + } + ] +} diff --git a/.changeset/openclaw-browserbase-migration.md b/.changeset/openclaw-browserbase-migration.md new file mode 100644 index 0000000..d09f800 --- /dev/null +++ b/.changeset/openclaw-browserbase-migration.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/openclaw-browserbase": patch +--- + +Migrate the OpenClaw Browserbase package into the integrations monorepo. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3e7a23b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + id-token: write + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + registry-url: "https://registry.npmjs.org" + + - name: Update npm for Trusted Publishing + run: npm install -g npm@latest + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Build packages + run: pnpm run build:packages + + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + publish: pnpm run release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true diff --git a/README.md b/README.md index 254f7dd..d9397ce 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ integrations/ │ ├── temporal/ # Temporal workflow orchestration │ ├── trigger/ # Trigger.dev background jobs & automation │ └── vercel/ # Vercel integrations +├── packages/ # Published npm packages └── README.md ``` @@ -86,4 +87,4 @@ This project is licensed under the MIT License. See individual integration direc --- -**Built with ❤️ by the Browserbase team** \ No newline at end of file +**Built with ❤️ by the Browserbase team** diff --git a/package.json b/package.json index 686233b..88f92f9 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,14 @@ "node": ">=18.0.0" }, "scripts": { + "build:packages": "pnpm -r --filter './packages/*' run build", + "changeset": "changeset", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "version-packages": "changeset version", + "release": "pnpm run build:packages && changeset publish" }, "keywords": [ "browserbase", @@ -25,6 +29,8 @@ "author": "Browserbase Team", "license": "MIT", "devDependencies": { + "@changesets/changelog-github": "^0.5.0", + "@changesets/cli": "^2.27.9", "@eslint/js": "^9.14.0", "@types/node": "^25.0.9", "eslint": "^10.1.0", diff --git a/packages/openclaw-browserbase/LICENSE b/packages/openclaw-browserbase/LICENSE new file mode 100644 index 0000000..3b33da2 --- /dev/null +++ b/packages/openclaw-browserbase/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/openclaw-browserbase/README.md b/packages/openclaw-browserbase/README.md new file mode 100644 index 0000000..11f3499 --- /dev/null +++ b/packages/openclaw-browserbase/README.md @@ -0,0 +1,87 @@ +# @browserbasehq/openclaw-browserbase + +Browserbase plugin for OpenClaw (with legacy ClawdBot compatibility). + +It provides: + +- interactive Browserbase credential setup, +- config status/env helpers, +- dynamic skill sync from `github:browserbase/skills`. + +## Install + +```bash +openclaw plugins install @browserbasehq/openclaw-browserbase +``` + +For local development: + +```bash +openclaw plugins install -l . +``` + +## Setup + +```bash +openclaw browserbase setup +``` + +By default setup will also sync Browserbase skills from `browserbase/skills` into +`~/.openclaw/skills`. + +You can manage skill sync directly: + +```bash +openclaw browserbase skills status +openclaw browserbase skills sync +openclaw browserbase skills sync --ref main +openclaw browserbase skills sync --dir ~/.openclaw/skills +``` + +## Commands + +```bash +openclaw browserbase setup # prompt for API key + project ID +openclaw browserbase status # show configuration status +openclaw browserbase status --json # machine-readable status +openclaw browserbase env --format shell # export commands +openclaw browserbase env --format dotenv # dotenv output +openclaw browserbase env --format json # JSON output +openclaw browserbase where # config file path used +openclaw browserbase skills status # check dynamic skills sync status +openclaw browserbase skills sync # download/update skills from browserbase/skills +``` + +Legacy CLI alias support remains: + +```bash +clawdbot browserbase setup +``` + +## Dynamic skills behavior + +OpenClaw installs plugins with lifecycle scripts disabled, so plugin install hooks are not a reliable place to fetch remote skill files. + +This plugin instead syncs skills during setup and (optionally) on startup when missing: + +- `browser-automation` +- `functions` + +Source of truth: [https://github.com/browserbase/skills](https://github.com/browserbase/skills) + +## Development + +```bash +pnpm install +pnpm run check-types +pnpm test +``` + +## References + +- OpenClaw Skills: https://docs.openclaw.ai/tools/skills +- OpenClaw Skills Config: https://docs.openclaw.ai/tools/skills-config +- OpenClaw Plugin System: https://docs.openclaw.ai/tools/plugin +- OpenClaw Plugin Manifest: https://docs.openclaw.ai/plugins/manifest +- Browserbase skills reference: https://github.com/browserbase/skills +- Example plugin reference: https://github.com/pepicrft/clawd-plugin-ralph diff --git a/packages/openclaw-browserbase/openclaw.plugin.json b/packages/openclaw-browserbase/openclaw.plugin.json new file mode 100644 index 0000000..fa5d06e --- /dev/null +++ b/packages/openclaw-browserbase/openclaw.plugin.json @@ -0,0 +1,47 @@ +{ + "id": "browserbase", + "name": "Browserbase", + "description": "Browse the web with anti-bot stealth, automatic CAPTCHA solving, and residential proxies. Automates browser interactions via natural language with full JavaScript rendering.", + "version": "0.1.0", + "kind": "tool", + "uiHints": { + "apiKey": { + "label": "Browserbase API Key", + "sensitive": true, + "placeholder": "bb_...", + "help": "Get your key from browserbase.com/settings or use ${BROWSERBASE_API_KEY}." + }, + "projectId": { + "label": "Browserbase Project ID", + "placeholder": "proj_...", + "help": "Get your project ID from browserbase.com/settings or use ${BROWSERBASE_PROJECT_ID}." + }, + "baseUrl": { + "label": "Browserbase API Base URL", + "placeholder": "https://api.browserbase.com", + "advanced": true + }, + "promptOnStart": { + "label": "Prompt On Startup", + "help": "Prompt interactively for credentials if missing (TTY only).", + "advanced": true + }, + "autoSyncSkills": { + "label": "Auto Sync Skills", + "help": "Automatically sync browser-automation/functions skills from github:browserbase/skills when missing.", + "advanced": true + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { "type": "string" }, + "projectId": { "type": "string" }, + "baseUrl": { "type": "string" }, + "promptOnStart": { "type": "boolean" }, + "autoSyncSkills": { "type": "boolean" } + }, + "required": [] + } +} diff --git a/packages/openclaw-browserbase/package.json b/packages/openclaw-browserbase/package.json new file mode 100644 index 0000000..fd0d03b --- /dev/null +++ b/packages/openclaw-browserbase/package.json @@ -0,0 +1,57 @@ +{ + "name": "@browserbasehq/openclaw-browserbase", + "version": "0.1.0", + "description": "OpenClaw plugin for cloud browser automation with anti-bot stealth, CAPTCHA solving, and residential proxies via Browserbase", + "packageManager": "pnpm@10.21.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "check-types": "tsc --noEmit" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "src", + "types", + "openclaw.plugin.json" + ], + "keywords": [ + "openclaw", + "plugin", + "browserbase", + "browser", + "automation", + "web-scraping", + "captcha", + "anti-bot", + "stealth", + "proxy", + "headless-browser", + "ai-agent" + ], + "author": "Browserbase", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/browserbase/integrations.git", + "directory": "packages/openclaw-browserbase" + }, + "peerDependencies": { + "openclaw": ">=2026.1.29" + }, + "openclaw": { + "extensions": [ + "./src/index.ts" + ] + }, + "devDependencies": { + "@types/node": "^24.3.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "tar": "^7.5.9" + } +} diff --git a/packages/openclaw-browserbase/src/config-store.ts b/packages/openclaw-browserbase/src/config-store.ts new file mode 100644 index 0000000..37bd9e6 --- /dev/null +++ b/packages/openclaw-browserbase/src/config-store.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const DEFAULT_CONFIG_PATH = path.join( + os.homedir(), + '.openclaw', + 'openclaw.json' +); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function ensureRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function readJsonFile(filePath: string): Record { + if (!fs.existsSync(filePath)) { + return {}; + } + + const raw = fs.readFileSync(filePath, 'utf-8'); + const trimmed = raw.trim(); + if (!trimmed) { + return {}; + } + + const parsed = JSON.parse(trimmed) as unknown; + if (!isRecord(parsed)) { + throw new Error(`Expected object at ${filePath}`); + } + + return parsed; +} + +export function resolveConfigPath(explicitPath?: string): string { + if (explicitPath && explicitPath.trim()) { + return path.resolve(process.cwd(), explicitPath.trim()); + } + + return DEFAULT_CONFIG_PATH; +} + +export function readPluginConfig( + filePath: string, + pluginId: string +): Record { + const root = readJsonFile(filePath); + const plugins = ensureRecord(root.plugins); + const entries = ensureRecord(plugins.entries); + const pluginEntry = ensureRecord(entries[pluginId]); + return ensureRecord(pluginEntry.config); +} + +export function writePluginConfig( + filePath: string, + pluginId: string, + pluginConfig: Record +): void { + const root = readJsonFile(filePath); + const plugins = ensureRecord(root.plugins); + const entries = ensureRecord(plugins.entries); + + const existingEntry = ensureRecord(entries[pluginId]); + const existingConfig = ensureRecord(existingEntry.config); + + entries[pluginId] = { + ...existingEntry, + enabled: existingEntry.enabled !== false, + config: { + ...existingConfig, + ...pluginConfig, + }, + }; + + plugins.entries = entries; + root.plugins = plugins; + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(root, null, 2)}\n`, 'utf-8'); +} + +export function maskSecret(value: string | undefined): string { + if (!value) { + return 'not set'; + } + + if (value.length <= 8) { + return `${'*'.repeat(Math.max(4, value.length))}`; + } + + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +export function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +export function dotenvEscape(value: string): string { + // Wrap in double quotes if value contains whitespace, quotes, #, =, or backslashes + if ( + /[\s"'#=\\]/.test(value) || + value.includes('\n') || + value.includes('\r') + ) { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`; + } + return value; +} diff --git a/packages/openclaw-browserbase/src/config.ts b/packages/openclaw-browserbase/src/config.ts new file mode 100644 index 0000000..0683257 --- /dev/null +++ b/packages/openclaw-browserbase/src/config.ts @@ -0,0 +1,129 @@ +export type BrowserbaseConfig = { + apiKey?: string; + projectId?: string; + baseUrl: string; + promptOnStart?: boolean; // undefined = not explicitly set; callers default to true + autoSyncSkills?: boolean; // undefined = not explicitly set; callers default to true +}; + +const DEFAULT_BASE_URL = 'https://api.browserbase.com'; + +const ALLOWED_KEYS = [ + 'apiKey', + 'projectId', + 'baseUrl', + 'promptOnStart', + 'autoSyncSkills', +]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertAllowedKeys( + value: Record, + label: string +): void { + const unknown = Object.keys(value).filter(key => !ALLOWED_KEYS.includes(key)); + if (unknown.length > 0) { + throw new Error(`${label} has unknown keys: ${unknown.join(', ')}`); + } +} + +function resolveEnvVars(value: string): string { + return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => { + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +function parseOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + return resolveEnvVars(trimmed); +} + +function parseBaseUrl(value: unknown): string { + if (typeof value !== 'string') { + return DEFAULT_BASE_URL; + } + + const trimmed = value.trim(); + if (!trimmed) { + return DEFAULT_BASE_URL; + } + + return resolveEnvVars(trimmed); +} + +export function parseConfig(raw: unknown): BrowserbaseConfig { + const cfg = isRecord(raw) ? raw : {}; + + if (Object.keys(cfg).length > 0) { + assertAllowedKeys(cfg, 'browserbase config'); + } + + let apiKey: string | undefined; + let projectId: string | undefined; + + try { + apiKey = parseOptionalString(cfg.apiKey) ?? process.env.BROWSERBASE_API_KEY; + } catch { + apiKey = process.env.BROWSERBASE_API_KEY; + } + + try { + projectId = + parseOptionalString(cfg.projectId) ?? process.env.BROWSERBASE_PROJECT_ID; + } catch { + projectId = process.env.BROWSERBASE_PROJECT_ID; + } + + return { + apiKey, + projectId, + baseUrl: parseBaseUrl(cfg.baseUrl), + promptOnStart: + typeof cfg.promptOnStart === 'boolean' ? cfg.promptOnStart : undefined, + autoSyncSkills: + typeof cfg.autoSyncSkills === 'boolean' ? cfg.autoSyncSkills : undefined, + }; +} + +export function mergeConfig( + primary: BrowserbaseConfig, + fallback: BrowserbaseConfig +): BrowserbaseConfig { + return { + apiKey: primary.apiKey ?? fallback.apiKey, + projectId: primary.projectId ?? fallback.projectId, + baseUrl: primary.baseUrl ?? fallback.baseUrl, + promptOnStart: primary.promptOnStart ?? fallback.promptOnStart, + autoSyncSkills: primary.autoSyncSkills ?? fallback.autoSyncSkills, + }; +} + +export const browserbaseConfigSchema = { + jsonSchema: { + type: 'object', + additionalProperties: false, + properties: { + apiKey: { type: 'string' }, + projectId: { type: 'string' }, + baseUrl: { type: 'string' }, + promptOnStart: { type: 'boolean' }, + autoSyncSkills: { type: 'boolean' }, + }, + }, + parse: parseConfig, +}; diff --git a/packages/openclaw-browserbase/src/index.ts b/packages/openclaw-browserbase/src/index.ts new file mode 100644 index 0000000..4069328 --- /dev/null +++ b/packages/openclaw-browserbase/src/index.ts @@ -0,0 +1,580 @@ +import readline from 'node:readline'; +import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'; +import { + browserbaseConfigSchema, + mergeConfig, + parseConfig, + type BrowserbaseConfig, +} from './config.js'; +import { + dotenvEscape, + maskSecret, + readPluginConfig, + resolveConfigPath, + shellEscape, + writePluginConfig, +} from './config-store.js'; +import { + defaultSkillsRoot, + hasBrowserbaseSkills, + installedSkillFiles, + resolveSkillsRoot, + syncBrowserbaseSkills, +} from './skills-sync.js'; + +const PLUGIN_ID = 'browserbase'; + +function createLogger( + api: Partial +): OpenClawPluginApi['logger'] { + if (api.logger) { + return api.logger; + } + + return { + info: (msg: string) => console.log(msg), + warn: (msg: string) => console.warn(msg), + error: (msg: string, ...args: unknown[]) => console.error(msg, ...args), + debug: (msg: string) => console.debug(msg), + }; +} + +function parseConfigSafe( + raw: unknown, + logger: OpenClawPluginApi['logger'], + label: string +): BrowserbaseConfig { + try { + return parseConfig(raw); + } catch (error) { + logger.warn( + `browserbase: ignoring invalid ${label} (${String((error as Error)?.message ?? error)})` + ); + return parseConfig({}); + } +} + +function runtimeConfig( + api: OpenClawPluginApi, + logger: OpenClawPluginApi['logger'] +): BrowserbaseConfig { + return parseConfigSafe(api.pluginConfig, logger, 'pluginConfig'); +} + +function loadMergedConfig( + api: OpenClawPluginApi, + logger: OpenClawPluginApi['logger'], + explicitPath?: string +): { configPath: string; config: BrowserbaseConfig } { + const configPath = resolveConfigPath(explicitPath); + const fromRuntime = runtimeConfig(api, logger); + let rawFileConfig: Record = {}; + try { + rawFileConfig = readPluginConfig(configPath, PLUGIN_ID); + } catch (error) { + logger.warn( + `browserbase: unable to read ${configPath} (${String((error as Error)?.message ?? error)})` + ); + } + const fromFile = parseConfigSafe(rawFileConfig, logger, `file ${configPath}`); + return { + configPath, + config: mergeConfig(fromRuntime, fromFile), + }; +} + +async function ask(rl: readline.Interface, question: string): Promise { + return new Promise(resolve => rl.question(question, resolve)); +} + +async function promptYesNo( + rl: readline.Interface, + question: string, + defaultYes: boolean +): Promise { + const answer = (await ask(rl, question)).trim().toLowerCase(); + if (!answer) { + return defaultYes; + } + + if (['y', 'yes'].includes(answer)) { + return true; + } + + if (['n', 'no'].includes(answer)) { + return false; + } + + return defaultYes; +} + +async function promptAndPersistCredentials( + configPath: string, + defaults: { apiKey?: string; projectId?: string }, + printHeader: boolean +): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + if (printHeader) { + console.log('\nBrowserbase setup\n'); + console.log('Get credentials from https://browserbase.com/settings\n'); + } + + const apiKeyQuestion = defaults.apiKey + ? `Browserbase API key [${maskSecret(defaults.apiKey)}]: ` + : 'Browserbase API key: '; + const projectIdQuestion = defaults.projectId + ? `Browserbase project ID [${defaults.projectId}]: ` + : 'Browserbase project ID: '; + + const apiKeyInput = (await ask(rl, apiKeyQuestion)).trim(); + const projectIdInput = (await ask(rl, projectIdQuestion)).trim(); + + const apiKey = apiKeyInput || defaults.apiKey; + const projectId = projectIdInput || defaults.projectId; + + if (!apiKey || !projectId) { + console.log( + '\nSetup cancelled: both API key and project ID are required.' + ); + return false; + } + + writePluginConfig(configPath, PLUGIN_ID, { + apiKey, + projectId, + }); + + console.log(`\nSaved Browserbase credentials to ${configPath}`); + console.log('Restart OpenClaw to activate remote browsing.\n'); + return true; + } finally { + rl.close(); + } +} + +async function runSkillsSync(options?: { + targetRoot?: string; + ref?: string; + logger?: OpenClawPluginApi['logger']; + silent?: boolean; +}): Promise { + const logger = options?.logger; + + try { + const result = await syncBrowserbaseSkills({ + targetRoot: options?.targetRoot, + ref: options?.ref, + }); + + if (!options?.silent) { + console.log( + `Synced Browserbase skills from browserbase/skills@${result.ref} to ${result.targetRoot}` + ); + console.log(`Files updated: ${result.filesWritten.length}`); + } + + logger?.info( + `browserbase: synced skills from browserbase/skills@${result.ref} to ${result.targetRoot}` + ); + return true; + } catch (error) { + const message = String((error as Error)?.message ?? error); + logger?.warn(`browserbase: skill sync failed (${message})`); + if (!options?.silent) { + console.warn(`Warning: failed to sync Browserbase skills: ${message}`); + console.warn('Retry with: openclaw browserbase skills sync'); + } + return false; + } +} + +function registerCli( + api: OpenClawPluginApi, + logger: OpenClawPluginApi['logger'] +): void { + api.registerCli(({ program }: { program: any }) => { + const browserbase = program + .command('browserbase') + .description('Browserbase plugin setup and credential helpers'); + + browserbase + .command('setup') + .description('Prompt for Browserbase API key and project ID') + .option('--api-key ', 'Browserbase API key') + .option('--project-id ', 'Browserbase project ID') + .option('-c, --config ', 'Override config file path') + .option( + '--skip-skills-sync', + 'Skip syncing Browserbase skills from github', + false + ) + .option('--skills-dir ', 'Override skills target directory') + .option('--skills-ref ', 'Git ref for browserbase/skills', 'main') + .action(async (options: any) => { + const configPath = resolveConfigPath(options.config); + const shouldSyncSkills = !options.skipSkillsSync; + const skillsTargetRoot = resolveSkillsRoot(options.skillsDir); + const skillsRef = + typeof options.skillsRef === 'string' + ? options.skillsRef.trim() + : 'main'; + let rawExisting: Record = {}; + try { + rawExisting = readPluginConfig(configPath, PLUGIN_ID); + } catch (error) { + logger.warn( + `browserbase: unable to read ${configPath} (${String((error as Error)?.message ?? error)})` + ); + } + const existing = parseConfigSafe( + rawExisting, + logger, + `file ${configPath}` + ); + + const apiKeyFlag = + typeof options.apiKey === 'string' ? options.apiKey.trim() : ''; + const projectIdFlag = + typeof options.projectId === 'string' ? options.projectId.trim() : ''; + + if (apiKeyFlag && projectIdFlag) { + if ( + /\$\{[^}]+\}/.test(apiKeyFlag) || + /\$\{[^}]+\}/.test(projectIdFlag) + ) { + console.warn( + 'Warning: Config contains env var placeholders (${...}). OpenClaw will fail to start if those variables are unset in future sessions.' + ); + } + writePluginConfig(configPath, PLUGIN_ID, { + apiKey: apiKeyFlag, + projectId: projectIdFlag, + }); + + console.log(`Saved Browserbase credentials to ${configPath}`); + console.log('Restart OpenClaw to activate remote browsing.'); + if (shouldSyncSkills) { + await runSkillsSync({ + targetRoot: skillsTargetRoot, + ref: skillsRef, + logger, + }); + } + return; + } + + if (!process.stdin.isTTY) { + console.error( + 'Error: --api-key and --project-id are required in non-interactive mode.' + ); + process.exit(1); + return; + } + + const configured = await promptAndPersistCredentials( + configPath, + { + apiKey: apiKeyFlag || existing.apiKey, + projectId: projectIdFlag || existing.projectId, + }, + true + ); + + if (configured && shouldSyncSkills) { + await runSkillsSync({ + targetRoot: skillsTargetRoot, + ref: skillsRef, + logger, + }); + } + }); + + browserbase + .command('status') + .description('Show Browserbase credential status') + .option('--json', 'Output JSON', false) + .option('-c, --config ', 'Override config file path') + .action((options: any) => { + const { configPath, config } = loadMergedConfig( + api, + logger, + options.config + ); + const configured = Boolean(config.apiKey && config.projectId); + + const payload = { + configured, + configPath, + apiKey: maskSecret(config.apiKey), + projectId: maskSecret(config.projectId), + baseUrl: config.baseUrl, + promptOnStart: config.promptOnStart ?? true, + autoSyncSkills: config.autoSyncSkills ?? true, + skillsInstalled: hasBrowserbaseSkills(defaultSkillsRoot()), + skillsPath: defaultSkillsRoot(), + }; + + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + return; + } + + console.log(`Configured: ${configured ? 'yes' : 'no'}`); + console.log(`Config file: ${configPath}`); + console.log(`API key: ${payload.apiKey}`); + console.log(`Project ID: ${payload.projectId}`); + console.log(`Base URL: ${payload.baseUrl}`); + console.log( + `Prompt on startup: ${payload.promptOnStart ? 'yes' : 'no'}` + ); + console.log( + `Auto-sync skills: ${payload.autoSyncSkills ? 'yes' : 'no'}` + ); + console.log( + `Skills installed: ${payload.skillsInstalled ? 'yes' : 'no'}` + ); + console.log(`Skills path: ${payload.skillsPath}`); + }); + + const browserbaseSkills = browserbase + .command('skills') + .description( + 'Manage Browserbase skills synced from github:browserbase/skills' + ); + + browserbaseSkills + .command('sync') + .description('Download/update Browserbase skills into ~/.openclaw/skills') + .option('--dir ', 'Override skills target directory') + .option('--ref ', 'Git ref for browserbase/skills', 'main') + .action(async (options: any) => { + const targetRoot = resolveSkillsRoot(options.dir); + const ref = + typeof options.ref === 'string' ? options.ref.trim() : 'main'; + await runSkillsSync({ + targetRoot, + ref, + logger, + }); + }); + + browserbaseSkills + .command('status') + .description('Check whether Browserbase skills are installed') + .option('--dir ', 'Override skills target directory') + .option('--json', 'Output JSON', false) + .action((options: any) => { + const targetRoot = resolveSkillsRoot(options.dir); + const installed = hasBrowserbaseSkills(targetRoot); + const files = installedSkillFiles(targetRoot); + + if (options.json) { + console.log( + JSON.stringify( + { + installed, + targetRoot, + files, + }, + null, + 2 + ) + ); + return; + } + + console.log(`Installed: ${installed ? 'yes' : 'no'}`); + console.log(`Target path: ${targetRoot}`); + }); + + browserbase + .command('env') + .description('Print Browserbase env vars for shell export') + .option('-f, --format ', 'shell|dotenv|json', 'shell') + .option('-c, --config ', 'Override config file path') + .action((options: any) => { + const { config } = loadMergedConfig(api, logger, options.config); + + if (!config.apiKey || !config.projectId) { + console.error( + "Browserbase is not configured. Run 'openclaw browserbase setup'." + ); + process.exit(1); + return; + } + + const format = String(options.format ?? 'shell').toLowerCase(); + + if (format === 'shell') { + console.log( + `export BROWSERBASE_API_KEY=${shellEscape(config.apiKey)}` + ); + console.log( + `export BROWSERBASE_PROJECT_ID=${shellEscape(config.projectId)}` + ); + return; + } + + if (format === 'dotenv') { + console.log(`BROWSERBASE_API_KEY=${dotenvEscape(config.apiKey)}`); + console.log( + `BROWSERBASE_PROJECT_ID=${dotenvEscape(config.projectId)}` + ); + return; + } + + if (format === 'json') { + console.log( + JSON.stringify( + { + BROWSERBASE_API_KEY: config.apiKey, + BROWSERBASE_PROJECT_ID: config.projectId, + }, + null, + 2 + ) + ); + return; + } + + console.error(`Unknown format: ${format}. Use shell, dotenv, or json.`); + process.exit(1); + }); + + browserbase + .command('where') + .description('Show the config file path used by setup') + .option('-c, --config ', 'Override config file path') + .action((options: any) => { + console.log(resolveConfigPath(options.config)); + }); + }); +} + +let didStartupPrompt = false; + +async function maybePromptOnStartup( + api: OpenClawPluginApi, + logger: OpenClawPluginApi['logger'] +): Promise { + if (didStartupPrompt) { + return; + } + + didStartupPrompt = true; + + const { configPath, config } = loadMergedConfig(api, logger); + const managedSkillsRoot = defaultSkillsRoot(); + + if ( + (config.autoSyncSkills ?? true) && + !hasBrowserbaseSkills(managedSkillsRoot) + ) { + logger.info( + `browserbase: Browserbase skills not found in ${managedSkillsRoot}; syncing from browserbase/skills` + ); + await runSkillsSync({ + targetRoot: managedSkillsRoot, + ref: 'main', + logger, + silent: true, + }); + } + + if (config.apiKey && config.projectId) { + // Bridge config → env so browse CLI auto-detects remote mode. + // Child processes (Bash tool) inherit process.env. + process.env.BROWSERBASE_API_KEY = config.apiKey; + process.env.BROWSERBASE_PROJECT_ID = config.projectId; + logger.info('browserbase: credentials loaded'); + return; + } + + if (!(config.promptOnStart ?? true)) { + logger.warn( + "browserbase: missing credentials. Run 'openclaw browserbase setup'." + ); + return; + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + logger.warn( + "browserbase: missing credentials in non-interactive mode. Run 'openclaw browserbase setup'." + ); + return; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const shouldSetup = await promptYesNo( + rl, + '\nBrowserbase is not configured. Configure now? [Y/n]: ', + true + ); + + if (!shouldSetup) { + logger.warn( + "browserbase: setup skipped. Run 'openclaw browserbase setup' later." + ); + return; + } + } finally { + rl.close(); + } + + const configured = await promptAndPersistCredentials( + configPath, + { apiKey: config.apiKey, projectId: config.projectId }, + true + ); + + if (configured && (config.autoSyncSkills ?? true)) { + await runSkillsSync({ + targetRoot: managedSkillsRoot, + ref: 'main', + logger, + silent: true, + }); + } +} + +export default { + id: PLUGIN_ID, + name: 'Browserbase', + description: + 'Browse the web with anti-bot stealth, automatic CAPTCHA solving, and residential proxies via Browserbase', + kind: 'tool' as const, + configSchema: browserbaseConfigSchema, + register(api: OpenClawPluginApi) { + const logger = createLogger(api); + registerCli(api, logger); + + const runStartup = () => { + maybePromptOnStartup(api, logger).catch((err: unknown) => { + logger.warn( + `browserbase: startup check failed (${String((err as Error)?.message ?? err)})` + ); + }); + }; + + if (typeof api.registerService === 'function') { + api.registerService({ + id: 'browserbase-startup-check', + start: runStartup, + stop: () => {}, + }); + return; + } + + runStartup(); + }, +}; diff --git a/packages/openclaw-browserbase/src/skills-sync.ts b/packages/openclaw-browserbase/src/skills-sync.ts new file mode 100644 index 0000000..eb135db --- /dev/null +++ b/packages/openclaw-browserbase/src/skills-sync.ts @@ -0,0 +1,153 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { x } from 'tar'; + +const TARBALL_BASE = 'https://codeload.github.com/browserbase/skills/tar.gz'; +const DEFAULT_SKILLS_REF = 'main'; + +/** + * Subdirectory inside the upstream repo that contains skills. + * Everything under this prefix is extracted to the target root. + */ +const SKILLS_SOURCE_PREFIX = 'skills/'; + +export type SkillSyncResult = { + targetRoot: string; + ref: string; + filesWritten: string[]; +}; + +export function defaultSkillsRoot(): string { + return path.join(os.homedir(), '.openclaw', 'skills'); +} + +export function resolveSkillsRoot(explicitPath?: string): string { + if (typeof explicitPath === 'string' && explicitPath.trim()) { + return path.resolve(process.cwd(), explicitPath.trim()); + } + + return defaultSkillsRoot(); +} + +export function hasBrowserbaseSkills( + targetRoot = defaultSkillsRoot() +): boolean { + try { + const entries = fs.readdirSync(targetRoot, { withFileTypes: true }); + return entries.some( + e => + e.isDirectory() && + fs.existsSync(path.join(targetRoot, e.name, 'SKILL.md')) + ); + } catch { + return false; + } +} + +export function installedSkillFiles( + targetRoot = defaultSkillsRoot() +): string[] { + const files: string[] = []; + + function walk(dir: string): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else { + files.push(full); + } + } + } + + walk(targetRoot); + return files.sort(); +} + +function normalizeRef(ref?: string): string { + if (typeof ref !== 'string' || !ref.trim()) { + return DEFAULT_SKILLS_REF; + } + + return ref.trim(); +} + +export async function syncBrowserbaseSkills(options?: { + targetRoot?: string; + ref?: string; + fetchImpl?: typeof fetch; +}): Promise { + const targetRoot = resolveSkillsRoot(options?.targetRoot); + const ref = normalizeRef(options?.ref); + const fetchImpl = options?.fetchImpl ?? fetch; + + if (typeof fetchImpl !== 'function') { + throw new Error('Global fetch is not available in this runtime.'); + } + + const tarballUrl = `${TARBALL_BASE}/${ref}`; + const response = await fetchImpl(tarballUrl, { + headers: { 'user-agent': 'openclaw-browserbase-plugin' }, + redirect: 'follow', + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${tarballUrl}`); + } + + if (!response.body) { + throw new Error(`Empty response body for ${tarballUrl}`); + } + + // Extract to a temp directory first, then swap atomically. + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bb-skills-sync-')); + + try { + // The tarball has a root directory like "skills-/" containing "skills/browser/", etc. + // We want to strip: (1) the repo root prefix, (2) the "skills/" prefix. + // That's strip: 2, and we filter to only entries under the "skills/" subtree. + await pipeline( + Readable.fromWeb(response.body as import('stream/web').ReadableStream), + x({ + cwd: tmpDir, + strip: 2, + filter: entryPath => { + // Entry paths look like: "skills-/skills/browser/SKILL.md" + // After the first "/" is the repo-relative path. + const repoRelative = entryPath.replace(/^[^/]+\//, ''); + return repoRelative.startsWith(SKILLS_SOURCE_PREFIX); + }, + }) + ); + + // Verify we got something. + const extracted = installedSkillFiles(tmpDir); + if (extracted.length === 0) { + throw new Error('Tarball extracted zero files under the skills/ prefix.'); + } + + // Swap: remove old skills, move new ones in. + await fsp.rm(targetRoot, { recursive: true, force: true }); + await fsp.mkdir(path.dirname(targetRoot), { recursive: true }); + await fsp.rename(tmpDir, targetRoot); + + const filesWritten = installedSkillFiles(targetRoot); + + return { targetRoot, ref, filesWritten }; + } catch (err) { + // Clean up temp dir on failure. + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + throw err; + } +} diff --git a/packages/openclaw-browserbase/tsconfig.json b/packages/openclaw-browserbase/tsconfig.json new file mode 100644 index 0000000..d8932a4 --- /dev/null +++ b/packages/openclaw-browserbase/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts", "types/**/*.d.ts"] +} diff --git a/packages/openclaw-browserbase/types/openclaw.d.ts b/packages/openclaw-browserbase/types/openclaw.d.ts new file mode 100644 index 0000000..a9b0891 --- /dev/null +++ b/packages/openclaw-browserbase/types/openclaw.d.ts @@ -0,0 +1,23 @@ +declare module 'openclaw/plugin-sdk' { + export interface OpenClawLogger { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string, ...args: unknown[]) => void; + debug: (msg: string) => void; + } + + export interface OpenClawPluginApi { + pluginConfig: unknown; + config?: unknown; + logger: OpenClawLogger; + registerCli: ( + handler: ({ program }: { program: any }) => void, + options?: any + ) => void; + registerService?: (service: { + id: string; + start?: () => void | Promise; + stop?: () => void | Promise; + }) => void; + } +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3a0aaf5..c949490 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - - 'examples/*' \ No newline at end of file + - 'examples/*' + - 'packages/*'