diff --git a/.gitignore b/.gitignore index 52559bd9a..8a1b49d73 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,20 @@ dist/ *.log .DS_Store +# Local env / secrets +.env +.env.* +!.env.example +!.env.*.example + +# Browserbase local state and sensitive session artifacts. +# The supported config path is ~/.opencli/browserbase.json; keep any copied +# project-local credentials, proxy passwords, Live View URLs, and connect URLs +# out of the repository. +browserbase*.json +*.browserbase.json +browserbase-sessions/ + # VitePress docs/.vitepress/dist docs/.vitepress/cache @@ -16,10 +30,10 @@ docs/.vitepress/cache *.pem *.crx *.zip -.envrc .windsurf .claude .cortex +.envrc # Database files *.db diff --git a/README.md b/README.md index f333c59ad..5dab8eeb0 100644 --- a/README.md +++ b/README.md @@ -168,14 +168,27 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command | | `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps | | `OPENCLI_CDP_TARGET` | — | Filter CDP targets by URL substring (e.g. `detail.1688.com`) | -| `BROWSERBASE_API_KEY` | — | API key for validating Browserbase cloud browser sessions | -| `BROWSERBASE_SESSION_ID` | — | Browserbase session ID for adapter browser commands; equivalent to root `--session ` | +| `BROWSERBASE_API_KEY` | — | API key for Browserbase cloud browser sessions, contexts, and account profiles | +| `BROWSERBASE_PROJECT_ID` | — | Browserbase project id required for context/account operations | +| `BROWSERBASE_SESSION_ID` | — | Existing Browserbase session ID for adapter browser commands; equivalent to root `--browserbase-session ` / legacy `--session ` | | `OPENCLI_VERBOSE` | `false` | Enable verbose logging (`-v` flag also works) | | `DEBUG_SNAPSHOT` | — | Set to `1` for DOM snapshot debug output | `opencli browser *` requires an explicit `` positional, uses a foreground browser window by default, and keeps that session's tab lease until `opencli browser close` or idle cleanup. Browser-backed adapters use a background adapter window and release one-shot tab leases by default. Interactive adapters can declare `siteSession: 'persistent'` to keep a stable site tab for continuity; pass `--site-session ephemeral` for a one-shot tab. -For cloud browsers, create a Browserbase session with the `bb` CLI, set `BROWSERBASE_API_KEY`, then run adapter commands with `opencli --session ...`. +For cloud browsers, OpenCLI can manage Browserbase account profiles, persistent +Contexts, proxy bindings, Live View login sessions, and parallel session pools: +`opencli browserbase account bootstrap ...`, +`opencli --browserbase-account ...`, and +`opencli run --browserbase ...`. Existing Browserbase sessions still work with +`--browserbase-session` or legacy `--session`. See +[`docs/advanced/browserbase.md`](./docs/advanced/browserbase.md). + +Social comment workflows are documented in +[`docs/adapters/social-comments.md`](./docs/adapters/social-comments.md), +including Twitter/X, YouTube, Reddit, LinkedIn, Instagram, TikTok, and +Xiaohongshu support. + ## Built-in Commands | Site | Commands | @@ -190,6 +203,9 @@ For cloud browsers, create a Browserbase session with the `bb` CLI, set `BROWSER | **linkedin** | `connect` `inbox` `safe-send` `search` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `get-comments` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `reply` `subscribe` `subscribed` | | **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-create` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `device-follow` `get-comments` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | +| **youtube** | `search` `video` `transcript` `comments` `reply` `reply-comment` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | +| **instagram** | `explore` `profile` `search` `search-posts` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `get-comments` `reply` `save` `unsave` `saved` | +| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `get-comments` `reply` `save` `unsave` `live` `notifications` `friends` | | **claude** | `ask` `send` `new` `status` `read` `history` `detail` | | **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | | **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 7f26bfce3..e4a9465bd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -154,24 +154,34 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) | | `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 | | `OPENCLI_CDP_TARGET` | — | 按 URL 子串过滤 CDP target(如 `detail.1688.com`) | +| `BROWSERBASE_API_KEY` | — | Browserbase 云端浏览器、Context 和账号配置所需 API key | +| `BROWSERBASE_PROJECT_ID` | — | Browserbase context/account 操作所需 project id | +| `BROWSERBASE_SESSION_ID` | — | 现有 Browserbase session ID;等价于根参数 `--browserbase-session ` / 兼容参数 `--session ` | | `OPENCLI_VERBOSE` | `false` | 启用详细日志(`-v` 也可以) | | `DEBUG_SNAPSHOT` | — | 设为 `1` 输出 DOM 快照调试信息 | `opencli browser *` 必须紧跟一个 `` 位置参数,默认使用前台窗口,并保留该 session 的 tab lease,直到你手动执行 `opencli browser close` 或等空闲超时。浏览器型 adapter 默认使用后台 adapter 窗口并在命令结束后释放一次性 tab lease;如果需要调试最终页面,可以传 `--window foreground --keep-tab true`。 +Browserbase 现在可以由 OpenCLI 直接管理账号配置、持久 Context、账号绑定 proxy、Live View 登录 session 和并发任务池:`opencli browserbase account bootstrap ...`、`opencli --browserbase-account ...`、`opencli run --browserbase ...`。已有 Browserbase session 仍可用 `--browserbase-session` 或兼容的 `--session`。详见 [`docs/advanced/browserbase.md`](./docs/advanced/browserbase.md)。 + +社交平台评论能力见 [`docs/adapters/social-comments.md`](./docs/adapters/social-comments.md),覆盖 Twitter/X、YouTube、Reddit、LinkedIn、Instagram、TikTok 和小红书。 + ## 内置命令 运行 `opencli list` 查看完整注册表。 | 站点 | 命令 | |------|------| -| **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | +| **xiaohongshu** | `search` `note` `comments` `reply` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` `delete-note` | | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | | **linkedin** | `connect` `inbox` `safe-send` `search` `people-search` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` | -| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | -| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | +| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `get-comments` `user` `user-posts` `user-comments` `upvote` `save` `comment` `reply` `subscribe` `subscribed` `saved` `upvoted` | +| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `profile` `thread` `get-comments` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | +| **youtube** | `search` `video` `transcript` `comments` `reply` `reply-comment` `channel` `playlist` `feed` `history` `watch-later` `subscriptions` `like` `unlike` `subscribe` `unsubscribe` | +| **instagram** | `explore` `profile` `search` `search-posts` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `get-comments` `reply` `save` `unsave` `saved` | +| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `get-comments` `reply` `save` `unsave` `live` `notifications` `friends` | | **claude** | `ask` `send` `new` `status` `read` `history` `detail` | | **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | | **notebooklm** | `status` `list` `open` `current` `get` `history` `summary` `note-list` `notes-get` `source-list` `source-get` `source-fulltext` `source-guide` | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index fdca055de..917c5796d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -44,6 +44,7 @@ export default defineConfig({ text: 'Adapters Overview', items: [ { text: 'All Adapters', link: '/adapters/' }, + { text: 'Social Comments', link: '/adapters/social-comments' }, ], }, { @@ -190,6 +191,7 @@ export default defineConfig({ { text: 'Chrome DevTools Protocol', link: '/advanced/cdp' }, { text: 'Electron Apps', link: '/advanced/electron' }, { text: 'Remote Chrome', link: '/advanced/remote-chrome' }, + { text: 'Browserbase', link: '/advanced/browserbase' }, { text: 'Download Support', link: '/advanced/download' }, ], }, diff --git a/docs/adapters/browser/instagram.md b/docs/adapters/browser/instagram.md index a2e60b495..3a18e606c 100644 --- a/docs/adapters/browser/instagram.md +++ b/docs/adapters/browser/instagram.md @@ -10,6 +10,9 @@ | `opencli instagram search` | Search users | | `opencli instagram user` | Get recent posts from a user | | `opencli instagram explore` | Discover trending posts | +| `opencli instagram get-comments` | Get comments on a post with reply-able IDs | +| `opencli instagram comment` | Comment on a post | +| `opencli instagram reply` | Reply to a specific comment | | `opencli instagram followers` | List user's followers | | `opencli instagram following` | List user's following | | `opencli instagram saved` | Get your saved posts (or one collection) | @@ -38,6 +41,14 @@ opencli instagram following nasa --limit 20 # Get your saved posts (default "All posts" feed) opencli instagram saved --limit 10 +# Read comments from a post URL, or from a user's Nth recent post +opencli instagram get-comments https://www.instagram.com/p/SHORTCODE/ --limit 50 +opencli instagram get-comments nasa --index 1 --limit 20 + +# Write comments and replies +opencli instagram comment nasa "Great post" --index 1 +opencli instagram reply nasa 18000000000000000 "Thanks" --index 1 + # Get posts from a specific collection (case-insensitive name match) opencli instagram saved --collection inspiration --limit 10 @@ -59,6 +70,13 @@ opencli instagram profile nasa -f json - `instagram collection-delete ` calls `POST /api/v1/collections/{id}/delete/`. Pass either a case-insensitive collection name or a numeric `collection_id`. If the name resolves to multiple collections (e.g. duplicates from `collection-create`), the adapter throws and lists the candidate ids so you can disambiguate by passing the id explicitly. Unknown names list the available collections in the error message. - Saving an existing post directly into a named collection in one shot is not exposed by the web app's documented endpoints (`/api/v1/web/save/{pk}/save/` only writes to "All posts"). Use `instagram save` first, then move the post in the UI, or extend with the `/api/v1/collections/{id}/edit/` mutation. +### Comments + +`get-comments` returns `comment_id`, `author`, `text`, `likes`, +`replies_count`, and `time`. It accepts a post/reel URL or a username plus +`--index` (`1` is the most recent post). `reply` expects the `comment_id` from +`get-comments`; `comment` and `reply` require a logged-in browser session. + ## Prerequisites - Chrome running and **logged into** instagram.com diff --git a/docs/adapters/browser/linkedin.md b/docs/adapters/browser/linkedin.md index 22dcb1771..bb979c0bd 100644 --- a/docs/adapters/browser/linkedin.md +++ b/docs/adapters/browser/linkedin.md @@ -95,6 +95,14 @@ Returns `rank`, `name`, `headline`, `location`, and `profile_url` from the rende `thread-snapshot` opens an exact messaging thread URL, validates `--max-scrolls` before navigation, scrolls for available history, and returns a JSON snapshot suitable for caller-side recipient safety checks. +### Timeline comments + +`timeline` returns each visible feed post with a numeric `comments` count. This +is the current LinkedIn comment surface in OpenCLI: it is useful for ranking and +triage, but it does not extract comment-thread text and does not expose +comment-write commands. Treat LinkedIn comment-thread automation as unsupported +until a dedicated `linkedin comments` adapter exists. + ### Sales Navigator commands `salesnav-search` uses the Sales Navigator lead search API and returns `rank`, `name`, `title`, `company`, `location`, `degree`, `profile_url`, `lead_url`, and `recipient_urn`. Missing lead identity or malformed API payloads fail typed instead of emitting unaddressable rows. diff --git a/docs/adapters/browser/reddit.md b/docs/adapters/browser/reddit.md index 5bf073d35..c0e4d9af1 100644 --- a/docs/adapters/browser/reddit.md +++ b/docs/adapters/browser/reddit.md @@ -14,6 +14,7 @@ | `opencli reddit subreddit` | Posts from a specific subreddit, with sort and time filters | | `opencli reddit subreddit-info` | **Subreddit metadata (subscribers, active, NSFW, created, description)** | | `opencli reddit read` | Read a post thread with comments | +| `opencli reddit get-comments` | Flat top-level comments with reply-able IDs | | `opencli reddit user` | View a user profile | | `opencli reddit user-posts` | A user's submitted posts | | `opencli reddit user-comments` | A user's comments | @@ -54,6 +55,9 @@ opencli reddit read 1abc123 --depth 2 # Read with "more comments" expansion via /api/morechildren.json opencli reddit read 1abc123 --depth 3 --expand-more --expand-rounds 3 +# Get reply-able comment IDs for automation +opencli reddit get-comments 1abc123 --sort old --limit 100 + # Comment on a post opencli reddit comment 1abc123 "Great post" @@ -86,6 +90,10 @@ itself rejects the request with 401/403, that's surfaced as `AuthRequiredError` because writeable/expand endpoints often require a logged- in session even though the post listing is public. +`get-comments` is optimized for follow-up automation: it returns `comment_id`, +`author`, `score`, `text`, `replies_count`, and `time` for up to 100 top-level +comments. Use `read --expand-more` when you need the threaded conversation tree. + ## Prerequisites - Chrome running and **logged into** reddit.com diff --git a/docs/adapters/browser/tiktok.md b/docs/adapters/browser/tiktok.md index 058f3d49f..9af7cffa0 100644 --- a/docs/adapters/browser/tiktok.md +++ b/docs/adapters/browser/tiktok.md @@ -21,7 +21,9 @@ | `opencli tiktok unsave` | Remove from Favorites | | `opencli tiktok follow` | Follow a user | | `opencli tiktok unfollow` | Unfollow a user | +| `opencli tiktok get-comments` | Get comments on a video with reply-able IDs | | `opencli tiktok comment` | Comment on a video | +| `opencli tiktok reply` | Reply to a specific comment | ## Usage Examples @@ -63,7 +65,9 @@ opencli tiktok follow nasa opencli tiktok unfollow nasa # Comment on a video +opencli tiktok get-comments "https://www.tiktok.com/@user/video/123" --limit 50 opencli tiktok comment "https://www.tiktok.com/@user/video/123" "Great!" +opencli tiktok reply "https://www.tiktok.com/@user/video/123" "COMMENT_ID" "Thanks" # JSON output opencli tiktok profile --username tiktok -f json @@ -146,9 +150,21 @@ same data their web client renders on the page. | `secUid` | string | Host's TikTok internal stable id | | `url` | string | Canonical `/@streamer/live` URL | -### `comment` / `follow` / `unfollow` (write commands) +### `get-comments` -These three commands click the live UI button + verify the post-click state +| Column | Type | Notes | +|--------|------|-------| +| `rank` | int | 1-based position | +| `comment_id` | string | TikTok comment id suitable for `tiktok reply` | +| `author` | string | Comment author's `uniqueId` | +| `text` | string | Comment text | +| `likes` | int | Like count | +| `replies_count` | int | Nested reply count exposed by the web payload | +| `time` | string | Comment timestamp when exposed | + +### `comment` / `reply` / `follow` / `unfollow` (write commands) + +These commands click the live UI button + verify the post-click state before returning. They never return a silent failure row — every failure path raises a typed error. The `result` enum makes idempotent fast paths (already-following / already-not-following / already-friends) explicit, so @@ -162,6 +178,15 @@ callers can distinguish "we just did it" from "it was already done". | `text` | string | Comment text actually submitted (trimmed, ≤150 chars) | | `result` | enum | `posted` (only — TikTok permits duplicate comments, no idempotent fast path) | +#### `reply` + +| Column | Type | Notes | +|--------|------|-------| +| `status` | string | `success` on verified submit | +| `message` | string | Human-readable result | +| `comment_id` | string | Parent comment id | +| `text` | string | Reply text actually submitted | + #### `follow` | Column | Type | Notes | @@ -201,21 +226,21 @@ empty rows — callers can treat any returned row as real data. ### Write commands — typed errors and retryability -`comment` / `follow` / `unfollow` validate input upfront and verify post-click +`comment` / `reply` / `follow` / `unfollow` validate input upfront and verify post-click state. Failure modes: | Failure | Typed error | `retryable` (in `hint`) | |---------|-------------|-------------------------| | Empty / overlong / malformed input | `ArgumentError` | n/a | | Not logged in (no session cookie + no viewer secUid) | `AuthRequiredError` | n/a | -| Required button missing (UI changed, blocked, private account) | `CommandExecutionError` | follow / unfollow: `true` (idempotent); comment: `false` | -| State did not flip within timeout (likes/follows count unchanged) | `CommandExecutionError` | follow / unfollow: `true`; comment: `false` | +| Required button missing (UI changed, blocked, private account) | `CommandExecutionError` | follow / unfollow: `true` (idempotent); comment / reply: `false` | +| State did not flip within timeout (likes/follows count unchanged) | `CommandExecutionError` | follow / unfollow: `true`; comment / reply: `false` | | Captcha / rate-limit popup detected | `CommandExecutionError` | same as above | The `retryable=` flag is encoded in the error `hint` string in the form `retryable= reason=<...>` so downstream agents and scripts can -grep it without parsing structured metadata. **Comment is `retryable=false`** -because the server may still have accepted the comment when our state-verify +grep it without parsing structured metadata. **Comment and reply are +`retryable=false`** because the server may still have accepted the write when our state-verify times out (server-fan-out semantics) — auto-retrying would double-post. **Follow / unfollow are `retryable=true`** because TikTok dedupes the relation flip server-side, so a transient blip can be safely retried. @@ -239,7 +264,7 @@ falls back to the corresponding API endpoint when more rows are requested This refactor applies the page-context API baseline across TikTok read commands: typed errors, full numeric stats columns, and no DOM-link scraping. -`comment` / `follow` / `unfollow` (Route 1) keep the UI button as the +`comment` / `reply` / `follow` / `unfollow` keep the UI button as the trigger and harden every transition with state verification + typed errors. We **do not** call `/api/commit/follow/user/` or `/api/comment/publish/` directly: those endpoints require X-Bogus signing engineering, which is a diff --git a/docs/adapters/browser/twitter.md b/docs/adapters/browser/twitter.md index deb1d60da..89d2e3c8c 100644 --- a/docs/adapters/browser/twitter.md +++ b/docs/adapters/browser/twitter.md @@ -12,6 +12,7 @@ | `opencli twitter search` | | | `opencli twitter timeline` | | | `opencli twitter thread` | | +| `opencli twitter get-comments` | Get replies to a tweet with reply-able IDs | | `opencli twitter following` | | | `opencli twitter followers` | | | `opencli twitter notifications` | | @@ -54,6 +55,9 @@ opencli twitter search "react 19" # Search latest/live tweets opencli twitter search "react 19" --filter live +# Get replies/comments for a tweet +opencli twitter get-comments https://x.com/jack/status/20 --limit 50 + # Get following/followers list (supports large limits) opencli twitter following @elonmusk --limit 200 opencli twitter followers @elonmusk --limit 100 @@ -75,6 +79,7 @@ opencli twitter unlike https://x.com/jack/status/20 opencli twitter retweet https://x.com/jack/status/20 opencli twitter unretweet https://x.com/jack/status/20 opencli twitter quote https://x.com/jack/status/20 "great take" +opencli twitter reply https://x.com/jack/status/20 "reply text" # JSON output opencli twitter trending -f json @@ -87,3 +92,10 @@ opencli twitter trending -v - Chrome running and **logged into** twitter.com - [Browser Bridge extension](/guide/browser-bridge) installed + +## Comments + +`get-comments` accepts a tweet URL or ID and returns `comment_id`, `author`, +`text`, `likes`, `time`, and reply URL rows. The returned IDs are suitable for +follow-up reply/hide-reply workflows where the platform still exposes the +target reply. diff --git a/docs/adapters/browser/xiaohongshu.md b/docs/adapters/browser/xiaohongshu.md index dc55da372..1758eba6a 100644 --- a/docs/adapters/browser/xiaohongshu.md +++ b/docs/adapters/browser/xiaohongshu.md @@ -9,6 +9,7 @@ | `opencli xiaohongshu search` | Search notes by keyword (returns title, author, likes, URL) | | `opencli xiaohongshu note` | Read full note content (title, author, description, likes, collects, comments, tags) | | `opencli xiaohongshu comments` | Read comments from a note (`--with-replies` for nested 楼中楼 replies) | +| `opencli xiaohongshu reply` | Reply to a note comment by `comment_id` | | `opencli xiaohongshu feed` | Home feed recommendations (via Pinia store interception) | | `opencli xiaohongshu notifications` | User notifications (mentions, likes, connections) | | `opencli xiaohongshu user` | Get public notes from a user profile | @@ -33,6 +34,9 @@ opencli xiaohongshu note "https://www.xiaohongshu.com/search_result/?xsec_to # Read comments with nested replies (楼中楼) opencli xiaohongshu comments "https://www.xiaohongshu.com/search_result/?xsec_token=..." --with-replies --limit 20 +# Reply to a comment returned by xiaohongshu comments +opencli xiaohongshu reply "https://www.xiaohongshu.com/search_result/?xsec_token=..." "" "谢谢分享" + # JSON output opencli xiaohongshu search 旅行 -f json @@ -52,6 +56,14 @@ opencli xiaohongshu delete-note 6a08ba0b000000000702a893 --execute > Note: `note` and `comments` now require a full signed note URL with `xsec_token`. `download` accepts either a signed note URL or an `xhslink` short link. Bare note IDs are no longer reliable on xiaohongshu. > `delete-note` operates in creator center and accepts a 24-character note ID or exact Xiaohongshu note URL; it defaults to dry-run verification and only deletes with `--execute`. +## Comments + +`comments` returns `comment_id`, `author`, `text`, `likes`, `time`, +`is_reply`, and `reply_to`. Pass `--with-replies` to include nested 楼中楼 +replies in the same flat output. `reply` uses the `comment_id` from +`comments`; when a platform-side ID lookup fails, it can also use +`--comment-text` and `--comment-author` for fuzzy matching. + ## Prerequisites - Chrome running and **logged into** xiaohongshu.com diff --git a/docs/adapters/browser/youtube.md b/docs/adapters/browser/youtube.md index 4a5565e0d..e12e07f1f 100644 --- a/docs/adapters/browser/youtube.md +++ b/docs/adapters/browser/youtube.md @@ -10,6 +10,8 @@ | `opencli youtube video` | Get video metadata | | `opencli youtube transcript` | Get video transcript/subtitles | | `opencli youtube comments` | Get video comments | +| `opencli youtube reply` | Post a top-level video comment | +| `opencli youtube reply-comment` | Reply to a specific comment ID | | `opencli youtube channel` | Get channel info and videos | | `opencli youtube playlist` | Get playlist video list | | `opencli youtube feed` | Homepage recommended videos | @@ -34,14 +36,24 @@ opencli youtube subscriptions --limit 30 opencli youtube search "rust programming" --limit 5 opencli youtube video "https://www.youtube.com/watch?v=xxx" opencli youtube transcript "https://www.youtube.com/watch?v=xxx" +opencli youtube comments "https://www.youtube.com/watch?v=xxx" --limit 100 # Write commands (requires login) opencli youtube like "https://www.youtube.com/watch?v=xxx" opencli youtube unlike "videoId" opencli youtube subscribe "@ChannelHandle" opencli youtube unsubscribe "UCxxxxxxxxxxxxxx" +opencli youtube reply "https://www.youtube.com/watch?v=xxx" "Top-level comment" +opencli youtube reply-comment Ugxxx "Nested reply" --url "https://www.youtube.com/watch?v=xxx" ``` +## Comments + +`comments` returns `comment_id`, `author`, `text`, `likes`, `replies`, and +`time`. Use `reply` for a new top-level video comment, and `reply-comment` with +the `comment_id` from `comments` plus `--url` so the adapter can open the right +video context before writing. + ## Prerequisites - Chrome running and **logged into** youtube.com diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 8382d1acc..613578aa3 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -2,6 +2,10 @@ Run `opencli list` for the live registry. +For comment extraction and reply workflows across Twitter/X, YouTube, Reddit, +LinkedIn, Instagram, TikTok, and Xiaohongshu, see +[Social Comment Support](./social-comments.md). + ## Browser Adapters | Site | Commands | Mode | @@ -23,7 +27,7 @@ Run `opencli list` for the live registry. | **[bloomberg](./browser/bloomberg.md)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 | | **[weibo](./browser/weibo.md)** | `hot` `search` `feed` `user` `user-posts` `me` `post` `favorites` `publish` `delete` `comments` | 🔐 Browser | | **[toutiao](./browser/toutiao.md)** | `articles` `hot` | 🌐 / 🔐 | -| **[linkedin](./browser/linkedin.md)** | `search` `people-search` `timeline` | 🔐 Browser | +| **[linkedin](./browser/linkedin.md)** | `connect` `inbox` `people-search` `safe-send` `salesnav-inbox` `salesnav-message` `salesnav-search` `salesnav-thread` `search` `sent-invitations` `thread-snapshot` `timeline` | 🔐 Browser | | **[linkedin-learning](./browser/linkedin-learning.md)** | `search` `trending` `course` | 🔐 Browser | | **[coupang](./browser/coupang.md)** | `search` `product` `add-to-cart` | 🔐 Browser | | **[boss](./browser/boss.md)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser | diff --git a/docs/adapters/social-comments.md b/docs/adapters/social-comments.md new file mode 100644 index 000000000..986d4801e --- /dev/null +++ b/docs/adapters/social-comments.md @@ -0,0 +1,73 @@ +# Social Comment Support + +OpenCLI exposes comment workflows for the major social adapters through the +same browser-backed runtime used by normal site commands. These commands reuse +your logged-in browser session, or a Browserbase account profile when you pass +`--browserbase-account`. + +## Support Matrix + +| Platform | Read comments | Post top-level comment | Reply to comment | Notes | +|----------|---------------|------------------------|------------------|-------| +| Twitter / X | `twitter get-comments` | `twitter reply` | `twitter reply` | Tweet replies are represented as comment rows with reply-able `comment_id` values. | +| YouTube | `youtube comments` | `youtube reply` | `youtube reply-comment` | `reply-comment` needs the original video URL for page context. | +| Reddit | `reddit read`, `reddit get-comments` | `reddit comment` | `reddit reply` | `read --expand-more` expands threaded comments; `get-comments` returns a flat top-level list with IDs. | +| LinkedIn | `linkedin timeline` comment counts | Not exposed | Not exposed | Current LinkedIn support reports timeline post `comments` counts, but does not extract or write comment threads. | +| Instagram | `instagram get-comments` | `instagram comment` | `instagram reply` | `get-comments` accepts a username plus post index, or a post/reel URL. | +| TikTok | `tiktok get-comments` | `tiktok comment` | `tiktok reply` | Reply can fall back to `--comment-text` and `--comment-author` when an ID cannot be matched. | +| Xiaohongshu | `xiaohongshu comments` | Not exposed | `xiaohongshu reply` | `comments --with-replies` includes nested replies; note URLs need `xsec_token`. | + +## Common Patterns + +Read comments in JSON so the returned IDs can be reused: + +```bash +opencli reddit get-comments https://www.reddit.com/r/example/comments/1abc123/title/ --limit 100 -f json +opencli twitter get-comments https://x.com/user/status/123 --limit 50 -f json +opencli youtube comments "https://www.youtube.com/watch?v=VIDEO_ID" --limit 100 -f json +opencli instagram get-comments https://www.instagram.com/p/SHORTCODE/ --limit 50 -f json +opencli tiktok get-comments "https://www.tiktok.com/@user/video/123" --limit 50 -f json +opencli xiaohongshu comments "https://www.xiaohongshu.com/search_result/?xsec_token=..." --with-replies -f json +``` + +Post or reply only when you intentionally want a write action: + +```bash +opencli reddit comment 1abc123 "Comment text" +opencli reddit reply t1_okf3s7u "Reply text" + +opencli youtube reply "https://www.youtube.com/watch?v=VIDEO_ID" "Comment text" +opencli youtube reply-comment Ugxxx "Reply text" --url "https://www.youtube.com/watch?v=VIDEO_ID" + +opencli instagram comment nasa "Comment text" --index 1 +opencli instagram reply nasa 18000000000000000 "Reply text" --index 1 + +opencli tiktok comment "https://www.tiktok.com/@user/video/123" "Comment text" +opencli tiktok reply "https://www.tiktok.com/@user/video/123" "COMMENT_ID" "Reply text" + +opencli xiaohongshu reply "https://www.xiaohongshu.com/search_result/?xsec_token=..." "COMMENT_ID" "Reply text" +``` + +## Browserbase Accounts + +Use Browserbase when the comment workflow needs a cloud browser, a fixed proxy, +or multiple logged-in identities: + +```bash +opencli --browserbase-account reddit1 reddit get-comments https://www.reddit.com/r/example/comments/1abc123/title/ +opencli --browserbase-account x-main-1 twitter get-comments https://x.com/user/status/123 +``` + +For durable login state, proxy CRUD, account-to-proxy binding, and pooled +parallel sessions, see [Browserbase Accounts, Login State, Proxies, and Parallel Sessions](../advanced/browserbase.md). + +## Limits And Safety + +- Read commands return the rows exposed by the platform's current web APIs or + UI state. Some platforms hide replies behind paginated "more" affordances or + rate limits. +- Write commands require a valid logged-in session. They raise typed errors on + auth walls, malformed IDs, captcha/rate-limit states, or failed post-click + verification instead of returning silent success rows. +- Treat returned comment IDs as platform-scoped IDs. Reuse them only with the + same platform adapter that produced them. diff --git a/docs/advanced/browserbase.md b/docs/advanced/browserbase.md new file mode 100644 index 000000000..a46c84a36 --- /dev/null +++ b/docs/advanced/browserbase.md @@ -0,0 +1,192 @@ +# Browserbase Accounts, Login State, Proxies, and Parallel Sessions + +OpenCLI can run browser-backed adapters through Browserbase in three ways: + +- an explicit Browserbase session (`--browserbase-session` or legacy `--session`) +- an OpenCLI Browserbase account profile (`--browserbase-account`) +- a pooled JSONL run (`opencli run --browserbase`) + +The durable login state is stored in Browserbase Contexts. Browserbase Sessions +are short-lived browser instances used for manual login or automation, and proxy +settings are applied when a session is created. Binding an account profile to a +proxy means every future session for that account uses the same exit path unless +you override it. + +## Configure + +```bash +export BROWSERBASE_API_KEY=... +export BROWSERBASE_PROJECT_ID=... +``` + +OpenCLI stores only local metadata at `~/.opencli/browserbase.json` with `0600` +permissions: account names, site tags, Browserbase `contextId` values, proxy +profile references, and login-state timestamps. Cookies, localStorage, and +IndexedDB stay inside Browserbase Contexts. Proxy passwords should be referenced +through environment variables. + +Add a proxy profile: + +```bash +opencli browserbase proxy add us-ny \ + --type browserbase \ + --country US \ + --state NY \ + --city "New York" +``` + +External proxies should use an environment variable for the password: + +```bash +export PROXY_DC1_PASS=... + +opencli browserbase proxy add dc1 \ + --type external \ + --server http://host:port \ + --username user \ + --password-env PROXY_DC1_PASS +``` + +Proxy CRUD: + +```bash +opencli browserbase proxy list +opencli browserbase proxy get dc1 +opencli browserbase proxy update dc1 --server http://new-host:port +opencli browserbase proxy test dc1 +opencli browserbase proxy delete dc1 +``` + +`proxy delete` refuses to remove a proxy that accounts still reference. Use +`--force` when you intentionally want OpenCLI to clear those account references. + +## Create Login Profiles + +Create ten account profiles, each with its own Browserbase Context, then open +Live View URLs for manual login: + +```bash +opencli browserbase account bootstrap \ + --site x \ + --count 10 \ + --name-prefix x-main \ + --proxy us-ny \ + --open +``` + +After logging in, either keep the sessions open and mark accounts manually: + +```bash +opencli browserbase account mark x-main-1 --state ready +``` + +or use `--wait` so OpenCLI waits for Enter, releases the login session, and +marks the account ready: + +```bash +opencli browserbase account login x-main-1 --open --wait +``` + +Use the same account name later and OpenCLI will create a fresh Browserbase +Session attached to the saved Context, so the site should see the prior login +state. Site-side cookie expiry, password changes, or server-side logout can still +invalidate that state; mark or refresh the account when that happens. + +```bash +opencli browserbase account check x-main-1 --command "reddit whoami" +opencli browserbase account mark x-main-1 --state invalidated +opencli browserbase account login x-main-1 --open --wait +``` + +To import an existing Browserbase Context as an OpenCLI account: + +```bash +opencli browserbase account import reddit1 --site reddit --context-id --proxy dc1 +``` + +## Run Commands + +Run one adapter command with a specific Browserbase account: + +```bash +opencli --browserbase-account x-main-1 reddit get-comments https://reddit.com/r/... +``` + +Run a JSONL job file across up to ten Browserbase account sessions: + +```bash +opencli run --browserbase \ + --accounts x-main-1,x-main-2,x-main-3,x-main-4,x-main-5,x-main-6,x-main-7,x-main-8,x-main-9,x-main-10 \ + --parallel 10 \ + --pool-size 10 \ + jobs.jsonl +``` + +Each line in `jobs.jsonl` is one job: + +```json +{"id":"reddit-1","command":"reddit get-comments","args":{"post-id":"https://reddit.com/r/...","limit":100}} +``` + +Jobs can pin an account with `"account"` or `"accountName"`; otherwise OpenCLI +round-robins across `--accounts`. The pool guarantees one active automation +session per account/context while allowing different accounts to run in +parallel. `--pool-size` is capped at 10; the effective limit can still be lower +if your Browserbase plan or API response rejects more sessions. + +## Manage State + +```bash +opencli browserbase account list +opencli browserbase account get x-main-1 +opencli browserbase account check x-main-1 --command "reddit whoami" +opencli browserbase account set-proxy x-main-1 us-ny +opencli browserbase account clear-proxy x-main-1 +opencli browserbase account clear-login x-main-1 --recreate-context --delete-old-context +opencli browserbase account delete x-main-1 --delete-context +``` + +`clear-login --recreate-context` replaces the account's Context so the next +login starts from an empty browser profile. `delete --delete-context` removes +both the local account profile and the remote Context. + +Session commands are available when you need a manual session: + +```bash +opencli browserbase session create --account x-main-1 --keep-alive --print-live-url +opencli browserbase session live-url +opencli browserbase session get +opencli browserbase session list +opencli browserbase session release +opencli browserbase session delete +``` + +Context commands are lower-level tools for direct Browserbase Context work: + +```bash +opencli browserbase context create +opencli browserbase context get +opencli browserbase context list-local +opencli browserbase context delete +``` + +Sensitive fields such as proxy plaintext passwords and Browserbase connect URLs +are redacted by default. Use `--show-sensitive` only when you need to inspect +them directly. + +## Session Selection Priority + +For browser-backed adapters, OpenCLI resolves remote browser settings in this +order: + +1. `--browserbase-account ` +2. `--browserbase-session ` +3. legacy `--session ` +4. `BROWSERBASE_SESSION_ID` +5. `OPENCLI_CDP_ENDPOINT` +6. local Browser Bridge + +Read-only checks run with `persistContext: false`; login and normal task +sessions persist by default. Pass `--no-persist-context` to `opencli run` or +`browserbase session create` when you intentionally do not want a session to +write back context changes. diff --git a/docs/advanced/remote-chrome.md b/docs/advanced/remote-chrome.md index 11fa82db5..226d351bb 100644 --- a/docs/advanced/remote-chrome.md +++ b/docs/advanced/remote-chrome.md @@ -49,37 +49,33 @@ opencli doctor ## Browserbase Cloud Browser -[Browserbase](https://browserbase.com) provides managed cloud browsers with proxy support, persistent login contexts, and stealth mode. OpenCLI consumes an existing Browserbase session; create and manage sessions with the `bb` CLI. +[Browserbase](https://browserbase.com) provides managed cloud browsers with proxy support, persistent login contexts, and stealth mode. OpenCLI can either consume an existing Browserbase session or manage Browserbase account profiles, contexts, proxies, and parallel sessions directly. ```bash export BROWSERBASE_API_KEY=your_key export BROWSERBASE_PROJECT_ID=your_project_id - -# Create a session with the Browserbase CLI. -bb sessions create --json ``` -Run adapter browser commands with either the root `--session` flag or `BROWSERBASE_SESSION_ID`: +For persistent login state, account-to-proxy binding, manual Live View login, +and parallel account pools, see +[Browserbase Accounts, Login State, Proxies, and Parallel Sessions](./browserbase.md). ```bash -opencli --session reddit get-comments --limit 5 - -export BROWSERBASE_SESSION_ID= -opencli bilibili comments BV1xxx --limit 5 +opencli browserbase account bootstrap --site x --count 10 --name-prefix x-main --open +opencli run --browserbase --accounts x-main-1,x-main-2 --parallel 2 jobs.jsonl ``` -For parallel work, create multiple Browserbase sessions and pass each one to a separate OpenCLI process: +You can still run adapter browser commands with an existing Browserbase session: ```bash -S1=$(bb sessions create --proxy us --json | jq -r .id) -S2=$(bb sessions create --proxy jp --json | jq -r .id) +opencli --browserbase-session reddit get-comments --limit 5 +opencli --session reddit get-comments --limit 5 -opencli --session "$S1" reddit get-comments & -opencli --session "$S2" bilibili comments BV1xxx & -wait +export BROWSERBASE_SESSION_ID= +opencli bilibili comments BV1xxx --limit 5 ``` -Session selection priority for adapter browser commands is `--session`, then `BROWSERBASE_SESSION_ID`, then `OPENCLI_CDP_ENDPOINT`, then local Browser Bridge. +Session selection priority for adapter browser commands is `--browserbase-account`, then `--browserbase-session`, then legacy `--session`, then `BROWSERBASE_SESSION_ID`, then `OPENCLI_CDP_ENDPOINT`, then local Browser Bridge. ## CI/CD Integration diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index a4be15c50..efff886b5 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -27,6 +27,13 @@ export interface CDPTarget { webSocketDebuggerUrl?: string; } +interface CDPTargetInfo { + targetId?: string; + type?: string; + url?: string; + title?: string; +} + interface RuntimeEvaluateResult { result?: { value?: unknown; @@ -49,6 +56,7 @@ export const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; export class CDPBridge implements IBrowserFactory { private _ws: WebSocket | null = null; + private _sessionId: string | null = null; private _idCounter = 0; private _pending = new Map void; reject: (err: Error) => void; timer: ReturnType }>(); private _eventListeners = new Map void>>(); @@ -82,8 +90,7 @@ export class CDPBridge implements IBrowserFactory { clearTimeout(timeout); this._ws = ws; try { - await this.send('Page.enable'); - await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() }); + await this.preparePageTarget(); } catch (err) { ws.close(); reject(err instanceof Error ? err : new Error(String(err))); @@ -131,6 +138,7 @@ export class CDPBridge implements IBrowserFactory { this._ws.close(); this._ws = null; } + this._sessionId = null; for (const p of this._pending.values()) { clearTimeout(p.timer); p.reject(new Error('CDP connection closed')); @@ -140,6 +148,10 @@ export class CDPBridge implements IBrowserFactory { } async send(method: string, params: Record = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise { + return this.sendRaw(method, params, timeoutMs, this._sessionId); + } + + private async sendRaw(method: string, params: Record = {}, timeoutMs: number = CDP_SEND_TIMEOUT, sessionId: string | null = null): Promise { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { throw new Error('CDP connection is not open'); } @@ -150,10 +162,44 @@ export class CDPBridge implements IBrowserFactory { reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`)); }, timeoutMs); this._pending.set(id, { resolve, reject, timer }); - this._ws!.send(JSON.stringify({ id, method, params })); + this._ws!.send(JSON.stringify({ + id, + method, + params, + ...(sessionId ? { sessionId } : {}), + })); }); } + private async preparePageTarget(): Promise { + try { + await this.send('Page.enable'); + } catch (err) { + if (!isMissingCdpMethodError(err, 'Page.enable')) throw err; + await this.attachToBrowserLevelPageTarget(); + await this.send('Page.enable'); + } + await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() }); + } + + private async attachToBrowserLevelPageTarget(): Promise { + const targets = await this.sendRaw('Target.getTargets') as { targetInfos?: CDPTargetInfo[] }; + let target = Array.isArray(targets.targetInfos) + ? selectBrowserLevelTarget(targets.targetInfos) + : undefined; + if (!target?.targetId) { + const created = await this.sendRaw('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }; + if (!created.targetId) throw new Error('Browser-level CDP endpoint did not create a page target'); + target = { targetId: created.targetId, type: 'page', url: 'about:blank' }; + } + const attached = await this.sendRaw('Target.attachToTarget', { + targetId: target.targetId, + flatten: true, + }) as { sessionId?: string }; + if (!attached.sessionId) throw new Error('Browser-level CDP endpoint did not return an attached sessionId'); + this._sessionId = attached.sessionId; + } + on(event: string, handler: (params: unknown) => void): void { let set = this._eventListeners.get(event); if (!set) { @@ -486,6 +532,42 @@ function matchesCookieDomain(cookieDomain: string, targetDomain: string): boolea || normalizedTargetDomain.endsWith(`.${normalizedCookieDomain}`); } +function isMissingCdpMethodError(error: unknown, method: string): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes(method) && message.includes("wasn't found"); +} + +function selectBrowserLevelTarget(targets: CDPTargetInfo[]): CDPTargetInfo | undefined { + const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET); + const ranked = targets + .map((target, index) => ({ target, index, score: scoreBrowserLevelTarget(target, preferredPattern) })) + .filter(({ score }) => Number.isFinite(score)) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.index - b.index; + }); + return ranked[0]?.target; +} + +function scoreBrowserLevelTarget(target: CDPTargetInfo, preferredPattern?: RegExp): number { + if (!target.targetId) return Number.NEGATIVE_INFINITY; + const type = (target.type ?? '').toLowerCase(); + const url = (target.url ?? '').toLowerCase(); + const title = (target.title ?? '').toLowerCase(); + const haystack = `${title} ${url}`; + if (haystack.includes('devtools')) return Number.NEGATIVE_INFINITY; + if (type === 'background_page' || type === 'service_worker') return Number.NEGATIVE_INFINITY; + if (type !== 'page' && type !== 'webview' && type !== 'app') return Number.NEGATIVE_INFINITY; + + let score = 0; + if (preferredPattern && preferredPattern.test(haystack)) score += 1000; + if (type === 'page') score += 120; + else if (type === 'webview') score += 100; + else if (type === 'app') score += 90; + if (!url || url === 'about:blank') score += 5; + return score; +} + function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined { const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET); diff --git a/src/browserbase.ts b/src/browserbase.ts index f79d5d188..686a85afd 100644 --- a/src/browserbase.ts +++ b/src/browserbase.ts @@ -1,12 +1,9 @@ -/** - * Browserbase session validation. - * - * OpenCLI consumes Browserbase sessions created by the `bb` CLI. Session - * creation, proxy selection, and persistent context management stay external; - * this module only validates a running session and returns its CDP connect URL. - */ - -const API_BASE = 'https://api.browserbase.com/v1'; +import { BrowserbaseClient, parseBrowserbaseSessionId, resolveBrowserbaseConfig } from './browserbase/client.js'; +export * from './browserbase/types.js'; +export * from './browserbase/client.js'; +export * from './browserbase/config-store.js'; +export * from './browserbase/account.js'; +export * from './browserbase/pool.js'; export interface BrowserbaseSession { id: string; @@ -22,8 +19,8 @@ export function resolveBrowserbaseSessionId(cliSessionArg?: string): string | nu } export async function validateBrowserbaseSession(sessionId: string): Promise { - const apiKey = process.env.BROWSERBASE_API_KEY; - if (!apiKey) { + const config = resolveBrowserbaseConfig(); + if (!config.ok) { throw new Error( 'BROWSERBASE_API_KEY not set.\n' + ' Set it with: export BROWSERBASE_API_KEY=your_key\n' @@ -31,44 +28,43 @@ export async function validateBrowserbaseSession(sessionId: string): Promise; - const status = String(data.status ?? ''); - if (status !== 'RUNNING') { + if (session.value.status !== 'RUNNING') { const hints: Record = { - TIMED_OUT: 'Create a new one with: bb sessions create --timeout 3600', - ERROR: `Check status with: bb sessions get ${sessionId}`, - COMPLETED: 'Create a new one with: bb sessions create', - PENDING: 'Wait for it to start, or create a new one with: bb sessions create', + TIMED_OUT: 'Create a new one with: opencli browserbase session create --timeout 3600', + ERROR: `Check status with: opencli browserbase session get ${sessionId}`, + COMPLETED: 'Create a new one with: opencli browserbase session create', + PENDING: 'Wait for it to start, or create a new one with: opencli browserbase session create', }; throw new Error( - `Browserbase session "${sessionId}" is ${status || 'UNKNOWN'}.\n` - + ` ${hints[status] || 'Create a new session with: bb sessions create'}`, + `Browserbase session "${sessionId}" is ${session.value.status || 'UNKNOWN'}.\n` + + ` ${hints[session.value.status] || 'Create a new session with: opencli browserbase session create'}`, ); } - const connectUrl = data.connectUrl; - if (typeof connectUrl !== 'string' || !connectUrl) { + if (!session.value.connectUrl) { throw new Error(`Browserbase session "${sessionId}" did not include a connectUrl.`); } return { id: sessionId, - status, - connectUrl, + status: session.value.status, + connectUrl: session.value.connectUrl, }; } diff --git a/src/browserbase/account.ts b/src/browserbase/account.ts new file mode 100644 index 000000000..6edd5677c --- /dev/null +++ b/src/browserbase/account.ts @@ -0,0 +1,246 @@ +import { ConfigError } from '../errors.js'; +import { asBrowserbaseContextId, BrowserbaseClient } from './client.js'; +import { + asBrowserbaseAccountName, + asBrowserbaseProxyName, + loadBrowserbaseStore, + removeAccountProfile, + saveBrowserbaseStore, + upsertAccountProfile, +} from './config-store.js'; +import { + type BrowserbaseAccountName, + type BrowserbaseAccountProfile, + type BrowserbaseApiError, + type BrowserbaseContextId, + type BrowserbaseProxyName, + type BrowserbaseRegion, + type LoginState, + type Result, + err, + ok, +} from './types.js'; + +export interface BrowserbaseAccountError { + readonly code: + | 'ACCOUNT_NOT_FOUND' + | 'PROXY_NOT_FOUND' + | 'CONTEXT_CREATE_FAILED' + | 'CONTEXT_DELETE_FAILED' + | 'SESSION_CREATE_FAILED' + | 'LIVE_URL_FAILED' + | 'CONFIG_FAILED'; + readonly message: string; + readonly hint?: string; +} + +export interface CreateAccountInput { + readonly name: BrowserbaseAccountName; + readonly site: string; + readonly proxyName: BrowserbaseProxyName | null; +} + +export interface LoginSessionOptions { + readonly keepAlive: boolean; + readonly timeoutSeconds: number; + readonly region: BrowserbaseRegion; + readonly persistContext: boolean; + readonly proxyNameOverride: BrowserbaseProxyName | null; +} + +export interface LoginSessionResult { + readonly account: BrowserbaseAccountProfile; + readonly sessionId: string; + readonly liveUrl: string; + readonly connectUrl: string | null; +} + +export interface ClearLoginOptions { + readonly recreateContext: boolean; + readonly deleteOldContext: boolean; +} + +export interface DeleteAccountOptions { + readonly deleteContext: boolean; +} + +export interface BrowserbaseAccountDeps { + readonly client: BrowserbaseClient; + readonly projectId: string; + readonly now: () => Date; +} + +function accountError(code: BrowserbaseAccountError['code'], message: string, hint?: string): BrowserbaseAccountError { + return { code, message, ...(hint ? { hint } : {}) }; +} + +function apiToAccountError(code: BrowserbaseAccountError['code'], error: BrowserbaseApiError): BrowserbaseAccountError { + return accountError(code, error.message, error.hint); +} + +function requireStore() { + const store = loadBrowserbaseStore(); + if (!store.ok) { + throw new ConfigError(store.error.message, store.error.hint); + } + return store.value; +} + +function saveStoreOrThrow(store: ReturnType): void { + const saved = saveBrowserbaseStore(store); + if (!saved.ok) throw new ConfigError(saved.error.message, saved.error.hint); +} + +export async function createBrowserbaseAccount( + input: CreateAccountInput, + deps: BrowserbaseAccountDeps, +): Promise> { + const store = requireStore(); + if (input.proxyName && !store.proxies[input.proxyName]) { + return err(accountError('PROXY_NOT_FOUND', `Browserbase proxy "${input.proxyName}" is not configured.`)); + } + const context = await deps.client.createContext(deps.projectId); + if (!context.ok) return err(apiToAccountError('CONTEXT_CREATE_FAILED', context.error)); + const profile: BrowserbaseAccountProfile = { + name: input.name, + site: input.site, + contextId: context.value.id, + defaultProxyName: input.proxyName, + loginState: 'empty', + lastLoginAtIso: null, + lastCheckedAtIso: null, + }; + saveStoreOrThrow(upsertAccountProfile(store, profile)); + return ok(profile); +} + +export async function openLoginSession( + accountName: BrowserbaseAccountName, + options: LoginSessionOptions, + deps: BrowserbaseAccountDeps, +): Promise> { + const store = requireStore(); + const account = store.accounts[accountName]; + if (!account) return err(accountError('ACCOUNT_NOT_FOUND', `Browserbase account "${accountName}" is not configured.`)); + const proxyName = options.proxyNameOverride ?? account.defaultProxyName; + const proxy = proxyName ? store.proxies[proxyName] : null; + if (proxyName && !proxy) return err(accountError('PROXY_NOT_FOUND', `Browserbase proxy "${proxyName}" is not configured.`)); + const session = await deps.client.createSession({ + accountName, + contextId: account.contextId, + proxyName, + region: options.region, + keepAlive: options.keepAlive, + persistContext: options.persistContext, + timeoutSeconds: options.timeoutSeconds, + }, proxy?.rules ?? []); + if (!session.ok) return err(apiToAccountError('SESSION_CREATE_FAILED', session.error)); + const liveUrls = await deps.client.getLiveUrls(session.value.id); + if (!liveUrls.ok) return err(apiToAccountError('LIVE_URL_FAILED', liveUrls.error)); + const updated: BrowserbaseAccountProfile = { + ...account, + loginState: 'login-session-open', + lastLoginAtIso: deps.now().toISOString(), + }; + saveStoreOrThrow(upsertAccountProfile(store, updated)); + return ok({ + account: updated, + sessionId: session.value.id, + liveUrl: liveUrls.value.debuggerFullscreenUrl, + connectUrl: session.value.connectUrl, + }); +} + +export async function clearAccountLoginState( + accountName: BrowserbaseAccountName, + options: ClearLoginOptions, + deps: BrowserbaseAccountDeps, +): Promise> { + const store = requireStore(); + const account = store.accounts[accountName]; + if (!account) return err(accountError('ACCOUNT_NOT_FOUND', `Browserbase account "${accountName}" is not configured.`)); + if (!options.recreateContext) { + const updated: BrowserbaseAccountProfile = { ...account, loginState: 'empty', lastLoginAtIso: null }; + saveStoreOrThrow(upsertAccountProfile(store, updated)); + return ok(updated); + } + if (options.deleteOldContext) { + const deleted = await deps.client.deleteContext(account.contextId); + if (!deleted.ok && deleted.error.code !== 'NOT_FOUND') return err(apiToAccountError('CONTEXT_DELETE_FAILED', deleted.error)); + } + const context = await deps.client.createContext(deps.projectId); + if (!context.ok) return err(apiToAccountError('CONTEXT_CREATE_FAILED', context.error)); + const updated: BrowserbaseAccountProfile = { + ...account, + contextId: context.value.id, + loginState: 'empty', + lastLoginAtIso: null, + }; + saveStoreOrThrow(upsertAccountProfile(store, updated)); + return ok(updated); +} + +export async function deleteBrowserbaseAccount( + accountName: BrowserbaseAccountName, + options: DeleteAccountOptions, + deps: BrowserbaseAccountDeps, +): Promise> { + const store = requireStore(); + const account = store.accounts[accountName]; + if (!account) return err(accountError('ACCOUNT_NOT_FOUND', `Browserbase account "${accountName}" is not configured.`)); + if (options.deleteContext) { + const deleted = await deps.client.deleteContext(account.contextId); + if (!deleted.ok && deleted.error.code !== 'NOT_FOUND') return err(apiToAccountError('CONTEXT_DELETE_FAILED', deleted.error)); + } + saveStoreOrThrow(removeAccountProfile(store, accountName)); + return ok(undefined); +} + +export function markBrowserbaseAccount( + accountName: string, + loginState: LoginState, + opts: { checked?: boolean } = {}, +): BrowserbaseAccountProfile { + const store = requireStore(); + const normalized = asBrowserbaseAccountName(accountName); + const account = store.accounts[normalized]; + if (!account) throw new ConfigError(`Browserbase account "${accountName}" is not configured.`); + const updated: BrowserbaseAccountProfile = { + ...account, + loginState, + ...(opts.checked ? { lastCheckedAtIso: new Date().toISOString() } : {}), + }; + saveStoreOrThrow(upsertAccountProfile(store, updated)); + return updated; +} + +export function setBrowserbaseAccountProxy(accountName: string, proxyName: string | null): BrowserbaseAccountProfile { + const store = requireStore(); + const normalizedAccount = asBrowserbaseAccountName(accountName); + const account = store.accounts[normalizedAccount]; + if (!account) throw new ConfigError(`Browserbase account "${accountName}" is not configured.`); + const normalizedProxy = proxyName ? asBrowserbaseProxyName(proxyName) : null; + if (normalizedProxy && !store.proxies[normalizedProxy]) { + throw new ConfigError(`Browserbase proxy "${proxyName}" is not configured.`); + } + const updated: BrowserbaseAccountProfile = { ...account, defaultProxyName: normalizedProxy }; + saveStoreOrThrow(upsertAccountProfile(store, updated)); + return updated; +} + +export function createLocalAccountProfile( + name: string, + site: string, + contextId: string, + proxyName: string | null, +): BrowserbaseAccountProfile { + return { + name: asBrowserbaseAccountName(name), + site, + contextId: asBrowserbaseContextId(contextId), + defaultProxyName: proxyName ? asBrowserbaseProxyName(proxyName) : null, + loginState: 'empty', + lastLoginAtIso: null, + lastCheckedAtIso: null, + }; +} diff --git a/src/browserbase/client.test.ts b/src/browserbase/client.test.ts new file mode 100644 index 000000000..fa562d439 --- /dev/null +++ b/src/browserbase/client.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BrowserbaseClient, asBrowserbaseContextId } from './client.js'; +import type { BrowserbaseConfig, BrowserbaseProxyRule } from './types.js'; + +const config: BrowserbaseConfig = { + apiKey: 'bb-key', + projectId: 'proj_123', + apiBaseUrl: 'https://api.browserbase.com/v1', +}; + +describe('BrowserbaseClient', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('creates sessions with persistent context and proxy settings', async () => { + vi.stubEnv('PROXY_PASS', 'secret'); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + id: 'sess_123', + status: 'RUNNING', + connectUrl: 'wss://connect.browserbase.example/devtools', + }))); + const client = new BrowserbaseClient(config, fetchImpl); + const rules: BrowserbaseProxyRule[] = [{ + type: 'external', + server: 'http://proxy.example:8080', + username: 'u', + passwordRef: { kind: 'env', envName: 'PROXY_PASS' }, + domainPattern: null, + }]; + + const result = await client.createSession({ + accountName: 'account-1' as never, + contextId: asBrowserbaseContextId('ctx_123'), + proxyName: 'proxy-1' as never, + region: 'us-west-2', + keepAlive: true, + persistContext: true, + timeoutSeconds: 1800, + }, rules); + + expect(result).toMatchObject({ ok: true, value: { id: 'sess_123' } }); + const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit | undefined]; + expect(init?.method).toBe('POST'); + expect(init?.headers).toMatchObject({ 'x-bb-api-key': 'bb-key', 'content-type': 'application/json' }); + expect(JSON.parse(String(init?.body))).toMatchObject({ + projectId: 'proj_123', + region: 'us-west-2', + keepAlive: true, + timeout: 1800, + browserSettings: { + context: { id: 'ctx_123', persist: true }, + }, + proxies: [{ + type: 'external', + server: 'http://proxy.example:8080', + username: 'u', + password: 'secret', + }], + }); + }); + + it('fails before calling Browserbase when a proxy password env var is missing', async () => { + const fetchImpl = vi.fn(); + const client = new BrowserbaseClient(config, fetchImpl); + + const result = await client.createSession({ + accountName: null, + contextId: null, + proxyName: null, + region: 'us-west-2', + keepAlive: false, + persistContext: false, + timeoutSeconds: 60, + }, [{ + type: 'external', + server: 'http://proxy.example:8080', + username: null, + passwordRef: { kind: 'env', envName: 'MISSING_PROXY_PASS' }, + domainPattern: null, + }]); + + expect(result).toMatchObject({ ok: false, error: { code: 'PROXY_PASSWORD_ENV_MISSING' } }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('releases sessions with REQUEST_RELEASE', async () => { + const fetchImpl = vi.fn(async () => new Response('{}')); + const client = new BrowserbaseClient(config, fetchImpl); + + await expect(client.releaseSession('sess_123' as never)).resolves.toMatchObject({ ok: true }); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://api.browserbase.com/v1/sessions/sess_123', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ status: 'REQUEST_RELEASE', projectId: 'proj_123' }), + }), + ); + }); +}); diff --git a/src/browserbase/client.ts b/src/browserbase/client.ts new file mode 100644 index 000000000..d41d9b2a5 --- /dev/null +++ b/src/browserbase/client.ts @@ -0,0 +1,330 @@ +import { isRecord } from '../utils.js'; +import { + type BrowserbaseApiError, + type BrowserbaseCliOptions, + type BrowserbaseConfig, + type BrowserbaseContext, + type BrowserbaseContextId, + type BrowserbaseLivePage, + type BrowserbaseLiveUrls, + type BrowserbaseProxyRule, + type BrowserbaseRegion, + type BrowserbaseSession, + type BrowserbaseSessionCreateRequest, + type BrowserbaseSessionId, + type BrowserbaseSessionStatus, + type FetchLike, + type Result, + err, + ok, +} from './types.js'; + +const DEFAULT_API_BASE = 'https://api.browserbase.com/v1'; +const VALID_REGIONS = new Set(['us-west-2', 'us-east-1', 'eu-central-1', 'ap-southeast-1']); +const VALID_STATUSES = new Set(['PENDING', 'RUNNING', 'ERROR', 'TIMED_OUT', 'COMPLETED']); + +type JsonObject = Record; + +export function asBrowserbaseSessionId(value: string): BrowserbaseSessionId { + return value as BrowserbaseSessionId; +} + +export function asBrowserbaseContextId(value: string): BrowserbaseContextId { + return value as BrowserbaseContextId; +} + +export function resolveBrowserbaseConfig( + env: NodeJS.ProcessEnv = process.env, + cli: BrowserbaseCliOptions = {}, +): Result { + const apiKey = cli.apiKey?.trim() || env.BROWSERBASE_API_KEY?.trim(); + if (!apiKey) { + return err({ + code: 'MISSING_API_KEY', + message: 'BROWSERBASE_API_KEY not set.', + hint: 'Set it with: export BROWSERBASE_API_KEY=your_key', + }); + } + const projectId = cli.projectId?.trim() || env.BROWSERBASE_PROJECT_ID?.trim() || null; + const apiBaseUrl = cli.apiBaseUrl?.trim() || env.BROWSERBASE_API_BASE_URL?.trim() || DEFAULT_API_BASE; + return ok({ apiKey, projectId, apiBaseUrl: apiBaseUrl.replace(/\/$/, '') }); +} + +export function parseBrowserbaseSessionId(raw: string): Result { + const value = raw.trim(); + if (!value) { + return err({ code: 'INVALID_RESPONSE', message: 'Browserbase session id is empty.' }); + } + return ok(asBrowserbaseSessionId(value)); +} + +function apiError(code: BrowserbaseApiError['code'], message: string, status?: number, hint?: string): BrowserbaseApiError { + return { code, message, ...(status !== undefined ? { status } : {}), ...(hint ? { hint } : {}) }; +} + +async function readJson(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) return null; + return JSON.parse(text) as unknown; +} + +function mapHttpError(status: number, fallback: string): BrowserbaseApiError { + if (status === 404 || status === 400) return apiError('NOT_FOUND', fallback, status); + if (status === 429) return apiError('QUOTA_EXHAUSTED', 'Browserbase concurrent session or rate limit exhausted.', status); + return apiError('API_ERROR', `Browserbase API error: HTTP ${status}`, status); +} + +function stringField(data: JsonObject, key: string): string | null { + const value = data[key]; + return typeof value === 'string' && value.trim() ? value : null; +} + +function numberField(data: JsonObject, key: string): number | null { + const value = data[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function boolField(data: JsonObject, key: string): boolean { + return data[key] === true; +} + +function parseRegion(value: unknown): BrowserbaseRegion | null { + return typeof value === 'string' && VALID_REGIONS.has(value as BrowserbaseRegion) + ? value as BrowserbaseRegion + : null; +} + +function parseStatus(value: unknown): BrowserbaseSessionStatus { + return typeof value === 'string' && VALID_STATUSES.has(value as BrowserbaseSessionStatus) + ? value as BrowserbaseSessionStatus + : 'ERROR'; +} + +function parseContext(data: unknown): Result { + if (!isRecord(data) || typeof data.id !== 'string') { + return err(apiError('INVALID_RESPONSE', 'Browserbase context response did not include an id.')); + } + return ok({ + id: asBrowserbaseContextId(data.id), + projectId: stringField(data, 'projectId'), + createdAtIso: stringField(data, 'createdAt'), + updatedAtIso: stringField(data, 'updatedAt'), + }); +} + +function parseSession(data: unknown, fallbackId?: BrowserbaseSessionId): Result { + if (!isRecord(data) || (typeof data.id !== 'string' && !fallbackId)) { + return err(apiError('INVALID_RESPONSE', 'Browserbase session response did not include an id.')); + } + const id = typeof data.id === 'string' ? asBrowserbaseSessionId(data.id) : fallbackId!; + return ok({ + id, + status: parseStatus(data.status), + connectUrl: stringField(data, 'connectUrl'), + contextId: typeof data.contextId === 'string' && data.contextId.trim() + ? asBrowserbaseContextId(data.contextId) + : null, + projectId: stringField(data, 'projectId'), + createdAtIso: stringField(data, 'createdAt'), + updatedAtIso: stringField(data, 'updatedAt'), + startedAtIso: stringField(data, 'startedAt'), + expiresAtIso: stringField(data, 'expiresAt'), + endedAtIso: stringField(data, 'endedAt'), + keepAlive: boolField(data, 'keepAlive'), + region: parseRegion(data.region), + proxyBytes: numberField(data, 'proxyBytes'), + }); +} + +function parseLiveUrls(data: unknown): Result { + if (!isRecord(data)) { + return err(apiError('INVALID_RESPONSE', 'Browserbase live URL response was not an object.')); + } + const debuggerFullscreenUrl = stringField(data, 'debuggerFullscreenUrl'); + const debuggerUrl = stringField(data, 'debuggerUrl'); + const wsUrl = stringField(data, 'wsUrl'); + if (!debuggerFullscreenUrl || !debuggerUrl || !wsUrl) { + return err(apiError('INVALID_RESPONSE', 'Browserbase live URL response is missing required URLs.')); + } + const pages: BrowserbaseLivePage[] = Array.isArray(data.pages) + ? data.pages.filter(isRecord).map((page) => ({ + id: stringField(page, 'id') ?? '', + url: stringField(page, 'url') ?? '', + title: stringField(page, 'title') ?? '', + debuggerUrl: stringField(page, 'debuggerUrl') ?? '', + debuggerFullscreenUrl: stringField(page, 'debuggerFullscreenUrl') ?? '', + })) + : []; + return ok({ debuggerFullscreenUrl, debuggerUrl, wsUrl, pages }); +} + +function proxyRuleToApi(rule: BrowserbaseProxyRule): Result { + if (rule.type === 'none') { + return ok({ type: 'none', domainPattern: rule.domainPattern }); + } + if (rule.type === 'browserbase') { + const geolocation = rule.geolocation + ? Object.fromEntries(Object.entries(rule.geolocation).filter(([, value]) => value !== null && value !== '')) + : undefined; + return ok({ + type: 'browserbase', + ...(geolocation && Object.keys(geolocation).length > 0 ? { geolocation } : {}), + ...(rule.domainPattern ? { domainPattern: rule.domainPattern } : {}), + }); + } + const password = rule.passwordRef?.kind === 'plain' + ? rule.passwordRef.value + : rule.passwordRef?.kind === 'env' + ? process.env[rule.passwordRef.envName] + : undefined; + if (rule.passwordRef?.kind === 'env' && !password) { + return err(apiError( + 'PROXY_PASSWORD_ENV_MISSING', + `Proxy password env var ${rule.passwordRef.envName} is not set.`, + undefined, + `Set ${rule.passwordRef.envName} before creating a Browserbase session.`, + )); + } + return ok({ + type: 'external', + server: rule.server, + ...(rule.username ? { username: rule.username } : {}), + ...(password ? { password } : {}), + ...(rule.domainPattern ? { domainPattern: rule.domainPattern } : {}), + }); +} + +function proxyRulesToApi(rules: ReadonlyArray): Result { + if (rules.length === 0) return ok(undefined); + const out: JsonObject[] = []; + for (const rule of rules) { + const mapped = proxyRuleToApi(rule); + if (!mapped.ok) return mapped; + out.push(mapped.value); + } + return ok(out); +} + +export class BrowserbaseClient { + constructor( + private readonly config: BrowserbaseConfig, + private readonly fetchImpl: FetchLike = globalThis.fetch.bind(globalThis), + ) {} + + async createContext(projectId: string): Promise> { + if (!projectId.trim()) { + return err(apiError('MISSING_PROJECT_ID', 'BROWSERBASE_PROJECT_ID is required to create a Browserbase context.')); + } + const response = await this.fetchImpl(`${this.config.apiBaseUrl}/contexts`, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ projectId }), + }); + if (!response.ok) return err(mapHttpError(response.status, 'Browserbase context was not found.')); + return parseContext(await readJson(response)); + } + + async getContext(contextId: BrowserbaseContextId): Promise> { + const response = await this.fetchImpl(`${this.config.apiBaseUrl}/contexts/${contextId}`, { + headers: this.authHeaders(), + }); + if (!response.ok) return err(mapHttpError(response.status, `Browserbase context "${contextId}" not found.`)); + return parseContext(await readJson(response)); + } + + async deleteContext(contextId: BrowserbaseContextId): Promise> { + const response = await this.fetchImpl(`${this.config.apiBaseUrl}/contexts/${contextId}`, { + method: 'DELETE', + headers: this.authHeaders(), + }); + if (!response.ok) return err(mapHttpError(response.status, `Browserbase context "${contextId}" not found.`)); + return ok(undefined); + } + + async createSession( + request: BrowserbaseSessionCreateRequest, + proxyRules: ReadonlyArray, + ): Promise> { + const proxies = proxyRulesToApi(proxyRules); + if (!proxies.ok) return proxies; + const body: JsonObject = { + ...(this.config.projectId ? { projectId: this.config.projectId } : {}), + region: request.region, + keepAlive: request.keepAlive, + timeout: request.timeoutSeconds, + ...(proxies.value ? { proxies: proxies.value } : {}), + ...(request.accountName ? { userMetadata: { opencliAccount: request.accountName } } : {}), + }; + if (request.contextId) { + body.browserSettings = { + context: { + id: request.contextId, + persist: request.persistContext, + }, + }; + } + const response = await this.fetchImpl(`${this.config.apiBaseUrl}/sessions`, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify(body), + }); + if (!response.ok) return err(mapHttpError(response.status, 'Browserbase session could not be created.')); + return parseSession(await readJson(response)); + } + + async getSession(sessionId: BrowserbaseSessionId): Promise> { + const response = await this.fetchImpl(`${this.config.apiBaseUrl}/sessions/${sessionId}`, { + headers: this.authHeaders(), + }); + if (!response.ok) return err(mapHttpError(response.status, `Browserbase session "${sessionId}" not found.`)); + return parseSession(await readJson(response), sessionId); + } + + async listSessions(): Promise, BrowserbaseApiError>> { + const response = await this.fetchImpl(`${this.config.apiBaseUrl}/sessions`, { + headers: this.authHeaders(), + }); + if (!response.ok) return err(mapHttpError(response.status, 'Browserbase sessions could not be listed.')); + const data = await readJson(response); + if (!Array.isArray(data)) return err(apiError('INVALID_RESPONSE', 'Browserbase list sessions response was not an array.')); + const sessions: BrowserbaseSession[] = []; + for (const item of data) { + const parsed = parseSession(item); + if (!parsed.ok) return parsed; + sessions.push(parsed.value); + } + return ok(sessions); + } + + async releaseSession(sessionId: BrowserbaseSessionId): Promise> { + const response = await this.fetchImpl(`${this.config.apiBaseUrl}/sessions/${sessionId}`, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ + status: 'REQUEST_RELEASE', + ...(this.config.projectId ? { projectId: this.config.projectId } : {}), + }), + }); + if (!response.ok) return err(mapHttpError(response.status, `Browserbase session "${sessionId}" could not be released.`)); + return ok(undefined); + } + + async getLiveUrls(sessionId: BrowserbaseSessionId): Promise> { + const response = await this.fetchImpl(`${this.config.apiBaseUrl}/sessions/${sessionId}/debug`, { + headers: this.authHeaders(), + }); + if (!response.ok) return err(mapHttpError(response.status, `Browserbase session "${sessionId}" live URLs could not be loaded.`)); + return parseLiveUrls(await readJson(response)); + } + + private headers(): HeadersInit { + return { + ...this.authHeaders(), + 'content-type': 'application/json', + }; + } + + private authHeaders(): HeadersInit { + return { 'x-bb-api-key': this.config.apiKey }; + } +} diff --git a/src/browserbase/config-store.test.ts b/src/browserbase/config-store.test.ts new file mode 100644 index 000000000..3d3eebf95 --- /dev/null +++ b/src/browserbase/config-store.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + asBrowserbaseAccountName, + asBrowserbaseProxyName, + browserbaseStorePath, + emptyBrowserbaseStore, + loadBrowserbaseStore, + redactProxyProfile, + removeProxyProfile, + saveBrowserbaseStore, + upsertAccountProfile, + upsertProxyProfile, +} from './config-store.js'; + +describe('browserbase config store', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('writes browserbase.json with user-only permissions', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browserbase-')); + vi.stubEnv('OPENCLI_CONFIG_DIR', dir); + + const result = saveBrowserbaseStore(emptyBrowserbaseStore()); + + expect(result).toMatchObject({ ok: true }); + expect(fs.statSync(browserbaseStorePath()).mode & 0o777).toBe(0o600); + expect(loadBrowserbaseStore()).toMatchObject({ ok: true, value: { version: 1 } }); + }); + + it('redacts plaintext proxy passwords in output helpers', () => { + const proxy = { + name: asBrowserbaseProxyName('dc1'), + updatedAtIso: '2026-01-01T00:00:00.000Z', + rules: [{ + type: 'external' as const, + server: 'http://proxy.example:8080', + username: 'user', + passwordRef: { kind: 'plain' as const, value: 'secret' }, + domainPattern: null, + }], + }; + + expect(redactProxyProfile(proxy).rules[0]).toMatchObject({ + passwordRef: { kind: 'plain', value: '[REDACTED]' }, + }); + }); + + it('rejects deleting an in-use proxy unless forced', () => { + let store = emptyBrowserbaseStore(); + store = upsertProxyProfile(store, { + name: asBrowserbaseProxyName('us-ny'), + updatedAtIso: '2026-01-01T00:00:00.000Z', + rules: [{ type: 'browserbase', geolocation: null, domainPattern: null }], + }); + store = upsertAccountProfile(store, { + name: asBrowserbaseAccountName('x-main-1'), + site: 'x', + contextId: 'ctx_123' as never, + defaultProxyName: asBrowserbaseProxyName('us-ny'), + loginState: 'ready', + lastLoginAtIso: null, + lastCheckedAtIso: null, + }); + + expect(removeProxyProfile(store, asBrowserbaseProxyName('us-ny'))).toMatchObject({ + ok: false, + error: { code: 'PROXY_IN_USE' }, + }); + expect(removeProxyProfile(store, asBrowserbaseProxyName('us-ny'), { force: true })).toMatchObject({ + ok: true, + value: { + proxies: {}, + accounts: { + 'x-main-1': { defaultProxyName: null }, + }, + }, + }); + }); +}); diff --git a/src/browserbase/config-store.ts b/src/browserbase/config-store.ts new file mode 100644 index 000000000..5151fcf83 --- /dev/null +++ b/src/browserbase/config-store.ts @@ -0,0 +1,251 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { isRecord } from '../utils.js'; +import { + type BrowserbaseAccountName, + type BrowserbaseAccountProfile, + type BrowserbaseConfigError, + type BrowserbaseContextId, + type BrowserbaseProxyName, + type BrowserbaseProxyProfile, + type BrowserbaseProxyRule, + type BrowserbaseStore, + type LoginState, + type ProxyPasswordRef, + type Result, + err, + ok, +} from './types.js'; + +const STORE_VERSION = 1; +const VALID_LOGIN_STATES = new Set(['empty', 'login-session-open', 'ready', 'invalidated', 'deleted']); + +export function asBrowserbaseAccountName(value: string): BrowserbaseAccountName { + return value as BrowserbaseAccountName; +} + +export function asBrowserbaseProxyName(value: string): BrowserbaseProxyName { + return value as BrowserbaseProxyName; +} + +export function browserbaseStorePath(): string { + const baseDir = process.env.OPENCLI_CONFIG_DIR || path.join(os.homedir(), '.opencli'); + return path.join(baseDir, 'browserbase.json'); +} + +export function emptyBrowserbaseStore(): BrowserbaseStore { + return { version: STORE_VERSION, accounts: {}, proxies: {} }; +} + +function configError(code: BrowserbaseConfigError['code'], message: string, hint?: string): BrowserbaseConfigError { + return { code, message, ...(hint ? { hint } : {}) }; +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function parsePasswordRef(value: unknown): ProxyPasswordRef | null { + if (!isRecord(value)) return null; + if (value.kind === 'env') { + const envName = nonEmptyString(value.envName); + return envName ? { kind: 'env', envName } : null; + } + if (value.kind === 'plain') { + const plain = typeof value.value === 'string' ? value.value : null; + return plain !== null ? { kind: 'plain', value: plain } : null; + } + return null; +} + +function parseProxyRule(value: unknown): BrowserbaseProxyRule | null { + if (!isRecord(value)) return null; + if (value.type === 'none') { + const domainPattern = nonEmptyString(value.domainPattern); + return domainPattern ? { type: 'none', domainPattern } : null; + } + if (value.type === 'browserbase') { + const geo = isRecord(value.geolocation) + ? { + country: stringOrNull(value.geolocation.country), + state: stringOrNull(value.geolocation.state), + city: stringOrNull(value.geolocation.city), + } + : null; + return { + type: 'browserbase', + geolocation: geo, + domainPattern: stringOrNull(value.domainPattern), + }; + } + if (value.type === 'external') { + const server = nonEmptyString(value.server); + if (!server) return null; + return { + type: 'external', + server, + username: stringOrNull(value.username), + passwordRef: parsePasswordRef(value.passwordRef), + domainPattern: stringOrNull(value.domainPattern), + }; + } + return null; +} + +function parseProxyProfile(name: string, value: unknown): BrowserbaseProxyProfile | null { + if (!isRecord(value)) return null; + const rawRules = Array.isArray(value.rules) ? value.rules : []; + const rules = rawRules.map(parseProxyRule).filter((rule): rule is BrowserbaseProxyRule => rule !== null); + if (rules.length === 0) return null; + return { + name: asBrowserbaseProxyName(nonEmptyString(value.name) ?? name), + rules, + updatedAtIso: nonEmptyString(value.updatedAtIso) ?? new Date(0).toISOString(), + }; +} + +function parseAccountProfile(name: string, value: unknown): BrowserbaseAccountProfile | null { + if (!isRecord(value)) return null; + const rawName = nonEmptyString(value.name) ?? name; + const site = nonEmptyString(value.site); + const contextId = nonEmptyString(value.contextId); + if (!site || !contextId) return null; + const loginState = typeof value.loginState === 'string' && VALID_LOGIN_STATES.has(value.loginState as LoginState) + ? value.loginState as LoginState + : 'empty'; + return { + name: asBrowserbaseAccountName(rawName), + site, + contextId: contextId as BrowserbaseContextId, + defaultProxyName: typeof value.defaultProxyName === 'string' && value.defaultProxyName.trim() + ? asBrowserbaseProxyName(value.defaultProxyName.trim()) + : null, + loginState, + lastLoginAtIso: stringOrNull(value.lastLoginAtIso), + lastCheckedAtIso: stringOrNull(value.lastCheckedAtIso), + }; +} + +export function loadBrowserbaseStore(): Result { + const target = browserbaseStorePath(); + try { + if (!fs.existsSync(target)) return ok(emptyBrowserbaseStore()); + const parsed = JSON.parse(fs.readFileSync(target, 'utf-8')) as unknown; + if (!isRecord(parsed)) return err(configError('INVALID_CONFIG', `${target} must contain a JSON object.`)); + const accounts: Record = {}; + const rawAccounts = isRecord(parsed.accounts) ? parsed.accounts : {}; + for (const [name, value] of Object.entries(rawAccounts)) { + const account = parseAccountProfile(name, value); + if (account) accounts[account.name] = account; + } + const proxies: Record = {}; + const rawProxies = isRecord(parsed.proxies) ? parsed.proxies : {}; + for (const [name, value] of Object.entries(rawProxies)) { + const proxy = parseProxyProfile(name, value); + if (proxy) proxies[proxy.name] = proxy; + } + const defaultAccountName = nonEmptyString(parsed.defaultAccountName); + return ok({ + version: STORE_VERSION, + accounts, + proxies, + ...(defaultAccountName ? { defaultAccountName: asBrowserbaseAccountName(defaultAccountName) } : {}), + }); + } catch (error) { + return err(configError('CONFIG_READ_FAILED', `Could not read ${target}: ${error instanceof Error ? error.message : String(error)}`)); + } +} + +export function saveBrowserbaseStore(store: BrowserbaseStore): Result { + const target = browserbaseStorePath(); + try { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify(store, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }); + fs.chmodSync(target, 0o600); + return ok(undefined); + } catch (error) { + return err(configError('CONFIG_WRITE_FAILED', `Could not write ${target}: ${error instanceof Error ? error.message : String(error)}`)); + } +} + +export function upsertAccountProfile( + store: BrowserbaseStore, + profile: BrowserbaseAccountProfile, +): BrowserbaseStore { + return { + ...store, + accounts: { + ...store.accounts, + [profile.name]: profile, + }, + }; +} + +export function removeAccountProfile( + store: BrowserbaseStore, + name: BrowserbaseAccountName, +): BrowserbaseStore { + const accounts = { ...store.accounts }; + delete accounts[name]; + const next: BrowserbaseStore = { ...store, accounts }; + if (next.defaultAccountName === name) { + const { defaultAccountName: _drop, ...rest } = next; + return rest; + } + return next; +} + +export function upsertProxyProfile( + store: BrowserbaseStore, + proxy: BrowserbaseProxyProfile, +): BrowserbaseStore { + return { + ...store, + proxies: { + ...store.proxies, + [proxy.name]: proxy, + }, + }; +} + +export function removeProxyProfile( + store: BrowserbaseStore, + name: BrowserbaseProxyName, + options: { force?: boolean } = {}, +): Result { + const users = Object.values(store.accounts).filter((account) => account.defaultProxyName === name); + if (users.length > 0 && !options.force) { + return err(configError( + 'PROXY_IN_USE', + `Browserbase proxy "${name}" is still used by ${users.length} account(s).`, + 'Run with --force to remove it and clear those account proxy references.', + )); + } + const proxies = { ...store.proxies }; + delete proxies[name]; + const accounts = Object.fromEntries(Object.entries(store.accounts).map(([accountName, account]) => [ + accountName, + account.defaultProxyName === name ? { ...account, defaultProxyName: null } : account, + ])); + return ok({ ...store, proxies, accounts }); +} + +export function redactProxyProfile(profile: BrowserbaseProxyProfile): BrowserbaseProxyProfile { + return { + ...profile, + rules: profile.rules.map((rule) => { + if (rule.type !== 'external' || !rule.passwordRef) return rule; + return { + ...rule, + passwordRef: rule.passwordRef.kind === 'env' + ? { kind: 'env', envName: rule.passwordRef.envName } + : { kind: 'plain', value: '[REDACTED]' }, + }; + }), + }; +} diff --git a/src/browserbase/pool.test.ts b/src/browserbase/pool.test.ts new file mode 100644 index 000000000..21e43f2b8 --- /dev/null +++ b/src/browserbase/pool.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import { BrowserbaseAccountSessionPool } from './pool.js'; +import type { BrowserbaseClient } from './client.js'; +import type { BrowserbaseStore } from './types.js'; + +const store: BrowserbaseStore = { + version: 1, + accounts: { + 'x-main-1': { + name: 'x-main-1' as never, + site: 'x', + contextId: 'ctx_123' as never, + defaultProxyName: null, + loginState: 'ready', + lastLoginAtIso: null, + lastCheckedAtIso: null, + }, + }, + proxies: {}, +}; + +describe('BrowserbaseAccountSessionPool', () => { + it('creates exclusive leases and releases non-keepalive sessions', async () => { + const createSession = vi.fn(async () => ({ + ok: true as const, + value: { + id: 'sess_123' as never, + status: 'RUNNING' as const, + connectUrl: 'wss://connect.browserbase.example/devtools', + contextId: 'ctx_123' as never, + projectId: 'proj_123', + createdAtIso: null, + updatedAtIso: null, + startedAtIso: null, + expiresAtIso: null, + endedAtIso: null, + keepAlive: false, + region: 'us-west-2' as const, + proxyBytes: null, + }, + })); + const releaseSession = vi.fn(async () => ({ ok: true as const, value: undefined })); + const client = { createSession, releaseSession } as unknown as BrowserbaseClient; + const pool = new BrowserbaseAccountSessionPool(client, { store, maxSessions: 1, waitIntervalMs: 1 }); + + const lease = await pool.acquire({ + accountName: 'x-main-1' as never, + keepAlive: false, + persistContext: true, + timeoutSeconds: 1800, + region: 'us-west-2', + }); + + expect(lease).toMatchObject({ ok: true, value: { sessionId: 'sess_123', accountName: 'x-main-1' } }); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + accountName: 'x-main-1', + contextId: 'ctx_123', + persistContext: true, + }), []); + if (!lease.ok) throw new Error('expected lease'); + await expect(pool.release(lease.value, { status: 'success' })).resolves.toMatchObject({ ok: true }); + expect(releaseSession).toHaveBeenCalledWith('sess_123'); + }); + + it('reports missing accounts without creating sessions', async () => { + const client = { + createSession: vi.fn(), + releaseSession: vi.fn(), + } as unknown as BrowserbaseClient; + const pool = new BrowserbaseAccountSessionPool(client, { store, maxSessions: 1 }); + + await expect(pool.acquire({ + accountName: 'missing' as never, + keepAlive: false, + persistContext: true, + timeoutSeconds: 1800, + region: 'us-west-2', + })).resolves.toMatchObject({ ok: false, error: { code: 'ACCOUNT_NOT_FOUND' } }); + }); +}); diff --git a/src/browserbase/pool.ts b/src/browserbase/pool.ts new file mode 100644 index 000000000..338166acf --- /dev/null +++ b/src/browserbase/pool.ts @@ -0,0 +1,109 @@ +import { sleep } from '../utils.js'; +import { BrowserbaseClient } from './client.js'; +import { + type AccountLeaseRequest, + type BrowserLease, + type BrowserLeaseOutcome, + type BrowserbaseAccountName, + type BrowserbasePoolError, + type BrowserbaseStore, + type Result, + err, + ok, +} from './types.js'; + +export interface BrowserbasePoolOptions { + readonly store: BrowserbaseStore; + readonly maxSessions: number; + readonly waitIntervalMs?: number; +} + +function poolError(code: BrowserbasePoolError['code'], message: string, hint?: string): BrowserbasePoolError { + return { code, message, ...(hint ? { hint } : {}) }; +} + +function leaseId(): string { + return `lease_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +export class BrowserbaseAccountSessionPool { + private readonly activeAccounts = new Set(); + private activeSessions = 0; + private closed = false; + + constructor( + private readonly client: BrowserbaseClient, + private readonly options: BrowserbasePoolOptions, + ) {} + + async acquire(request: AccountLeaseRequest): Promise> { + if (this.closed) return err(poolError('POOL_CLOSED', 'Browserbase session pool is already closed.')); + const account = this.options.store.accounts[request.accountName]; + if (!account) return err(poolError('ACCOUNT_NOT_FOUND', `Browserbase account "${request.accountName}" is not configured.`)); + const proxy = account.defaultProxyName ? this.options.store.proxies[account.defaultProxyName] : null; + if (account.defaultProxyName && !proxy) { + return err(poolError('PROXY_NOT_FOUND', `Browserbase proxy "${account.defaultProxyName}" is not configured.`)); + } + + await this.waitForCapacity(request.accountName); + this.activeAccounts.add(request.accountName); + this.activeSessions += 1; + + const session = await this.client.createSession({ + accountName: request.accountName, + contextId: account.contextId, + proxyName: account.defaultProxyName, + region: request.region, + keepAlive: request.keepAlive, + persistContext: request.persistContext, + timeoutSeconds: request.timeoutSeconds, + }, proxy?.rules ?? []); + + if (!session.ok) { + this.activeAccounts.delete(request.accountName); + this.activeSessions = Math.max(0, this.activeSessions - 1); + return err(poolError('ACQUIRE_FAILED', session.error.message, session.error.hint)); + } + if (!session.value.connectUrl) { + this.activeAccounts.delete(request.accountName); + this.activeSessions = Math.max(0, this.activeSessions - 1); + return err(poolError('ACQUIRE_FAILED', `Browserbase session "${session.value.id}" did not include a connectUrl.`)); + } + return ok({ + leaseId: leaseId() as BrowserLease['leaseId'], + sessionId: session.value.id, + accountName: request.accountName, + connectUrl: session.value.connectUrl, + contextId: account.contextId, + proxyName: account.defaultProxyName, + keepAlive: request.keepAlive, + }); + } + + async release( + lease: BrowserLease, + _outcome: BrowserLeaseOutcome, + ): Promise> { + this.activeAccounts.delete(lease.accountName); + this.activeSessions = Math.max(0, this.activeSessions - 1); + if (lease.keepAlive) return ok(undefined); + const released = await this.client.releaseSession(lease.sessionId); + if (!released.ok) return err(poolError('RELEASE_FAILED', released.error.message, released.error.hint)); + return ok(undefined); + } + + async close(): Promise> { + this.closed = true; + return ok(undefined); + } + + private async waitForCapacity(accountName: BrowserbaseAccountName): Promise { + const interval = this.options.waitIntervalMs ?? 100; + while ( + !this.closed && + (this.activeSessions >= this.options.maxSessions || this.activeAccounts.has(accountName)) + ) { + await sleep(interval); + } + } +} diff --git a/src/browserbase/types.ts b/src/browserbase/types.ts new file mode 100644 index 000000000..704c64535 --- /dev/null +++ b/src/browserbase/types.ts @@ -0,0 +1,213 @@ +export type Result = + | { readonly ok: true; readonly value: TValue } + | { readonly ok: false; readonly error: TError }; + +export function ok(value: TValue): Result { + return { ok: true, value }; +} + +export function err(error: TError): Result { + return { ok: false, error }; +} + +export type BrowserbaseContextId = string & { readonly __brand: 'BrowserbaseContextId' }; +export type BrowserbaseSessionId = string & { readonly __brand: 'BrowserbaseSessionId' }; +export type BrowserbaseAccountName = string & { readonly __brand: 'BrowserbaseAccountName' }; +export type BrowserbaseProxyName = string & { readonly __brand: 'BrowserbaseProxyName' }; +export type BrowserbaseLeaseId = string & { readonly __brand: 'BrowserbaseLeaseId' }; + +export type LoginState = 'empty' | 'login-session-open' | 'ready' | 'invalidated' | 'deleted'; + +export type BrowserbaseRegion = 'us-west-2' | 'us-east-1' | 'eu-central-1' | 'ap-southeast-1'; + +export type BrowserbaseSessionStatus = 'PENDING' | 'RUNNING' | 'ERROR' | 'TIMED_OUT' | 'COMPLETED'; + +export interface BrowserbaseGeolocation { + readonly country: string | null; + readonly state: string | null; + readonly city: string | null; +} + +export type ProxyPasswordRef = + | { readonly kind: 'env'; readonly envName: string } + | { readonly kind: 'plain'; readonly value: string }; + +export type BrowserbaseProxyRule = + | { + readonly type: 'none'; + readonly domainPattern: string; + } + | { + readonly type: 'browserbase'; + readonly geolocation: BrowserbaseGeolocation | null; + readonly domainPattern: string | null; + } + | { + readonly type: 'external'; + readonly server: string; + readonly username: string | null; + readonly passwordRef: ProxyPasswordRef | null; + readonly domainPattern: string | null; + }; + +export interface BrowserbaseProxyProfile { + readonly name: BrowserbaseProxyName; + readonly rules: ReadonlyArray; + readonly updatedAtIso: string; +} + +export interface BrowserbaseAccountProfile { + readonly name: BrowserbaseAccountName; + readonly site: string; + readonly contextId: BrowserbaseContextId; + readonly defaultProxyName: BrowserbaseProxyName | null; + readonly loginState: LoginState; + readonly lastLoginAtIso: string | null; + readonly lastCheckedAtIso: string | null; +} + +export interface BrowserbaseStore { + readonly version: 1; + readonly accounts: Readonly>; + readonly proxies: Readonly>; + readonly defaultAccountName?: BrowserbaseAccountName; +} + +export interface BrowserbaseConfig { + readonly apiKey: string; + readonly projectId: string | null; + readonly apiBaseUrl: string; +} + +export interface BrowserbaseCliOptions { + readonly apiKey?: string; + readonly projectId?: string; + readonly apiBaseUrl?: string; +} + +export interface BrowserbaseContext { + readonly id: BrowserbaseContextId; + readonly projectId: string | null; + readonly createdAtIso: string | null; + readonly updatedAtIso: string | null; +} + +export interface BrowserbaseSessionCreateRequest { + readonly accountName: BrowserbaseAccountName | null; + readonly contextId: BrowserbaseContextId | null; + readonly proxyName: BrowserbaseProxyName | null; + readonly region: BrowserbaseRegion; + readonly keepAlive: boolean; + readonly persistContext: boolean; + readonly timeoutSeconds: number; +} + +export interface BrowserbaseSession { + readonly id: BrowserbaseSessionId; + readonly status: BrowserbaseSessionStatus; + readonly connectUrl: string | null; + readonly contextId: BrowserbaseContextId | null; + readonly projectId: string | null; + readonly createdAtIso: string | null; + readonly updatedAtIso: string | null; + readonly startedAtIso: string | null; + readonly expiresAtIso: string | null; + readonly endedAtIso: string | null; + readonly keepAlive: boolean; + readonly region: BrowserbaseRegion | null; + readonly proxyBytes: number | null; +} + +export interface BrowserbaseLivePage { + readonly id: string; + readonly url: string; + readonly title: string; + readonly debuggerUrl: string; + readonly debuggerFullscreenUrl: string; +} + +export interface BrowserbaseLiveUrls { + readonly debuggerFullscreenUrl: string; + readonly debuggerUrl: string; + readonly wsUrl: string; + readonly pages: ReadonlyArray; +} + +export type BrowserbaseApiErrorCode = + | 'MISSING_API_KEY' + | 'MISSING_PROJECT_ID' + | 'INVALID_RESPONSE' + | 'API_ERROR' + | 'NOT_FOUND' + | 'QUOTA_EXHAUSTED' + | 'SESSION_NOT_RUNNING' + | 'CONNECT_URL_MISSING' + | 'PROXY_PASSWORD_ENV_MISSING'; + +export interface BrowserbaseApiError { + readonly code: BrowserbaseApiErrorCode; + readonly message: string; + readonly status?: number; + readonly hint?: string; +} + +export interface BrowserbaseConfigError { + readonly code: + | 'CONFIG_READ_FAILED' + | 'CONFIG_WRITE_FAILED' + | 'INVALID_CONFIG' + | 'INVALID_ACCOUNT' + | 'INVALID_PROXY' + | 'PROXY_IN_USE'; + readonly message: string; + readonly hint?: string; +} + +export interface BrowserbasePoolError { + readonly code: + | 'ACCOUNT_NOT_FOUND' + | 'PROXY_NOT_FOUND' + | 'POOL_CLOSED' + | 'ACQUIRE_FAILED' + | 'RELEASE_FAILED'; + readonly message: string; + readonly hint?: string; +} + +export interface BrowserLease { + readonly leaseId: BrowserbaseLeaseId; + readonly sessionId: BrowserbaseSessionId; + readonly accountName: BrowserbaseAccountName; + readonly connectUrl: string; + readonly contextId: BrowserbaseContextId; + readonly proxyName: BrowserbaseProxyName | null; + readonly keepAlive: boolean; +} + +export interface AccountLeaseRequest { + readonly accountName: BrowserbaseAccountName; + readonly keepAlive: boolean; + readonly persistContext: boolean; + readonly timeoutSeconds: number; + readonly region: BrowserbaseRegion; +} + +export interface BrowserLeaseOutcome { + readonly status: 'success' | 'failed'; + readonly errorMessage?: string; +} + +export interface FetchLike { + (input: string, init?: RequestInit): Promise; +} + +export type CommandArgValue = string | number | boolean | null; +export type CommandArgsMap = Readonly>; + +export interface RunJobSpec { + readonly id: string; + readonly command: string; + readonly args: CommandArgsMap; + readonly accountName: BrowserbaseAccountName | null; + readonly timeoutSeconds: number | null; +} diff --git a/src/cli.ts b/src/cli.ts index a4943dca2..8b2e8b2b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,8 @@ import { buildHtmlTreeJs, type HtmlTreeResult } from './browser/html-tree.js'; import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js'; import { analyzeSite, type PageSignals } from './browser/analyze.js'; import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; +import { registerBrowserbaseCommands } from './commands/browserbase.js'; +import { registerRunCommand } from './commands/run.js'; import { log } from './logger.js'; import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js'; import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js'; @@ -559,6 +561,8 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command .version(PKG_VERSION) .option('--profile ', 'Chrome profile/context alias for Browser Bridge commands') .option('--session ', 'Browserbase session ID for adapter browser commands') + .option('--browserbase-session ', 'Browserbase session ID for adapter browser commands') + .option('--browserbase-account ', 'Browserbase account profile for adapter browser commands') .enablePositionalOptions(); // ── Built-in: list ──────────────────────────────────────────────────────── @@ -3183,6 +3187,13 @@ cli({ } }); + // ── Built-in: Browserbase account/session/proxy management ─────────────── + const browserbaseCmd = registerBrowserbaseCommands(program); + const originalBrowserbaseDescription = browserbaseCmd.description(); + + // ── Built-in: batch run ────────────────────────────────────────────────── + registerRunCommand(program); + // ── Built-in: daemon ────────────────────────────────────────────────────── const daemonCmd = program.command('daemon').description('Manage the opencli daemon'); // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing. @@ -3329,6 +3340,7 @@ cli({ installCommanderNamespaceStructuredHelp(pluginCmd, { globalCommand: program, description: originalPluginDescription }); installCommanderNamespaceStructuredHelp(adapterCmd, { globalCommand: program, description: originalAdapterDescription }); installCommanderNamespaceStructuredHelp(profileCmd, { globalCommand: program, description: originalProfileDescription }); + installCommanderNamespaceStructuredHelp(browserbaseCmd, { globalCommand: program, description: originalBrowserbaseDescription }); program.configureHelp({ visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())), }); diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index 86d8411aa..89df47576 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -120,6 +120,39 @@ describe('commanderAdapter arg passing', () => { ); }); + it('passes root --browserbase-account to browser commands before session fallback', async () => { + const program = new Command(); + program + .option('--session ', 'Browserbase session ID') + .option('--browserbase-account ', 'Browserbase account profile'); + const siteCmd = program.command('paperreview'); + const browserCmd = { + ...cmd, + browser: true, + func: vi.fn(async () => []), + } as unknown as CliCommand; + registerCommandToProgram(siteCmd, browserCmd); + + await program.parseAsync([ + 'node', + 'opencli', + '--session', + 'sess_123', + '--browserbase-account', + 'x-main-1', + 'paperreview', + 'submit', + './paper.pdf', + ]); + + expect(mockExecuteCommand).toHaveBeenCalledWith( + expect.objectContaining({ site: 'paperreview', name: 'submit' }), + expect.objectContaining({ pdf: './paper.pdf' }), + false, + { prepared: true, browserbaseAccount: 'x-main-1' }, + ); + }); + it('rejects invalid bool values before calling executeCommand', async () => { const program = new Command(); const siteCmd = program.command('paperreview'); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index c15810bb1..1fdb6fadf 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -114,10 +114,21 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi const formatExplicit = subCmd.getOptionValueSource('format') === 'cli'; if (verbose) process.env.OPENCLI_VERBOSE = '1'; const globals = typeof subCmd.optsWithGlobals === 'function' ? subCmd.optsWithGlobals() as Record : {}; + const browserbaseAccount = typeof globals.browserbaseAccount === 'string' && globals.browserbaseAccount.trim() + ? globals.browserbaseAccount.trim() + : null; + const browserbaseSession = typeof globals.browserbaseSession === 'string' && globals.browserbaseSession.trim() + ? globals.browserbaseSession.trim() + : null; + const legacyBrowserbaseSession = typeof globals.session === 'string' && globals.session.trim() + ? globals.session.trim() + : null; const result = await executeCommand(cmd, kwargs, verbose, { prepared: true, ...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}), - ...(cmd.browser && typeof globals.session === 'string' && globals.session.trim() ? { browserbaseSession: globals.session.trim() } : {}), + ...(cmd.browser && browserbaseAccount ? { browserbaseAccount } : {}), + ...(cmd.browser && !browserbaseAccount && browserbaseSession ? { browserbaseSession } : {}), + ...(cmd.browser && !browserbaseAccount && !browserbaseSession && legacyBrowserbaseSession ? { browserbaseSession: legacyBrowserbaseSession } : {}), ...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}), ...(cmd.browser && typeof optionsRecord.window === 'string' ? { windowMode: optionsRecord.window } : {}), ...(cmd.browser && typeof optionsRecord.siteSession === 'string' ? { siteSession: optionsRecord.siteSession } : {}), diff --git a/src/commands/browserbase.ts b/src/commands/browserbase.ts new file mode 100644 index 000000000..c91c4a407 --- /dev/null +++ b/src/commands/browserbase.ts @@ -0,0 +1,649 @@ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { Command, InvalidArgumentError } from 'commander'; +import { executeCommand } from '../execution.js'; +import { ConfigError, EXIT_CODES, getErrorMessage } from '../errors.js'; +import { getRegistry } from '../registry.js'; +import { + BrowserbaseClient, + asBrowserbaseAccountName, + asBrowserbaseContextId, + asBrowserbaseProxyName, + asBrowserbaseSessionId, + createBrowserbaseAccount, + deleteBrowserbaseAccount, + clearAccountLoginState, + createLocalAccountProfile, + loadBrowserbaseStore, + markBrowserbaseAccount, + openLoginSession, + redactProxyProfile, + removeProxyProfile, + resolveBrowserbaseConfig, + saveBrowserbaseStore, + setBrowserbaseAccountProxy, + upsertAccountProfile, + upsertProxyProfile, + type BrowserbaseAccountProfile, + type BrowserbaseProxyName, + type BrowserbaseProxyProfile, + type BrowserbaseProxyRule, + type BrowserbaseRegion, + type CommandArgsMap, + type LoginState, + type ProxyPasswordRef, +} from '../browserbase.js'; + +const DEFAULT_REGION: BrowserbaseRegion = 'us-west-2'; +const DEFAULT_SESSION_TIMEOUT = 1800; + +type BrowserbaseDeps = { + client: BrowserbaseClient; + projectId: string; + now: () => Date; +}; + +function runBrowserbaseAction( + fn: (...args: Args) => Promise | void, +): (...args: Args) => Promise { + return async (...args: Args) => { + try { + await fn(...args); + } catch (error) { + console.error(`Error: ${getErrorMessage(error)}`); + if (error instanceof ConfigError) process.exitCode = error.exitCode; + else process.exitCode = EXIT_CODES.GENERIC_ERROR; + } + }; +} + +function json(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + +function parsePositiveInteger(value: string, label: string): number { + if (!/^\d+$/.test(value)) throw new InvalidArgumentError(`${label} must be a positive integer.`); + const parsed = Number.parseInt(value, 10); + if (parsed <= 0) throw new InvalidArgumentError(`${label} must be a positive integer.`); + return parsed; +} + +function parseRegion(value: string | undefined): BrowserbaseRegion { + const region = value ?? DEFAULT_REGION; + if (region === 'us-west-2' || region === 'us-east-1' || region === 'eu-central-1' || region === 'ap-southeast-1') { + return region; + } + throw new InvalidArgumentError(`--region must be one of: us-west-2, us-east-1, eu-central-1, ap-southeast-1.`); +} + +function requireDeps(): BrowserbaseDeps { + const config = resolveBrowserbaseConfig(); + if (!config.ok) throw new ConfigError(config.error.message, config.error.hint); + if (!config.value.projectId) { + throw new ConfigError( + 'BROWSERBASE_PROJECT_ID is required for context/account operations.', + 'Set it with: export BROWSERBASE_PROJECT_ID=your_project_id', + ); + } + return { + client: new BrowserbaseClient(config.value), + projectId: config.value.projectId, + now: () => new Date(), + }; +} + +function requireClient(): BrowserbaseClient { + const config = resolveBrowserbaseConfig(); + if (!config.ok) throw new ConfigError(config.error.message, config.error.hint); + return new BrowserbaseClient(config.value); +} + +function requireStore() { + const store = loadBrowserbaseStore(); + if (!store.ok) throw new ConfigError(store.error.message, store.error.hint); + return store.value; +} + +function saveStoreOrThrow(store: ReturnType): void { + const saved = saveBrowserbaseStore(store); + if (!saved.ok) throw new ConfigError(saved.error.message, saved.error.hint); +} + +function openExternalUrl(url: string): void { + const command = process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'cmd' + : 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.unref(); +} + +async function waitForLoginConfirmation(message: string): Promise { + const rl = createInterface({ input, output }); + try { + await rl.question(message); + } finally { + rl.close(); + } +} + +function proxyPasswordRef(opts: { passwordEnv?: string; password?: string; storePasswordPlaintext?: boolean }): ProxyPasswordRef | null { + if (opts.passwordEnv?.trim()) return { kind: 'env', envName: opts.passwordEnv.trim() }; + if (opts.password?.trim()) { + if (!opts.storePasswordPlaintext) { + throw new ConfigError('Refusing to store proxy password plaintext.', 'Use --password-env, or pass --store-password-plaintext explicitly.'); + } + return { kind: 'plain', value: opts.password }; + } + return null; +} + +function buildProxyProfile( + name: string, + opts: { + type?: string; + country?: string; + state?: string; + city?: string; + domainPattern?: string; + server?: string; + username?: string; + passwordEnv?: string; + password?: string; + storePasswordPlaintext?: boolean; + }, + existing?: BrowserbaseProxyProfile, +): BrowserbaseProxyProfile { + const type = opts.type ?? existing?.rules[0]?.type ?? 'browserbase'; + let rule: BrowserbaseProxyRule; + if (type === 'none') { + if (!opts.domainPattern?.trim()) throw new ConfigError('--domain-pattern is required for proxy type "none".'); + rule = { type: 'none', domainPattern: opts.domainPattern.trim() }; + } else if (type === 'browserbase') { + const geolocation = opts.country || opts.state || opts.city + ? { + country: opts.country?.trim() || null, + state: opts.state?.trim() || null, + city: opts.city?.trim() || null, + } + : existing?.rules[0]?.type === 'browserbase' + ? existing.rules[0].geolocation + : null; + rule = { + type: 'browserbase', + geolocation, + domainPattern: opts.domainPattern?.trim() || (existing?.rules[0]?.type === 'browserbase' ? existing.rules[0].domainPattern : null), + }; + } else if (type === 'external') { + const existingExternal = existing?.rules[0]?.type === 'external' ? existing.rules[0] : null; + const server = opts.server?.trim() || existingExternal?.server; + if (!server) throw new ConfigError('--server is required for proxy type "external".'); + rule = { + type: 'external', + server, + username: opts.username?.trim() || existingExternal?.username || null, + passwordRef: proxyPasswordRef(opts) ?? existingExternal?.passwordRef ?? null, + domainPattern: opts.domainPattern?.trim() || existingExternal?.domainPattern || null, + }; + } else { + throw new ConfigError('--type must be one of: browserbase, external, none.'); + } + return { + name: asBrowserbaseProxyName(name), + rules: [rule], + updatedAtIso: new Date().toISOString(), + }; +} + +function outputAccount(account: BrowserbaseAccountProfile): Record { + return { + name: account.name, + site: account.site, + contextId: account.contextId, + defaultProxyName: account.defaultProxyName, + loginState: account.loginState, + lastLoginAtIso: account.lastLoginAtIso, + lastCheckedAtIso: account.lastCheckedAtIso, + }; +} + +function outputProxy(profile: BrowserbaseProxyProfile, showSensitive: boolean): BrowserbaseProxyProfile { + return showSensitive ? profile : redactProxyProfile(profile); +} + +function outputSessionWithSensitivity( + session: T, + showSensitive: boolean, +): T { + return showSensitive ? session : { ...session, connectUrl: session.connectUrl ? '[REDACTED]' : null }; +} + +function parseCommandString(command: string): { commandName: string; args: CommandArgsMap } { + const tokens = command.trim().split(/\s+/).filter(Boolean); + if (tokens.length < 2) throw new ConfigError('--command must start with " ".'); + const [site, name, ...rest] = tokens; + const args: Record = {}; + for (let i = 0; i < rest.length; i++) { + const token = rest[i]; + if (!token.startsWith('--')) continue; + const key = token.slice(2); + const next = rest[i + 1]; + if (next && !next.startsWith('--')) { + args[key] = next; + i += 1; + } else { + args[key] = true; + } + } + return { commandName: `${site}/${name}`, args }; +} + +export function registerBrowserbaseCommands(program: Command): Command { + const browserbase = program.command('browserbase').description('Manage Browserbase accounts, sessions, contexts, and proxies'); + + const proxy = browserbase.command('proxy').description('Manage Browserbase proxy profiles'); + proxy.command('list') + .description('List Browserbase proxy profiles') + .option('--show-sensitive', 'Show stored plaintext proxy password values', false) + .action(runBrowserbaseAction((opts: { showSensitive?: boolean }) => { + json(Object.values(requireStore().proxies).map((profile) => outputProxy(profile, opts.showSensitive === true))); + })); + + proxy.command('get') + .description('Show a Browserbase proxy profile') + .argument('') + .option('--show-sensitive', 'Show stored plaintext proxy password values', false) + .action(runBrowserbaseAction((name: string, opts: { showSensitive?: boolean }) => { + const profile = requireStore().proxies[asBrowserbaseProxyName(name)]; + if (!profile) throw new ConfigError(`Browserbase proxy "${name}" is not configured.`); + json(outputProxy(profile, opts.showSensitive === true)); + })); + + proxy.command('add') + .description('Create a Browserbase proxy profile') + .argument('') + .requiredOption('--type ', 'browserbase, external, or none') + .option('--country ') + .option('--state ') + .option('--city ') + .option('--domain-pattern ') + .option('--server ') + .option('--username ') + .option('--password-env ') + .option('--password ') + .option('--store-password-plaintext', 'Allow writing proxy password plaintext to ~/.opencli/browserbase.json', false) + .action(runBrowserbaseAction((name: string, opts) => { + const store = requireStore(); + const profile = buildProxyProfile(name, opts); + saveStoreOrThrow(upsertProxyProfile(store, profile)); + json(redactProxyProfile(profile)); + })); + + proxy.command('update') + .description('Update a Browserbase proxy profile') + .argument('') + .option('--type ', 'browserbase, external, or none') + .option('--country ') + .option('--state ') + .option('--city ') + .option('--domain-pattern ') + .option('--server ') + .option('--username ') + .option('--password-env ') + .option('--password ') + .option('--store-password-plaintext', 'Allow writing proxy password plaintext to ~/.opencli/browserbase.json', false) + .action(runBrowserbaseAction((name: string, opts) => { + const store = requireStore(); + const existing = store.proxies[asBrowserbaseProxyName(name)]; + if (!existing) throw new ConfigError(`Browserbase proxy "${name}" is not configured.`); + const updated = buildProxyProfile(name, opts, existing); + saveStoreOrThrow(upsertProxyProfile(store, updated)); + json(redactProxyProfile(updated)); + })); + + proxy.command('delete') + .description('Delete a Browserbase proxy profile') + .argument('') + .option('--force', 'Clear account references and delete anyway', false) + .action(runBrowserbaseAction((name: string, opts: { force?: boolean }) => { + const removed = removeProxyProfile(requireStore(), asBrowserbaseProxyName(name), { force: opts.force === true }); + if (!removed.ok) throw new ConfigError(removed.error.message, removed.error.hint); + saveStoreOrThrow(removed.value); + json({ deleted: true, name }); + })); + + proxy.command('test') + .description('Create and release a Browserbase session to validate proxy settings') + .argument('') + .option('--region ', 'Browserbase region', DEFAULT_REGION) + .action(runBrowserbaseAction(async (name: string, opts: { region?: string }) => { + const store = requireStore(); + const profile = store.proxies[asBrowserbaseProxyName(name)]; + if (!profile) throw new ConfigError(`Browserbase proxy "${name}" is not configured.`); + const client = requireClient(); + const session = await client.createSession({ + accountName: null, + contextId: null, + proxyName: asBrowserbaseProxyName(name), + region: parseRegion(opts.region), + keepAlive: true, + persistContext: false, + timeoutSeconds: 60, + }, profile.rules); + if (!session.ok) throw new ConfigError(session.error.message, session.error.hint); + await client.releaseSession(session.value.id); + json({ ok: true, proxy: name, sessionId: session.value.id, status: session.value.status }); + })); + + const account = browserbase.command('account').description('Manage Browserbase account profiles and login state'); + account.command('list') + .description('List Browserbase account profiles') + .action(runBrowserbaseAction(() => { + json(Object.values(requireStore().accounts).map(outputAccount)); + })); + + account.command('get') + .description('Show a Browserbase account profile') + .argument('') + .action(runBrowserbaseAction((name: string) => { + const profile = requireStore().accounts[asBrowserbaseAccountName(name)]; + if (!profile) throw new ConfigError(`Browserbase account "${name}" is not configured.`); + json(outputAccount(profile)); + })); + + account.command('create') + .description('Create one Browserbase account profile and context') + .argument('') + .requiredOption('--site ') + .option('--proxy ') + .action(runBrowserbaseAction(async (name: string, opts: { site: string; proxy?: string }) => { + const result = await createBrowserbaseAccount({ + name: asBrowserbaseAccountName(name), + site: opts.site, + proxyName: opts.proxy ? asBrowserbaseProxyName(opts.proxy) : null, + }, requireDeps()); + if (!result.ok) throw new ConfigError(result.error.message, result.error.hint); + json(outputAccount(result.value)); + })); + + account.command('bootstrap') + .description('Create multiple Browserbase account profiles and login sessions') + .requiredOption('--site ') + .requiredOption('--count ') + .requiredOption('--name-prefix ') + .option('--proxy ') + .option('--open', 'Open Live View URLs in the system browser', false) + .option('--wait', 'Wait for Enter, release login sessions, and mark accounts ready', false) + .option('--region ', 'Browserbase region', DEFAULT_REGION) + .option('--timeout ', 'Browserbase session timeout seconds', String(DEFAULT_SESSION_TIMEOUT)) + .action(runBrowserbaseAction(async (opts: { site: string; count: string; namePrefix: string; proxy?: string; open?: boolean; wait?: boolean; region?: string; timeout?: string }) => { + const deps = requireDeps(); + const count = parsePositiveInteger(opts.count, '--count'); + const timeoutSeconds = parsePositiveInteger(opts.timeout ?? String(DEFAULT_SESSION_TIMEOUT), '--timeout'); + const region = parseRegion(opts.region); + const sessions: Array<{ account: string; sessionId: string; liveUrl: string }> = []; + for (let index = 1; index <= count; index += 1) { + const name = `${opts.namePrefix}-${index}`; + const created = await createBrowserbaseAccount({ + name: asBrowserbaseAccountName(name), + site: opts.site, + proxyName: opts.proxy ? asBrowserbaseProxyName(opts.proxy) : null, + }, deps); + if (!created.ok) throw new ConfigError(created.error.message, created.error.hint); + const login = await openLoginSession(asBrowserbaseAccountName(name), { + keepAlive: true, + timeoutSeconds, + region, + persistContext: true, + proxyNameOverride: null, + }, deps); + if (!login.ok) throw new ConfigError(login.error.message, login.error.hint); + sessions.push({ account: name, sessionId: login.value.sessionId, liveUrl: login.value.liveUrl }); + if (opts.open) openExternalUrl(login.value.liveUrl); + } + json({ sessions }); + if (opts.wait) { + await waitForLoginConfirmation('Finish logging in through the Live View URLs, then press Enter to persist contexts and release sessions...'); + for (const session of sessions) { + await deps.client.releaseSession(asBrowserbaseSessionId(session.sessionId)); + markBrowserbaseAccount(session.account, 'ready'); + } + json({ ready: sessions.map((session) => session.account) }); + } + })); + + account.command('login') + .description('Open a Browserbase Live View session for manual login') + .argument('') + .option('--open', 'Open Live View URL in the system browser', false) + .option('--wait', 'Wait for Enter, release login session, and mark account ready', false) + .option('--region ', 'Browserbase region', DEFAULT_REGION) + .option('--timeout ', 'Browserbase session timeout seconds', String(DEFAULT_SESSION_TIMEOUT)) + .action(runBrowserbaseAction(async (name: string, opts: { open?: boolean; wait?: boolean; region?: string; timeout?: string }) => { + const deps = requireDeps(); + const login = await openLoginSession(asBrowserbaseAccountName(name), { + keepAlive: true, + timeoutSeconds: parsePositiveInteger(opts.timeout ?? String(DEFAULT_SESSION_TIMEOUT), '--timeout'), + region: parseRegion(opts.region), + persistContext: true, + proxyNameOverride: null, + }, deps); + if (!login.ok) throw new ConfigError(login.error.message, login.error.hint); + json({ account: name, sessionId: login.value.sessionId, liveUrl: login.value.liveUrl }); + if (opts.open) openExternalUrl(login.value.liveUrl); + if (opts.wait) { + await waitForLoginConfirmation('Finish logging in through the Live View URL, then press Enter to persist context and release the session...'); + await deps.client.releaseSession(asBrowserbaseSessionId(login.value.sessionId)); + json(outputAccount(markBrowserbaseAccount(name, 'ready'))); + } + })); + + account.command('mark') + .description('Set Browserbase account login state') + .argument('') + .requiredOption('--state ', 'empty, login-session-open, ready, invalidated, deleted') + .action(runBrowserbaseAction((name: string, opts: { state: LoginState }) => { + if (!['empty', 'login-session-open', 'ready', 'invalidated', 'deleted'].includes(opts.state)) { + throw new ConfigError('--state must be one of: empty, login-session-open, ready, invalidated, deleted.'); + } + json(outputAccount(markBrowserbaseAccount(name, opts.state))); + })); + + account.command('check') + .description('Check a Browserbase account or run a read-only adapter command against it') + .argument('') + .option('--command ', 'Adapter command, e.g. "reddit whoami"') + .action(runBrowserbaseAction(async (name: string, opts: { command?: string }) => { + if (!opts.command) { + const profile = requireStore().accounts[asBrowserbaseAccountName(name)]; + if (!profile) throw new ConfigError(`Browserbase account "${name}" is not configured.`); + json(outputAccount(markBrowserbaseAccount(name, profile.loginState, { checked: true }))); + return; + } + const parsed = parseCommandString(opts.command); + const cmd = getRegistry().get(parsed.commandName); + if (!cmd) throw new ConfigError(`Adapter command "${parsed.commandName}" is not registered.`); + const result = await executeCommand(cmd, parsed.args, false, { + prepared: false, + browserbaseAccount: name, + browserbasePersistContext: false, + }); + markBrowserbaseAccount(name, 'ready', { checked: true }); + json({ account: name, ok: true, result }); + })); + + account.command('clear-login') + .description('Clear account login state by replacing its Browserbase context') + .argument('') + .option('--recreate-context', 'Create a fresh context and attach it to the account', false) + .option('--delete-old-context', 'Delete the old Browserbase context', false) + .action(runBrowserbaseAction(async (name: string, opts: { recreateContext?: boolean; deleteOldContext?: boolean }) => { + if (!opts.recreateContext) throw new ConfigError('clear-login currently requires --recreate-context.'); + const cleared = await clearAccountLoginState(asBrowserbaseAccountName(name), { + recreateContext: true, + deleteOldContext: opts.deleteOldContext === true, + }, requireDeps()); + if (!cleared.ok) throw new ConfigError(cleared.error.message, cleared.error.hint); + json(outputAccount(cleared.value)); + })); + + account.command('delete') + .description('Delete a Browserbase account profile') + .argument('') + .option('--delete-context', 'Delete the remote Browserbase context too', false) + .action(runBrowserbaseAction(async (name: string, opts: { deleteContext?: boolean }) => { + const deleted = await deleteBrowserbaseAccount(asBrowserbaseAccountName(name), { + deleteContext: opts.deleteContext === true, + }, requireDeps()); + if (!deleted.ok) throw new ConfigError(deleted.error.message, deleted.error.hint); + json({ deleted: true, account: name, deletedContext: opts.deleteContext === true }); + })); + + account.command('set-proxy') + .description('Set an account default proxy') + .argument('') + .argument('') + .action(runBrowserbaseAction((accountName: string, proxyName: string) => { + json(outputAccount(setBrowserbaseAccountProxy(accountName, proxyName))); + })); + + account.command('clear-proxy') + .description('Remove an account default proxy') + .argument('') + .action(runBrowserbaseAction((accountName: string) => { + json(outputAccount(setBrowserbaseAccountProxy(accountName, null))); + })); + + const session = browserbase.command('session').description('Manage Browserbase sessions'); + session.command('create') + .description('Create a Browserbase browser session') + .option('--account ') + .option('--context-id ') + .option('--proxy ') + .option('--keep-alive', 'Keep the session alive after disconnect', false) + .option('--print-live-url', 'Fetch and print Browserbase Live View URL', false) + .option('--region ', 'Browserbase region', DEFAULT_REGION) + .option('--timeout ', 'Browserbase session timeout seconds', String(DEFAULT_SESSION_TIMEOUT)) + .option('--no-persist-context', 'Do not persist context changes') + .option('--show-sensitive', 'Show the Browserbase connectUrl in output', false) + .action(runBrowserbaseAction(async (opts: { account?: string; contextId?: string; proxy?: string; keepAlive?: boolean; printLiveUrl?: boolean; region?: string; timeout?: string; persistContext?: boolean; showSensitive?: boolean }) => { + const store = requireStore(); + const accountProfile = opts.account ? store.accounts[asBrowserbaseAccountName(opts.account)] : null; + if (opts.account && !accountProfile) throw new ConfigError(`Browserbase account "${opts.account}" is not configured.`); + const proxyName: BrowserbaseProxyName | null = opts.proxy + ? asBrowserbaseProxyName(opts.proxy) + : accountProfile?.defaultProxyName ?? null; + const proxyProfile = proxyName ? store.proxies[proxyName] : null; + if (proxyName && !proxyProfile) throw new ConfigError(`Browserbase proxy "${proxyName}" is not configured.`); + const client = requireClient(); + const created = await client.createSession({ + accountName: opts.account ? asBrowserbaseAccountName(opts.account) : null, + contextId: opts.contextId ? asBrowserbaseContextId(opts.contextId) : accountProfile?.contextId ?? null, + proxyName, + region: parseRegion(opts.region), + keepAlive: opts.keepAlive === true, + persistContext: opts.persistContext !== false, + timeoutSeconds: parsePositiveInteger(opts.timeout ?? String(DEFAULT_SESSION_TIMEOUT), '--timeout'), + }, proxyProfile?.rules ?? []); + if (!created.ok) throw new ConfigError(created.error.message, created.error.hint); + const liveUrls = opts.printLiveUrl ? await client.getLiveUrls(created.value.id) : null; + if (liveUrls && !liveUrls.ok) throw new ConfigError(liveUrls.error.message, liveUrls.error.hint); + json({ + ...outputSessionWithSensitivity(created.value, opts.showSensitive === true), + liveUrl: liveUrls?.ok ? liveUrls.value.debuggerFullscreenUrl : undefined, + }); + })); + + session.command('list') + .description('List Browserbase sessions') + .option('--show-sensitive', 'Show Browserbase connectUrl values in output', false) + .action(runBrowserbaseAction(async (opts: { showSensitive?: boolean }) => { + const listed = await requireClient().listSessions(); + if (!listed.ok) throw new ConfigError(listed.error.message, listed.error.hint); + json(listed.value.map((item) => outputSessionWithSensitivity(item, opts.showSensitive === true))); + })); + + session.command('get') + .description('Show a Browserbase session') + .argument('') + .option('--show-sensitive', 'Show the Browserbase connectUrl in output', false) + .action(runBrowserbaseAction(async (sessionId: string, opts: { showSensitive?: boolean }) => { + const result = await requireClient().getSession(asBrowserbaseSessionId(sessionId)); + if (!result.ok) throw new ConfigError(result.error.message, result.error.hint); + json(outputSessionWithSensitivity(result.value, opts.showSensitive === true)); + })); + + session.command('live-url') + .description('Print Browserbase Live View URL for a session') + .argument('') + .action(runBrowserbaseAction(async (sessionId: string) => { + const result = await requireClient().getLiveUrls(asBrowserbaseSessionId(sessionId)); + if (!result.ok) throw new ConfigError(result.error.message, result.error.hint); + json(result.value); + })); + + const releaseSession = runBrowserbaseAction(async (sessionId: string) => { + const result = await requireClient().releaseSession(asBrowserbaseSessionId(sessionId)); + if (!result.ok) throw new ConfigError(result.error.message, result.error.hint); + json({ released: true, sessionId }); + }); + session.command('release').description('Release a Browserbase keep-alive session').argument('').action(releaseSession); + session.command('delete').description('Alias for session release').argument('').action(releaseSession); + + const context = browserbase.command('context').description('Manage Browserbase contexts'); + context.command('create') + .description('Create a Browserbase context') + .action(runBrowserbaseAction(async () => { + const deps = requireDeps(); + const created = await deps.client.createContext(deps.projectId); + if (!created.ok) throw new ConfigError(created.error.message, created.error.hint); + json(created.value); + })); + + context.command('get') + .description('Show a Browserbase context') + .argument('') + .action(runBrowserbaseAction(async (contextId: string) => { + const result = await requireClient().getContext(asBrowserbaseContextId(contextId)); + if (!result.ok) throw new ConfigError(result.error.message, result.error.hint); + json(result.value); + })); + + context.command('delete') + .description('Delete a Browserbase context') + .argument('') + .action(runBrowserbaseAction(async (contextId: string) => { + const result = await requireClient().deleteContext(asBrowserbaseContextId(contextId)); + if (!result.ok) throw new ConfigError(result.error.message, result.error.hint); + json({ deleted: true, contextId }); + })); + + context.command('list-local') + .description('List contexts referenced by local Browserbase accounts') + .action(runBrowserbaseAction(() => { + json(Object.values(requireStore().accounts).map((account) => ({ + account: account.name, + site: account.site, + contextId: account.contextId, + loginState: account.loginState, + }))); + })); + + account.command('import') + .description('Import an existing Browserbase context as an account profile') + .argument('') + .requiredOption('--site ') + .requiredOption('--context-id ') + .option('--proxy ') + .action(runBrowserbaseAction((name: string, opts: { site: string; contextId: string; proxy?: string }) => { + const store = requireStore(); + const profile = createLocalAccountProfile(name, opts.site, opts.contextId, opts.proxy ?? null); + saveStoreOrThrow(upsertAccountProfile(store, profile)); + json(outputAccount(profile)); + })); + + return browserbase; +} diff --git a/src/commands/run.ts b/src/commands/run.ts new file mode 100644 index 000000000..0c884f869 --- /dev/null +++ b/src/commands/run.ts @@ -0,0 +1,205 @@ +import * as fs from 'node:fs'; +import { Command, InvalidArgumentError } from 'commander'; +import { executeCommand } from '../execution.js'; +import { EXIT_CODES, ConfigError, getErrorMessage } from '../errors.js'; +import { getRegistry } from '../registry.js'; +import { mapConcurrent } from '../utils.js'; +import { + BrowserbaseAccountSessionPool, + BrowserbaseClient, + asBrowserbaseAccountName, + loadBrowserbaseStore, + resolveBrowserbaseConfig, + type BrowserbaseAccountName, + type BrowserbaseRegion, + type CommandArgsMap, + type RunJobSpec, +} from '../browserbase.js'; + +const DEFAULT_REGION: BrowserbaseRegion = 'us-west-2'; +const DEFAULT_TIMEOUT_SECONDS = 1800; + +interface RawRunJob { + readonly id?: unknown; + readonly command?: unknown; + readonly args?: unknown; + readonly account?: unknown; + readonly accountName?: unknown; + readonly timeoutSeconds?: unknown; +} + +function parsePositiveInteger(value: string | undefined, label: string, fallback: number): number { + if (value === undefined) return fallback; + if (!/^\d+$/.test(value)) throw new InvalidArgumentError(`${label} must be a positive integer.`); + const parsed = Number.parseInt(value, 10); + if (parsed <= 0) throw new InvalidArgumentError(`${label} must be a positive integer.`); + return parsed; +} + +function parseRegion(value: string | undefined): BrowserbaseRegion { + const region = value ?? DEFAULT_REGION; + if (region === 'us-west-2' || region === 'us-east-1' || region === 'eu-central-1' || region === 'ap-southeast-1') { + return region; + } + throw new InvalidArgumentError('--region must be one of: us-west-2, us-east-1, eu-central-1, ap-southeast-1.'); +} + +function parseAccounts(raw: string | undefined): BrowserbaseAccountName[] { + if (!raw?.trim()) return []; + return raw.split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map(asBrowserbaseAccountName); +} + +function parseArgsMap(value: unknown): CommandArgsMap { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const args: Record = {}; + for (const [key, argValue] of Object.entries(value as Record)) { + if ( + typeof argValue === 'string' + || typeof argValue === 'number' + || typeof argValue === 'boolean' + || argValue === null + ) { + args[key] = argValue; + } + } + return args; +} + +function parseRunPlanJsonl(text: string): RunJobSpec[] { + const jobs: RunJobSpec[] = []; + const seen = new Set(); + const lines = text.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line) continue; + let raw: RawRunJob; + try { + raw = JSON.parse(line) as RawRunJob; + } catch (error) { + throw new ConfigError(`Invalid JSON on jobs line ${index + 1}: ${getErrorMessage(error)}`); + } + if (typeof raw.id !== 'string' || !raw.id.trim()) throw new ConfigError(`jobs line ${index + 1} is missing string id.`); + if (typeof raw.command !== 'string' || !raw.command.trim()) throw new ConfigError(`jobs line ${index + 1} is missing string command.`); + if (seen.has(raw.id)) throw new ConfigError(`Duplicate job id "${raw.id}".`); + seen.add(raw.id); + const timeoutSeconds = typeof raw.timeoutSeconds === 'number' && Number.isFinite(raw.timeoutSeconds) && raw.timeoutSeconds > 0 + ? Math.trunc(raw.timeoutSeconds) + : null; + const accountRaw = typeof raw.accountName === 'string' && raw.accountName.trim() + ? raw.accountName + : typeof raw.account === 'string' && raw.account.trim() + ? raw.account + : null; + jobs.push({ + id: raw.id, + command: raw.command, + args: parseArgsMap(raw.args), + accountName: accountRaw ? asBrowserbaseAccountName(accountRaw) : null, + timeoutSeconds, + }); + } + return jobs; +} + +function commandKey(command: string): string { + const parts = command.trim().split(/\s+/); + if (parts.length < 2) throw new ConfigError(`Job command "${command}" must start with " ".`); + return `${parts[0]}/${parts[1]}`; +} + +function emitRunEvent(event: Record): void { + console.log(JSON.stringify({ at: new Date().toISOString(), ...event })); +} + +export function registerRunCommand(program: Command): Command { + return program.command('run') + .description('Run a JSONL batch of adapter jobs') + .argument('', 'JSONL file with one job per line') + .option('--browserbase', 'Run browser jobs through Browserbase account sessions', false) + .option('--accounts ', 'Comma-separated Browserbase account names') + .option('--parallel ', 'Maximum concurrent jobs', '1') + .option('--pool-size ', 'Maximum Browserbase sessions to create at once') + .option('--region ', 'Browserbase region', DEFAULT_REGION) + .option('--timeout ', 'Default Browserbase session timeout seconds', String(DEFAULT_TIMEOUT_SECONDS)) + .option('--keep-alive', 'Keep job sessions alive after each job', false) + .option('--no-persist-context', 'Do not persist context changes after job sessions') + .action(async (jobsJsonl: string, opts: { + browserbase?: boolean; + accounts?: string; + parallel?: string; + poolSize?: string; + region?: string; + timeout?: string; + keepAlive?: boolean; + persistContext?: boolean; + }) => { + try { + if (!opts.browserbase) throw new ConfigError('opencli run currently requires --browserbase.'); + const accounts = parseAccounts(opts.accounts); + if (accounts.length === 0) throw new ConfigError('--accounts is required for --browserbase runs.'); + const parallel = parsePositiveInteger(opts.parallel, '--parallel', 1); + const poolSize = Math.min(10, parsePositiveInteger(opts.poolSize, '--pool-size', parallel)); + const region = parseRegion(opts.region); + const defaultTimeoutSeconds = parsePositiveInteger(opts.timeout, '--timeout', DEFAULT_TIMEOUT_SECONDS); + const config = resolveBrowserbaseConfig(); + if (!config.ok) throw new ConfigError(config.error.message, config.error.hint); + const store = loadBrowserbaseStore(); + if (!store.ok) throw new ConfigError(store.error.message, store.error.hint); + for (const account of accounts) { + if (!store.value.accounts[account]) throw new ConfigError(`Browserbase account "${account}" is not configured.`); + } + const jobs = parseRunPlanJsonl(fs.readFileSync(jobsJsonl, 'utf-8')); + const pool = new BrowserbaseAccountSessionPool(new BrowserbaseClient(config.value), { + store: store.value, + maxSessions: poolSize, + }); + let failures = 0; + await mapConcurrent(jobs, Math.min(parallel, jobs.length || 1), async (job, index) => { + const accountName = job.accountName ?? accounts[index % accounts.length]; + const key = commandKey(job.command); + const cmd = getRegistry().get(key); + if (!cmd) { + failures += 1; + emitRunEvent({ type: 'failed', jobId: job.id, accountName, error: `Adapter command "${key}" is not registered.` }); + return; + } + emitRunEvent({ type: 'queued', jobId: job.id, accountName, command: key }); + const lease = await pool.acquire({ + accountName, + keepAlive: opts.keepAlive === true, + persistContext: opts.persistContext !== false, + timeoutSeconds: job.timeoutSeconds ?? defaultTimeoutSeconds, + region, + }); + if (!lease.ok) { + failures += 1; + emitRunEvent({ type: 'failed', jobId: job.id, accountName, error: lease.error.message }); + return; + } + emitRunEvent({ type: 'started', jobId: job.id, accountName, sessionId: lease.value.sessionId }); + const started = Date.now(); + try { + const result = await executeCommand(cmd, job.args, false, { + prepared: false, + cdpEndpoint: lease.value.connectUrl, + }); + await pool.release(lease.value, { status: 'success' }); + emitRunEvent({ type: 'succeeded', jobId: job.id, accountName, durationMs: Date.now() - started, result }); + } catch (error) { + failures += 1; + await pool.release(lease.value, { status: 'failed', errorMessage: getErrorMessage(error) }); + emitRunEvent({ type: 'failed', jobId: job.id, accountName, durationMs: Date.now() - started, error: getErrorMessage(error) }); + } + }); + await pool.close(); + emitRunEvent({ type: 'summary', jobs: jobs.length, failures }); + process.exitCode = failures > 0 ? EXIT_CODES.GENERIC_ERROR : EXIT_CODES.SUCCESS; + } catch (error) { + console.error(`Error: ${getErrorMessage(error)}`); + process.exitCode = error instanceof ConfigError ? error.exitCode : EXIT_CODES.GENERIC_ERROR; + } + }); +} diff --git a/src/execution.test.ts b/src/execution.test.ts index 752d0bb1e..ea32b856e 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -9,6 +9,7 @@ import { cli, Strategy } from './registry.js'; import { withTimeoutMs } from './runtime.js'; import * as runtime from './runtime.js'; import * as capRouting from './capabilityRouting.js'; +import { saveBrowserbaseStore } from './browserbase.js'; describe('executeCommand — non-browser timeout', () => { it('applies the user --timeout arg as the ceiling for non-browser commands', async () => { @@ -563,6 +564,74 @@ describe('executeCommand — non-browser timeout', () => { } }); + it('lets --browserbase-account override BROWSERBASE_SESSION_ID for browser adapter commands', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browserbase-exec-')); + vi.stubEnv('OPENCLI_CONFIG_DIR', tmpDir); + vi.stubEnv('BROWSERBASE_API_KEY', 'bb-key'); + vi.stubEnv('BROWSERBASE_PROJECT_ID', 'proj_123'); + vi.stubEnv('BROWSERBASE_SESSION_ID', 'sess_env'); + saveBrowserbaseStore({ + version: 1, + accounts: { + 'x-main-1': { + name: 'x-main-1' as never, + site: 'x', + contextId: 'ctx_123' as never, + defaultProxyName: null, + loginState: 'ready', + lastLoginAtIso: null, + lastCheckedAtIso: null, + }, + }, + proxies: {}, + }); + const closeWindow = vi.fn().mockResolvedValue(undefined); + const mockPage = { closeWindow } as any; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url: string | URL | Request) => { + const href = String(url); + if (href.endsWith('/sessions')) { + return new Response(JSON.stringify({ + id: 'sess_account', + status: 'RUNNING', + connectUrl: 'wss://connect.browserbase.example/account', + })); + } + if (href.endsWith('/sessions/sess_account')) return new Response('{}'); + return new Response('{}', { status: 404 }); + }); + const sessionOpts: Array<{ cdpEndpoint?: string }> = []; + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => { + sessionOpts.push(opts ?? {}); + return fn(mockPage); + }); + + try { + const cmd = cli({ + site: 'test-execution', + name: 'browserbase-account', access: 'read', + description: 'test Browserbase account routing', + browser: true, + strategy: Strategy.PUBLIC, + func: async () => [{ ok: true }], + }); + + await executeCommand(cmd, {}, false, { browserbaseAccount: 'x-main-1' }); + + expect(fetchSpy).not.toHaveBeenCalledWith( + 'https://api.browserbase.com/v1/sessions/sess_env', + expect.anything(), + ); + expect(sessionOpts[0]).toMatchObject({ + cdpEndpoint: 'wss://connect.browserbase.example/account', + }); + expect(closeWindow).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + } + }); + it('does not re-run custom validation when args are already prepared', async () => { const validateArgs = vi.fn(); const cmd: CliCommand = { diff --git a/src/execution.ts b/src/execution.ts index 20daa8e10..eb70de421 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -26,7 +26,7 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import { executePipeline } from './pipeline/index.js'; -import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js'; +import { adapterLoadError, ArgumentError, CommandExecutionError, ConfigError, attachTraceReceipt, getErrorMessage } from './errors.js'; import { shouldUseBrowserSession } from './capabilityRouting.js'; import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT, type BrowserWindowMode } from './runtime.js'; import { resolveProfileContextId } from './browser/profile.js'; @@ -36,7 +36,15 @@ import { isElectronApp } from './electron-apps.js'; import { probeCDP, resolveElectronEndpoint } from './launcher.js'; import { ObservationSession, exportObservationSession, type ObservationExportResult, type ObservationExportStatus } from './observation/index.js'; import { resolveAdapterSourcePath } from './adapter-source.js'; -import { resolveBrowserbaseSessionId, validateBrowserbaseSession } from './browserbase.js'; +import { + BrowserbaseClient, + asBrowserbaseAccountName, + loadBrowserbaseStore, + resolveBrowserbaseConfig, + resolveBrowserbaseSessionId, + validateBrowserbaseSession, + type BrowserbaseRegion, +} from './browserbase.js'; const _loadedModules = new Map>(); /** Track mtime of loaded user adapter files for hot-reload in daemon mode. */ @@ -206,6 +214,12 @@ export async function executeCommand( windowMode?: string; siteSession?: string; browserbaseSession?: string; + browserbaseAccount?: string; + browserbasePersistContext?: boolean; + browserbaseKeepAlive?: boolean; + browserbaseRegion?: BrowserbaseRegion; + browserbaseTimeoutSeconds?: number; + cdpEndpoint?: string; onTraceExport?: (trace: ObservationExportResult) => void; } = {}, ): Promise { @@ -231,11 +245,22 @@ export async function executeCommand( try { if (shouldUseBrowserSession(cmd)) { const electron = isElectronApp(cmd.site); - let cdpEndpoint: string | undefined; - let useCDP = false; - - const browserbaseSessionId = resolveBrowserbaseSessionId(opts.browserbaseSession); - if (browserbaseSessionId) { + let cdpEndpoint: string | undefined = opts.cdpEndpoint; + let useCDP = !!cdpEndpoint; + let browserbaseRelease: (() => Promise) | undefined; + + const browserbaseSessionId = opts.browserbaseAccount ? null : resolveBrowserbaseSessionId(opts.browserbaseSession); + if (opts.browserbaseAccount && !cdpEndpoint) { + const created = await createBrowserbaseExecutionSession(opts.browserbaseAccount, { + persistContext: opts.browserbasePersistContext !== false, + keepAlive: opts.browserbaseKeepAlive === true, + region: opts.browserbaseRegion ?? 'us-west-2', + timeoutSeconds: opts.browserbaseTimeoutSeconds ?? userTimeoutSec ?? 1800, + }); + cdpEndpoint = created.connectUrl; + useCDP = true; + browserbaseRelease = created.release; + } else if (browserbaseSessionId) { const browserbaseSession = await validateBrowserbaseSession(browserbaseSessionId); cdpEndpoint = browserbaseSession.connectUrl; useCDP = true; @@ -266,121 +291,127 @@ export async function executeCommand( const session = resolveAdapterBrowserSession(cmd, siteSession); const keepTab = resolveKeepTab(siteSession, opts.keepTab); const windowMode = resolveBrowserWindowMode('background', opts.windowMode); - result = await browserSession(BrowserFactory, async (page) => { - const observation = traceMode === 'off' - ? null - : new ObservationSession({ - scope: { - contextId, - session, - target: page.getActivePage?.(), - site: cmd.site, - command: fullName(cmd), - adapterSourcePath: resolveAdapterSourcePath(internal), - }, - }); - if (observation) { - observation.record({ - stream: 'action', - name: 'command', - phase: 'start', - data: { args: kwargs }, - }); - await page.startNetworkCapture?.().catch(() => false); - } - const preNavUrl = resolvePreNav(cmd); - if (preNavUrl && await shouldRunPreNav(cmd, page, siteSession, preNavUrl)) { - observation?.record({ - stream: 'action', - name: 'pre_navigate', - phase: 'start', - data: { url: preNavUrl }, - }); - // Navigate directly — the extension's handleNavigate already has a fast-path - // that skips navigation if the tab is already at the target URL. - // This avoids an extra exec round-trip (getCurrentUrl) on first command and - // lets the extension create the automation window with the target URL directly - // instead of about:blank. - try { - await page.goto(preNavUrl); + try { + result = await browserSession(BrowserFactory, async (page) => { + const observation = traceMode === 'off' + ? null + : new ObservationSession({ + scope: { + contextId, + session, + target: page.getActivePage?.(), + site: cmd.site, + command: fullName(cmd), + adapterSourcePath: resolveAdapterSourcePath(internal), + }, + }); + if (observation) { observation?.record({ stream: 'action', - name: 'pre_navigate', - phase: 'end', - data: { url: preNavUrl }, + name: 'command', + phase: 'start', + data: { args: kwargs }, }); - } catch (err) { + await page.startNetworkCapture?.().catch(() => false); + } + const preNavUrl = resolvePreNav(cmd); + if (preNavUrl && await shouldRunPreNav(cmd, page, siteSession, preNavUrl)) { observation?.record({ stream: 'action', name: 'pre_navigate', - phase: 'error', - data: { url: preNavUrl, error: err instanceof Error ? err.message : String(err) }, + phase: 'start', + data: { url: preNavUrl }, }); - const wrapped = new CommandExecutionError( - `Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, - 'Check that the site is reachable and the browser extension is running.', - ); - if (observation && (traceMode === 'on' || traceMode === 'retain-on-failure')) { - observation.record({ - stream: 'error', - message: wrapped.message, - stack: wrapped.stack, - code: wrapped.code, - hint: wrapped.hint, + // Navigate directly — the extension's handleNavigate already has a fast-path + // that skips navigation if the tab is already at the target URL. + // This avoids an extra exec round-trip (getCurrentUrl) on first command and + // lets the extension create the automation window with the target URL directly + // instead of about:blank. + try { + await page.goto(preNavUrl); + observation?.record({ + stream: 'action', + name: 'pre_navigate', + phase: 'end', + data: { url: preNavUrl }, }); - await collectObservationEvidence(observation, page).catch(() => {}); - exportTraceArtifact(observation, 'failure', wrapped, opts.onTraceExport); + } catch (err) { + observation?.record({ + stream: 'action', + name: 'pre_navigate', + phase: 'error', + data: { url: preNavUrl, error: err instanceof Error ? err.message : String(err) }, + }); + const wrapped = new CommandExecutionError( + `Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, + 'Check that the site is reachable and the browser extension is running.', + ); + if (observation && (traceMode === 'on' || traceMode === 'retain-on-failure')) { + observation.record({ + stream: 'error', + message: wrapped.message, + stack: wrapped.stack, + code: wrapped.code, + hint: wrapped.hint, + }); + await collectObservationEvidence(observation, page).catch(() => {}); + exportTraceArtifact(observation, 'failure', wrapped, opts.onTraceExport); + } + throw wrapped; } - throw wrapped; } - } - try { - const browserTimeout = userTimeoutSec !== null - ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS - : DEFAULT_BROWSER_COMMAND_TIMEOUT; - const result = await runWithTimeout(runCommand(cmd, page, kwargs, debug), { - timeout: browserTimeout, - label: fullName(cmd), - }); - observation?.record({ - stream: 'action', - name: 'command', - phase: 'end', - }); - if (observation && traceMode === 'on') { - await collectObservationEvidence(observation, page).catch(() => {}); - exportTraceArtifact(observation, 'success', undefined, opts.onTraceExport); - } - // Adapter commands are one-shot — release the current tab lease immediately - // instead of waiting for the 30s idle timeout. The automation container - // window stays open for reuse. - if (!keepTab) await page.closeWindow?.().catch(() => {}); - return result; - } catch (err) { - if (observation) { - observation.record({ + try { + const browserTimeout = userTimeoutSec !== null + ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS + : DEFAULT_BROWSER_COMMAND_TIMEOUT; + const result = await runWithTimeout(runCommand(cmd, page, kwargs, debug), { + timeout: browserTimeout, + label: fullName(cmd), + }); + observation?.record({ stream: 'action', name: 'command', - phase: 'error', - data: { error: err instanceof Error ? err.message : String(err) }, - }); - observation.record({ - stream: 'error', - message: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined, + phase: 'end', }); - if (traceMode === 'on' || traceMode === 'retain-on-failure') { + if (observation && traceMode === 'on') { await collectObservationEvidence(observation, page).catch(() => {}); - exportTraceArtifact(observation, 'failure', err, opts.onTraceExport); + exportTraceArtifact(observation, 'success', undefined, opts.onTraceExport); + } + // Adapter commands are one-shot — release the current tab lease immediately + // instead of waiting for the 30s idle timeout. The automation container + // window stays open for reuse. + if (!keepTab) await page.closeWindow?.().catch(() => {}); + return result; + } catch (err) { + if (observation) { + observation.record({ + stream: 'action', + name: 'command', + phase: 'error', + data: { error: err instanceof Error ? err.message : String(err) }, + }); + observation.record({ + stream: 'error', + message: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + if (traceMode === 'on' || traceMode === 'retain-on-failure') { + await collectObservationEvidence(observation, page).catch(() => {}); + exportTraceArtifact(observation, 'failure', err, opts.onTraceExport); + } } + // Release the tab lease on failure too — without this, the lease lingers + // until the extension's idle timer fires (unreliable on Windows where + // MV3 service workers may be suspended before setTimeout triggers). + if (!keepTab) await page.closeWindow?.().catch(() => {}); + throw err; } - // Release the tab lease on failure too — without this, the lease lingers - // until the extension's idle timer fires (unreliable on Windows where - // MV3 service workers may be suspended before setTimeout triggers). - if (!keepTab) await page.closeWindow?.().catch(() => {}); - throw err; - } - }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession }); + }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession }); + } finally { + await browserbaseRelease?.().catch((err) => { + if (process.env.OPENCLI_VERBOSE) log.warn(`[browserbase] Failed to release session: ${getErrorMessage(err)}`); + }); + } } else { // Non-browser commands: enforce a timeout only when the command exposes // a `--timeout` arg (and the resolved value is positive). Without that @@ -409,6 +440,59 @@ export async function executeCommand( return result; } +async function createBrowserbaseExecutionSession( + accountNameRaw: string, + opts: { + persistContext: boolean; + keepAlive: boolean; + region: BrowserbaseRegion; + timeoutSeconds: number; + }, +): Promise<{ connectUrl: string; release: () => Promise }> { + const accountName = asBrowserbaseAccountName(accountNameRaw.trim()); + const config = resolveBrowserbaseConfig(); + if (!config.ok) throw new ConfigError(config.error.message, config.error.hint); + + const store = loadBrowserbaseStore(); + if (!store.ok) throw new ConfigError(store.error.message, store.error.hint); + + const account = store.value.accounts[accountName]; + if (!account) { + throw new ConfigError( + `Browserbase account "${accountNameRaw}" is not configured.`, + 'Run opencli browserbase account bootstrap or account create first.', + ); + } + const proxy = account.defaultProxyName ? store.value.proxies[account.defaultProxyName] : null; + if (account.defaultProxyName && !proxy) { + throw new ConfigError(`Browserbase proxy "${account.defaultProxyName}" is not configured.`); + } + + const client = new BrowserbaseClient(config.value); + const timeoutSeconds = Math.max(60, Math.min(21600, Math.trunc(opts.timeoutSeconds))); + const session = await client.createSession({ + accountName, + contextId: account.contextId, + proxyName: account.defaultProxyName, + region: opts.region, + keepAlive: opts.keepAlive, + persistContext: opts.persistContext, + timeoutSeconds, + }, proxy?.rules ?? []); + if (!session.ok) throw new CommandExecutionError(session.error.message, session.error.hint); + if (!session.value.connectUrl) { + throw new CommandExecutionError(`Browserbase session "${session.value.id}" did not include a connectUrl.`); + } + return { + connectUrl: session.value.connectUrl, + release: async () => { + if (opts.keepAlive) return; + const released = await client.releaseSession(session.value.id); + if (!released.ok) throw new CommandExecutionError(released.error.message, released.error.hint); + }, + }; +} + async function collectObservationEvidence(session: ObservationSession, page: IPage): Promise { const target = page.getActivePage?.() ?? session.scope.target; const [url, snapshot, networkEntries, consoleMessages, screenshot] = await Promise.all([