From 0386045fe0c1e9ab7e8f633bc8e1c973af879699 Mon Sep 17 00:00:00 2001 From: internet-dot <207546839+internet-dot@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:53:50 +0000 Subject: [PATCH] Remove CI workflow from plugin manifest Re-signed branch history. Previous subjects: - Add Codex CLI plugin manifest - Fix: add skills field to plugin manifest - Fix: address code review feedback - Add Codex plugin quality gate CI - Remove CI workflow from plugin PR - Remove CI workflow from plugin manifest --- .codex-plugin/plugin.json | 24 + .github/workflows/plugin-quality-gate.yml | 20 + .github/workflows/publish.yml | 10 + .mcp.json | 11 + .release-please-manifest.json | 2 +- CHANGELOG.md | 131 -- bin/bb-browserd.ts | 1080 +++++++++-------- package.json | 2 +- packages/cli/package.json | 2 +- .../cli/src/__tests__/daemon-startup.test.ts | 426 ------- packages/cli/src/daemon-manager.ts | 27 +- .../src/__tests__/daemon-lifecycle.test.ts | 275 ----- packages/daemon/src/cdp-connection.ts | 20 - packages/daemon/src/http-server.ts | 8 +- packages/daemon/src/index.ts | 4 +- packages/mcp/src/index.ts | 485 ++++---- packages/shared/package.json | 7 +- packages/shared/src/commands.ts | 447 ------- packages/shared/src/index.ts | 7 - pnpm-lock.yaml | 4 - release-please-config.json | 2 - skills/bb-browser/SKILL.md | 359 +----- 22 files changed, 950 insertions(+), 2403 deletions(-) create mode 100644 .codex-plugin/plugin.json create mode 100644 .github/workflows/plugin-quality-gate.yml create mode 100644 .mcp.json delete mode 100644 packages/cli/src/__tests__/daemon-startup.test.ts delete mode 100644 packages/daemon/src/__tests__/daemon-lifecycle.test.ts delete mode 100644 packages/shared/src/commands.ts diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 00000000..285dd64f --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "bb-browser", + "version": "0.1.0", + "description": "Control Chrome with login state from Codex via MCP", + "author": { + "name": "epiral", + "url": "https://github.com/epiral/bb-browser" + }, + "homepage": "https://github.com/epiral/bb-browser", + "repository": "https://github.com/epiral/bb-browser", + "keywords": [ + "mcp", + "codex" + ], + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "BB Browser", + "shortDescription": "Control Chrome with login state from Codex via MCP", + "longDescription": "Your browser is the API. CLI + MCP server for AI agents to control Chrome with your login state.", + "category": "Productivity", + "websiteURL": "https://github.com/epiral/bb-browser" + }, + "skills": "./skills/" +} \ No newline at end of file diff --git a/.github/workflows/plugin-quality-gate.yml b/.github/workflows/plugin-quality-gate.yml new file mode 100644 index 00000000..c60a724a --- /dev/null +++ b/.github/workflows/plugin-quality-gate.yml @@ -0,0 +1,20 @@ +name: Plugin Quality Gate + +on: + pull_request: + paths: + - ".codex-plugin/**" + - "skills/**" + - ".mcp.json" + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Codex plugin quality gate + uses: hashgraph-online/hol-codex-plugin-scanner-action@v1 + with: + plugin_dir: "." + min_score: 80 + fail_on_severity: high diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c219b53d..3f43e3dd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,6 +41,16 @@ jobs: - name: Build run: pnpm build + - name: Package extension + run: | + cd extension + zip -r "../bb-browser-extension-${{ inputs.tag_name }}.zip" . + + - name: Upload extension asset + run: gh release upload "${{ inputs.tag_name }}" "bb-browser-extension-${{ inputs.tag_name }}.zip" --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish to npm run: pnpm publish --no-git-checks env: diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..b195499f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "bb-browser": { + "command": "npx", + "args": [ + "-y", + "bb-browser" + ] + } + } +} \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8ad7375d..78e7f271 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.11.2" + ".": "0.11.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2070af35..135d6e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,136 +1,5 @@ # Changelog -## [0.11.2](https://github.com/epiral/bb-browser/compare/bb-browser-v0.11.1...bb-browser-v0.11.2) (2026-04-03) - - -### Bug Fixes - -* CDP 503 returns diagnostics immediately instead of 30s timeout ([#157](https://github.com/epiral/bb-browser/issues/157)) ([67efdd3](https://github.com/epiral/bb-browser/commit/67efdd38fd70a872b67388e52a7df9d70422fe8f)) -* remove extension packaging from publish workflow ([#155](https://github.com/epiral/bb-browser/issues/155)) ([1931c55](https://github.com/epiral/bb-browser/commit/1931c5584160b8798281cd797718dcc4feb119bd)) - -## [0.11.1](https://github.com/epiral/bb-browser/compare/bb-browser-v0.11.0...bb-browser-v0.11.1) (2026-04-03) - - -### ⚠ BREAKING CHANGES - -* Chrome Extension is removed. Users who relied on the extension-based flow should use the daemon-direct mode instead. - -### Features - -* add bb-browser-sites skill for OpenClaw ([facd29d](https://github.com/epiral/bb-browser/commit/facd29d2d47e6c4284241b0f9e97f3e1b56aece8)) -* add fetch and recipe commands ([5668470](https://github.com/epiral/bb-browser/commit/56684707dae055cadbfa59742ac443296460acb4)) -* add history, site recommend, site info, --jq, and Agent UX guide ([fdb8288](https://github.com/epiral/bb-browser/commit/fdb8288ce2baf6d3579c746252e042e468540cdd)) -* add MCP instructions + bb-browser guide command ([f0869aa](https://github.com/epiral/bb-browser/commit/f0869aabef86623908be371e9e94dc9ce4dfb078)) -* add pinix.json (Edge Clip manifest) ([54e02b0](https://github.com/epiral/bb-browser/commit/54e02b02652b68fd77d7635c60a9ab5230077692)) -* Agent UX guide system + history, site recommend/info, --jq ([c1c9bc4](https://github.com/epiral/bb-browser/commit/c1c9bc42a8c12e3d33e49cd15545409881ee6fd2)) -* auto-launch Chrome in bb-browserd + clip.json ([#123](https://github.com/epiral/bb-browser/issues/123)) ([1fe733b](https://github.com/epiral/bb-browser/commit/1fe733b4168dcc9f7a629b9b6a8e05a30cd5411a)) -* bb-browserd — Pinix capability adapter ([3af2b4c](https://github.com/epiral/bb-browser/commit/3af2b4c8562292edf5807315075b42e520063449)) -* bb-browserd CDP direct connection (replaces daemon HTTP) ([2ce47c5](https://github.com/epiral/bb-browser/commit/2ce47c55cec12a5e9a57faebf604643f75ac5cac)) -* bb-browserd migrate from WebSocket to Connect-RPC ProviderStream ([e730b0e](https://github.com/epiral/bb-browser/commit/e730b0ef554f7664e4d25f0e0e5b845dd6c35b50)) -* **cli,mcp:** add setup hints when extension/daemon not connected ([1f15f3e](https://github.com/epiral/bb-browser/commit/1f15f3e43389759b06e0cb10a905a5a38ba1120f)) -* **cli:** add --mcp flag to start MCP server ([cb28e81](https://github.com/epiral/bb-browser/commit/cb28e815aa0047dee10c4129ab1a7018bd2d77a7)) -* **cli:** add CDP monitor background process for persistent monitoring (Phase 1, [#77](https://github.com/epiral/bb-browser/issues/77)) ([94c94b7](https://github.com/epiral/bb-browser/commit/94c94b7c4ebe4b7b20c5088459024debb60576b2)) -* **cli:** add CDP monitor for persistent monitoring ([aa3010f](https://github.com/epiral/bb-browser/commit/aa3010f015ee4e4910373879d8a2a2af15dbc5e6)) -* **cli:** add star command and hint in site update ([00ed75f](https://github.com/epiral/bb-browser/commit/00ed75fd67f273686f9c558c5f8fdf76f1b6f260)) -* **cli:** auto-check for CLI updates on site update, bump to 0.7.0 ([0551493](https://github.com/epiral/bb-browser/commit/0551493aa0a1e556ce0a3c8e61f7ae0224062706)) -* **cli:** categorize --help output, promote site system ([#14](https://github.com/epiral/bb-browser/issues/14)) ([bed7fbd](https://github.com/epiral/bb-browser/commit/bed7fbd9872c5917186dca5394ebc1efbee38031)), closes [#13](https://github.com/epiral/bb-browser/issues/13) -* **cli:** support cdpUrl and cdpHost from OpenClaw ([#70](https://github.com/epiral/bb-browser/issues/70)) ([2086436](https://github.com/epiral/bb-browser/commit/208643638e9b26f80ec591884aca1483c1d3f08e)) -* **daemon:** add --host flag to configure listen address ([3a69040](https://github.com/epiral/bb-browser/commit/3a69040bf717961768295aad38dee971d2fca424)) -* **guide:** add bb-browser site adapter as gh CLI alternative ([bd9e896](https://github.com/epiral/bb-browser/commit/bd9e89619cb63e9e4ca69c736d36d97a882eecfa)) -* **mcp:** add MCP server for AI agent integration ([537e553](https://github.com/epiral/bb-browser/commit/537e5536f951ff229850216a487b38366ab16748)) -* **mcp:** add session tab cleanup tool ([#91](https://github.com/epiral/bb-browser/issues/91)) ([cfa1b0f](https://github.com/epiral/bb-browser/commit/cfa1b0fd8bdf5e02cc12e7954e800b821c6e98db)) -* **mcp:** add site adapter tools ([22d38c1](https://github.com/epiral/bb-browser/commit/22d38c136837cf5fbaddc788f134279c2f2001c3)) -* **mcp:** add site adapter tools ([7804f06](https://github.com/epiral/bb-browser/commit/7804f062269cf0cd7d05c34cb60c5b488c794814)) -* **mcp:** auto-start daemon when not running ([fa33c5a](https://github.com/epiral/bb-browser/commit/fa33c5af2ab7698dc3d8a71beba46871d58bcb3a)) -* network requests --with-body captures full request/response data ([a2e1f29](https://github.com/epiral/bb-browser/commit/a2e1f29daa0a5c3195b626ef21b67528bb61467c)) -* open 命令支持 --tab 参数,解决并发打开页面冲突 ([9582e10](https://github.com/epiral/bb-browser/commit/9582e1034ad6321fe9c43bd096c5041eb30666dc)) -* remove Chrome Extension package ([7936566](https://github.com/epiral/bb-browser/commit/7936566ec511de7ff7ee3fe8ff2b8fa0597964ba)) -* set managed Chrome profile name to "bb-browser" ([7e5c227](https://github.com/epiral/bb-browser/commit/7e5c2275c4b91197d6e25909275817a498f6e1e8)) -* **site:** add --openclaw mode to run adapters via OpenClaw CDP browser ([701fa7b](https://github.com/epiral/bb-browser/commit/701fa7bc7ea3ff7c28460424ec4a566450c8e366)), closes [#28](https://github.com/epiral/bb-browser/issues/28) -* **site:** add reportHint in error output for agents ([de713a2](https://github.com/epiral/bb-browser/commit/de713a2761edf0dd1a7082c6e77dbb00e5861786)) -* **site:** auto-detect auth errors and show login hint ([c68653b](https://github.com/epiral/bb-browser/commit/c68653b2fa7197697d5ea81282e069ec39b53702)) -* **site:** include bb-browser site adapter as report alternative ([847a995](https://github.com/epiral/bb-browser/commit/847a995db73af97b90077033fc0ef622e60811c0)) -* **site:** silent background update after every site command ([638020d](https://github.com/epiral/bb-browser/commit/638020d3bbcb1ff17c387b26a919b1b2edd760c6)), closes [#20](https://github.com/epiral/bb-browser/issues/20) -* tab select/close 支持按 tabId 操作 ([1acfb60](https://github.com/epiral/bb-browser/commit/1acfb6031bd55345445fc994437b6aac676a939c)) -* v0.8.0 — CDP direct connection, managed Chrome, no extension needed ([59ab96b](https://github.com/epiral/bb-browser/commit/59ab96b4c314b7739598fd9e0feb9d78f334af97)) -* v2.0 CDP 架构迁移 - 使用 chrome.debugger 实现所有 DOM 操作 ([827f24d](https://github.com/epiral/bb-browser/commit/827f24d6a2c437a94ab7b6a2c3e22874d6014f88)) -* 全局 --tab 参数支持多 tab 并发隔离 ([7acd596](https://github.com/epiral/bb-browser/commit/7acd596073f99bb4793ab627a0773b11787a8be3)) -* 切换 snapshot 到 CDP Accessibility Tree ([9095463](https://github.com/epiral/bb-browser/commit/90954639885a1f705ee9b76eba6926d1538158e4)) -* 切换 snapshot 到 CDP Accessibility Tree ([e66256a](https://github.com/epiral/bb-browser/commit/e66256a13869a8f7d827072b613e32ebddf60e0a)), closes [#4](https://github.com/epiral/bb-browser/issues/4) -* 实现 bb-browser 核心功能 ([601f4ae](https://github.com/epiral/bb-browser/commit/601f4ae813b0d6a752b505e3932071a1052251d1)) -* 实现 close 命令 ([b92a8b1](https://github.com/epiral/bb-browser/commit/b92a8b184e8324d2abbf9e0e28741d0ae319d84f)) -* 实现 get 命令组 (text/url/title) ([028478b](https://github.com/epiral/bb-browser/commit/028478b5628157a5febabf3557ad5623132b5150)) -* 实现 hover 命令 ([260e8ce](https://github.com/epiral/bb-browser/commit/260e8cedf76f6a85ba5844d4472173685a4a86a0)) -* 实现 press 命令 ([16f3d84](https://github.com/epiral/bb-browser/commit/16f3d842f3c8c7f7648f66c65dbfa3d17cbab52b)) -* 实现 screenshot 命令 ([29c335e](https://github.com/epiral/bb-browser/commit/29c335e51d9ffbb3523c2b6611df02ebfa13efdb)) -* 实现 scroll 命令 ([0d60f93](https://github.com/epiral/bb-browser/commit/0d60f938dfdf1dc93cb75163c071c21df5f7e9f8)) -* 实现 wait 命令 ([247e21c](https://github.com/epiral/bb-browser/commit/247e21cda11959f5a035a2aba4d4c84088b39975)) -* 实现导航命令 (back/forward/refresh) ([91a3398](https://github.com/epiral/bb-browser/commit/91a33983e6f800373cc43ed5cf5a9d9985f52462)) -* 插件支持配置上游 URL ([ca78b45](https://github.com/epiral/bb-browser/commit/ca78b457f3ccf03d603dcc8b769a34016af63cdf)), closes [#2](https://github.com/epiral/bb-browser/issues/2) -* 插件支持配置上游 URL + 修改后立即重连 ([44761b8](https://github.com/epiral/bb-browser/commit/44761b874848a9180c2380c03e2efcec7503bd10)) -* 添加 check/uncheck 命令 - 复选框操作 ([6385279](https://github.com/epiral/bb-browser/commit/638527916fa04a520f3190d87345babef0fafb67)) -* 添加 dialog 命令(accept/dismiss) ([8d6e6c1](https://github.com/epiral/bb-browser/commit/8d6e6c1d75ac2db48f2d21dcf4fe06d90036fe67)) -* 添加 eval 命令 - 执行 JavaScript ([7e6bf96](https://github.com/epiral/bb-browser/commit/7e6bf96abaf6a9009140f992c2ce3b546dd0dc2a)) -* 添加 frame 命令(切换 iframe) ([394d7e7](https://github.com/epiral/bb-browser/commit/394d7e7dc3cde4d48c5f89e3ee543d2bb5d712e5)) -* 添加 network/console/errors 调试命令 ([02edcb4](https://github.com/epiral/bb-browser/commit/02edcb4c85c4d86e22fe5c4fecff93e328b241e4)) -* 添加 Phase 3 命令 (eval/type/check/uncheck/select) ([bbfda3f](https://github.com/epiral/bb-browser/commit/bbfda3fc6246f5dedb1f427a2008abaab69c6125)) -* 添加 reload 命令支持 CDP 热重载扩展 ([530e992](https://github.com/epiral/bb-browser/commit/530e992caa39bbae8b5eefe4417d1e21523f57c5)) -* 添加 tab 命令(list/new/select/close) ([a608124](https://github.com/epiral/bb-browser/commit/a608124f06b348e6e4739dfc6c2d6d73a1fcfc06)) -* 添加 trace 命令 - 录制用户操作 ([f02b081](https://github.com/epiral/bb-browser/commit/f02b081f48497f2149879639fd89561fce979135)) -* 添加 type 命令 - 逐字符输入 ([de22235](https://github.com/epiral/bb-browser/commit/de2223587ea7987738ca1f44b733477a9fb7edb4)) - - -### Bug Fixes - -* add Chrome Dev/Beta/Canary and Brave to Windows browser candidates ([b9ee3aa](https://github.com/epiral/bb-browser/commit/b9ee3aaca2c3884038005e0d259bfa4c500de912)) -* align daemon host defaults and restore daemon command ([#137](https://github.com/epiral/bb-browser/issues/137)) ([37f8d64](https://github.com/epiral/bb-browser/commit/37f8d64959c40899173f6d7d55567fe98460b012)) -* auto-launch Chrome when daemon starts ([#152](https://github.com/epiral/bb-browser/issues/152)) ([9243517](https://github.com/epiral/bb-browser/commit/9243517f18603bfb640c78041042b31afdd58ea8)) -* auto-reconnect after Hub restart ([#111](https://github.com/epiral/bb-browser/issues/111)) ([ef4f684](https://github.com/epiral/bb-browser/commit/ef4f6849e40771b224b8bf67f593aa8c261e8400)), closes [#103](https://github.com/epiral/bb-browser/issues/103) -* CDP direct mode bugs (snapshot, tab list, discovery) ([b0cb83a](https://github.com/epiral/bb-browser/commit/b0cb83a8f6fb1059c076d87e4cf36b74bc25196d)) -* **cli:** --tab flag works correctly for all commands ([a882809](https://github.com/epiral/bb-browser/commit/a882809abc7af4aaa7ee3c38e96496ed588fbbb3)) -* **cli:** add Chrome Dev/Canary/Beta/Arc to browser detection ([#42](https://github.com/epiral/bb-browser/issues/42)) ([#43](https://github.com/epiral/bb-browser/issues/43)) ([e7de67c](https://github.com/epiral/bb-browser/commit/e7de67c4df94f9afb2d54e84b108d32ae936ffed)) -* **cli:** add guide command to help text ([09ac697](https://github.com/epiral/bb-browser/commit/09ac697877d57826c714021055318207041a683c)) -* **cli:** avoid double-inserting typed text ([600244d](https://github.com/epiral/bb-browser/commit/600244d0dacecb60286e5d86c3388c0709b775f0)) -* **cli:** daemon command works in npm-published layout ([c3fa112](https://github.com/epiral/bb-browser/commit/c3fa1126244f2e98891aac2b5f00a9e1800d13af)) -* **cli:** harden OpenClaw JSON parsing ([b0b7c7b](https://github.com/epiral/bb-browser/commit/b0b7c7be0e072cddedceb24b8193f51de7838093)) -* **cli:** pass timeout through to OpenClaw ([#82](https://github.com/epiral/bb-browser/issues/82)) ([7c2ee5e](https://github.com/epiral/bb-browser/commit/7c2ee5e14e0487e8203338d4e1608928b8141a59)) -* **cli:** persist selected target for get commands ([578e10f](https://github.com/epiral/bb-browser/commit/578e10f56dc2849814f75cbbbdda86e800e5213d)) -* **cli:** place openclaw browser flags before subcommand ([#102](https://github.com/epiral/bb-browser/issues/102)) ([1b3a394](https://github.com/epiral/bb-browser/commit/1b3a39437f1254e46094adebac1edc44fd367b9f)) -* **cli:** tolerate noisy OpenClaw JSON output ([28e0867](https://github.com/epiral/bb-browser/commit/28e0867ee42f7d5c6a10d422fea4abb31838f91c)) -* **cli:** use viewport coordinates for click targets ([92fa9f7](https://github.com/epiral/bb-browser/commit/92fa9f7fc7da438d8a667d7cf1922b70c66f4288)) -* **cli:** wait for extension connection before sending commands ([b478226](https://github.com/epiral/bb-browser/commit/b478226d74e0cb87ab808badc8c03d1a1f506ff0)) -* **cli:** wire top-level status command ([3a15411](https://github.com/epiral/bb-browser/commit/3a1541114f96a85755ae723fc978b095479ea447)) -* **cli:** wire top-level status command ([c8b1aab](https://github.com/epiral/bb-browser/commit/c8b1aab0ada923fc4e725dd8ca99515408796322)) -* consolidate daemon state into daemon.json with PID liveness check ([6d52b22](https://github.com/epiral/bb-browser/commit/6d52b22601ebdf462f7d5ec59806c950862bbb39)), closes [#140](https://github.com/epiral/bb-browser/issues/140) -* cross-process refs, fill command, snapshot robustness ([936047a](https://github.com/epiral/bb-browser/commit/936047ad2b457f0a046ccb2e37aa9baa74a5c1e9)) -* **daemon:** allow positional args in parseArgs for CLI passthrough ([4eea8e5](https://github.com/epiral/bb-browser/commit/4eea8e5880f26960a037c10082a19e8927586141)) -* derive version metadata from package.json ([a1b9f38](https://github.com/epiral/bb-browser/commit/a1b9f386176d3eba815f6e40fa9d307e410af513)) -* **docs:** update ClawHub skill link in English README ([61a3493](https://github.com/epiral/bb-browser/commit/61a349313b3c17e1a79808ab7a080f84eaabc417)) -* eval 命令使用 MAIN world 访问页面 DOM ([c16b442](https://github.com/epiral/bb-browser/commit/c16b442bb6af10d9d2d90aa8cf22f5e7b936578f)) -* **extension:** add history permission to source manifest ([526551e](https://github.com/epiral/bb-browser/commit/526551e071a864cda2ded206d8de413abeddd489)) -* **extension:** never give up reconnecting to daemon ([5e8ca91](https://github.com/epiral/bb-browser/commit/5e8ca91d4eaa3641ebde2ecc29289459a568fc59)) -* follow-up fixes for merged PRs [#82](https://github.com/epiral/bb-browser/issues/82), [#91](https://github.com/epiral/bb-browser/issues/91), [#25](https://github.com/epiral/bb-browser/issues/25) ([ee2c404](https://github.com/epiral/bb-browser/commit/ee2c4042455c17b65bedb5b4ac8f8d3785f5f60f)) -* improve extension setup hints with direct download link ([0491e72](https://github.com/epiral/bb-browser/commit/0491e72c4fe8e1d75c8e1cd5ea13ba21d1dbdf91)) -* interactive 模式去掉 /url: 和子节点,扁平输出 ([2bc77bc](https://github.com/epiral/bb-browser/commit/2bc77bcf17265b56e0d4790b8261880871e625f3)) -* open new tabs in background to avoid bringing Chrome to foreground ([6d8f18b](https://github.com/epiral/bb-browser/commit/6d8f18b39336524a4c808f35b75d05d104260098)) -* process hangs after CDP command completes ([4d63ae2](https://github.com/epiral/bb-browser/commit/4d63ae29353272daef3c34c7714a8505b36c6c69)) -* remove replMode from CDP evaluate to fix async/Promise results ([ea6f08c](https://github.com/epiral/bb-browser/commit/ea6f08c007fbe2c6e9460c5f79bb690185b033bf)) -* restore npm package name to bb-browser ([ab43e2b](https://github.com/epiral/bb-browser/commit/ab43e2bfe455a2f04bf26194726f7e1e8eeef098)) -* **shared:** avoid global crypto for request ids ([#89](https://github.com/epiral/bb-browser/issues/89)) ([45c1fbb](https://github.com/epiral/bb-browser/commit/45c1fbb385f21450fcd2c0b9ac1c6c1c6aef60dc)) -* simplify extension setup hints to 4 clear steps ([ce12d3a](https://github.com/epiral/bb-browser/commit/ce12d3a9f8fc8f94911c1c4312915fa9379cd18a)) -* **site:** add guide hint to site --help ([9407040](https://github.com/epiral/bb-browser/commit/94070402e1c76cd65fc80b4882980acfe8c4e741)) -* **site:** detect auth errors in adapter hint text too ([9d8594a](https://github.com/epiral/bb-browser/commit/9d8594a1687cfdae5d21797276ff235072c52baa)) -* **site:** filter noise from recommend not_available list ([31fb169](https://github.com/epiral/bb-browser/commit/31fb169516a9129aa0b5748a937b5eb45a94118c)) -* snapshot, tab list, and discovery overhead in CDP mode ([c3a3bf8](https://github.com/epiral/bb-browser/commit/c3a3bf8d1ded85aad5646a00667ea290bdf34351)) -* support top-level await in eval ([cfbae07](https://github.com/epiral/bb-browser/commit/cfbae0763abf4a2cdfc773cb26f3ddbccfa43459)) -* update openclaw-bridge tests for new flag ordering ([#102](https://github.com/epiral/bb-browser/issues/102)) ([cbdd26c](https://github.com/epiral/bb-browser/commit/cbdd26c2c8159acf48580d4af8ef069d2e646e4f)) -* use fileURLToPath for Windows-compatible MCP path resolution ([7061ab7](https://github.com/epiral/bb-browser/commit/7061ab792a3998a776a01350a3e270661ab06464)), closes [#17](https://github.com/epiral/bb-browser/issues/17) -* 使用 CDP Runtime.evaluate 替代 eval 绕过 MV3 CSP 限制 ([c74fca3](https://github.com/epiral/bb-browser/commit/c74fca3a2a7a1d4b7dcda9f7a8080c97d2ab0470)) -* 修复 back/forward 命令 - 使用 CDP 和 tabs.update 导航 ([8395ae7](https://github.com/epiral/bb-browser/commit/8395ae7a07525c0aac88b07819e65660f8f46173)) -* 修复 trace 录制 - manifest 匹配规则和页面导航后自动恢复录制 ([fa39bdb](https://github.com/epiral/bb-browser/commit/fa39bdb360759fa24267c50f12bba277a74e47b2)) -* 解决 MV3 Service Worker 休眠导致连接断开的问题 ([4c7c64f](https://github.com/epiral/bb-browser/commit/4c7c64f8bac07874f7d440afdb99d5c396f1e6b3)) -* 选项页修改 URL 后立即重连 SSE ([e67e69f](https://github.com/epiral/bb-browser/commit/e67e69fc33b030ac79606baf67615bf689891b51)) - ## [0.10.0](https://github.com/epiral/bb-browser/compare/bb-browser-v0.9.0...bb-browser-v0.10.0) (2026-03-22) diff --git a/bin/bb-browserd.ts b/bin/bb-browserd.ts index 12ed8819..46cdbeb7 100644 --- a/bin/bb-browserd.ts +++ b/bin/bb-browserd.ts @@ -1,45 +1,23 @@ #!/usr/bin/env bun -/** - * bb-browserd — Edge Clip provider for Pinix Hub - * - * Thin adapter: reads the command registry, registers ALL commands with Hub, - * and translates Hub invocations to daemon HTTP POST /command calls. - * - * No direct CDP code — all browser interaction goes through the daemon. - */ - import { createClient, type CallOptions } from "@connectrpc/connect"; import { createGrpcTransport } from "@connectrpc/connect-node"; import { fileDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; -import { COMMANDS, type CommandDef } from "../packages/shared/src/commands.ts"; -import { COMMAND_TIMEOUT, generateId } from "../packages/shared/src/index.ts"; -import type { Request, Response } from "../packages/shared/src/protocol.ts"; +import { COMMAND_TIMEOUT } from "../packages/shared/src/constants.ts"; import { readFileSync } from "node:fs"; -import { readFile, unlink } from "node:fs/promises"; -import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join, dirname, resolve } from "node:path"; -import { existsSync } from "node:fs"; +import { join } from "node:path"; import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { request as httpRequest } from "node:http"; -import type { z } from "zod"; +import * as path from "node:path"; declare const process: { argv: string[]; env: Record; exit(code?: number): never; on(event: string, listener: (...args: unknown[]) => void): void; - execPath: string; - kill(pid: number, signal: number): void; - platform: string; }; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - const DEFAULT_PINIX_URL = "http://127.0.0.1:9000"; const DEFAULT_CLIP_NAME = "browser"; const PROVIDER_NAME_PREFIX = "bb-browserd"; @@ -48,322 +26,23 @@ const CLIP_DOMAIN = "浏览器"; const RECONNECT_DELAY_MS = 5000; const REGISTER_TIMEOUT_MS = 10000; const HEARTBEAT_INTERVAL_MS = 15000; +const DEFAULT_CDP_PORT = 19825; +const CDP_PORT_FILE = join(homedir(), ".bb-browser", "browser", "cdp-port"); +const WAIT_POLL_INTERVAL = 200; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); -// --------------------------------------------------------------------------- -// Daemon connection -// --------------------------------------------------------------------------- - -const DAEMON_DIR = process.env.BB_BROWSER_HOME || join(homedir(), ".bb-browser"); -const DAEMON_JSON = join(DAEMON_DIR, "daemon.json"); - -interface DaemonInfo { - pid: number; - host: string; - port: number; - token: string; -} - -let cachedDaemonInfo: DaemonInfo | null = null; -let daemonReady = false; - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function httpJson( - method: "GET" | "POST", - urlPath: string, - info: { host: string; port: number; token: string }, - body?: unknown, - timeout = 5000, -): Promise { - return new Promise((resolveP, reject) => { - const payload = body !== undefined ? JSON.stringify(body) : undefined; - const req = httpRequest( - { - hostname: info.host, - port: info.port, - path: urlPath, - method, - headers: { - Authorization: `Bearer ${info.token}`, - ...(payload - ? { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(payload), - } - : {}), - }, - timeout, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); - res.on("end", () => { - const raw = Buffer.concat(chunks).toString("utf8"); - if ((res.statusCode ?? 500) >= 400) { - reject(new Error(`Daemon HTTP ${res.statusCode}: ${raw}`)); - return; - } - try { - resolveP(JSON.parse(raw) as T); - } catch { - reject(new Error(`Invalid JSON from daemon: ${raw}`)); - } - }); - }, - ); - req.on("error", reject); - req.on("timeout", () => { - req.destroy(); - reject(new Error("Daemon request timed out")); - }); - if (payload) req.write(payload); - req.end(); - }); -} - -async function readDaemonJson(): Promise { - try { - const raw = await readFile(DAEMON_JSON, "utf8"); - const info = JSON.parse(raw) as DaemonInfo; - if ( - typeof info.pid === "number" && - typeof info.host === "string" && - typeof info.port === "number" && - typeof info.token === "string" - ) { - return info; - } - return null; - } catch { - return null; - } -} - -async function deleteDaemonJson(): Promise { - try { - await unlink(DAEMON_JSON); - } catch {} -} - -function getDaemonPath(): string { - const currentFile = fileURLToPath(import.meta.url); - const currentDir = dirname(currentFile); - // In dev: ../packages/daemon/dist/index.js - // In release: ../dist/daemon.js - const releasePath = resolve(currentDir, "../dist/daemon.js"); - if (existsSync(releasePath)) return releasePath; - return resolve(currentDir, "../packages/daemon/dist/index.js"); -} - -async function ensureDaemon(): Promise { - if (daemonReady && cachedDaemonInfo) { - try { - await httpJson<{ running: boolean }>("GET", "/status", cachedDaemonInfo, undefined, 2000); - return; - } catch { - daemonReady = false; - cachedDaemonInfo = null; - } - } - - let info = await readDaemonJson(); - if (info) { - if (!isProcessAlive(info.pid)) { - await deleteDaemonJson(); - info = null; - } else { - try { - const status = await httpJson<{ running?: boolean }>("GET", "/status", info, undefined, 2000); - if (status.running) { - cachedDaemonInfo = info; - daemonReady = true; - return; - } - } catch { - // Daemon process exists but HTTP not responding — fall through to spawn - } - } - } - - // Spawn daemon (it handles Chrome discovery internally) - const daemonPath = getDaemonPath(); - console.log(`[bb-browserd] Spawning daemon: ${daemonPath}`); - const child = spawn(process.execPath, [daemonPath], { - detached: true, - stdio: "ignore", - }); - child.unref(); - - // Wait for daemon to become healthy (up to 15 seconds — includes Chrome launch time) - const deadline = Date.now() + 15000; - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 300)); - info = await readDaemonJson(); - if (!info) continue; - try { - const status = await httpJson<{ running?: boolean }>("GET", "/status", info, undefined, 2000); - if (status.running) { - cachedDaemonInfo = info; - daemonReady = true; - console.log(`[bb-browserd] Daemon ready at ${info.host}:${info.port}`); - return; - } - } catch { - // Not ready yet - } - } - - throw new Error("bb-browserd: Daemon did not start in time"); -} - -async function daemonCommand(request: Request): Promise { - if (!cachedDaemonInfo) { - cachedDaemonInfo = await readDaemonJson(); - } - if (!cachedDaemonInfo) { - throw new Error("No daemon.json found. Is the daemon running?"); - } - return httpJson("POST", "/command", cachedDaemonInfo, request, COMMAND_TIMEOUT); -} - -// --------------------------------------------------------------------------- -// Zod → JSON Schema (lightweight inline converter) -// --------------------------------------------------------------------------- - -function zodToJsonSchema(schema: z.ZodTypeAny): Record { - return convertZodType(schema); -} - -function convertZodType(schema: z.ZodTypeAny): Record { - const def = (schema as any)._def; - const typeName: string = def?.typeName ?? ""; - - // Unwrap wrappers (preserve outer description) - if (typeName === "ZodOptional" || typeName === "ZodNullable") { - const outerDesc = def.description; - const inner = convertZodType(def.innerType); - if (outerDesc && !inner.description) inner.description = outerDesc; - return inner; - } - if (typeName === "ZodDefault") { - const outerDesc = def.description; - const inner = convertZodType(def.innerType); - inner.default = def.defaultValue(); - if (outerDesc && !inner.description) inner.description = outerDesc; - return inner; - } - if (typeName === "ZodEffects") { - return convertZodType(def.schema); - } - - // Get description - const description = def?.description; - const base: Record = {}; - if (description) base.description = description; - - if (typeName === "ZodObject") { - const shape = def.shape?.() ?? {}; - const properties: Record = {}; - const required: string[] = []; - - for (const [key, value] of Object.entries(shape)) { - properties[key] = convertZodType(value as z.ZodTypeAny); - if (!isOptional(value as z.ZodTypeAny)) { - required.push(key); - } - } - - return { - ...base, - type: "object", - properties, - ...(required.length > 0 ? { required } : {}), - additionalProperties: true, - }; - } - - if (typeName === "ZodString") { - return { ...base, type: "string" }; - } - - if (typeName === "ZodNumber") { - return { ...base, type: "number" }; - } - - if (typeName === "ZodBoolean") { - return { ...base, type: "boolean" }; - } - - if (typeName === "ZodEnum") { - return { ...base, type: "string", enum: def.values }; - } - - if (typeName === "ZodLiteral") { - return { ...base, const: def.value }; - } - - if (typeName === "ZodUnion") { - const options = (def.options as z.ZodTypeAny[]).map(convertZodType); - return { ...base, oneOf: options }; - } - - if (typeName === "ZodArray") { - return { ...base, type: "array", items: convertZodType(def.type) }; - } - - if (typeName === "ZodRecord") { - return { - ...base, - type: "object", - additionalProperties: convertZodType(def.valueType), - }; - } - - // Fallback - return { ...base, type: "object", additionalProperties: true }; -} - -function isOptional(schema: z.ZodTypeAny): boolean { - const def = (schema as any)._def; - const typeName: string = def?.typeName ?? ""; - if (typeName === "ZodOptional") return true; - if (typeName === "ZodDefault") return true; - return false; -} - -// --------------------------------------------------------------------------- -// Command registry → Hub registration -// --------------------------------------------------------------------------- - -interface CommandInfoRegistration { - name: string; - description: string; - input: string; - output: string; -} - -const COMMAND_INFOS: CommandInfoRegistration[] = COMMANDS.map((cmd) => ({ - name: cmd.name, - description: cmd.description, - input: JSON.stringify(zodToJsonSchema(cmd.args)), - output: JSON.stringify({ type: "object", additionalProperties: true }), -})); - -const COMMAND_NAMES = COMMANDS.map((c) => c.name); - -// --------------------------------------------------------------------------- -// ProviderStream protocol types -// --------------------------------------------------------------------------- - +const CAPABILITIES = [ + "navigate", + "click", + "type", + "evaluate", + "screenshot", + "getCookies", + "waitForSelector", +] as const; + +type CapabilityCommand = (typeof CAPABILITIES)[number]; type InputObject = Record; interface Options { @@ -371,6 +50,13 @@ interface Options { name: string; } +interface CommandRegistration { + name: CapabilityCommand; + description: string; + input: Record; + output: Record; +} + interface HubErrorPayload { code?: string; message?: string; @@ -419,10 +105,11 @@ interface HubMessage { payload: HubMessagePayload; } -interface RegisterRequestPayload { - providerName: string; - acceptsManage: boolean; - clips: ClipRegistrationPayload[]; +interface CommandInfoRegistration { + name: string; + description: string; + input: string; + output: string; } interface ClipRegistrationPayload { @@ -436,6 +123,12 @@ interface ClipRegistrationPayload { tokenProtected: boolean; } +interface RegisterRequestPayload { + providerName: string; + acceptsManage: boolean; + clips: ClipRegistrationPayload[]; +} + interface InvokeResultPayload { requestId: string; output?: Uint8Array; @@ -463,10 +156,6 @@ interface HubClient { providerStream(request: AsyncIterable, options?: CallOptions): AsyncIterable; } -// --------------------------------------------------------------------------- -// Error helpers -// --------------------------------------------------------------------------- - class PinixInvokeError extends Error { constructor( readonly code: string, @@ -477,24 +166,6 @@ class PinixInvokeError extends Error { } } -function formatError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function toHubError(error: unknown): HubErrorPayload { - if (error instanceof PinixInvokeError) { - return { code: error.code, message: error.message }; - } - return { code: "internal", message: formatError(error) }; -} - -// --------------------------------------------------------------------------- -// AsyncMessageQueue (unchanged from original) -// --------------------------------------------------------------------------- - class AsyncMessageQueue implements AsyncIterable, AsyncIterator { private readonly values: T[] = []; private readonly waiters: Array<{ @@ -570,32 +241,493 @@ class AsyncMessageQueue implements AsyncIterable, AsyncIterator { } } -// --------------------------------------------------------------------------- -// Protobuf descriptor for HubService -// --------------------------------------------------------------------------- +const COMMAND_REGISTRATIONS: Record = { + navigate: { + name: "navigate", + description: "打开页面", + input: { + type: "object", + properties: { + url: { type: "string", description: "目标 URL" }, + waitUntil: { + type: "string", + enum: ["load", "domcontentloaded", "networkidle"], + description: "等待策略(当前仅作兼容保留)", + }, + }, + required: ["url"], + additionalProperties: true, + }, + output: { + type: "object", + properties: { + url: { type: "string" }, + title: { type: "string" }, + }, + required: ["url", "title"], + additionalProperties: false, + }, + }, + click: { + name: "click", + description: "点击元素", + input: { + type: "object", + properties: { + selector: { type: "string", description: "CSS 选择器" }, + }, + required: ["selector"], + additionalProperties: true, + }, + output: { + type: "object", + additionalProperties: false, + }, + }, + type: { + name: "type", + description: "向输入框追加文本", + input: { + type: "object", + properties: { + selector: { type: "string", description: "CSS 选择器" }, + text: { type: "string", description: "要输入的文本" }, + delay: { type: "number", description: "字符延迟(当前忽略)" }, + }, + required: ["selector", "text"], + additionalProperties: true, + }, + output: { + type: "object", + additionalProperties: false, + }, + }, + evaluate: { + name: "evaluate", + description: "执行页面 JavaScript", + input: { + type: "object", + properties: { + js: { type: "string", description: "要执行的 JavaScript" }, + }, + required: ["js"], + additionalProperties: true, + }, + output: { + type: "object", + properties: { + result: {}, + }, + required: ["result"], + additionalProperties: false, + }, + }, + screenshot: { + name: "screenshot", + description: "截取当前页面 PNG", + input: { + type: "object", + properties: { + fullPage: { type: "boolean", description: "整页截图(当前忽略)" }, + }, + additionalProperties: true, + }, + output: { + type: "object", + properties: { + base64: { type: "string", description: "PNG base64 数据" }, + }, + required: ["base64"], + additionalProperties: false, + }, + }, + getCookies: { + name: "getCookies", + description: "读取当前页面 Cookie", + input: { + type: "object", + additionalProperties: true, + }, + output: { + type: "object", + properties: { + cookies: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "string" }, + domain: { type: "string" }, + path: { type: "string" }, + }, + required: ["name", "value", "domain", "path"], + additionalProperties: false, + }, + }, + }, + required: ["cookies"], + additionalProperties: false, + }, + }, + waitForSelector: { + name: "waitForSelector", + description: "等待元素出现", + input: { + type: "object", + properties: { + selector: { type: "string", description: "CSS 选择器" }, + timeout: { type: "number", description: "超时时间(毫秒)" }, + }, + required: ["selector"], + additionalProperties: true, + }, + output: { + type: "object", + additionalProperties: false, + }, + }, +}; const file_pinix_v2_hub = fileDesc("ChJwaW5peC92Mi9odWIucHJvdG8SCHBpbml4LnYyIikKCEh1YkVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSJPCgtDb21tYW5kSW5mbxIMCgRuYW1lGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEg0KBWlucHV0GAMgASgJEg4KBm91dHB1dBgEIAEoCSLFAQoIQ2xpcEluZm8SDAoEbmFtZRgBIAEoCRIPCgdwYWNrYWdlGAIgASgJEg8KB3ZlcnNpb24YAyABKAkSEAoIcHJvdmlkZXIYBCABKAkSDgoGZG9tYWluGAUgASgJEicKCGNvbW1hbmRzGAYgAygLMhUucGluaXgudjIuQ29tbWFuZEluZm8SDwoHaGFzX3dlYhgHIAEoCBIXCg90b2tlbl9wcm90ZWN0ZWQYCCABKAgSFAoMZGVwZW5kZW5jaWVzGAkgAygJIsUBCgxDbGlwTWFuaWZlc3QSDAoEbmFtZRgBIAEoCRIPCgdwYWNrYWdlGAIgASgJEg8KB3ZlcnNpb24YAyABKAkSDgoGZG9tYWluGAQgASgJEhMKC2Rlc2NyaXB0aW9uGAUgASgJEicKCGNvbW1hbmRzGAYgAygLMhUucGluaXgudjIuQ29tbWFuZEluZm8SFAoMZGVwZW5kZW5jaWVzGAcgAygJEg8KB2hhc193ZWIYCCABKAgSEAoIcGF0dGVybnMYCSADKAkiWQoMUHJvdmlkZXJJbmZvEgwKBG5hbWUYASABKAkSFgoOYWNjZXB0c19tYW5hZ2UYAiABKAgSDQoFY2xpcHMYAyADKAkSFAoMY29ubmVjdGVkX2F0GAQgASgDIqwCCg9Qcm92aWRlck1lc3NhZ2USLQoIcmVnaXN0ZXIYASABKAsyGS5waW5peC52Mi5SZWdpc3RlclJlcXVlc3RIABIpCgpjbGlwX2FkZGVkGAIgASgLMhMucGluaXgudjIuQ2xpcEFkZGVkSAASLQoMY2xpcF9yZW1vdmVkGAMgASgLMhUucGluaXgudjIuQ2xpcFJlbW92ZWRIABIvCg1pbnZva2VfcmVzdWx0GAQgASgLMhYucGluaXgudjIuSW52b2tlUmVzdWx0SAASIwoEcGluZxgFIAEoCzITLnBpbml4LnYyLkhlYXJ0YmVhdEgAEi8KDW1hbmFnZV9yZXN1bHQYBiABKAsyFi5waW5peC52Mi5NYW5hZ2VSZXN1bHRIAEIJCgdwYXlsb2FkIooCCgpIdWJNZXNzYWdlEjcKEXJlZ2lzdGVyX3Jlc3BvbnNlGAEgASgLMhoucGluaXgudjIuUmVnaXN0ZXJSZXNwb25zZUgAEjEKDmludm9rZV9jb21tYW5kGAIgASgLMhcucGluaXgudjIuSW52b2tlQ29tbWFuZEgAEi0KDGludm9rZV9pbnB1dBgDIAEoCzIVLnBpbml4LnYyLkludm9rZUlucHV0SAASMQoObWFuYWdlX2NvbW1hbmQYBCABKAsyFy5waW5peC52Mi5NYW5hZ2VDb21tYW5kSAASIwoEcG9uZxgFIAEoCzITLnBpbml4LnYyLkhlYXJ0YmVhdEgAQgkKB3BheWxvYWQiawoPUmVnaXN0ZXJSZXF1ZXN0EhUKDXByb3ZpZGVyX25hbWUYASABKAkSFgoOYWNjZXB0c19tYW5hZ2UYAiABKAgSKQoFY2xpcHMYAyADKAsyGi5waW5peC52Mi5DbGlwUmVnaXN0cmF0aW9uIrsBChBDbGlwUmVnaXN0cmF0aW9uEgwKBG5hbWUYASABKAkSDwoHcGFja2FnZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJEg4KBmRvbWFpbhgEIAEoCRInCghjb21tYW5kcxgFIAMoCzIVLnBpbml4LnYyLkNvbW1hbmRJbmZvEg8KB2hhc193ZWIYBiABKAgSFAoMZGVwZW5kZW5jaWVzGAcgAygJEhcKD3Rva2VuX3Byb3RlY3RlZBgIIAEoCCJJCglDbGlwQWRkZWQSKAoEY2xpcBgBIAEoCzIaLnBpbml4LnYyLkNsaXBSZWdpc3RyYXRpb24SEgoKcmVxdWVzdF9pZBgCIAEoCSIvCgtDbGlwUmVtb3ZlZBIMCgRuYW1lGAEgASgJEhIKCnJlcXVlc3RfaWQYAiABKAkiYwoMSW52b2tlUmVzdWx0EhIKCnJlcXVlc3RfaWQYASABKAkSDgoGb3V0cHV0GAIgASgMEiEKBWVycm9yGAMgASgLMhIucGluaXgudjIuSHViRXJyb3ISDAoEZG9uZRgEIAEoCCJFCgxNYW5hZ2VSZXN1bHQSEgoKcmVxdWVzdF9pZBgBIAEoCRIhCgVlcnJvchgCIAEoCzISLnBpbml4LnYyLkh1YkVycm9yIjUKEFJlZ2lzdGVyUmVzcG9uc2USEAoIYWNjZXB0ZWQYASABKAgSDwoHbWVzc2FnZRgCIAEoCSJqCg1JbnZva2VDb21tYW5kEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJY2xpcF9uYW1lGAIgASgJEg8KB2NvbW1hbmQYAyABKAkSDQoFaW5wdXQYBCABKAwSEgoKY2xpcF90b2tlbhgFIAEoCSI9CgtJbnZva2VJbnB1dBISCgpyZXF1ZXN0X2lkGAEgASgJEgwKBGRhdGEYAiABKAwSDAoEZG9uZRgDIAEoCCKDAQoNTWFuYWdlQ29tbWFuZBISCgpyZXF1ZXN0X2lkGAEgASgJEiYKA2FkZBgCIAEoCzIXLnBpbml4LnYyLkFkZENsaXBBY3Rpb25IABIsCgZyZW1vdmUYAyABKAsyGi5waW5peC52Mi5SZW1vdmVDbGlwQWN0aW9uSABCCAoGYWN0aW9uIkEKDUFkZENsaXBBY3Rpb24SDgoGc291cmNlGAEgASgJEgwKBG5hbWUYAiABKAkSEgoKY2xpcF90b2tlbhgDIAEoCSIlChBSZW1vdmVDbGlwQWN0aW9uEhEKCWNsaXBfbmFtZRgBIAEoCSIkCglIZWFydGJlYXQSFwoPc2VudF9hdF91bml4X21zGAEgASgDIhIKEExpc3RDbGlwc1JlcXVlc3QiNgoRTGlzdENsaXBzUmVzcG9uc2USIQoFY2xpcHMYASADKAsyEi5waW5peC52Mi5DbGlwSW5mbyIWChRMaXN0UHJvdmlkZXJzUmVxdWVzdCJCChVMaXN0UHJvdmlkZXJzUmVzcG9uc2USKQoJcHJvdmlkZXJzGAEgAygLMhYucGluaXgudjIuUHJvdmlkZXJJbmZvIicKEkdldE1hbmlmZXN0UmVxdWVzdBIRCgljbGlwX25hbWUYASABKAkiPwoTR2V0TWFuaWZlc3RSZXNwb25zZRIoCghtYW5pZmVzdBgBIAEoCzIWLnBpbml4LnYyLkNsaXBNYW5pZmVzdCJLChFHZXRDbGlwV2ViUmVxdWVzdBIRCgljbGlwX25hbWUYASABKAkSDAoEcGF0aBgCIAEoCRIVCg1pZl9ub25lX21hdGNoGAMgASgJIl8KEkdldENsaXBXZWJSZXNwb25zZRIPCgdjb250ZW50GAEgASgMEhQKDGNvbnRlbnRfdHlwZRgCIAEoCRIMCgRldGFnGAMgASgJEhQKDG5vdF9tb2RpZmllZBgEIAEoCCJWCg1JbnZva2VSZXF1ZXN0EhEKCWNsaXBfbmFtZRgBIAEoCRIPCgdjb21tYW5kGAIgASgJEg0KBWlucHV0GAMgASgMEhIKCmNsaXBfdG9rZW4YBCABKAkiQwoOSW52b2tlUmVzcG9uc2USDgoGb3V0cHV0GAEgASgMEiEKBWVycm9yGAIgASgLMhIucGluaXgudjIuSHViRXJyb3IicQoTSW52b2tlU3RyZWFtTWVzc2FnZRIoCgVzdGFydBgBIAEoCzIXLnBpbml4LnYyLkludm9rZVJlcXVlc3RIABIlCgVjaHVuaxgCIAEoCzIULnBpbml4LnYyLklucHV0Q2h1bmtIAEIJCgdwYXlsb2FkIigKCklucHV0Q2h1bmsSDAoEZGF0YRgBIAEoDBIMCgRkb25lGAIgASgIIlQKDkFkZENsaXBSZXF1ZXN0Eg4KBnNvdXJjZRgBIAEoCRIMCgRuYW1lGAIgASgJEhAKCHByb3ZpZGVyGAMgASgJEhIKCmNsaXBfdG9rZW4YBCABKAkiMwoPQWRkQ2xpcFJlc3BvbnNlEiAKBGNsaXAYASABKAsyEi5waW5peC52Mi5DbGlwSW5mbyImChFSZW1vdmVDbGlwUmVxdWVzdBIRCgljbGlwX25hbWUYASABKAkiJwoSUmVtb3ZlQ2xpcFJlc3BvbnNlEhEKCWNsaXBfbmFtZRgBIAEoCTKVBQoKSHViU2VydmljZRJFCg5Qcm92aWRlclN0cmVhbRIZLnBpbml4LnYyLlByb3ZpZGVyTWVzc2FnZRoULnBpbml4LnYyLkh1Yk1lc3NhZ2UoATABEkQKCUxpc3RDbGlwcxIaLnBpbml4LnYyLkxpc3RDbGlwc1JlcXVlc3QaGy5waW5peC52Mi5MaXN0Q2xpcHNSZXNwb25zZRJQCg1MaXN0UHJvdmlkZXJzEh4ucGluaXgudjIuTGlzdFByb3ZpZGVyc1JlcXVlc3QaHy5waW5peC52Mi5MaXN0UHJvdmlkZXJzUmVzcG9uc2USSgoLR2V0TWFuaWZlc3QSHC5waW5peC52Mi5HZXRNYW5pZmVzdFJlcXVlc3QaHS5waW5peC52Mi5HZXRNYW5pZmVzdFJlc3BvbnNlEkcKCkdldENsaXBXZWISGy5waW5peC52Mi5HZXRDbGlwV2ViUmVxdWVzdBocLnBpbml4LnYyLkdldENsaXBXZWJSZXNwb25zZRI9CgZJbnZva2USFy5waW5peC52Mi5JbnZva2VSZXF1ZXN0GhgucGluaXgudjIuSW52b2tlUmVzcG9uc2UwARJLCgxJbnZva2VTdHJlYW0SHS5waW5peC52Mi5JbnZva2VTdHJlYW1NZXNzYWdlGhgucGluaXgudjIuSW52b2tlUmVzcG9uc2UoATABEj4KB0FkZENsaXASGC5waW5peC52Mi5BZGRDbGlwUmVxdWVzdBoZLnBpbml4LnYyLkFkZENsaXBSZXNwb25zZRJHCgpSZW1vdmVDbGlwEhsucGluaXgudjIuUmVtb3ZlQ2xpcFJlcXVlc3QaHC5waW5peC52Mi5SZW1vdmVDbGlwUmVzcG9uc2VCMVovZ2l0aHViLmNvbS9lcGlyYWwvcGluaXgvZ2VuL2dvL3Bpbml4L3YyO3Bpbml4djJiBnByb3RvMw"); const HubServiceDescriptor = serviceDesc(file_pinix_v2_hub, 0); -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +const COMMAND_INFOS = CAPABILITIES.map((command) => ({ + name: command, + description: COMMAND_REGISTRATIONS[command].description, + input: JSON.stringify(COMMAND_REGISTRATIONS[command].input), + output: JSON.stringify(COMMAND_REGISTRATIONS[command].output), +} satisfies CommandInfoRegistration)); const CLIP_VERSION = readPackageVersion(); -function readPackageVersion(): string { +// CDP client + +interface CdpTarget { + id: string; + type: string; + title: string; + url: string; + webSocketDebuggerUrl?: string; +} + +let cdpSocket: WebSocket | null = null; +let cdpNextId = 1; +let cdpSessionId: string | null = null; +let cdpTargetId: string | null = null; +const cdpPending = new Map void; reject: (e: Error) => void }>(); + +function discoverCdpPort(): number { try { - const packageJson = JSON.parse( - readFileSync(new URL("../package.json", import.meta.url), "utf-8"), - ) as { version?: string }; - return typeof packageJson.version === "string" && packageJson.version.trim() - ? packageJson.version.trim() - : "0.0.0"; + const content = readFileSync(CDP_PORT_FILE, "utf-8").trim(); + const port = parseInt(content, 10); + return isNaN(port) ? DEFAULT_CDP_PORT : port; } catch { - return "0.0.0"; + return DEFAULT_CDP_PORT; + } +} + +async function canConnect(port: number): Promise { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`); + return res.ok; + } catch { + return false; + } +} + +function findBrowserExecutable(): string | null { + const candidates = process.platform === "darwin" + ? [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ] + : [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + "/usr/bin/microsoft-edge", + ]; + for (const p of candidates) { + try { readFileSync(p); return p; } catch {} + } + return null; +} + +async function launchManagedBrowser(port: number): Promise { + const executable = findBrowserExecutable(); + if (!executable) { + console.log("[bb-browserd] No browser executable found, cannot auto-launch"); + return false; + } + const userDataDir = join(homedir(), ".bb-browser", "browser", "chrome-data"); + await mkdir(userDataDir, { recursive: true }); + console.log(`[bb-browserd] Launching browser: ${path.basename(executable)} on port ${port}`); + try { + const child = spawn(executable, [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${userDataDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-sync", + "about:blank", + ], { detached: true, stdio: "ignore" }); + child.unref(); + } catch { + return false; + } + // Wait for CDP to be ready + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + if (await canConnect(port)) { + await mkdir(join(homedir(), ".bb-browser", "browser"), { recursive: true }); + await writeFile(join(homedir(), ".bb-browser", "browser", "cdp-port"), String(port), "utf8"); + console.log(`[bb-browserd] Browser launched, CDP ready on port ${port}`); + return true; + } + await new Promise(r => setTimeout(r, 300)); + } + console.log("[bb-browserd] Browser launched but CDP not ready within timeout"); + return false; +} + +async function ensureCdp(): Promise { + if (cdpSocket && cdpSocket.readyState === WebSocket.OPEN && cdpSessionId) { + return; + } + + const port = discoverCdpPort(); + if (!await canConnect(port)) { + // Auto-launch browser + const launched = await launchManagedBrowser(port); + if (!launched) { + throw new Error(`Chrome not reachable at port ${port} and auto-launch failed`); + } + } + const versionRes = await fetch(`http://127.0.0.1:${port}/json/version`); + if (!versionRes.ok) { + throw new Error(`Chrome not reachable at port ${port}`); } + const version = (await versionRes.json()) as { webSocketDebuggerUrl?: string }; + const wsUrl = version.webSocketDebuggerUrl; + if (!wsUrl) { + throw new Error("Chrome CDP missing webSocketDebuggerUrl"); + } + + await new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + ws.onopen = () => { + cdpSocket = ws; + console.log(`[bb-browserd] CDP connected to ${wsUrl}`); + resolve(); + }; + ws.onerror = () => reject(new Error("CDP WebSocket connection failed")); + ws.onmessage = (event) => { + try { + const data = typeof event.data === "string" + ? event.data + : readBinaryText(event.data); + if (!data) { + return; + } + const msg = JSON.parse(data) as { + id?: number; + result?: unknown; + error?: { message?: string }; + }; + if (typeof msg.id === "number") { + const pending = cdpPending.get(msg.id); + if (pending) { + cdpPending.delete(msg.id); + if (msg.error) { + pending.reject(new Error(msg.error.message || "CDP error")); + } else { + pending.resolve(msg.result); + } + } + } + } catch { + // Ignore malformed CDP frames. + } + }; + ws.onclose = () => { + cdpSocket = null; + cdpSessionId = null; + cdpTargetId = null; + console.error("[bb-browserd] CDP disconnected"); + }; + }); + + const targetsRes = await fetch(`http://127.0.0.1:${port}/json/list`); + const targets = (await targetsRes.json()) as CdpTarget[]; + const page = targets.find((target) => target.type === "page"); + if (!page) { + throw new Error("No page target found in Chrome"); + } + + const attachResult = await cdpBrowserCommand<{ sessionId: string }>("Target.attachToTarget", { + targetId: page.id, + flatten: true, + }); + cdpSessionId = attachResult.sessionId; + cdpTargetId = page.id; + console.log(`[bb-browserd] Attached to target: ${page.title} (${page.url})`); +} + +function cdpBrowserCommand(method: string, params: Record = {}): Promise { + if (!cdpSocket || cdpSocket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("CDP not connected")); + } + const id = cdpNextId++; + return new Promise((resolve, reject) => { + cdpPending.set(id, { resolve, reject }); + cdpSocket!.send(JSON.stringify({ id, method, params })); + setTimeout(() => { + if (cdpPending.has(id)) { + cdpPending.delete(id); + reject(new Error(`CDP command ${method} timed out`)); + } + }, COMMAND_TIMEOUT); + }); } +function cdpSessionCommand(method: string, params: Record = {}): Promise { + if (!cdpSocket || cdpSocket.readyState !== WebSocket.OPEN || !cdpSessionId) { + return Promise.reject(new Error("CDP session not available")); + } + const id = cdpNextId++; + return new Promise((resolve, reject) => { + cdpPending.set(id, { resolve, reject }); + cdpSocket!.send(JSON.stringify({ id, method, params, sessionId: cdpSessionId })); + setTimeout(() => { + if (cdpPending.has(id)) { + cdpPending.delete(id); + reject(new Error(`CDP command ${method} timed out`)); + } + }, COMMAND_TIMEOUT); + }); +} + +// Browser commands + +async function cmdNavigate(input: InputObject): Promise { + const url = getRequiredString(input, "url"); + await ensureCdp(); + await cdpSessionCommand("Page.navigate", { url }); + await delay(1000); + const result = await cdpSessionCommand<{ result: { value: unknown } }>("Runtime.evaluate", { + expression: "JSON.stringify({url: location.href, title: document.title})", + returnByValue: true, + }); + return JSON.parse(String(result.result.value)); +} + +async function cmdClick(input: InputObject): Promise { + const selector = getRequiredString(input, "selector", "ref"); + await ensureCdp(); + await cdpSessionCommand("Runtime.evaluate", { + expression: `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) throw new Error(${JSON.stringify(`Element not found: ${selector}`)}); el.click(); })()`, + awaitPromise: true, + returnByValue: true, + }); + return {}; +} + +async function cmdType(input: InputObject): Promise { + const selector = getRequiredString(input, "selector", "ref"); + const text = getRequiredStringAllowEmpty(input, "text"); + await ensureCdp(); + await cdpSessionCommand("Runtime.evaluate", { + expression: `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) throw new Error(${JSON.stringify(`Element not found: ${selector}`)}); el.focus(); el.value += ${JSON.stringify(text)}; el.dispatchEvent(new Event("input", { bubbles: true })); })()`, + awaitPromise: true, + returnByValue: true, + }); + return {}; +} + +async function cmdEvaluate(input: InputObject): Promise { + const js = getRequiredString(input, "js", "script"); + await ensureCdp(); + const result = await cdpSessionCommand<{ + result: { value?: unknown }; + exceptionDetails?: { text?: string; exception?: { description?: string } }; + }>("Runtime.evaluate", { + expression: js, + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description + || result.exceptionDetails.text + || "Runtime.evaluate failed", + ); + } + return { result: result.result.value ?? null }; +} + +async function cmdScreenshot(_input: InputObject): Promise { + await ensureCdp(); + const result = await cdpSessionCommand<{ data: string }>("Page.captureScreenshot", { + format: "png", + }); + return { base64: result.data }; +} + +async function cmdGetCookies(_input: InputObject): Promise { + await ensureCdp(); + const result = await cdpSessionCommand<{ cookies: Array<{ name: string; value: string; domain: string; path: string }> }>( + "Network.getCookies", + {}, + ); + return { + cookies: result.cookies.map((cookie) => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + })), + }; +} + +async function cmdWaitForSelector(input: InputObject): Promise { + const selector = getRequiredString(input, "selector", "ref"); + const timeout = typeof input.timeout === "number" ? input.timeout : 10000; + await ensureCdp(); + + const start = Date.now(); + while (Date.now() - start < timeout) { + const result = await cdpSessionCommand<{ result: { value: unknown } }>("Runtime.evaluate", { + expression: `document.querySelector(${JSON.stringify(selector)}) !== null`, + returnByValue: true, + }); + if (result.result.value === true) { + return {}; + } + await delay(WAIT_POLL_INTERVAL); + } + throw new Error(`Timeout waiting for selector: ${selector}`); +} + +const COMMAND_HANDLERS: Record Promise> = { + navigate: cmdNavigate, + click: cmdClick, + type: cmdType, + evaluate: cmdEvaluate, + screenshot: cmdScreenshot, + getCookies: cmdGetCookies, + waitForSelector: cmdWaitForSelector, +}; + +async function executeCommand(command: CapabilityCommand, input: InputObject): Promise { + return COMMAND_HANDLERS[command](input); +} + +// Helpers + function printUsage(): void { console.log(`Usage: bun run bin/bb-browserd.ts [--pinix ] [--name ] @@ -661,6 +793,19 @@ function normalizePinixBaseUrl(value: string): string { return url.toString().replace(/\/$/, ""); } +function readPackageVersion(): string { + try { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf-8"), + ) as { version?: string }; + return typeof packageJson.version === "string" && packageJson.version.trim() + ? packageJson.version.trim() + : "0.0.0"; + } catch { + return "0.0.0"; + } +} + function getProviderName(clipName: string): string { const suffix = clipName.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || DEFAULT_CLIP_NAME; return `${PROVIDER_NAME_PREFIX}-${suffix}`; @@ -690,6 +835,10 @@ function readHubToken(): string | null { return null; } +function isCapabilityCommand(command: string): command is CapabilityCommand { + return (CAPABILITIES as readonly string[]).includes(command); +} + function asInputObject(value: unknown): InputObject { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; @@ -697,6 +846,46 @@ function asInputObject(value: unknown): InputObject { return value as InputObject; } +function getRequiredString(input: InputObject, ...keys: string[]): string { + for (const key of keys) { + const value = input[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + throw new PinixInvokeError("invalid_argument", `Missing or invalid "${keys[0]}"`); +} + +function getRequiredStringAllowEmpty(input: InputObject, ...keys: string[]): string { + for (const key of keys) { + const value = input[key]; + if (typeof value === "string") { + return value; + } + } + throw new PinixInvokeError("invalid_argument", `Missing or invalid "${keys[0]}"`); +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function readBinaryText(data: unknown): string | null { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return textDecoder.decode(data); + } + if (ArrayBuffer.isView(data)) { + return textDecoder.decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); + } + return null; +} + function decodeInvokeInput(data: Uint8Array | undefined): InputObject { if (!data || data.length === 0) { return {}; @@ -718,103 +907,19 @@ function encodeInvokeOutput(value: unknown): Uint8Array { return textEncoder.encode(JSON.stringify(value ?? {})); } -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function maybeUnref(timer: ReturnType | ReturnType): void { - if (typeof timer === "object" && timer !== null && "unref" in timer && typeof timer.unref === "function") { - timer.unref(); - } -} - -// --------------------------------------------------------------------------- -// Command invocation via daemon -// --------------------------------------------------------------------------- - -/** - * Translate Hub command args into a daemon Request and execute via HTTP. - * - * Maps the command registry's field names to the daemon protocol: - * - `tab` → `tabId` (short tab ID) - * - `action` is set from the CommandDef - * - All other fields are passed through directly - */ -// --------------------------------------------------------------------------- -// Site commands — routed through CLI (not daemon) -// --------------------------------------------------------------------------- - -function runSiteCli(args: string[]): Promise { - return new Promise((resolve, reject) => { - // Use the installed bb-browser CLI binary, not source (which needs tsup define) - execFile("bb-browser", ["site", ...args], { timeout: 30000, encoding: "utf8" }, (err, stdout, stderr) => { - if (err) { - // Fallback: try node with dist/cli.js - const distPath = new URL("../dist/cli.js", import.meta.url).pathname; - execFile("node", [distPath, "site", ...args], { timeout: 30000, encoding: "utf8" }, (err2, stdout2, stderr2) => { - if (err2) reject(new Error(stderr2 || stderr || err2.message)); - else resolve(stdout2.trim()); - }); - } else { - resolve(stdout.trim()); - } - }); - }); -} - -const SITE_HANDLERS: Record Promise> = { - site_list: async () => JSON.parse(await runSiteCli(["list", "--json"])), - site_search: async (input) => JSON.parse(await runSiteCli(["search", String(input.query || ""), "--json"])), - site_info: async (input) => JSON.parse(await runSiteCli(["info", String(input.name || ""), "--json"])), - site_recommend: async (input) => JSON.parse(await runSiteCli(["recommend", "--json", ...(input.days ? ["--days", String(input.days)] : [])])), - site_run: async (input) => { - const name = String(input.name || ""); - const args = Array.isArray(input.args) ? input.args.map(String) : []; - return JSON.parse(await runSiteCli(["run", name, ...args, "--json"])); - }, - site_update: async () => JSON.parse(await runSiteCli(["update", "--json"])), -}; - -// --------------------------------------------------------------------------- -// Command execution — routes to daemon or CLI based on category -// --------------------------------------------------------------------------- - -async function executeViaDaemon(cmdName: string, input: InputObject): Promise { - const cmd = COMMANDS.find((c) => c.name === cmdName); - if (!cmd) { - throw new PinixInvokeError("not_found", `Unknown command: ${cmdName}`); - } - - // Site commands go through CLI, not daemon - const siteHandler = SITE_HANDLERS[cmdName]; - if (siteHandler) { - return siteHandler(input); - } - - await ensureDaemon(); - - // Build daemon Request from command args - const { tab, ...rest } = input; - const request: Request = { - id: generateId(), - action: cmd.action as Request["action"], - ...rest, - ...(tab !== undefined ? { tabId: tab } : {}), - } as Request; - - const response = await daemonCommand(request); - - if (!response.success) { - throw new PinixInvokeError("internal", response.error || "Command failed"); +function toHubError(error: unknown): HubErrorPayload { + if (error instanceof PinixInvokeError) { + return { + code: error.code, + message: error.message, + }; } - - return response.data ?? {}; + return { + code: "internal", + message: formatError(error), + }; } -// --------------------------------------------------------------------------- -// ProviderStream message builders -// --------------------------------------------------------------------------- - function buildRegisterPayload(options: Options): RegisterRequestPayload { return { providerName: getProviderName(options.name), @@ -885,9 +990,17 @@ function createManageUnsupportedMessage(requestId: string): ProviderMessage { }; } -// --------------------------------------------------------------------------- -// PinixBridge — ProviderStream connection to Hub -// --------------------------------------------------------------------------- +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function maybeUnref(timer: ReturnType | ReturnType): void { + if (typeof timer === "object" && timer !== null && "unref" in timer && typeof timer.unref === "function") { + timer.unref(); + } +} + +// Pinix bridge class PinixBridge { private readonly transport; @@ -990,7 +1103,7 @@ class PinixBridge { this.clearReconnectTimer(); console.log(`[bb-browserd] Connected to pinixd at ${this.options.pinixUrl}`); console.log( - `[bb-browserd] Registered clip "${this.options.name}" via provider "${getProviderName(this.options.name)}" with ${COMMAND_NAMES.length} commands: ${COMMAND_NAMES.join(", ")}`, + `[bb-browserd] Registered clip "${this.options.name}" via provider "${getProviderName(this.options.name)}" with commands: ${CAPABILITIES.join(", ")}`, ); break; } @@ -1050,12 +1163,12 @@ class PinixBridge { } const command = message.command?.trim() || ""; - if (!COMMAND_NAMES.includes(command)) { - throw new PinixInvokeError("not_found", `Unknown command: ${command || "(empty)"}`); + if (!isCapabilityCommand(command)) { + throw new PinixInvokeError("not_found", `Unknown browser command: ${command || "(empty)"}`); } const input = decodeInvokeInput(message.input); - const output = await executeViaDaemon(command, input); + const output = await executeCommand(command, input); this.send(inputQueue, createInvokeResultMessage(requestId, encodeInvokeOutput(output), undefined)); } catch (error) { this.send(inputQueue, createInvokeResultMessage(requestId, undefined, toHubError(error))); @@ -1066,7 +1179,7 @@ class PinixBridge { const requestId = message.requestId?.trim(); const hasData = Boolean(message.data && message.data.length > 0); if (requestId && (hasData || message.done === true)) { - console.warn(`[bb-browserd] Ignoring InvokeInput for unary command: ${requestId}`); + console.warn(`[bb-browserd] Ignoring InvokeInput for unary browser command: ${requestId}`); } } @@ -1100,9 +1213,7 @@ class PinixBridge { } } -// --------------------------------------------------------------------------- // Main -// --------------------------------------------------------------------------- function installProcessHandlers(bridge: PinixBridge): void { process.on("SIGINT", () => { @@ -1123,9 +1234,6 @@ function installProcessHandlers(bridge: PinixBridge): void { function main(): void { const options = parseArgs(process.argv.slice(2)); - - console.log(`[bb-browserd] Starting Edge Clip adapter (${COMMAND_NAMES.length} commands from registry)`); - const bridge = new PinixBridge(options); installProcessHandlers(bridge); bridge.start(); diff --git a/package.json b/package.json index 9d29ee02..235c5a8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bb-browser", - "version": "0.11.2", + "version": "0.11.0", "description": "Your browser is the API. CLI + MCP server for AI agents to control Chrome with your login state.", "type": "module", "bin": { diff --git a/packages/cli/package.json b/packages/cli/package.json index b8a5f6b6..d0002115 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "tsx --test src/openclaw-json.test.ts src/openclaw-bridge.test.ts src/__tests__/daemon-startup.test.ts" + "test": "tsx --test src/openclaw-json.test.ts src/openclaw-bridge.test.ts" }, "dependencies": { "@bb-browser/shared": "workspace:*" diff --git a/packages/cli/src/__tests__/daemon-startup.test.ts b/packages/cli/src/__tests__/daemon-startup.test.ts deleted file mode 100644 index 5927cad7..00000000 --- a/packages/cli/src/__tests__/daemon-startup.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Daemon startup tests — reproduces and verifies fixes for: - * - #136: "Daemon did not start in time" when no Chrome running - * - #143: "找不到chrome" on Windows/Linux - * - #141: IPv6/IPv4 mismatch - * - #118: daemon exits immediately - * - * Root cause: daemon-manager.ts spawns daemon without passing CDP info, - * daemon's own discoverCdpPort only tries port 19825 + managed port file, - * does NOT auto-launch Chrome. CLI's cdp-discovery.ts has full 6-level - * discovery (including auto-launch) but daemon never uses it. - * - * These tests verify the startup chain WITHOUT requiring a real Chrome browser. - */ - -import { describe, it, beforeEach, afterEach } from "node:test"; -import assert from "node:assert/strict"; -import { spawn, execSync } from "node:child_process"; -import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import http from "node:http"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -// Use isolated temp dir to avoid conflicts with parallel test suites -const DAEMON_DIR = path.join(os.tmpdir(), `bb-browser-test-startup-${process.pid}`); -mkdirSync(DAEMON_DIR, { recursive: true }); -process.env.BB_BROWSER_HOME = DAEMON_DIR; -const DAEMON_JSON = path.join(DAEMON_DIR, "daemon.json"); -const MANAGED_PORT_FILE = path.join(DAEMON_DIR, "browser", "cdp-port"); -const DAEMON_ENTRY = path.resolve( - import.meta.dirname, - "../../../daemon/src/index.ts", -); -const TSX = path.resolve( - import.meta.dirname, - "../../../../node_modules/.bin/tsx", -); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function readDaemonJson(): { pid: number; host: string; port: number; token: string } | null { - try { - return JSON.parse(readFileSync(DAEMON_JSON, "utf8")); - } catch { - return null; - } -} - -function cleanupDaemonJson(): void { - try { unlinkSync(DAEMON_JSON); } catch {} -} - -function cleanupManagedPortFile(): void { - try { unlinkSync(MANAGED_PORT_FILE); } catch {} -} - -/** Wait for daemon.json to appear (or timeout) */ -async function waitForDaemonJson(timeoutMs = 8000): Promise<{ pid: number; host: string; port: number; token: string }> { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const info = readDaemonJson(); - if (info) return info; - await new Promise(r => setTimeout(r, 200)); - } - throw new Error("daemon.json not created in time"); -} - -/** Start a fake CDP server that responds to /json/version */ -function startFakeCdpServer(port: number): Promise { - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { - if (req.url === "/json/version") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - Browser: "FakeChrome/1.0", - "Protocol-Version": "1.3", - webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/fake`, - })); - } else if (req.url === "/json/list" || req.url === "/json") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify([{ - id: "DEADBEEF1234", - type: "page", - title: "Test Page", - url: "about:blank", - webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/DEADBEEF1234`, - }])); - } else { - res.writeHead(404); - res.end(); - } - }); - server.on("error", reject); - server.listen(port, "127.0.0.1", () => resolve(server)); - }); -} - -function killProcess(pid: number): void { - try { process.kill(pid, "SIGTERM"); } catch {} -} - -function isProcessAlive(pid: number): boolean { - try { process.kill(pid, 0); return true; } catch { return false; } -} - -// --------------------------------------------------------------------------- -// Test: Daemon with no Chrome — reproduces #136, #143 -// --------------------------------------------------------------------------- - -describe("daemon startup without Chrome", () => { - beforeEach(() => { - cleanupDaemonJson(); - cleanupManagedPortFile(); - }); - - it("daemon exits with error when no CDP is available", async () => { - // Spawn daemon with a port that nothing is listening on - const unusedPort = 39999; - const child = spawn(TSX, [DAEMON_ENTRY, "--cdp-port", String(unusedPort)], { - stdio: ["ignore", "pipe", "pipe"], - }); - - let stderr = ""; - child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); - - const exitCode = await new Promise(resolve => { - child.on("exit", resolve); - }); - - assert.notEqual(exitCode, 0, "daemon should exit with non-zero when no Chrome"); - assert.match(stderr, /Cannot connect to Chrome CDP/, "should report CDP connection failure"); - assert.equal(readDaemonJson(), null, "daemon.json should NOT be written on failure"); - }); - - it("ensureDaemon flow fails within 5s when daemon cannot start (current bug)", async () => { - // This reproduces what users see: CLI spawns daemon, daemon can't find Chrome, exits - // CLI waits 5 seconds, never sees daemon.json, throws "Daemon did not start in time" - const unusedPort = 39999; - const child = spawn(TSX, [DAEMON_ENTRY, "--cdp-port", String(unusedPort)], { - detached: true, - stdio: "ignore", - }); - child.unref(); - - // daemon should exit quickly — daemon.json should never appear - await new Promise(r => setTimeout(r, 3000)); - assert.equal(readDaemonJson(), null, "daemon.json should not exist — daemon exited"); - }); -}); - -// --------------------------------------------------------------------------- -// Test: Daemon with fake Chrome — proves fix works -// --------------------------------------------------------------------------- - -describe("daemon startup with CDP available", () => { - let fakeCdp: http.Server | null = null; - const cdpPort = 39998; - let daemonPid: number | null = null; - - beforeEach(async () => { - cleanupDaemonJson(); - cleanupManagedPortFile(); - fakeCdp = await startFakeCdpServer(cdpPort); - }); - - afterEach(async () => { - if (daemonPid && isProcessAlive(daemonPid)) { - killProcess(daemonPid); - await new Promise(r => setTimeout(r, 500)); - } - daemonPid = null; - cleanupDaemonJson(); - if (fakeCdp) { - await new Promise(resolve => fakeCdp!.close(() => resolve())); - fakeCdp = null; - } - }); - - it("daemon starts successfully when CDP port is reachable", async () => { - const child = spawn(TSX, [DAEMON_ENTRY, "--cdp-port", String(cdpPort), "--port", "39997"], { - detached: true, - stdio: "ignore", - }); - child.unref(); - - const info = await waitForDaemonJson(); - daemonPid = info.pid; - - assert.equal(typeof info.pid, "number"); - assert.equal(typeof info.token, "string"); - assert.ok(info.token.length > 0, "token should be generated"); - assert.ok(isProcessAlive(info.pid), "daemon process should be alive"); - }); - - it("daemon writes correct host/port in daemon.json", async () => { - const child = spawn(TSX, [DAEMON_ENTRY, "--cdp-port", String(cdpPort), "--host", "127.0.0.1", "--port", "39996"], { - detached: true, - stdio: "ignore", - }); - child.unref(); - - const info = await waitForDaemonJson(); - daemonPid = info.pid; - - assert.equal(info.host, "127.0.0.1"); - assert.equal(info.port, 39996); - }); - - it("daemon HTTP /status responds when CDP is connected", async () => { - const child = spawn(TSX, [DAEMON_ENTRY, "--cdp-port", String(cdpPort), "--port", "39995"], { - detached: true, - stdio: "ignore", - }); - child.unref(); - - const info = await waitForDaemonJson(); - daemonPid = info.pid; - - // Wait a bit for CDP connection - await new Promise(r => setTimeout(r, 1000)); - - const status = await new Promise>((resolve, reject) => { - const req = http.request({ - hostname: info.host, - port: info.port, - path: "/status", - method: "GET", - headers: { Authorization: `Bearer ${info.token}` }, - timeout: 3000, - }, res => { - const chunks: Buffer[] = []; - res.on("data", (c: Buffer) => chunks.push(c)); - res.on("end", () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString())); - } catch (e) { - reject(e); - } - }); - }); - req.on("error", reject); - req.end(); - }); - - assert.equal(status.running, true, "/status should report running"); - }); -}); - -// --------------------------------------------------------------------------- -// Test: CLI ensureDaemon should pass CDP info to daemon -// --------------------------------------------------------------------------- - -describe("ensureDaemon passes CDP info to daemon", () => { - let fakeCdp: http.Server | null = null; - const cdpPort = 39994; - - beforeEach(async () => { - cleanupDaemonJson(); - cleanupManagedPortFile(); - // Write managed port file pointing to our fake CDP - mkdirSync(path.dirname(MANAGED_PORT_FILE), { recursive: true }); - writeFileSync(MANAGED_PORT_FILE, String(cdpPort)); - fakeCdp = await startFakeCdpServer(cdpPort); - }); - - afterEach(async () => { - cleanupDaemonJson(); - cleanupManagedPortFile(); - // Kill any daemon we spawned - const info = readDaemonJson(); - if (info && isProcessAlive(info.pid)) { - killProcess(info.pid); - } - if (fakeCdp) { - await new Promise(resolve => fakeCdp!.close(() => resolve())); - fakeCdp = null; - } - }); - - it("daemon discovers CDP via managed port file", async () => { - // Daemon should read ~/.bb-browser/browser/cdp-port and find our fake CDP - const child = spawn(TSX, [DAEMON_ENTRY, "--port", "39993"], { - detached: true, - stdio: "ignore", - }); - child.unref(); - - const info = await waitForDaemonJson(); - assert.ok(isProcessAlive(info.pid), "daemon should be running"); - - // Cleanup - killProcess(info.pid); - }); -}); - -// --------------------------------------------------------------------------- -// Test: The core bug — daemon spawned without CDP info -// --------------------------------------------------------------------------- - -describe("core bug: daemon spawned without --cdp-port fails silently (#136)", () => { - beforeEach(() => { - cleanupDaemonJson(); - cleanupManagedPortFile(); - }); - - afterEach(() => { - cleanupDaemonJson(); - }); - - it("daemon with default port 19825 and nothing listening → exits, no daemon.json", async () => { - // This is exactly what ensureDaemon() does: spawn daemon with NO args - // Default --cdp-port is 19825, nothing listening there → daemon exits - const child = spawn(TSX, [DAEMON_ENTRY], { - detached: true, - stdio: "ignore", - env: { ...process.env, BB_BROWSER_CDP_URL: undefined }, - }); - child.unref(); - - // daemon should exit within 3 seconds, daemon.json never appears - await new Promise(r => setTimeout(r, 3000)); - assert.equal(readDaemonJson(), null, - "BUG CONFIRMED: daemon exits without daemon.json when spawned with no args and no Chrome on default port"); - }); - - it("daemon with explicit --cdp-port pointing to fake CDP → starts successfully", async () => { - // This proves the fix: if CLI passes --cdp-port to daemon, it works - const cdpPort = 39992; - const fakeCdp = await startFakeCdpServer(cdpPort); - - try { - const child = spawn(TSX, [DAEMON_ENTRY, "--cdp-port", String(cdpPort), "--port", "39991"], { - detached: true, - stdio: "ignore", - }); - child.unref(); - - const info = await waitForDaemonJson(); - assert.ok(isProcessAlive(info.pid), "daemon should be running when given correct CDP port"); - killProcess(info.pid); - } finally { - await new Promise(resolve => fakeCdp.close(() => resolve())); - } - }); -}); - -// --------------------------------------------------------------------------- -// Test: 503 error includes diagnostics -// --------------------------------------------------------------------------- - -describe("CDP 503 error includes diagnostics", () => { - let daemonPid: number | null = null; - - afterEach(async () => { - if (daemonPid && isProcessAlive(daemonPid)) { - killProcess(daemonPid); - await new Promise(r => setTimeout(r, 500)); - } - daemonPid = null; - cleanupDaemonJson(); - }); - - it("503 response includes CDP target, reason, and hint — responds immediately not 30s", async () => { - const cdpPort = 39989; - const daemonPort = 39988; - const fakeCdp = await startFakeCdpServer(cdpPort); - - try { - const child = spawn(TSX, [DAEMON_ENTRY, "--cdp-port", String(cdpPort), "--port", String(daemonPort)], { - detached: true, - stdio: "ignore", - }); - child.unref(); - - const info = await waitForDaemonJson(); - daemonPid = info.pid; - - // Wait for daemon to try and fail CDP WebSocket - await new Promise(r => setTimeout(r, 2000)); - - const start = Date.now(); - const response = await new Promise>((resolve, reject) => { - const req = http.request({ - hostname: info.host, - port: info.port, - path: "/command", - method: "POST", - headers: { - Authorization: `Bearer ${info.token}`, - "Content-Type": "application/json", - }, - timeout: 10000, - }, res => { - const chunks: Buffer[] = []; - res.on("data", (c: Buffer) => chunks.push(c)); - res.on("end", () => { - try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } - catch (e) { reject(e); } - }); - }); - req.on("error", reject); - req.write(JSON.stringify({ id: "diag-test", action: "tab_list" })); - req.end(); - }); - const elapsed = Date.now() - start; - - // Must not wait 30s - assert.ok(elapsed < 10000, `should respond quickly, not wait 30s (took ${elapsed}ms)`); - - // Must have diagnostics - assert.equal(response.success, false); - assert.match(response.error as string, /Chrome not connected/, "error should mention Chrome"); - assert.ok(typeof response.reason === "string", "should include reason"); - assert.ok((response.reason as string).length > 0, "reason should not be empty"); - assert.ok(typeof response.hint === "string", "should include hint"); - } finally { - await new Promise(resolve => fakeCdp.close(() => resolve())); - } - }); -}); diff --git a/packages/cli/src/daemon-manager.ts b/packages/cli/src/daemon-manager.ts index d3f8b31b..f678818f 100644 --- a/packages/cli/src/daemon-manager.ts +++ b/packages/cli/src/daemon-manager.ts @@ -12,13 +12,12 @@ import os from "node:os"; import path from "node:path"; import type { Request, Response } from "@bb-browser/shared"; import { COMMAND_TIMEOUT } from "@bb-browser/shared"; -import { discoverCdpPort } from "./cdp-discovery.js"; // --------------------------------------------------------------------------- // Paths & types // --------------------------------------------------------------------------- -const DAEMON_DIR = process.env.BB_BROWSER_HOME || path.join(os.homedir(), ".bb-browser"); +const DAEMON_DIR = path.join(os.homedir(), ".bb-browser"); const DAEMON_JSON = path.join(DAEMON_DIR, "daemon.json"); interface DaemonInfo { @@ -192,28 +191,16 @@ export async function ensureDaemon(): Promise { } } - // Discover CDP port (auto-launches Chrome if needed) - const cdpInfo = await discoverCdpPort(); - if (!cdpInfo) { - throw new Error( - "bb-browser: Cannot find a Chromium-based browser.\n\n" + - "Please do one of the following:\n" + - " 1. Install Google Chrome, Edge, or Brave\n" + - " 2. Start Chrome with: google-chrome --remote-debugging-port=19825\n" + - " 3. Set BB_BROWSER_CDP_URL=http://host:port", - ); - } - - // Spawn daemon process with discovered CDP endpoint + // Spawn daemon process const daemonPath = getDaemonPath(); - const child = spawn(process.execPath, [daemonPath, "--cdp-host", cdpInfo.host, "--cdp-port", String(cdpInfo.port)], { + const child = spawn(process.execPath, [daemonPath], { detached: true, stdio: "ignore", }); child.unref(); - // Wait for daemon to become healthy (up to 10 seconds — includes Chrome launch time) - const deadline = Date.now() + 10000; + // Wait for daemon to become healthy (up to 5 seconds) + const deadline = Date.now() + 5000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 200)); // Re-read daemon.json each iteration (daemon writes it on startup) @@ -232,9 +219,7 @@ export async function ensureDaemon(): Promise { } throw new Error( - "bb-browser: Daemon did not start in time.\n\n" + - "Chrome CDP is reachable, but the daemon process failed to initialize.\n" + - "Try: bb-browser daemon status", + "bb-browser: Daemon did not start in time.\n\nMake sure Chrome is installed, then try again.", ); } diff --git a/packages/daemon/src/__tests__/daemon-lifecycle.test.ts b/packages/daemon/src/__tests__/daemon-lifecycle.test.ts deleted file mode 100644 index bcd20e71..00000000 --- a/packages/daemon/src/__tests__/daemon-lifecycle.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Daemon lifecycle integration tests — daemon.json + HTTP server. - * - * These tests verify the daemon.json file lifecycle WITHOUT requiring Chrome. - * Each test uses unique ports to avoid EADDRINUSE conflicts. - */ - -import { describe, it, afterEach } from "node:test"; -import assert from "node:assert/strict"; -import { - spawn, - type ChildProcess, -} from "node:child_process"; -import { readFile, unlink } from "node:fs/promises"; -import { existsSync, mkdirSync } from "node:fs"; -import { createServer, type Server } from "node:http"; -import path from "node:path"; -import os from "node:os"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -// Use isolated temp dir to avoid conflicts with parallel test suites -const TEST_HOME = path.join(os.tmpdir(), `bb-browser-test-lifecycle-${process.pid}`); -mkdirSync(TEST_HOME, { recursive: true }); -process.env.BB_BROWSER_HOME = TEST_HOME; -const DAEMON_JSON = path.join(TEST_HOME, "daemon.json"); - -// Each test gets unique ports to avoid EADDRINUSE -let portCounter = 49800; -function nextPorts(): { daemonPort: number; cdpPort: number } { - const daemonPort = portCounter++; - const cdpPort = portCounter++; - return { daemonPort, cdpPort }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function findTsx(): string { - const candidates = [ - path.resolve(__dirname, "../../../../node_modules/.bin/tsx"), - path.resolve(__dirname, "../../../node_modules/.bin/tsx"), - ]; - for (const c of candidates) { - if (existsSync(c)) return c; - } - return "tsx"; -} - -function startFakeCdp(port: number): Promise { - return new Promise((resolve, reject) => { - const server = createServer((req, res) => { - if (req.url === "/json/version" && req.method === "GET") { - const body = JSON.stringify({ - Browser: "FakeChrome/0.0", - webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/fake`, - }); - res.writeHead(200, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }); - res.end(body); - } else { - res.writeHead(404); - res.end(); - } - }); - server.on("error", reject); - server.listen(port, "127.0.0.1", () => resolve(server)); - }); -} - -function stopFakeCdp(server: Server): Promise { - return new Promise((resolve) => { - server.close(() => resolve()); - }); -} - -function spawnDaemon(port: number, cdpPort: number): ChildProcess { - const sourceEntry = path.resolve(__dirname, "../index.ts"); - return spawn( - findTsx(), - [sourceEntry, "--port", String(port), "--cdp-port", String(cdpPort)], - { stdio: "pipe", env: { ...process.env } }, - ); -} - -async function waitForDaemonJson(timeoutMs = 8000): Promise> { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const raw = await readFile(DAEMON_JSON, "utf8"); - return JSON.parse(raw) as Record; - } catch { - await new Promise((r) => setTimeout(r, 200)); - } - } - throw new Error("daemon.json not created in time"); -} - -async function waitForStatus( - host: string, port: number, token: string, timeoutMs = 8000, -): Promise> { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(`http://${host}:${port}/status`, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(1000), - }); - if (res.ok) return (await res.json()) as Record; - } catch {} - await new Promise((r) => setTimeout(r, 200)); - } - throw new Error("daemon /status not reachable in time"); -} - -function killDaemon(child: ChildProcess): Promise { - return new Promise((resolve) => { - if (child.killed || child.exitCode !== null) { resolve(); return; } - child.on("exit", () => resolve()); - child.kill("SIGTERM"); - const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 3000); - child.on("exit", () => clearTimeout(timer)); - }); -} - -async function cleanupDaemonJson(): Promise { - try { await unlink(DAEMON_JSON); } catch {} -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("daemon lifecycle (no Chrome needed)", () => { - let daemon: ChildProcess | null = null; - let fakeCdp: Server | null = null; - - afterEach(async () => { - // Kill the actual daemon process (from daemon.json PID, not tsx wrapper PID) - try { - const raw = await readFile(DAEMON_JSON, "utf8"); - const info = JSON.parse(raw); - if (info.pid) { - try { process.kill(info.pid, "SIGKILL"); } catch {} - } - } catch {} - - // Also kill the tsx wrapper - if (daemon && !daemon.killed && daemon.exitCode === null) { - await killDaemon(daemon); - } - daemon = null; - if (fakeCdp) { - await stopFakeCdp(fakeCdp); - fakeCdp = null; - } - await cleanupDaemonJson(); - // Wait for ports to release - await new Promise((r) => setTimeout(r, 500)); - }); - - it("writes daemon.json on startup with pid/host/port/token", async () => { - const { daemonPort, cdpPort } = nextPorts(); - await cleanupDaemonJson(); - fakeCdp = await startFakeCdp(cdpPort); - daemon = spawnDaemon(daemonPort, cdpPort); - - const info = await waitForDaemonJson(); - - assert.equal(typeof info.pid, "number"); - assert.equal(typeof info.host, "string"); - assert.equal(info.port, daemonPort); - assert.equal(typeof info.token, "string"); - assert.ok((info.token as string).length > 0); - // Note: daemon.pid is tsx wrapper PID, info.pid is the actual daemon PID - assert.ok(info.pid as number > 0, "daemon PID should be positive"); - }); - - it("GET /status returns running: true", async () => { - const { daemonPort, cdpPort } = nextPorts(); - await cleanupDaemonJson(); - fakeCdp = await startFakeCdp(cdpPort); - daemon = spawnDaemon(daemonPort, cdpPort); - - const info = await waitForDaemonJson(); - const status = await waitForStatus( - info.host as string, info.port as number, info.token as string, - ); - - assert.equal(status.running, true); - assert.equal(typeof status.uptime, "number"); - }); - - it("daemon.json is deleted on graceful shutdown (SIGTERM)", async () => { - const { daemonPort, cdpPort } = nextPorts(); - await cleanupDaemonJson(); - fakeCdp = await startFakeCdp(cdpPort); - daemon = spawnDaemon(daemonPort, cdpPort); - await waitForDaemonJson(); - - assert.ok(existsSync(DAEMON_JSON)); - - await killDaemon(daemon); - daemon = null; - await new Promise((r) => setTimeout(r, 500)); - - assert.ok(!existsSync(DAEMON_JSON), "daemon.json should be deleted after graceful shutdown"); - }); - - it("stale daemon.json survives kill -9", async () => { - const { daemonPort, cdpPort } = nextPorts(); - await cleanupDaemonJson(); - fakeCdp = await startFakeCdp(cdpPort); - daemon = spawnDaemon(daemonPort, cdpPort); - const info = await waitForDaemonJson(); - const oldPid = info.pid; - - // Kill the actual daemon PID (not tsx wrapper) - try { process.kill(oldPid as number, "SIGKILL"); } catch {} - daemon.kill("SIGKILL"); - await new Promise((r) => setTimeout(r, 1000)); - daemon = null; - - assert.ok(existsSync(DAEMON_JSON), "daemon.json should survive SIGKILL"); - const staleInfo = JSON.parse(await readFile(DAEMON_JSON, "utf8")); - assert.equal(staleInfo.pid, oldPid); - }); - - it("new daemon after kill -9 gets new PID and token", async () => { - const ports1 = nextPorts(); - const ports2 = nextPorts(); // completely separate ports for second daemon - await cleanupDaemonJson(); - fakeCdp = await startFakeCdp(ports1.cdpPort); - - // First daemon - daemon = spawnDaemon(ports1.daemonPort, ports1.cdpPort); - const info1 = await waitForDaemonJson(); - const pid1 = info1.pid; - const token1 = info1.token; - - // Force kill the actual daemon process - try { process.kill(pid1 as number, "SIGKILL"); } catch {} - daemon.kill("SIGKILL"); - await new Promise((r) => setTimeout(r, 1500)); - daemon = null; - - assert.ok(existsSync(DAEMON_JSON)); - - // Stop old fake CDP server and wait for port release - await stopFakeCdp(fakeCdp); - await new Promise((r) => setTimeout(r, 500)); - - // Start new fake CDP on different port - fakeCdp = await startFakeCdp(ports2.cdpPort); - - // Delete stale daemon.json so waitForDaemonJson detects the NEW one - await cleanupDaemonJson(); - - // Second daemon on completely new ports - daemon = spawnDaemon(ports2.daemonPort, ports2.cdpPort); - const info2 = await waitForDaemonJson(); - - assert.notEqual(info2.pid, pid1, "new daemon should have different PID"); - assert.notEqual(info2.token, token1, "new daemon should have different token"); - assert.equal(info2.port, ports2.daemonPort); - }); -}); diff --git a/packages/daemon/src/cdp-connection.ts b/packages/daemon/src/cdp-connection.ts index bc9a2d73..cdba53b2 100644 --- a/packages/daemon/src/cdp-connection.ts +++ b/packages/daemon/src/cdp-connection.ts @@ -96,9 +96,6 @@ export class CdpConnection { private connectionPromise: Promise | null = null; private _connected = false; - /** Last connection error (for diagnostics in 503 responses). */ - lastError: string | null = null; - /** Resolvers for commands queued before CDP is ready. */ private readyWaiters: Array<{ resolve: () => void; reject: (err: Error) => void }> = []; @@ -127,15 +124,6 @@ export class CdpConnection { this.connectionPromise = this.doConnect(); try { await this.connectionPromise; - this.lastError = null; - } catch (err) { - this.lastError = err instanceof Error ? err.message : String(err); - const connErr = new Error(this.lastError); - for (const waiter of this.readyWaiters) { - waiter.reject(connErr); - } - this.readyWaiters = []; - throw err; } finally { this.connectionPromise = null; } @@ -176,7 +164,6 @@ export class CdpConnection { /** Wait until CDP connection is established (for two-phase startup). */ waitUntilReady(): Promise { if (this._connected) return Promise.resolve(); - if (this.lastError) return Promise.reject(new Error(this.lastError)); return new Promise((resolve, reject) => { this.readyWaiters.push({ resolve, reject }); }); @@ -297,17 +284,10 @@ export class CdpConnection { ws.on("close", () => { this._connected = false; this.socket = null; - this.lastError = "CDP WebSocket closed unexpectedly"; for (const p of this.pending.values()) { p.reject(new Error("CDP connection closed")); } this.pending.clear(); - - const closeErr = new Error(this.lastError); - for (const waiter of this.readyWaiters) { - waiter.reject(closeErr); - } - this.readyWaiters = []; }); ws.on("error", () => {}); diff --git a/packages/daemon/src/http-server.ts b/packages/daemon/src/http-server.ts index b607d34c..312100a0 100644 --- a/packages/daemon/src/http-server.ts +++ b/packages/daemon/src/http-server.ts @@ -135,15 +135,11 @@ export class HttpServer { setTimeout(() => reject(new Error("CDP connection timeout")), COMMAND_TIMEOUT), ), ]); - } catch { - const cdpTarget = `${this.cdp.host}:${this.cdp.port}`; - const reason = this.cdp.lastError || "unknown"; + } catch (error) { this.sendJson(res, 503, { id: request.id, success: false, - error: `Chrome not connected (CDP at ${cdpTarget})`, - reason, - hint: "Make sure Chrome is running. Try: bb-browser daemon shutdown && bb-browser tab list", + error: error instanceof Error ? error.message : "CDP not ready", }); return; } diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 3998e748..d51af01e 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -24,7 +24,7 @@ import { TabStateManager } from "./tab-state.js"; // Constants // --------------------------------------------------------------------------- -const DAEMON_DIR = process.env.BB_BROWSER_HOME || path.join(os.homedir(), ".bb-browser"); +const DAEMON_DIR = path.join(os.homedir(), ".bb-browser"); const DAEMON_JSON = path.join(DAEMON_DIR, "daemon.json"); const DEFAULT_CDP_PORT = 19825; @@ -160,7 +160,7 @@ async function discoverCdpPort(host: string, port: number): Promise<{ host: stri } catch {} // Try reading managed browser port file - const managedPortFile = path.join(DAEMON_DIR, "browser", "cdp-port"); + const managedPortFile = path.join(os.homedir(), ".bb-browser", "browser", "cdp-port"); try { const rawPort = readFileSync(managedPortFile, "utf8").trim(); const managedPort = parseInt(rawPort, 10); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 30ae5d0f..e9dd9c43 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,7 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { DAEMON_BASE_URL, COMMAND_TIMEOUT, COMMANDS, generateId } from "@bb-browser/shared"; -import type { CommandDef, Request, Response } from "@bb-browser/shared"; +import { DAEMON_BASE_URL, COMMAND_TIMEOUT, generateId } from "@bb-browser/shared"; +import type { Request, Response } from "@bb-browser/shared"; import { execFile, spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -19,10 +19,6 @@ const CHROME_NOT_CONNECTED_HINT = [ const sessionOpenedTabs = new Set(); -// --------------------------------------------------------------------------- -// Path helpers -// --------------------------------------------------------------------------- - function getDaemonPath(): string { const currentDir = dirname(fileURLToPath(import.meta.url)); const sameDirPath = resolve(currentDir, "daemon.js"); @@ -37,10 +33,6 @@ function getCliPath(): string { return resolve(currentDir, "../../cli/dist/index.js"); } -// --------------------------------------------------------------------------- -// Daemon lifecycle -// --------------------------------------------------------------------------- - async function isDaemonRunning(): Promise { try { const controller = new AbortController(); @@ -53,46 +45,17 @@ async function isDaemonRunning(): Promise { async function ensureDaemon(): Promise { if (await isDaemonRunning()) return; - - // Discover CDP port first (auto-launches Chrome if needed) - let cdpArgs: string[] = []; - try { - const cliPath = getCliPath(); - await new Promise((resolve, reject) => { - execFile(process.execPath, [cliPath, "daemon", "status", "--json"], { timeout: 15000 }, (err, stdout) => { - if (err) reject(err); else resolve(stdout); - }); - }); - // If CLI daemon status succeeded, daemon is already running - if (await isDaemonRunning()) return; - } catch { - // CLI failed — daemon not running, try spawning with CDP discovery - // Read managed port file as fallback - const { readFile } = await import("node:fs/promises"); - const os = await import("node:os"); - const path = await import("node:path"); - try { - const portFile = path.join(os.default.homedir(), ".bb-browser", "browser", "cdp-port"); - const port = (await readFile(portFile, "utf8")).trim(); - if (port) cdpArgs = ["--cdp-port", port]; - } catch {} - } - - const child = spawn(process.execPath, [getDaemonPath(), ...cdpArgs], { + const child = spawn(process.execPath, [getDaemonPath()], { detached: true, stdio: "ignore", env: { ...process.env }, }); child.unref(); - // wait up to 10s - for (let i = 0; i < 50; i++) { + // wait up to 5s + for (let i = 0; i < 25; i++) { await new Promise(r => setTimeout(r, 200)); if (await isDaemonRunning()) return; } } -// --------------------------------------------------------------------------- -// Command transport -// --------------------------------------------------------------------------- - async function sendCommand(request: Request): Promise { await ensureDaemon(); const controller = new AbortController(); @@ -115,10 +78,6 @@ async function sendCommand(request: Request): Promise { } } -// --------------------------------------------------------------------------- -// Result helpers -// --------------------------------------------------------------------------- - function errorResult(message: string) { return { content: [{ type: "text" as const, text: `Error: ${message}` }], @@ -139,10 +98,6 @@ async function runCommand(request: Omit & Record return sendCommand({ id: generateId(), ...request } as Request); } -// --------------------------------------------------------------------------- -// Session tab tracking -// --------------------------------------------------------------------------- - function normalizeTabId(tabId: string | number | undefined): string | undefined { if (typeof tabId === "string" && tabId) { return tabId; @@ -172,10 +127,6 @@ function rememberSessionTabFromResponse(data: Response["data"]): void { rememberSessionTab((data as Response["data"] & { tabId?: string | number }).tabId); } -// --------------------------------------------------------------------------- -// Site CLI helpers -// --------------------------------------------------------------------------- - function tryParseJson(raw: string): T | null { const trimmed = raw.trim(); if (!trimmed) { @@ -262,10 +213,6 @@ async function runSiteCli(args: string[]): Promise { return parsed ?? result.stdout.trim(); } -// --------------------------------------------------------------------------- -// MCP Server -// --------------------------------------------------------------------------- - const server = new McpServer( { name: "bb-browser", version: __BB_BROWSER_VERSION__ }, { instructions: `bb-browser lets you control the user's real Chrome browser via CDP (Chrome DevTools Protocol). @@ -298,158 +245,214 @@ Site adapters (pre-built commands for popular sites): To create a new site adapter, run: bb-browser guide` }, ); -// --------------------------------------------------------------------------- -// Build args→request mapping: remap "tab" → "tabId" for daemon protocol -// --------------------------------------------------------------------------- - -function buildRequest(cmd: CommandDef, args: Record): Omit & Record { - const { tab, ...rest } = args; - const request: Record = { action: cmd.action, ...rest }; - if (tab !== undefined) { - request.tabId = tab; - } - return request as Omit & Record; -} - -// --------------------------------------------------------------------------- -// Commands that need special handling — keyed by command name -// --------------------------------------------------------------------------- - -type ToolHandler = (args: Record) => Promise<{ - content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>; - isError?: boolean; -}>; - -const specialHandlers: Record ToolHandler> = { - snapshot: (cmd) => async (args) => { - const resp = await runCommand(buildRequest(cmd, args)); +server.tool( + "browser_snapshot", + "Get accessibility tree snapshot of the current page", + { + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + interactive: z.boolean().optional().describe("Only show interactive elements"), + }, + async ({ tab, interactive }) => { + const resp = await runCommand({ action: "snapshot", interactive, tabId: tab }); if (!resp.success) return responseError(resp); return textResult(resp.data?.snapshotData?.snapshot || "(empty)"); - }, + } +); - screenshot: (cmd) => async (args) => { - const resp = await runCommand(buildRequest(cmd, args)); - if (!resp.success) return responseError(resp); - const dataUrl = resp.data?.dataUrl; - if (typeof dataUrl !== "string") return errorResult("Screenshot data missing"); - return { - content: [{ - type: "image" as const, - data: dataUrl.replace(/^data:image\/png;base64,/, ""), - mimeType: "image/png", - }], - }; +server.tool( + "browser_click", + "Click an element by ref", + { + ref: z.string().describe("Element ref from snapshot"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), }, - - eval: (cmd) => async (args) => { - const resp = await runCommand(buildRequest(cmd, args)); + async ({ ref, tab }) => { + const resp = await runCommand({ action: "click", ref, tabId: tab }); if (!resp.success) return responseError(resp); - return textResult(resp.data?.result ?? null); - }, + return textResult(resp.data || "Clicked"); + } +); - get: (cmd) => async (args) => { - const resp = await runCommand(buildRequest(cmd, args)); - if (!resp.success) return responseError(resp); - return textResult(resp.data?.value ?? ""); +server.tool( + "browser_fill", + "Fill text into an input", + { + ref: z.string().describe("Element ref from snapshot"), + text: z.string().describe("Text to fill"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), }, - - tab_list: (cmd) => async (args) => { - const resp = await runCommand(buildRequest(cmd, args)); + async ({ ref, text, tab }) => { + const resp = await runCommand({ action: "fill", ref, text, tabId: tab }); if (!resp.success) return responseError(resp); - return textResult(resp.data?.tabs || []); + return textResult(resp.data || "Filled"); + } +); + +server.tool( + "browser_type", + "Type text into an input without clearing", + { + ref: z.string().describe("Element ref from snapshot"), + text: z.string().describe("Text to type"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), }, + async ({ ref, text, tab }) => { + const resp = await runCommand({ action: "type", ref, text, tabId: tab }); + if (!resp.success) return responseError(resp); + return textResult(resp.data || "Typed"); + } +); - open: (cmd) => async (args) => { - const resp = await runCommand(buildRequest(cmd, args)); +server.tool( + "browser_open", + "Navigate to a URL", + { + url: z.string().describe("URL to open"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + }, + async ({ url, tab }) => { + const resp = await runCommand({ action: "open", url, tabId: tab }); if (!resp.success) return responseError(resp); - if (args.tab === undefined) { + if (tab === undefined) { rememberSessionTabFromResponse(resp.data); } - return textResult(resp.data || `Opened ${args.url}`); - }, + return textResult(resp.data || `Opened ${url}`); + } +); - tab_new: (cmd) => async (args) => { - const resp = await runCommand(buildRequest(cmd, args)); +server.tool( + "browser_tab_list", + "List all tabs", + {}, + async () => { + const resp = await runCommand({ action: "tab_list" }); + if (!resp.success) return responseError(resp); + return textResult(resp.data?.tabs || []); + } +); + +server.tool( + "browser_tab_new", + "Open a new tab", + { + url: z.string().optional().describe("Optional URL to open"), + }, + async ({ url }) => { + const resp = await runCommand({ action: "tab_new", url }); if (!resp.success) return responseError(resp); rememberSessionTabFromResponse(resp.data); return textResult(resp.data || "Opened new tab"); - }, + } +); - close: (_cmd) => async (args) => { - const action = args.tab === undefined ? "close" : "tab_close"; - const { tab, ...rest } = args; - const request: Record = { action, ...rest }; - if (tab !== undefined) request.tabId = tab; - const resp = await runCommand(request as Omit & Record); - if (!resp.success) return responseError(resp); - forgetSessionTab(args.tab as string | undefined); - return textResult(resp.data || "Closed tab"); +server.tool( + "browser_press", + "Press a keyboard key", + { + key: z.string().describe("Key name to press, e.g. Enter or Control+a"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), }, - - press: (_cmd) => async (args) => { - const key = args.key as string; + async ({ key, tab }) => { const parts = key.split("+"); const modifierNames = new Set(["Control", "Alt", "Shift", "Meta"]); const modifiers = parts.filter((part) => modifierNames.has(part)); const mainKey = parts.find((part) => !modifierNames.has(part)); if (!mainKey) return errorResult("Invalid key format"); - const { tab, ...rest } = args; - const request: Record = { - action: "press", - ...rest, - key: mainKey, - modifiers, - }; - if (tab !== undefined) request.tabId = tab; - const resp = await runCommand(request as Omit & Record); + const resp = await runCommand({ action: "press", key: mainKey, modifiers, tabId: tab }); if (!resp.success) return responseError(resp); return textResult(resp.data || `Pressed ${key}`); - }, + } +); - wait: (_cmd) => async (args) => { - const ms = args.ms ?? (args as Record).time ?? 1000; - const { tab, ...rest } = args; - const request: Record = { - action: "wait", - waitType: "time", - ...rest, - ms, - }; - // Remove legacy arg name if present - delete (request as Record).time; - if (tab !== undefined) request.tabId = tab; - const resp = await runCommand(request as Omit & Record); +server.tool( + "browser_scroll", + "Scroll the page", + { + direction: z.enum(["up", "down", "left", "right"]).describe("Scroll direction"), + pixels: z.number().optional().default(500).describe("Scroll distance in pixels"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + }, + async ({ direction, pixels, tab }) => { + const resp = await runCommand({ action: "scroll", direction, pixels, tabId: tab }); if (!resp.success) return responseError(resp); - return textResult(resp.data || `Waited ${ms}ms`); + return textResult(resp.data || `Scrolled ${direction} ${pixels}px`); + } +); + +server.tool( + "browser_eval", + "Execute JavaScript in page context", + { + script: z.string().describe("JavaScript source to execute"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), }, + async ({ script, tab }) => { + const resp = await runCommand({ action: "eval", script, tabId: tab }); + if (!resp.success) return responseError(resp); + return textResult(resp.data?.result ?? null); + } +); - network: (cmd) => async (args) => { - // Backward compat: accept old "command" arg name - const networkCommand = args.networkCommand ?? (args as Record).command ?? "requests"; - const mappedArgs = { ...args, networkCommand }; - delete (mappedArgs as Record).command; - const resp = await runCommand(buildRequest(cmd, mappedArgs)); +server.tool( + "browser_network", + "Inspect or clear network activity", + { + command: z.enum(["requests", "clear"]).describe("Network command"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + filter: z.string().optional().describe("URL substring filter"), + since: z.union([z.literal("last_action"), z.number()]).optional() + .describe("Incremental query: 'last_action' for events since last operation, or seq number"), + method: z.string().optional().describe("Filter by HTTP method (GET, POST, etc.)"), + status: z.string().optional().describe("Filter by status: '4xx', '5xx', or exact code like '200'"), + limit: z.number().optional().describe("Max number of results to return"), + withBody: z.boolean().optional().describe("Include request and response bodies"), + }, + async ({ command, tab, filter, since, method, status, limit, withBody }) => { + const resp = await runCommand({ + action: "network", + networkCommand: command, + filter, + since, + method, + status, + limit, + withBody, + tabId: tab, + }); if (!resp.success) return responseError(resp); - const nc = networkCommand as string | undefined; - if (nc === "requests" || nc === undefined) { + if (command === "requests") { const data = resp.data as Record; return textResult({ requests: data?.networkRequests || data?.requests || [], cursor: data?.cursor, }); } - return textResult(resp.data || "Done"); - }, + return textResult(resp.data || "Cleared"); + } +); - console: (cmd) => async (args) => { - // Backward compat: accept old "command" arg name - const consoleCommand = args.consoleCommand ?? (args as Record).command ?? "get"; - const mappedArgs = { ...args, consoleCommand }; - delete (mappedArgs as Record).command; - const resp = await runCommand(buildRequest(cmd, mappedArgs)); +server.tool( + "browser_console", + "Get or clear console messages", + { + command: z.enum(["get", "clear"]).describe("Console command"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + since: z.union([z.literal("last_action"), z.number()]).optional() + .describe("Incremental query: 'last_action' for events since last operation, or seq number"), + filter: z.string().optional().describe("Filter console messages by text substring"), + limit: z.number().optional().describe("Max number of results to return"), + }, + async ({ command, tab, since, filter, limit }) => { + const resp = await runCommand({ + action: "console", + consoleCommand: command, + filter, + since, + limit, + tabId: tab, + }); if (!resp.success) return responseError(resp); - const cc = consoleCommand as string | undefined; - if (cc === "get" || cc === undefined) { + if (command === "get") { const data = resp.data as Record; return textResult({ messages: data?.consoleMessages || data?.messages || [], @@ -457,17 +460,29 @@ const specialHandlers: Record ToolHandler> = { }); } return textResult(resp.data || "Cleared"); - }, + } +); - errors: (cmd) => async (args) => { - // Backward compat: accept old "command" arg name - const errorsCommand = args.errorsCommand ?? (args as Record).command ?? "get"; - const mappedArgs = { ...args, errorsCommand }; - delete (mappedArgs as Record).command; - const resp = await runCommand(buildRequest(cmd, mappedArgs)); +server.tool( + "browser_errors", + "Get or clear JavaScript errors", + { + command: z.enum(["get", "clear"]).describe("Errors command"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + since: z.union([z.literal("last_action"), z.number()]).optional() + .describe("Incremental query: 'last_action' for events since last operation, or seq number"), + limit: z.number().optional().describe("Max number of results to return"), + }, + async ({ command, tab, since, limit }) => { + const resp = await runCommand({ + action: "errors", + errorsCommand: command, + since, + limit, + tabId: tab, + }); if (!resp.success) return responseError(resp); - const ec = errorsCommand as string | undefined; - if (ec === "get" || ec === undefined) { + if (command === "get") { const data = resp.data as Record; return textResult({ errors: data?.jsErrors || data?.errors || [], @@ -475,36 +490,58 @@ const specialHandlers: Record ToolHandler> = { }); } return textResult(resp.data || "Cleared"); + } +); + +server.tool( + "browser_screenshot", + "Take a screenshot", + { + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), }, -}; - -// --------------------------------------------------------------------------- -// Auto-generate tools from COMMANDS registry -// --------------------------------------------------------------------------- - -for (const cmd of COMMANDS) { - // Site commands use CLI, not daemon — handled separately below - if (cmd.category === "site") continue; - - const toolName = "browser_" + cmd.name; - const handler = specialHandlers[cmd.name]; - - if (handler) { - // Command with special handling - server.tool(toolName, cmd.description, cmd.args.shape, handler(cmd)); - } else { - // Standard command: send to daemon and return data - server.tool(toolName, cmd.description, cmd.args.shape, async (args: Record) => { - const resp = await runCommand(buildRequest(cmd, args)); - if (!resp.success) return responseError(resp); - return textResult(resp.data || "Done"); - }); + async ({ tab }) => { + const resp = await runCommand({ action: "screenshot", tabId: tab }); + if (!resp.success) return responseError(resp); + const dataUrl = resp.data?.dataUrl; + if (typeof dataUrl !== "string") return errorResult("Screenshot data missing"); + return { + content: [{ + type: "image" as const, + data: dataUrl.replace(/^data:image\/png;base64,/, ""), + mimeType: "image/png", + }], + }; } -} +); -// --------------------------------------------------------------------------- -// browser_close_all — session-scoped, not in COMMANDS registry -// --------------------------------------------------------------------------- +server.tool( + "browser_get", + "Get element text or attribute", + { + attribute: z.enum(["text", "url", "title", "value", "html"]).describe("Attribute to retrieve"), + ref: z.string().optional().describe("Optional element ref"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + }, + async ({ attribute, ref, tab }) => { + const resp = await runCommand({ action: "get", attribute, ref, tabId: tab }); + if (!resp.success) return responseError(resp); + return textResult(resp.data?.value ?? ""); + } +); + +server.tool( + "browser_close", + "Close the current or specified tab", + { + tab: z.string().optional().describe("Tab short ID to close"), + }, + async ({ tab }) => { + const resp = await runCommand({ action: tab === undefined ? "close" : "tab_close", tabId: tab }); + if (!resp.success) return responseError(resp); + forgetSessionTab(tab); + return textResult(resp.data || "Closed tab"); + } +); server.tool( "browser_close_all", @@ -543,9 +580,33 @@ server.tool( } ); -// --------------------------------------------------------------------------- -// Site tools — route through CLI instead of daemon -// --------------------------------------------------------------------------- +server.tool( + "browser_hover", + "Hover over an element", + { + ref: z.string().describe("Element ref from snapshot"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + }, + async ({ ref, tab }) => { + const resp = await runCommand({ action: "hover", ref, tabId: tab }); + if (!resp.success) return responseError(resp); + return textResult(resp.data || "Hovered"); + } +); + +server.tool( + "browser_wait", + "Wait for a number of milliseconds", + { + time: z.number().describe("Time to wait in milliseconds"), + tab: z.string().optional().describe("Tab short ID (from tab list or open command)"), + }, + async ({ time, tab }) => { + const resp = await runCommand({ action: "wait", waitType: "time", ms: time, tabId: tab }); + if (!resp.success) return responseError(resp); + return textResult(resp.data || `Waited ${time}ms`); + } +); server.tool( "site_list", @@ -666,10 +727,6 @@ server.tool( } ); -// --------------------------------------------------------------------------- -// Start -// --------------------------------------------------------------------------- - export async function startMcpServer() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/packages/shared/package.json b/packages/shared/package.json index 6d36bd05..37c4e093 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,9 +10,7 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsup", "dev": "tsup --watch", @@ -23,8 +21,5 @@ "@types/node": "^22.0.0", "tsup": "^8.3.0", "typescript": "^5.7.0" - }, - "dependencies": { - "zod": "^3.24.1" } } diff --git a/packages/shared/src/commands.ts b/packages/shared/src/commands.ts deleted file mode 100644 index 616e9b57..00000000 --- a/packages/shared/src/commands.ts +++ /dev/null @@ -1,447 +0,0 @@ -/** - * Unified command registry — single source of truth for all bb-browser commands. - * - * CLI, MCP, and Edge Clip can auto-generate their interfaces from this registry. - * This module is metadata only — it does not execute anything. - */ - -import { z } from "zod"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface CommandDef { - /** Human-readable name (e.g. "snapshot", "click") */ - name: string; - /** Maps to command-dispatch case (e.g. "snapshot", "click") */ - action: string; - /** One-line description */ - description: string; - /** Command category */ - category: "navigate" | "interact" | "observe" | "tab" | "network" | "site" | "system"; - /** Zod schema for arguments */ - args: z.ZodObject; -} - -// --------------------------------------------------------------------------- -// Command definitions -// --------------------------------------------------------------------------- - -export const COMMANDS: CommandDef[] = [ - // --------------------------------------------------------------------------- - // Navigate - // --------------------------------------------------------------------------- - { - name: "open", - action: "open", - description: "Navigate to a URL. Opens in a new tab if no tab is specified.", - category: "navigate", - args: z.object({ - url: z.string().describe("URL to open"), - tab: z.string().optional().describe("Tab short ID to navigate in (omit to open in a new tab)"), - }), - }, - { - name: "back", - action: "back", - description: "Navigate back in browser history", - category: "navigate", - args: z.object({ - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "forward", - action: "forward", - description: "Navigate forward in browser history", - category: "navigate", - args: z.object({ - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "refresh", - action: "refresh", - description: "Reload the current page", - category: "navigate", - args: z.object({ - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "close", - action: "close", - description: "Close the current tab", - category: "navigate", - args: z.object({ - tab: z.string().optional().describe("Tab short ID"), - }), - }, - - // --------------------------------------------------------------------------- - // Observe - // --------------------------------------------------------------------------- - { - name: "snapshot", - action: "snapshot", - description: "Get accessibility tree snapshot of the current page. Returns ref numbers for interactive elements.", - category: "observe", - args: z.object({ - tab: z.string().optional().describe("Tab short ID"), - interactive: z.boolean().optional().describe("Only show interactive elements"), - compact: z.boolean().optional().describe("Remove empty structural nodes for a more concise tree"), - maxDepth: z.number().optional().describe("Limit tree depth"), - selector: z.string().optional().describe("CSS selector to filter the snapshot scope"), - }), - }, - { - name: "screenshot", - action: "screenshot", - description: "Take a screenshot of the current page and return it as a PNG data URL", - category: "observe", - args: z.object({ - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "get", - action: "get", - description: "Get element text, attribute, or page-level values (url, title)", - category: "observe", - args: z.object({ - attribute: z.enum(["text", "url", "title", "value", "html"]).describe("Attribute to retrieve"), - ref: z.string().optional().describe("Element ref from snapshot (optional for url/title)"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - - // --------------------------------------------------------------------------- - // Interact - // --------------------------------------------------------------------------- - { - name: "click", - action: "click", - description: "Click an element by ref number from snapshot", - category: "interact", - args: z.object({ - ref: z.string().describe("Element ref from snapshot"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "hover", - action: "hover", - description: "Hover over an element by ref number from snapshot", - category: "interact", - args: z.object({ - ref: z.string().describe("Element ref from snapshot"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "fill", - action: "fill", - description: "Clear an input field and fill it with new text", - category: "interact", - args: z.object({ - ref: z.string().describe("Element ref from snapshot"), - text: z.string().describe("Text to fill"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "type", - action: "type", - description: "Type text into an input field without clearing existing content", - category: "interact", - args: z.object({ - ref: z.string().describe("Element ref from snapshot"), - text: z.string().describe("Text to type"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "check", - action: "check", - description: "Check a checkbox element", - category: "interact", - args: z.object({ - ref: z.string().describe("Element ref from snapshot"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "uncheck", - action: "uncheck", - description: "Uncheck a checkbox element", - category: "interact", - args: z.object({ - ref: z.string().describe("Element ref from snapshot"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "select", - action: "select", - description: "Select a value from a dropdown (select element)", - category: "interact", - args: z.object({ - ref: z.string().describe("Element ref from snapshot"), - value: z.string().describe("Option value to select"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "press", - action: "press", - description: "Press a keyboard key (e.g. Enter, Tab, Control+a)", - category: "interact", - args: z.object({ - key: z.string().describe("Key name to press, e.g. Enter or Control+a"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "scroll", - action: "scroll", - description: "Scroll the page in a given direction", - category: "interact", - args: z.object({ - direction: z.enum(["up", "down", "left", "right"]).describe("Scroll direction"), - pixels: z.number().default(300).describe("Scroll distance in pixels"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "eval", - action: "eval", - description: "Execute JavaScript in the page context and return the result", - category: "interact", - args: z.object({ - script: z.string().describe("JavaScript source to execute"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - - // --------------------------------------------------------------------------- - // System - // --------------------------------------------------------------------------- - { - name: "wait", - action: "wait", - description: "Wait for a specified number of milliseconds", - category: "system", - args: z.object({ - ms: z.number().default(1000).describe("Time to wait in milliseconds"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "dialog", - action: "dialog", - description: "Arm a handler for the next browser dialog (alert, confirm, prompt, beforeunload)", - category: "system", - args: z.object({ - dialogResponse: z.enum(["accept", "dismiss"]).default("accept").describe("How to respond to the dialog"), - promptText: z.string().optional().describe("Text to enter in a prompt dialog (optional, used with accept)"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "frame", - action: "frame", - description: "Switch context to an iframe by CSS selector", - category: "system", - args: z.object({ - selector: z.string().describe("CSS selector for the iframe element"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "frame_main", - action: "frame_main", - description: "Switch context back to the main frame", - category: "system", - args: z.object({ - tab: z.string().optional().describe("Tab short ID"), - }), - }, - - // --------------------------------------------------------------------------- - // Tab - // --------------------------------------------------------------------------- - { - name: "tab_list", - action: "tab_list", - description: "List all open browser tabs with their URLs, titles, and short IDs", - category: "tab", - args: z.object({}), - }, - { - name: "tab_new", - action: "tab_new", - description: "Open a new browser tab, optionally navigating to a URL", - category: "tab", - args: z.object({ - url: z.string().optional().describe("URL to open in the new tab (defaults to about:blank)"), - }), - }, - { - name: "tab_select", - action: "tab_select", - description: "Switch to a tab by short ID or index", - category: "tab", - args: z.object({ - tab: z.string().optional().describe("Tab short ID or full target ID"), - index: z.number().optional().describe("Tab index (0-based, used if tab is not specified)"), - }), - }, - { - name: "tab_close", - action: "tab_close", - description: "Close a specific tab by short ID or index", - category: "tab", - args: z.object({ - tab: z.string().optional().describe("Tab short ID or full target ID"), - index: z.number().optional().describe("Tab index (0-based, used if tab is not specified)"), - }), - }, - - // --------------------------------------------------------------------------- - // Network / observation - // --------------------------------------------------------------------------- - { - name: "network", - action: "network", - description: "Inspect or manage network activity. Supports incremental queries with since.", - category: "network", - args: z.object({ - networkCommand: z.enum(["requests", "route", "unroute", "clear"]).default("requests").describe("Network sub-command"), - filter: z.string().optional().describe("URL substring filter for requests"), - since: z.union([z.literal("last_action"), z.number()]).optional().describe("Incremental query: 'last_action' for events since last operation, or a seq number"), - method: z.string().optional().describe("Filter by HTTP method (GET, POST, etc.)"), - status: z.string().optional().describe("Filter by status: '4xx', '5xx', or exact code like '200'"), - limit: z.number().optional().describe("Max number of results to return"), - withBody: z.boolean().optional().describe("Include request and response bodies"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "console", - action: "console", - description: "Get or clear console messages from the page", - category: "network", - args: z.object({ - consoleCommand: z.enum(["get", "clear"]).default("get").describe("Console sub-command"), - filter: z.string().optional().describe("Filter console messages by text substring"), - since: z.union([z.literal("last_action"), z.number()]).optional().describe("Incremental query: 'last_action' for events since last operation, or a seq number"), - limit: z.number().optional().describe("Max number of results to return"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "errors", - action: "errors", - description: "Get or clear JavaScript errors from the page", - category: "network", - args: z.object({ - errorsCommand: z.enum(["get", "clear"]).default("get").describe("Errors sub-command"), - filter: z.string().optional().describe("Filter errors by text substring"), - since: z.union([z.literal("last_action"), z.number()]).optional().describe("Incremental query: 'last_action' for events since last operation, or a seq number"), - limit: z.number().optional().describe("Max number of results to return"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "trace", - action: "trace", - description: "Record user interactions for replay or code generation", - category: "network", - args: z.object({ - traceCommand: z.enum(["start", "stop", "status"]).describe("Trace sub-command"), - tab: z.string().optional().describe("Tab short ID"), - }), - }, - { - name: "history", - action: "history", - description: "Search browsing history or list domains (not supported in daemon mode)", - category: "network", - args: z.object({ - historyCommand: z.enum(["search", "domains"]).describe("History sub-command"), - query: z.string().optional().describe("Search query string (used with 'search' sub-command)"), - days: z.number().default(30).describe("Number of days to look back"), - }), - }, - - // --------------------------------------------------------------------------- - // Site - // --------------------------------------------------------------------------- - { - name: "site_list", - action: "site_list", - description: "List all available site adapters", - category: "site", - args: z.object({}), - }, - { - name: "site_search", - action: "site_search", - description: "Search site adapters by name, description, or domain", - category: "site", - args: z.object({ - query: z.string().describe("Search query"), - }), - }, - { - name: "site_info", - action: "site_info", - description: "Show detailed metadata for a site adapter", - category: "site", - args: z.object({ - name: z.string().describe("Adapter name (e.g. reddit/thread)"), - }), - }, - { - name: "site_recommend", - action: "site_recommend", - description: "Recommend site adapters based on browsing history", - category: "site", - args: z.object({ - days: z.number().default(30).describe("Number of days of history to analyze"), - }), - }, - { - name: "site_run", - action: "site_run", - description: "Run a site adapter to extract structured data from a website", - category: "site", - args: z.object({ - name: z.string().describe("Adapter name (e.g. reddit/thread, twitter/user)"), - args: z.string().optional().describe("Arguments to pass to the adapter (space-separated or --flag value)"), - tab: z.string().optional().describe("Tab short ID (auto-detected from adapter domain if omitted)"), - }), - }, - { - name: "site_update", - action: "site_update", - description: "Update community site adapter library (git clone/pull)", - category: "site", - args: z.object({}), - }, -]; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Find a command definition by its action name. */ -export function findCommand(action: string): CommandDef | undefined { - return COMMANDS.find((c) => c.action === action); -} - -/** Get all commands in a given category. */ -export function getCommandsByCategory(category: CommandDef["category"]): CommandDef[] { - return COMMANDS.filter((c) => c.category === category); -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8f0feea3..c9934bb4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -29,10 +29,3 @@ export { SSE_MAX_RECONNECT_ATTEMPTS, SSE_RECONNECT_DELAY, } from "./constants.js"; - -export { - type CommandDef, - COMMANDS, - findCommand, - getCommandsByCategory, -} from "./commands.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c425deea..efd1d367 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,10 +140,6 @@ importers: version: 5.9.3 packages/shared: - dependencies: - zod: - specifier: ^3.24.1 - version: 3.25.76 devDependencies: '@types/node': specifier: ^22.0.0 diff --git a/release-please-config.json b/release-please-config.json index c5585f93..ee4d8052 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,7 +1,5 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, "packages": { ".": { "release-type": "node" diff --git a/skills/bb-browser/SKILL.md b/skills/bb-browser/SKILL.md index d533588b..57cd7f09 100644 --- a/skills/bb-browser/SKILL.md +++ b/skills/bb-browser/SKILL.md @@ -1,359 +1,12 @@ --- name: bb-browser -description: 强大的信息获取与浏览器自动化工具。通过浏览器 + 用户登录态,获取公域和私域信息。可访问任意网页、内部系统、登录后页面,执行表单填写、信息提取、页面操作。支持 site 系统(36 平台 103 命令一键调用)、带登录态的 fetch、网络请求拦截与 mock、操作录制等高级功能。 -allowed-tools: Bash(bb-browser:*) +description: Control Chrome with login state from Codex via MCP --- -# bb-browser - 信息获取与浏览器自动化 +# BB Browser for Codex -## 核心价值 +Use BB Browser from Codex via MCP. -通过浏览器 + 用户登录态,可以获取: -- 公域信息:任意公开网页、搜索结果、新闻资讯 -- 私域信息:内部系统、企业应用、登录后页面、个人账户数据 - -还可以代替用户执行浏览器操作:表单填写、按钮点击、数据提取、截图保存、批量操作。 - -运行在用户真实浏览器中,复用已登录的账号,不触发反爬检测。 - -## 快速开始 - -```bash -bb-browser open # 打开页面(新 tab) -bb-browser snapshot -i # 获取可交互元素 -bb-browser click @5 # 点击元素 -bb-browser fill @3 "text" # 填写输入框 -bb-browser close # 完成后关闭 tab -``` - -## Site 系统 — 把任何网站变成命令行 API - -site 系统是 bb-browser 的核心特性,通过 adapter 将网站功能 CLI 化,覆盖 36+ 平台。 - -```bash -# 常用命令 -bb-browser site list # 列出所有 adapter -bb-browser site search # 搜索 adapter -bb-browser site [args...] # 运行 adapter -bb-browser site update # 更新社区 adapter 库 - -# 使用示例 -bb-browser site twitter/search "Claude Code" # 搜索推文 -bb-browser site zhihu/hot # 知乎热榜 -bb-browser site github/repo owner/repo # 仓库信息 -bb-browser site youtube/transcript # 获取字幕 -bb-browser site reddit/thread # 帖子详情 -bb-browser site eastmoney/stock "茅台" # 股票查询 -bb-browser site weibo/hot # 微博热搜 -bb-browser site arxiv/search "transformer" # 论文搜索 -``` - -adapter 自动处理 tab 管理(查找匹配域名的 tab 或新建),自动检测登录错误并提示。 - -详细用法和 36 平台完整列表:参见 [references/site-system.md](references/site-system.md) -创建自定义 adapter:参见 [references/adapter-development.md](references/adapter-development.md) - -## fetch — 带登录态的 curl - -在浏览器上下文中执行 fetch,自动携带 Cookie 和登录态。 - -```bash -bb-browser fetch # GET 请求 -bb-browser fetch --method POST --body '{"k":"v"}' # POST 请求 -bb-browser fetch --headers '{"Auth":"Bearer xxx"}' # 自定义请求头 -bb-browser fetch --output data.json # 保存到文件 -bb-browser fetch /api/me.json # 相对路径(用当前 tab 的 origin) -``` - -自动域名路由:绝对路径自动查找匹配 tab 或新建;相对路径使用当前 tab。 - -详细用法:参见 [references/fetch-and-network.md](references/fetch-and-network.md) - -## Tab 管理规范 - -操作完成后必须关闭自己打开的 tab。 - -```bash -# 单 tab 场景 -bb-browser open https://example.com # 打开新 tab -bb-browser snapshot -i -bb-browser click @5 -bb-browser close # 完成后关闭 - -# 多 tab 场景 -bb-browser open https://site-a.com # tabId: 123 -bb-browser open https://site-b.com # tabId: 456 -# ... 操作 ... -bb-browser tab close # 关闭当前 tab -bb-browser tab close # 关闭剩余 tab - -# 指定 tab 操作 -bb-browser open https://example.com --tab current # 在当前 tab 打开(不新建) -bb-browser open https://example.com --tab 123 # 在指定 tabId 打开 -``` - -## 核心工作流 - -1. `open` 打开页面 -2. `snapshot -i` 查看可操作元素(返回 @ref) -3. 用 `@ref` 执行操作(click, fill, etc.) -4. 页面变化后重新 `snapshot -i` -5. 任务完成后 `close` 关闭 tab - -## 命令速查 - -### 导航 - -```bash -bb-browser open # 打开 URL(新 tab) -bb-browser open --tab current # 在当前 tab 打开 -bb-browser back # 后退 -bb-browser forward # 前进 -bb-browser refresh # 刷新 -bb-browser close # 关闭当前 tab -``` - -### 快照 - -```bash -bb-browser snapshot # 完整页面结构 -bb-browser snapshot -i # 只显示可交互元素(推荐) -bb-browser snapshot -c # 移除空结构节点 -bb-browser snapshot -d 3 # 限制树深度为 3 层 -bb-browser snapshot -s ".main" # 限定 CSS 选择器范围 -bb-browser snapshot --json # JSON 格式输出 -# 选项可组合:bb-browser snapshot -i -c -d 5 -``` - -### 元素交互 - -```bash -bb-browser click @5 # 点击 -bb-browser hover @5 # 悬停 -bb-browser fill @3 "text" # 清空并填写 -bb-browser type @3 "text" # 追加输入(不清空) -bb-browser check @7 # 勾选复选框 -bb-browser uncheck @7 # 取消勾选 -bb-browser select @4 "option" # 下拉选择 -bb-browser press Enter # 按键 -bb-browser press Control+a # 组合键 -bb-browser scroll down # 向下滚动(默认 300px) -bb-browser scroll up 500 # 向上滚动 500px -``` - -### 获取信息 - -```bash -bb-browser get text @5 # 获取元素文本 -bb-browser get url # 获取当前 URL -bb-browser get title # 获取页面标题 -``` - -### Tab 管理 - -```bash -bb-browser tab # 列出所有 tab -bb-browser tab new [url] # 新建 tab -bb-browser tab 2 # 切换到第 2 个 tab(按 index) -bb-browser tab select --id 123 # 切换到指定 tabId 的 tab -bb-browser tab close # 关闭当前 tab -bb-browser tab close 3 # 关闭第 3 个 tab(按 index) -bb-browser tab close --id 123 # 关闭指定 tabId 的 tab -``` - -### 截图 - -```bash -bb-browser screenshot # 截图(自动保存) -bb-browser screenshot path.png # 截图到指定路径 -``` - -### 等待 - -```bash -bb-browser wait 2000 # 等待 2 秒 -bb-browser wait @5 # 等待元素出现 -``` - -### JavaScript - -```bash -bb-browser eval "document.title" # 执行 JS -bb-browser eval "window.scrollTo(0, 1000)" # 滚动到指定位置 -``` - -### Frame 切换 - -```bash -bb-browser frame "#iframe-id" # 切换到 iframe -bb-browser frame main # 返回主 frame -``` - -### 对话框处理 - -```bash -bb-browser dialog accept # 确认对话框 -bb-browser dialog dismiss # 取消对话框 -bb-browser dialog accept "text" # 确认并输入(prompt) -``` - -### 网络与调试 - -```bash -bb-browser network requests # 查看网络请求 -bb-browser network requests "api" --with-body # 过滤 + 完整请求/响应体 -bb-browser network route "*analytics*" --abort # 拦截并阻止请求 -bb-browser network route "*/api/user" --body '{}' # 拦截并 mock 响应 -bb-browser network unroute # 移除所有拦截规则 -bb-browser network clear # 清空请求记录 -bb-browser console # 查看控制台消息 -bb-browser console --clear # 清空控制台 -bb-browser errors # 查看 JS 错误 -bb-browser errors --clear # 清空错误记录 -bb-browser trace start # 开始录制用户操作 -bb-browser trace stop # 停止录制,输出事件列表 -bb-browser trace status # 查看录制状态 -``` - -详细的 network 高级用法:参见 [references/fetch-and-network.md](references/fetch-and-network.md) - -## 全局选项 - -```bash ---json # 以 JSON 格式输出(所有命令通用) ---tab # 指定操作的标签页 ID(几乎所有命令通用) ---mcp # 启动 MCP server(用于 Claude Code / Cursor 等 AI 工具) -``` - -## Ref 使用说明 - -snapshot 返回的 `@ref` 是元素的临时标识: - -``` -@1 [button] "提交" -@2 [input type="text"] placeholder="请输入姓名" -@3 [a] "查看详情" -``` - -注意: -- 页面导航后 ref 失效,需重新 snapshot -- 动态内容加载后需重新 snapshot -- ref 格式:`@1`, `@2`, `@3`... - -详细说明:参见 [references/snapshot-refs.md](references/snapshot-refs.md) - -## 并发操作 - -```bash -# 并发打开多个页面(各自独立 tab) -bb-browser open https://site-a.com & -bb-browser open https://site-b.com & -bb-browser open https://site-c.com & -wait -# 每个返回独立的 tabId,互不干扰 -``` - -## 信息提取 vs 页面操作 - -根据目的选择不同的方法: - -### 提取页面内容(用 eval) - -当需要提取文章、正文等长文本时,用 `eval` 直接获取: - -```bash -# 微信公众号文章 -bb-browser eval "document.querySelector('#js_content').innerText" - -# 知乎回答 -bb-browser eval "document.querySelector('.RichContent-inner').innerText" - -# 通用:获取页面主体文本 -bb-browser eval "document.body.innerText.substring(0, 5000)" - -# 获取所有链接 -bb-browser eval "[...document.querySelectorAll('a')].map(a => a.href).join('\n')" -``` - -有些网站 DOM 嵌套很深,snapshot 输出冗长,`eval` 直接提取文本更高效。 - -### 操作页面元素(用 snapshot -i) - -当需要点击、填写、选择时,用 `snapshot -i` 获取可交互元素: - -```bash -bb-browser snapshot -i -# @1 [button] "登录" -# @2 [input] placeholder="用户名" -# @3 [input type="password"] - -bb-browser fill @2 "username" -bb-browser fill @3 "password" -bb-browser click @1 -``` - -`-i` 只显示可交互元素,过滤掉大量无关内容。 - -## MCP 集成 - -bb-browser 提供 MCP server,可与 Claude Code / Cursor 等 AI 工具集成: - -```bash -# 启动 MCP server -bb-browser --mcp -``` - -配置示例(Claude Code / Cursor): -```json -{ - "mcpServers": { - "bb-browser": { - "command": "npx", - "args": ["-y", "bb-browser", "--mcp"] - } - } -} -``` - -## 常见任务示例 - -### 表单填写 - -```bash -bb-browser open https://example.com/form -bb-browser snapshot -i -bb-browser fill @1 "张三" -bb-browser fill @2 "zhangsan@example.com" -bb-browser click @3 -bb-browser wait 2000 -bb-browser close -``` - -### 信息提取 - -```bash -bb-browser open https://example.com/dashboard -bb-browser snapshot -i -bb-browser get text @5 -bb-browser screenshot report.png -bb-browser close -``` - -### 批量操作 - -```bash -for url in "url1" "url2" "url3"; do - bb-browser open "$url" - bb-browser snapshot -i --json - bb-browser close -done -``` - -## 深入文档 - -| 文档 | 说明 | -|------|------| -| [references/site-system.md](references/site-system.md) | Site 系统完整指南:35 平台列表、命令用法、自动 tab 管理 | -| [references/adapter-development.md](references/adapter-development.md) | Adapter 开发指南:API 逆向、三层复杂度、元数据格式 | -| [references/fetch-and-network.md](references/fetch-and-network.md) | Fetch 与 Network 高级功能:带登录态请求、请求拦截与 mock | -| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref 生命周期、最佳实践、常见问题 | +## When to use +- When you need bb-browser capabilities in your Codex workflow +- See https://github.com/epiral/bb-browser for full setup instructions