Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02e23ebe11 | ||
|
|
d5cd9cd621 | ||
|
|
284a60a54c | ||
|
|
10d196b6eb | ||
|
|
5be01e292d | ||
|
|
a83d1b1df9 | ||
|
|
50b27ac0e0 | ||
|
|
d81bc01645 | ||
|
|
c667e0e704 | ||
|
|
08cb8dbeb9 | ||
|
|
fd2cdcde77 | ||
|
|
8e001e3d88 | ||
|
|
eb2bc343a0 | ||
|
|
d86c9c4be2 | ||
|
|
32c25a6627 | ||
|
|
a8ce3dd3f8 | ||
|
|
997373fd57 | ||
|
|
5a858af93f | ||
|
|
58dc02bfdc | ||
|
|
c47601bd7b | ||
|
|
0296bc7a88 | ||
|
|
32e203b908 | ||
|
|
fc51cd63ba | ||
|
|
f714c7920b | ||
|
|
1ac8ef7732 | ||
|
|
9f24e66033 | ||
|
|
70ab38d35f | ||
|
|
6830df50ea | ||
|
|
cd47ec43d0 | ||
|
|
2cd361817d | ||
|
|
42f47c49aa | ||
|
|
e29800df72 | ||
|
|
e7e849ea39 | ||
|
|
ebb02c65c8 | ||
|
|
4317db636f | ||
|
|
c99838a034 | ||
|
|
dc2aa4cade | ||
|
|
7085f3bf36 | ||
|
|
29815ff5f3 | ||
|
|
2a338d4c29 | ||
|
|
6fcf52db60 | ||
|
|
af8823b27b | ||
|
|
c85e3faa82 | ||
|
|
b25958946c | ||
|
|
d4ff49caa8 | ||
|
|
4e949fffbf | ||
|
|
af46490812 | ||
|
|
57c52d6517 | ||
|
|
2707ceb1c4 | ||
|
|
f7a657ac46 | ||
|
|
4e295ce139 | ||
|
|
3d82f11ff2 | ||
|
|
33269adc1a | ||
|
|
9ab8753b48 | ||
|
|
770708b8e6 | ||
|
|
7c594820da | ||
|
|
81d18bbd2e | ||
|
|
d99a223d23 | ||
|
|
4e7e80a596 | ||
|
|
9bf79a4242 | ||
|
|
5b4ffdb2bb | ||
|
|
62e7229b47 | ||
|
|
23ab4ce68f | ||
|
|
e272546b5c | ||
|
|
c7de19b099 | ||
|
|
6b9de10c73 | ||
|
|
63e0dd5921 | ||
|
|
c0ee65d0d8 | ||
|
|
7601919a04 | ||
|
|
345c0d62a2 | ||
|
|
0644fb2d0b | ||
|
|
1ea6b1a2c5 | ||
|
|
7bb50d54b3 |
@@ -39,7 +39,6 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
fetch-depth: 0 # full history so the changelog step can diff tags
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -117,6 +116,16 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# The release job is separate from the build matrix and has no repo by
|
||||
# default — check it out (full history + tags) so the changelog step has a
|
||||
# git repo to diff. Without this, `git` failed with "not a git repository"
|
||||
# and the changelog came out empty.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -133,18 +142,36 @@ jobs:
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# fetch-depth:0 gets history, but the tag refs the changelog needs
|
||||
# aren't always present in a detached-HEAD tag checkout — pull them in.
|
||||
git fetch --tags --force --quiet origin 2>/dev/null || true
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||
RANGE="${TAG}"
|
||||
[ -n "$PREV" ] && RANGE="${PREV}..${TAG}"
|
||||
# Group commit subjects by conventional-commit type so the notes are
|
||||
# scannable ("what's new / what's fixed") instead of a flat dev log.
|
||||
LOG="$(git log "$RANGE" --no-merges --pretty='%s' | grep -v '^chore(release)' || true)"
|
||||
# NOTE: the job runs under `bash -e`. grep returning 1 (no match) and
|
||||
# the `[ -n "$body" ]` test returning 1 (empty section) must NOT abort
|
||||
# the script — otherwise a release whose commit range lacks a whole
|
||||
# category (e.g. only `feat`, no `fix`) dies before writing the closing
|
||||
# heredoc delimiter and the whole release step fails. `|| true` +
|
||||
# `return 0` keep section() always-succeeding.
|
||||
section() { # $1=header $2=grep-pattern
|
||||
local body; body="$(printf '%s\n' "$LOG" | grep -E "$2" | sed 's/^/- /' || true)"
|
||||
[ -n "$body" ] && printf '\n### %s\n%s\n' "$1" "$body"
|
||||
return 0
|
||||
}
|
||||
{
|
||||
echo "notes<<__NOTES_EOF__"
|
||||
echo "## What changed"
|
||||
echo ""
|
||||
section "✨ Features" '^feat'
|
||||
section "🐛 Fixes" '^fix'
|
||||
section "🔧 Other" '^(perf|refactor|docs|build|ci|test|style|revert)'
|
||||
if [ -n "$PREV" ]; then
|
||||
git log "${PREV}..${TAG}" --no-merges --pretty='- %s' | grep -v '^- chore(release)' || true
|
||||
echo ""
|
||||
echo "**Full changelog**: https://github.com/${{ github.repository }}/compare/${PREV}...${TAG}"
|
||||
else
|
||||
git log "${TAG}" --no-merges --pretty='- %s' | grep -v '^- chore(release)' || true
|
||||
fi
|
||||
echo "__NOTES_EOF__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -205,6 +205,50 @@ chrome-use --launch --profile auto open https://x.com/home
|
||||
|
||||
In CI environments, standalone mode is used automatically.
|
||||
|
||||
## Site adapters — turn a website into a structured-data CLI
|
||||
|
||||
Most "read GitHub issues" / "search Reddit" / "get my Bilibili feed" tasks don't
|
||||
need clicking and screenshotting at all — the site already has a JSON API behind
|
||||
its own login. A **site adapter** is a tiny JS function that calls that API *from
|
||||
inside your logged-in tab* (your cookies, same-origin `fetch`, the site's own
|
||||
modules) and returns clean JSON. The site can't tell it apart from you, because it
|
||||
*is* you.
|
||||
|
||||
chrome-use ships none of these adapters — `site update` fetches the community
|
||||
[**bb-sites**](https://github.com/epiral/bb-sites) pack at runtime (like a package
|
||||
manager pulling a dependency), then runs them over chrome-use's stealth transport:
|
||||
|
||||
```bash
|
||||
chrome-use site update # fetch the adapter pack (~145 commands)
|
||||
chrome-use site list # github/issues, reddit/search, bilibili/feed, …
|
||||
chrome-use site info github/issues # see an adapter's args + domain
|
||||
|
||||
# Run one — navigates to the site (reusing the tab if you're already there) and returns JSON
|
||||
chrome-use site github/issues epiral/bb-browser --json
|
||||
chrome-use site reddit/search "rust async" --json
|
||||
chrome-use site bilibili/feed --json # works because it's your logged-in session
|
||||
```
|
||||
|
||||
Positional args fill the adapter's declared args in order; `--key value` overrides
|
||||
by name. Adapters are authored by the bb-sites community and remain their authors'
|
||||
property — chrome-use just runs them.
|
||||
|
||||
**Auto-sync + auto-suggest.** You rarely type `site update` yourself: chrome-use
|
||||
syncs the pack on first use and refreshes it weekly in the background (tune with
|
||||
`AGENT_BROWSER_SITES_TTL_DAYS`, disable with `AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1`).
|
||||
And when you `open`/`snapshot` a page whose domain has adapters, chrome-use surfaces
|
||||
them right in the output — a `💡 site adapters for <domain>` line, plus a
|
||||
`siteAdapters` field under `--json` — so an agent reaches for the structured-data
|
||||
adapter instead of scraping the DOM:
|
||||
|
||||
```text
|
||||
$ chrome-use open https://github.com
|
||||
💡 site adapters for github.com — prefer these for structured data:
|
||||
github/issues, github/me, github/repo, …
|
||||
e.g. chrome-use site github/issues --json
|
||||
✓ GitHub
|
||||
```
|
||||
|
||||
## Automated testing (`chrome-use test`)
|
||||
|
||||
Turn the repetitive "open it, click around, check it's right" work into a
|
||||
@@ -326,3 +370,6 @@ We deliberately **don't ship our own bot detector** — the strongest, most hone
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
---
|
||||
|
||||
> Built by **leeguooooo** — field notes on AI agents, reverse engineering & Cloudflare Workers at **[blog.misonote.com](https://blog.misonote.com)** · follow on **[X @leeguooooo](https://x.com/leeguooooo)**
|
||||
|
||||
+138
-1
@@ -63,6 +63,25 @@ chrome-use 让**任意** agent(Claude Code、Cursor、Codex、你自己的脚
|
||||
|
||||
每个 `--session` 拿到**自己的彩色标签组**,多个 agent 共用同一个真实浏览器、互不干扰,也不动你自己的标签页。
|
||||
|
||||
## 为什么用扩展(而非裸调试端口)
|
||||
|
||||
其他本地工具走裸 `--remote-debugging-port`(CDP)驱动 Chrome。从 **Chrome 136** 起,每次这样连接都会弹出一个阻塞式的 **"Allow remote debugging?"** 同意框 —— 而且端口得提前开好。我们的扩展改用原生消息:**装一次,之后零确认。**
|
||||
|
||||
| | **chrome-use**(本扩展) | web-access(裸 CDP 端口) | Claude in Chrome(chrome.debugger) |
|
||||
|---|---|---|---|
|
||||
| 连接方式 | 原生消息 —— 无端口、无 token | `--remote-debugging-port` | `chrome.debugger` |
|
||||
| **"Allow remote debugging?" 弹框** | **从不** ✅ | **每次连都弹** 🔴 | 无 |
|
||||
| 复用你的真实登录 | 是 | 是 | 是 |
|
||||
| `Runtime.enable`(CDP)泄漏¹ | **默认关闭 → 干净** ✅ | 域已启用 | 不适用 |
|
||||
| CreepJS 隐身分² | **0% stealth · 0% headless** ✅ | 真实 Chrome | 真实 Chrome |
|
||||
| 每会话标签组 / 并发 agent | **支持** ✅ | 无 | 无 |
|
||||
| 为 chrome-use CLI 打造 | 是 | 独立代理 | 单 app 助手 |
|
||||
|
||||
> ¹ 对 [rebrowser-bot-detector](https://bot-detector.rebrowser.net/) 实测:我们的中继报 `runtimeEnableLeak: 🟢 No leak`、`navigatorWebdriver: 🟢`。
|
||||
> ² 对 [CreepJS](https://abrahamjuliot.github.io/creepjs/) 在「连接真实 Chrome」路径上实测 —— 见 [反检测](#反检测)。
|
||||
>
|
||||
> 同意框不是假想:裸端口工具**每次** attach 都会弹(Chrome 136+ 安全策略)。扩展路径从不弹。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
@@ -71,6 +90,15 @@ curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.
|
||||
|
||||
从最新的 [GitHub Release](https://github.com/leeguooooo/chrome-use/releases) 下载对应平台的预编译二进制,安装 `chrome-use`(以及 `abs` 别名)。无需 npm,无需 token。
|
||||
|
||||
<details>
|
||||
<summary>其他安装方式</summary>
|
||||
|
||||
- **锁定版本:** `AGENT_BROWSER_VERSION=v0.27.0-fork.12 curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh | sh`
|
||||
- **自定义路径:** `AGENT_BROWSER_BIN_DIR=$HOME/bin curl -fsSL … | sh`
|
||||
- **Windows:** 从 [Releases 页](https://github.com/leeguooooo/chrome-use/releases) 下载 `chrome-use-win32-x64.tar.gz`,把 `chrome-use.exe` 放进 PATH。
|
||||
- **npm(旧渠道):** `npm install -g chrome-use` —— 仍在发布,但 GitHub Releases 现在是主渠道。
|
||||
</details>
|
||||
|
||||
### 安装 AI agent skills
|
||||
|
||||
```bash
|
||||
@@ -79,6 +107,10 @@ npx skills add leeguooooo/chrome-use
|
||||
|
||||
把 `skills/chrome-use` 拉进当前项目,让你的 AI agent 拿到正确的用法和预授权的 bash 权限。
|
||||
|
||||
## 命令名
|
||||
|
||||
`chrome-use`、`chrome-use`、`abs` 是**同一个二进制** —— `abs` 只是短别名。没有单独的「隐身可执行文件」;隐身是**运行时行为**(见下方 [反检测](#反检测)),根据你是连接真实 Chrome 还是 `--launch` 全新实例自动启用。
|
||||
|
||||
## 连接你的 Chrome
|
||||
|
||||
**推荐 —— 浏览器扩展(一键,无弹窗)。** 从 Chrome 应用商店安装 [**chrome-use** 扩展](https://chromewebstore.google.com/detail/chrome-use/knfcmbamhjmaonkfnjhldjedeobeafmk),再注册一次本地桥:
|
||||
@@ -128,8 +160,83 @@ chrome-use --launch open https://example.com
|
||||
|
||||
# 保留登录:用你真实的 Chrome profile 启动
|
||||
chrome-use --launch --profile auto open https://x.com/home
|
||||
# 或显式指定:--profile Default / --profile "Profile 1"
|
||||
```
|
||||
|
||||
## 站点适配器 —— 把一个网站变成「结构化数据 CLI」
|
||||
|
||||
大多数「读 GitHub issue」「搜 Reddit」「拉我的 B 站动态」这类任务,根本不需要点击 +
|
||||
截图 —— 网站登录态背后本来就有 JSON 接口。**站点适配器**就是一小段 JS 函数,它在你
|
||||
**已登录的标签页内**调用那个接口(用你的 cookie、同源 `fetch`、网站自己的模块),返回
|
||||
干净的 JSON。网站分辨不出它和你的区别,因为它**就是你**。
|
||||
|
||||
chrome-use 本身不附带任何适配器 —— `site update` 会在运行时拉取社区的
|
||||
[**bb-sites**](https://github.com/epiral/bb-sites) 适配器包(就像包管理器拉依赖),
|
||||
然后在 chrome-use 的隐身通道上运行它们:
|
||||
|
||||
```bash
|
||||
chrome-use site update # 拉取适配器包(约 145 条命令)
|
||||
chrome-use site list # github/issues、reddit/search、bilibili/feed…
|
||||
chrome-use site info github/issues # 查看某个适配器的参数 + 域名
|
||||
|
||||
# 运行一个 —— 会导航到对应站点(已在该站点则复用当前标签页)并返回 JSON
|
||||
chrome-use site github/issues epiral/bb-browser --json
|
||||
chrome-use site reddit/search "rust async" --json
|
||||
chrome-use site bilibili/feed --json # 能用,因为走的是你的登录态
|
||||
```
|
||||
|
||||
位置参数按适配器声明的参数顺序填入;`--key value` 按名覆盖。适配器由 bb-sites 社区编写、
|
||||
版权归各自作者所有 —— chrome-use 只负责运行它们。
|
||||
|
||||
**自动同步 + 自动提示。** 你基本不用手动 `site update`:chrome-use 首次使用时自动拉取,
|
||||
之后每周后台刷新一次(`AGENT_BROWSER_SITES_TTL_DAYS` 调周期,`AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1`
|
||||
关闭)。而当你 `open`/`snapshot` 一个有适配器的域名时,chrome-use 会在输出里直接把可用命令
|
||||
亮出来 —— 一行 `💡 site adapters for <域名>`,`--json` 下则是 `siteAdapters` 字段 —— 这样
|
||||
agent 会直接改用结构化适配器,而不是去扒 DOM:
|
||||
|
||||
```text
|
||||
$ chrome-use open https://github.com
|
||||
💡 site adapters for github.com — prefer these for structured data:
|
||||
github/issues, github/me, github/repo, …
|
||||
e.g. chrome-use site github/issues --json
|
||||
✓ GitHub
|
||||
```
|
||||
|
||||
## 自动化测试(`chrome-use test`)
|
||||
|
||||
把反复的「打开它、点一圈、看对不对」变成**可重跑的测试套件** —— 前端的单元测试。用 YAML 写用例;步骤复用 chrome-use 自己的命令,断言编译成一次检查:
|
||||
|
||||
```yaml
|
||||
# smoke.yaml
|
||||
suite: chatgpt smoke
|
||||
setup:
|
||||
- account: chatgpt/huayue # 注入一个 cookie-use 登录(可选)
|
||||
cases:
|
||||
- name: home loads logged in
|
||||
steps:
|
||||
- open: https://chatgpt.com/
|
||||
- wait: { load: networkidle }
|
||||
assert:
|
||||
- url: { contains: chatgpt.com }
|
||||
- visible: "#prompt-textarea"
|
||||
```
|
||||
|
||||
```bash
|
||||
chrome-use test smoke.yaml # 启动隔离浏览器,跑用例
|
||||
chrome-use test smoke.yaml --session default # …或对你已连接的 Chrome 跑
|
||||
```
|
||||
|
||||
```
|
||||
suite: chatgpt smoke (session cu-test)
|
||||
✓ home loads logged in 1.2s
|
||||
✗ composer takes text 0.8s
|
||||
assert text "#prompt-textarea" contains "hi" → got ""
|
||||
↳ cu-test-artifacts/composer-takes-text.png
|
||||
2 cases · 1 passed · 1 failed
|
||||
```
|
||||
|
||||
任一用例失败时退出码非零(可直接丢进 CI),失败用例会存截图。断言:`url` · `visible` · `hidden` · `text` · `count` · `eval`。步骤:`open` · `click` · `fill` · `type` · `press` · `wait` · `scroll` · `eval`。完整指南:`chrome-use skills get test`。发现回归?加个用例 —— 用得越多,套件越值钱。
|
||||
|
||||
## 反检测
|
||||
|
||||
连接你真实 Chrome 时,我们**零** JS 注入 —— 浏览器指纹完全是真的。指导原则是 **native CDP/Chrome 覆盖优先于 JS 谎言**:被重定义的 getter 本身可被检测,原生覆盖则不会。
|
||||
@@ -146,7 +253,9 @@ chrome-use --launch --profile auto open https://x.com/home
|
||||
| [rebrowser-bot-detector](https://bot-detector.rebrowser.net/) | `runtimeEnableLeak` 🟢 · `pwInitScripts` 🟢 |
|
||||
| [bot.sannysoft.com](https://bot.sannysoft.com) | 全绿 |
|
||||
|
||||
`--launch` 独立模式下会改用一整套隐身补丁,同样过上述检测。
|
||||
CreepJS 上的 `0% stealth` 是关键数字:因为连接路径**什么都不打补丁**,根本没有可供说谎检测器抓的 override。(读 `navigator.languages` 顺序或 IP 地理位置的面板可能给个软性的「navigator」/「location」标记 —— 那反映的是*你真实 Chrome* 的语言列表和网络,不是自动化破绽。)
|
||||
|
||||
`--launch` 独立模式(全新浏览器)会改用一整套隐身补丁,也能过上述检测 —— 唯一例外:CreepJS 报 **~20% stealth**,因为 srcdoc-iframe 的 `contentWindow` 补丁触发了它的 `hasIframeProxy` 探测(用来藏自动化的 proxy 本身成了破绽)。其余全干净(`0% headless`、sannysoft/browserscan 全绿、Cloudflare 通过)。设 **`AGENT_BROWSER_DISABLE_IFRAME_PROXY=1`** 去掉那个补丁即可拿到干净的 **0% stealth**(代价是放弃小众的 srcdoc-iframe 遮蔽)。**扩展连接路径**(你的真实 Chrome)零 JS 注入、不受影响 —— 它才是货真价实的 0% 路径。
|
||||
|
||||
### 类人输入(行为隐身)
|
||||
|
||||
@@ -167,6 +276,30 @@ chrome-use --launch --profile auto open https://x.com/home
|
||||
|
||||
操作你的真实 Chrome 不该打断你的工作。agent **全程在后台操作**:新标签后台打开(在自己的彩色会话标签组里),**从不强制把标签拽到前台**,并用 `Emulation.setFocusEmulationEnabled` 让每个 agent 标签照常渲染、`document.hasFocus()` / `visibilityState` 仍报 `visible`。于是截图正常、页面不被降频,"标签全程隐藏"也不会变成新的机器人信号。你在自己的标签里照常工作,agent 在旁边默默干活。(想置顶某个标签仍可显式调用命令。)
|
||||
|
||||
### 自己验证
|
||||
|
||||
别光听我们说 —— 把你连接的 Chrome 指向最硬的公开检测器,自己对比:
|
||||
|
||||
- **[CreepJS](https://abrahamjuliot.github.io/creepjs/)** —— 最全面的指纹 / 说谎检测器
|
||||
- **[bot.incolumitas.com](https://bot.incolumitas.com/)** —— 行为 + 指纹打分,方法公开
|
||||
- **[BrowserScan](https://www.browserscan.net/bot-detection)** —— Webdriver / User-Agent / CDP / Navigator
|
||||
- **[bot.sannysoft.com](https://bot.sannysoft.com)** —— 经典自动化特征清单
|
||||
- **[pixelscan.net](https://pixelscan.net/)** · **[iphey.com](https://iphey.com/)** —— 一致性与身份
|
||||
|
||||
我们故意**不自带 bot 检测器** —— 最强、最诚实的基准,就是拿市面上最好的检测器去测你的真实浏览器。
|
||||
|
||||
### 调参(环境变量)
|
||||
|
||||
| 变量 | 默认 | 作用 |
|
||||
|---|---|---|
|
||||
| `AGENT_BROWSER_CAPTURE_CONSOLE` | 关 | 启用 `Runtime` 域,让 `console` / `errors` 捕获页面输出。关闭可保持最隐身的画像。 |
|
||||
| `AGENT_BROWSER_HUMANIZE` | 关 | 类人输入动作:`off`(瞬时)、`fast`(轻量缓动轨迹)、`human`(全套曲线轨迹 + 落点抖动 + 击键节奏 + 缓动滚动/拖拽)。也可用 `--humanize`。默认 `off`;自适应检测器会把 Akamai/PerimeterX/DataDome 守护的页面自动升到 `human`。 |
|
||||
| `AGENT_BROWSER_TIMEZONE` | 未设 | 仅 `--launch`。IANA id(如 `Asia/Tokyo`)原生设置时区(Intl + Date 跟随,无 JS 谎言)以匹配代理;`auto` 按 locale 推导。 |
|
||||
| `AGENT_BROWSER_BLOCK_WEBRTC` | auto | 仅 `--launch`。设了代理时自动强制 WebRTC 走代理(不泄漏真实 IP)。`1` 无代理时也隐藏本地 IP;`0` 退出。 |
|
||||
| `AGENT_BROWSER_HIDE_CANVAS` | 关 | 仅 `--launch`。加入会话稳定的 canvas/audio 指纹噪声。默认关(噪声本身就是一种「谎言」)。 |
|
||||
| `AGENT_BROWSER_ADAPTIVE_REF` | 开 | 当保存的 `@ref` 移动且 role/name 重查失败时,按指纹相似度重定位(需高分 + 明显领先,否则明确报错)。`0` 关闭。 |
|
||||
| `AGENT_BROWSER_CLICK_MODE` | _(auto)_ | 点击策略。默认先滚动入视、派发坐标点击,若被浮层遮挡则回退 DOM `.click()`。`dom` 始终用 `.click()`(适合 blur 即关的自动补全/菜单项);`coord` 严格只用坐标(遮挡时硬失败)。 |
|
||||
|
||||
## chrome-use 的独特之处
|
||||
|
||||
- **默认 auto-connect** —— `chrome-use open` 连你现有的 Chrome 而非启新的
|
||||
@@ -181,3 +314,7 @@ chrome-use --launch --profile auto open https://x.com/home
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
---
|
||||
|
||||
> 由 **leeguooooo** 打造 —— AI agent、逆向工程与 Cloudflare Workers 的实战笔记见 **[blog.misonote.com](https://blog.misonote.com)** · 关注 **[X @leeguooooo](https://x.com/leeguooooo)**
|
||||
|
||||
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.2.2"
|
||||
version = "1.5.24"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.2.2"
|
||||
version = "1.5.24"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -3,6 +3,23 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Embed the version of the `ab-connect` extension this CLI ships alongside, so
|
||||
/// `doctor` can tell a connected extension "you're older than what this CLI
|
||||
/// expects, update it." Read from the extension manifest at build time so it
|
||||
/// stays in sync with whatever extension version is in the same checkout/release
|
||||
/// (the ext is on its own 0.4.x line, separate from the CLI version). Falls back
|
||||
/// to "unknown" if the manifest can't be read.
|
||||
fn embed_extension_version() {
|
||||
let manifest = Path::new("../extensions/ab-connect/manifest.json");
|
||||
println!("cargo:rerun-if-changed=../extensions/ab-connect/manifest.json");
|
||||
let version = fs::read_to_string(manifest)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
println!("cargo:rustc-env=AB_CONNECT_VERSION={}", version);
|
||||
}
|
||||
|
||||
/// Ensure `packages/dashboard/out/` exists so `rust-embed` doesn't fail during
|
||||
/// Rust-only dev builds where the dashboard hasn't been built. The placeholder
|
||||
/// `index.html` is only written when the directory is completely absent.
|
||||
@@ -20,6 +37,7 @@ fn ensure_dashboard_dir() {
|
||||
|
||||
fn main() {
|
||||
ensure_dashboard_dir();
|
||||
embed_extension_version();
|
||||
|
||||
let protocol_dir = Path::new("cdp-protocol");
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
|
||||
+931
-82
File diff suppressed because it is too large
Load Diff
@@ -449,6 +449,69 @@ pub fn relay_url() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a one-line record of how a CDP connection was established, to
|
||||
/// `~/.chrome-use/connect-mode.log`. This is the smoking-gun detector for the
|
||||
/// "Allow remote debugging?" consent modal: that modal ONLY appears on a raw
|
||||
/// remote-debugging attach / a browser we launched with a debug port — NEVER on
|
||||
/// the extension relay. When the modal reappears, this log says which session
|
||||
/// took which path and when, so we can tell a code regression (`raw-port` /
|
||||
/// `launched` while the relay was up) from Chrome's own extension-debugger
|
||||
/// consent UX. Low volume (one line per connection); best-effort, never fails a
|
||||
/// connection.
|
||||
pub fn log_connect_mode(ws_url: &str, launched: bool, session: &str) {
|
||||
let relay = relay_url();
|
||||
let relay_up = relay.is_some();
|
||||
let mode = if launched {
|
||||
"launched(debug-port)"
|
||||
} else if relay.as_deref() == Some(ws_url) {
|
||||
"relay"
|
||||
} else if ws_url.contains("127.0.0.1") || ws_url.contains("localhost") {
|
||||
"raw-port-attach"
|
||||
} else {
|
||||
"remote-ws"
|
||||
};
|
||||
// A raw-port attach or a self-launch while the relay was available is the
|
||||
// exact thing that pops the consent modal — flag it loudly in the line.
|
||||
let suspect = (mode == "raw-port-attach" || launched) && relay_up;
|
||||
let line = format!(
|
||||
"session={session} mode={mode} relay_up={relay_up}{} ws={ws_url}\n",
|
||||
if suspect { " CONSENT-MODAL-RISK" } else { "" }
|
||||
);
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let path = home.join(".chrome-use").join("connect-mode.log");
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidecar recording the connected extension's version, written by the host when
|
||||
/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets
|
||||
/// `doctor` surface which extension build is live without a CDP round-trip.
|
||||
fn relay_ext_version_path() -> PathBuf {
|
||||
relay_url_path().with_file_name("relay-ext-version")
|
||||
}
|
||||
|
||||
/// Version of the connected `ab-connect` extension, if the host learned it from
|
||||
/// the extension's `hello`. `None` when no extension has connected since the
|
||||
/// host started, or the extension predates version reporting.
|
||||
pub fn relay_ext_version() -> Option<String> {
|
||||
let s = std::fs::read_to_string(relay_ext_version_path())
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension.
|
||||
///
|
||||
/// Bridges the extension (native-messaging stdio, envelope protocol) to a local
|
||||
@@ -570,6 +633,15 @@ async fn nm_host_main() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Extension version handshake: record it next to the relay URL so
|
||||
// `doctor` can report which extension build is live (and whether it's
|
||||
// behind). Best-effort; the message carries no CDP payload.
|
||||
if v.get("method").and_then(|m| m.as_str()) == Some("hello") {
|
||||
if let Some(ver) = v.get("version").and_then(|x| x.as_str()) {
|
||||
let _ = std::fs::write(relay_ext_version_path(), ver);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let outs = {
|
||||
let mut s = state.lock().await;
|
||||
s.handle_ext_message(&v, "")
|
||||
@@ -602,6 +674,7 @@ async fn nm_host_main() {
|
||||
}
|
||||
nm_log("[nm-host] stdin EOF — Chrome closed the port");
|
||||
let _ = std::fs::remove_file(relay_url_path());
|
||||
let _ = std::fs::remove_file(relay_ext_version_path());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
+66
-1
@@ -595,7 +595,7 @@ fn query_current_url(session: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Kill a running daemon by reading its PID file and sending a kill signal.
|
||||
fn kill_stale_daemon(session: &str) {
|
||||
pub fn kill_stale_daemon(session: &str) {
|
||||
// Remove the socket first so no new connections reach the old daemon
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -642,6 +642,22 @@ fn kill_stale_daemon(session: &str) {
|
||||
cleanup_stale_files(session);
|
||||
}
|
||||
|
||||
/// Kill every per-session daemon worker (SIGTERM→SIGKILL + sidecar cleanup),
|
||||
/// leaving the Chrome-launched `__nm-host` native-messaging bridge alone — it's
|
||||
/// not a tracked session daemon, so the extension relay stays up. Returns the
|
||||
/// session names that were stopped. Powers `chrome-use daemon restart`, which
|
||||
/// clears corrupted/cross-leaked daemon state (e.g. after a version-mismatch
|
||||
/// restart) without the user resorting to `pgrep`/`kill` (issue #20).
|
||||
pub fn restart_all_daemons() -> Vec<String> {
|
||||
let inventory = walk_daemons();
|
||||
let mut stopped = Vec::new();
|
||||
for session in &inventory.sessions {
|
||||
kill_stale_daemon(&session.name);
|
||||
stopped.push(session.name.clone());
|
||||
}
|
||||
stopped
|
||||
}
|
||||
|
||||
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
|
||||
// Socket connectivity is the sole liveness check — no PID check — so
|
||||
// callers in a different PID namespace (e.g. unshare) can still reuse
|
||||
@@ -1182,6 +1198,55 @@ mod tests {
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_all_daemons_empty_dir() {
|
||||
let dir = std::env::temp_dir().join("ab-test-restart-empty");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
|
||||
|
||||
// No daemons registered → nothing to stop, and it must not blow up.
|
||||
assert!(restart_all_daemons().is_empty());
|
||||
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_restart_all_daemons_kills_live_session() {
|
||||
let dir = std::env::temp_dir().join("ab-test-restart-live");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
|
||||
|
||||
// Spawn a real, killable child and register it as a session daemon.
|
||||
let mut child = Command::new("sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.expect("spawn sleep");
|
||||
let pid = child.id();
|
||||
let _ = fs::write(dir.join("rktest.pid"), pid.to_string());
|
||||
let _ = fs::write(get_socket_path("rktest"), b"");
|
||||
|
||||
let stopped = restart_all_daemons();
|
||||
assert!(
|
||||
stopped.contains(&"rktest".to_string()),
|
||||
"stopped: {:?}",
|
||||
stopped
|
||||
);
|
||||
|
||||
// Reap the killed child first — until the parent waits, it lingers as a
|
||||
// zombie that still answers `kill(pid, 0)`, so is_pid_alive would lie.
|
||||
let _ = child.wait();
|
||||
assert!(!is_pid_alive(pid));
|
||||
|
||||
// Sidecars are cleaned up.
|
||||
assert!(!dir.join("rktest.pid").exists());
|
||||
assert!(!get_socket_path("rktest").exists());
|
||||
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_stale_files_removes_version() {
|
||||
let dir = std::env::temp_dir().join("ab-test-cleanup-version");
|
||||
|
||||
@@ -18,6 +18,7 @@ mod launch;
|
||||
mod network;
|
||||
mod providers;
|
||||
mod security;
|
||||
mod versions;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -97,6 +98,7 @@ pub fn run_doctor(opts: DoctorOptions) -> i32 {
|
||||
let mut fixed: Vec<String> = Vec::new();
|
||||
|
||||
environment::check(&mut checks);
|
||||
versions::check(&mut checks);
|
||||
chrome::check(&mut checks);
|
||||
daemon::check(&mut checks);
|
||||
config::check(&mut checks);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Version-coherence checks across all four moving parts: the CLI binary, the
|
||||
//! per-session daemons (covered by `daemon.rs`), the connected `ab-connect`
|
||||
//! extension, and the bundled skill. The extension was previously a black box —
|
||||
//! nothing reported which build was live — so a user could sit on an old
|
||||
//! extension with no signal. The extension now reports its version over the
|
||||
//! relay (`hello`), the host records it, and this surfaces it in one place.
|
||||
|
||||
use super::{Check, Status};
|
||||
use crate::{connect, upgrade};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Versions";
|
||||
let cli_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// CLI — compare against the latest seen by the background update check.
|
||||
match upgrade::cached_latest_version() {
|
||||
Some(latest) if upgrade::version_is_newer(&latest, cli_version) => {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"versions.cli",
|
||||
category,
|
||||
Status::Warn,
|
||||
format!("CLI {cli_version} (newer available: {latest})"),
|
||||
)
|
||||
.with_fix("chrome-use upgrade".to_string()),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
checks.push(Check::new(
|
||||
"versions.cli",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("CLI {cli_version}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Extension — the build this CLI shipped alongside (embedded at compile time
|
||||
// from the extension manifest) is what we expect to be running.
|
||||
let expected_ext = env!("AB_CONNECT_VERSION");
|
||||
match connect::relay_ext_version() {
|
||||
Some(ext) if upgrade::version_is_newer(expected_ext, &ext) => {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"versions.extension",
|
||||
category,
|
||||
Status::Warn,
|
||||
format!("extension {ext} is behind the bundled {expected_ext}"),
|
||||
)
|
||||
.with_fix(
|
||||
"update ab-connect in Chrome: chrome://extensions \u{2192} reload \
|
||||
(or wait for the Web Store auto-update)"
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(ext) => {
|
||||
checks.push(Check::new(
|
||||
"versions.extension",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("extension {ext}"),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
checks.push(Check::new(
|
||||
"versions.extension",
|
||||
category,
|
||||
Status::Info,
|
||||
format!(
|
||||
"extension not connected (or it predates version reporting — \
|
||||
expected {expected_ext})"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Skill — ships inside the same release artifact as the binary, so it's
|
||||
// version-locked here. Copies made elsewhere via `skills add` aren't.
|
||||
checks.push(Check::new(
|
||||
"versions.skill",
|
||||
category,
|
||||
Status::Info,
|
||||
format!(
|
||||
"skills bundled with this CLI ({cli_version}); copies made via `skills add` \
|
||||
elsewhere may be stale — re-run to refresh"
|
||||
),
|
||||
));
|
||||
}
|
||||
+255
-2
@@ -10,6 +10,7 @@ mod flags;
|
||||
mod install;
|
||||
mod native;
|
||||
mod output;
|
||||
mod site;
|
||||
mod skills;
|
||||
mod test_runner;
|
||||
#[cfg(test)]
|
||||
@@ -29,8 +30,8 @@ use windows_sys::Win32::System::Threading::OpenProcess;
|
||||
|
||||
use commands::{gen_id, parse_command, ParseError};
|
||||
use connection::{
|
||||
cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, send_command, walk_daemons,
|
||||
DaemonOptions,
|
||||
cleanup_stale_files, ensure_daemon, get_socket_dir, is_pid_alive, restart_all_daemons,
|
||||
send_command, walk_daemons, DaemonOptions,
|
||||
};
|
||||
use flags::{clean_args, parse_flags, Flags};
|
||||
use install::run_install;
|
||||
@@ -320,6 +321,94 @@ fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `chrome-use daemon <restart|status>` — manage the per-session daemon workers
|
||||
/// without resorting to `pgrep`/`kill`. `restart` clears corrupted or
|
||||
/// cross-leaked daemon state (e.g. after a mid-session `chrome-use upgrade`
|
||||
/// where stale tab handles bleed across sessions, issue #20) by killing every
|
||||
/// session worker. The Chrome-launched `__nm-host` native-messaging bridge is
|
||||
/// NOT a tracked session daemon, so the extension relay survives a restart —
|
||||
/// the next command spins up a fresh, clean daemon against the same live Chrome.
|
||||
fn run_daemon(args: &[String], json_mode: bool) {
|
||||
match args.get(1).map(|s| s.as_str()) {
|
||||
Some("restart") => {
|
||||
let stopped = restart_all_daemons();
|
||||
let relay_up = connect::relay_url().is_some();
|
||||
if json_mode {
|
||||
print_json_value(json!({
|
||||
"success": true,
|
||||
"data": { "stopped": stopped, "count": stopped.len(), "relay": relay_up },
|
||||
}));
|
||||
} else if stopped.is_empty() {
|
||||
println!("No session daemons running — nothing to restart.");
|
||||
if relay_up {
|
||||
println!(
|
||||
"{}",
|
||||
color::dim("Extension relay still up; next command starts a fresh daemon.")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for s in &stopped {
|
||||
println!("{} Stopped daemon: {}", color::green("✓"), s);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
color::dim(if relay_up {
|
||||
"Extension relay (__nm-host) left running; next command starts a fresh daemon."
|
||||
} else {
|
||||
"Next command starts a fresh daemon."
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Some("status") | Some("list") => {
|
||||
let inventory = walk_daemons();
|
||||
let relay_up = connect::relay_url().is_some();
|
||||
if json_mode {
|
||||
let sessions: Vec<_> = inventory
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|s| json!({ "name": s.name, "pid": s.pid, "version": s.version }))
|
||||
.collect();
|
||||
print_json_value(json!({
|
||||
"success": true,
|
||||
"data": { "sessions": sessions, "relay": relay_up },
|
||||
}));
|
||||
} else if inventory.sessions.is_empty() {
|
||||
println!("No session daemons running.");
|
||||
if relay_up {
|
||||
println!("{}", color::dim("Extension relay (__nm-host): up"));
|
||||
}
|
||||
} else {
|
||||
println!("Session daemons:");
|
||||
for s in &inventory.sessions {
|
||||
let ver = s
|
||||
.version
|
||||
.as_deref()
|
||||
.map(|v| format!(" {}", color::dim(&format!("(v{})", v))))
|
||||
.unwrap_or_default();
|
||||
println!(" {} pid {}{}", s.name, s.pid, ver);
|
||||
}
|
||||
if relay_up {
|
||||
println!("{}", color::dim("Extension relay (__nm-host): up"));
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
eprintln!(
|
||||
"{} usage: chrome-use daemon <restart|status>",
|
||||
color::error_indicator()
|
||||
);
|
||||
if let Some(unknown) = other {
|
||||
eprintln!(
|
||||
"{}",
|
||||
color::dim(&format!(" unknown subcommand: {}", unknown))
|
||||
);
|
||||
}
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_dashboard_pid_path() -> std::path::PathBuf {
|
||||
get_socket_dir().join("dashboard.pid")
|
||||
}
|
||||
@@ -746,6 +835,105 @@ fn main() {
|
||||
exit(test_runner::run_test(suite, &flags));
|
||||
}
|
||||
|
||||
// Handle `site`: site adapters — turn a website into a structured-data CLI by
|
||||
// running a per-command JS adapter inside your logged-in tab. `update`/`list`/
|
||||
// `info` are CLI-side (download/filesystem); `site <name>/<cmd> [args]` falls
|
||||
// through to the daemon dispatch below (navigate to the adapter's domain + eval).
|
||||
if clean.first().map(|s| s.as_str()) == Some("site") {
|
||||
// Auto-sync the adapter pack on first use and periodically (TTL, default
|
||||
// 7d) so adapters stay fresh without a manual `site update`. Skipped for an
|
||||
// explicit `update` (full sync below). Best-effort: offline → cached pack.
|
||||
// Disable with AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1.
|
||||
if clean.get(1).map(|s| s.as_str()) != Some("update") && site::needs_refresh() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
match rt.block_on(site::update()) {
|
||||
Ok(n) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
color::dim(&format!("site: synced {n} adapters (auto)"))
|
||||
)
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"{}",
|
||||
color::dim(&format!(
|
||||
"site: auto-sync skipped ({e}); using cached adapters"
|
||||
))
|
||||
),
|
||||
}
|
||||
}
|
||||
match clean.get(1).map(|s| s.as_str()) {
|
||||
Some("update") => {
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
match rt.block_on(site::update()) {
|
||||
Ok(n) if flags.json => {
|
||||
println!("{}", json!({ "success": true, "adapters": n }))
|
||||
}
|
||||
Ok(n) => println!(
|
||||
"{} synced {} site adapters → ~/.chrome-use/sites (run `chrome-use site list`)",
|
||||
color::success_indicator(),
|
||||
n
|
||||
),
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some("list") => {
|
||||
match site::list_adapters() {
|
||||
Ok(list) if flags.json => {
|
||||
println!("{}", json!({ "success": true, "adapters": list }))
|
||||
}
|
||||
Ok(list) if list.is_empty() => {
|
||||
println!("no site adapters installed — run `chrome-use site update`")
|
||||
}
|
||||
Ok(list) => {
|
||||
for a in &list {
|
||||
println!("{a}");
|
||||
}
|
||||
eprintln!(
|
||||
"{}",
|
||||
color::dim(&format!(
|
||||
"{} adapters · run: chrome-use site <name>/<cmd> [args]",
|
||||
list.len()
|
||||
))
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some("info") => {
|
||||
let spec = clean.get(2).cloned().unwrap_or_default();
|
||||
match site::load_adapter(&spec) {
|
||||
Ok(a) => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&a.meta).unwrap_or_default()
|
||||
),
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// `site <name>/<cmd> [args]` → fall through to the daemon dispatch.
|
||||
Some(spec) if spec.contains('/') => {}
|
||||
_ => {
|
||||
eprintln!(
|
||||
"{} usage: chrome-use site <name>/<cmd> [args] | site update | site list | \
|
||||
site info <name>/<cmd>",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle skills command (doesn't need daemon)
|
||||
if clean.first().map(|s| s.as_str()) == Some("skills") {
|
||||
skills::run_skills(&clean, flags.json);
|
||||
@@ -793,12 +981,63 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// `adopt <url|targetId>`: read a PRE-EXISTING tab (the user's own, or another
|
||||
// session's) WITHOUT opening a new one. Forces a fresh daemon and points it at
|
||||
// the relay (like `extension connect`); the AGENT_BROWSER_ADOPT env makes the
|
||||
// daemon's first connect ADOPT the matching tab instead of creating an
|
||||
// about:blank. Rewrites into `connect <relay-url>` BEFORE parse_command so the
|
||||
// daemon attaches to the user's real Chrome. Must run before parse_command.
|
||||
if clean.first().map(|s| s.as_str()) == Some("adopt") {
|
||||
match clean.get(1) {
|
||||
Some(spec) if !spec.trim().is_empty() => {
|
||||
std::env::set_var("AGENT_BROWSER_ADOPT", spec.trim());
|
||||
connection::kill_stale_daemon(&flags.session);
|
||||
match connect::relay_url() {
|
||||
Some(url) => {
|
||||
flags.cdp = Some(url.clone());
|
||||
flags.auto_connect = false;
|
||||
clean = vec!["connect".to_string(), url];
|
||||
}
|
||||
None => {
|
||||
eprintln!(
|
||||
"{} extension relay not connected — open Chrome with the ab-connect \
|
||||
extension first (this command reads an EXISTING tab, it won't launch one).",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
eprintln!(
|
||||
"{} usage: chrome-use adopt <url-substring|targetId> (reads an existing tab, no new tab)",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle session separately (doesn't need daemon)
|
||||
if clean.first().map(|s| s.as_str()) == Some("session") {
|
||||
run_session(&clean, &flags.session, flags.json);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle daemon management (doesn't talk to a daemon — it manages them).
|
||||
if clean.first().map(|s| s.as_str()) == Some("daemon") {
|
||||
run_daemon(&clean, flags.json);
|
||||
return;
|
||||
}
|
||||
|
||||
// `sessions` is a natural top-level guess for "list my sessions" (the skill
|
||||
// advertises sessions as a feature) — route it to the daemon inventory the
|
||||
// same way `daemon status` does (issue #29).
|
||||
if clean.first().map(|s| s.as_str()) == Some("sessions") {
|
||||
run_daemon(&["sessions".to_string(), "status".to_string()], flags.json);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle close --all: close all active sessions
|
||||
if matches!(
|
||||
clean.first().map(|s| s.as_str()),
|
||||
@@ -1267,6 +1506,20 @@ fn main() {
|
||||
&& flags.provider.is_none()
|
||||
&& (flags.force_launch || !flags.auto_connect)
|
||||
{
|
||||
// Launching a debug-port Chrome pops Chrome's "Allow remote debugging?"
|
||||
// consent modal (Chrome 136+). When the ab-connect relay is already up,
|
||||
// this is almost always unintended — the relay drives the user's real
|
||||
// Chrome with NO modal. Warn so the modal is self-explained and the
|
||||
// caller (often a stray --launch / --no-auto-connect) is fixable (#32).
|
||||
if !flags.json && connect::relay_url().is_some() {
|
||||
eprintln!(
|
||||
"{} launching a new Chrome with a debug port — this pops Chrome's \
|
||||
\"Allow remote debugging?\" modal.\n The ab-connect relay is up; \
|
||||
drop --launch/--new (and don't pass --no-auto-connect) to drive your \
|
||||
real Chrome with no modal.",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
let mut launch_cmd = json!({
|
||||
"id": gen_id(),
|
||||
"action": "launch",
|
||||
|
||||
+906
-33
File diff suppressed because it is too large
Load Diff
+1130
-72
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,16 @@ pub async fn run_daemon(session: &str) {
|
||||
// (via the ab-connect extension) land in a per-session Chrome tab group.
|
||||
let _ = super::browser::DAEMON_SESSION.set(session.to_string());
|
||||
|
||||
// Bootstrap / refresh the site-adapter pack in the background (first-run +
|
||||
// periodic TTL). This populates ~/.chrome-use/sites/.index.json so navigation
|
||||
// can auto-suggest `site` commands for the page you land on, with zero added
|
||||
// latency to any command. Best-effort; offline is a no-op.
|
||||
if crate::site::needs_refresh() {
|
||||
tokio::spawn(async {
|
||||
let _ = crate::site::update().await;
|
||||
});
|
||||
}
|
||||
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
|
||||
@@ -35,6 +35,7 @@ fn native_test_fixture_html(name: &str) -> &'static str {
|
||||
"html5_drag_probe" => include_str!("test_fixtures/html5_drag_probe.html"),
|
||||
"pointer_capture_probe" => include_str!("test_fixtures/pointer_capture_probe.html"),
|
||||
"upload_probe" => include_str!("test_fixtures/upload_probe.html"),
|
||||
"iframe_button_probe" => include_str!("test_fixtures/iframe_button_probe.html"),
|
||||
_ => panic!("Unknown native test fixture: {}", name),
|
||||
}
|
||||
}
|
||||
@@ -573,6 +574,76 @@ async fn e2e_snapshot_and_click_ref() {
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
/// Clicking a button INSIDE an iframe by `@ref` must deliver a TRUSTED activation
|
||||
/// (`event.isTrusted === true`), not a synthetic DOM `.click()`. Security-sensitive
|
||||
/// embedded forms (Google Payments' `保存`) reject `isTrusted:false` clicks, so an
|
||||
/// enabled submit button silently no-op'd (issue #39). The fix routes iframe-ref
|
||||
/// clicks to a real `Input.dispatchMouseEvent` on the element's own frame session.
|
||||
/// The fixture's iframe button writes `clicked:<isTrusted>` into its own text on
|
||||
/// click, which the cross-frame snapshot reads back.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_iframe_button_click_is_trusted() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": native_test_fixture_url("iframe_button_probe") }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Snapshot (interactive) — the button lives in the iframe and must appear with
|
||||
// a ref; that ref carries the frame_id so the click resolves into the frame.
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "3", "action": "snapshot", "interactive": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap_or("");
|
||||
let ref_id = snapshot
|
||||
.lines()
|
||||
.find(|l| l.contains("button \"save\""))
|
||||
.and_then(|l| l.split("ref=").nth(1))
|
||||
.map(|r| r.trim_end_matches(']').trim())
|
||||
.unwrap_or_else(|| panic!("iframe button not found in snapshot:\n{snapshot}"));
|
||||
|
||||
// Click it by ref.
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "4", "action": "click", "selector": ref_id }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||||
|
||||
// The button rewrote its own text with the click's isTrusted flag; read it
|
||||
// back across frames.
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "5", "action": "snapshot", "interactive": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
let after = get_data(&resp)["snapshot"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
after.contains("clicked:true"),
|
||||
"iframe button click must be trusted (isTrusted:true); snapshot:\n{after}"
|
||||
);
|
||||
|
||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screenshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+381
-12
@@ -100,6 +100,15 @@ impl RefMap {
|
||||
self.map.get(ref_id)
|
||||
}
|
||||
|
||||
/// Whether `selector_or_ref` is a `@ref` whose snapshot entry lives inside an
|
||||
/// iframe (has a `frame_id`). Pointer interactions use this to choose
|
||||
/// DOM-dispatch over coordinates for OOPIF elements (issue #36).
|
||||
pub fn ref_is_in_iframe(&self, selector_or_ref: &str) -> bool {
|
||||
parse_ref(selector_or_ref)
|
||||
.and_then(|r| self.map.get(&r).map(|e| e.frame_id.is_some()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn entries_sorted(&self) -> Vec<(String, RefEntry)> {
|
||||
let mut entries = self
|
||||
.map
|
||||
@@ -796,16 +805,43 @@ pub(super) fn extract_ax_string(value: &Option<AXValue>) -> String {
|
||||
/// Build a JS expression that finds a DOM element by CSS selector or XPath.
|
||||
fn build_find_element_js(selector: &str) -> String {
|
||||
if let Some(xpath) = selector.strip_prefix("xpath=") {
|
||||
format!(
|
||||
return format!(
|
||||
"document.evaluate({}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue",
|
||||
serde_json::to_string(xpath).unwrap_or_default()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"document.querySelector({})",
|
||||
serde_json::to_string(selector).unwrap_or_default()
|
||||
)
|
||||
);
|
||||
}
|
||||
// Bare string (or explicit `text=`): try CSS first, then fall back to
|
||||
// matching an interactive element by its VISIBLE TEXT. snapshot exposes
|
||||
// buttons/links by their name, so `click "購入手続きへ"` should resolve by
|
||||
// that label — previously it was fed straight to `querySelector` as CSS and
|
||||
// failed as an invalid selector even though the button was right there
|
||||
// (issue #24-B). CSS still wins when it matches, so existing selectors are
|
||||
// unaffected; nested/non-ASCII labels now resolve too.
|
||||
let text_only = selector.strip_prefix("text=");
|
||||
let force_text = text_only.is_some();
|
||||
let sel_json = serde_json::to_string(selector).unwrap_or_default();
|
||||
let want_json = serde_json::to_string(text_only.unwrap_or(selector)).unwrap_or_default();
|
||||
format!(
|
||||
r#"(() => {{
|
||||
const sel = {sel};
|
||||
const css = {force_text} ? null : (() => {{ try {{ return document.querySelector(sel); }} catch (_e) {{ return null; }} }})();
|
||||
if (css) return css;
|
||||
const norm = s => (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
|
||||
const w = norm({want}); if (!w) return null;
|
||||
const wl = w.toLowerCase();
|
||||
const interactive = Array.from(document.querySelectorAll(
|
||||
'button,a,[role=button],[role=link],[role=menuitem],[role=tab],[role=option],input[type=submit],input[type=button],input[type=reset],summary,label,[onclick]'));
|
||||
const textOf = e => norm(e.innerText || e.textContent) || norm(e.value) ||
|
||||
norm(e.getAttribute && e.getAttribute('aria-label')) || norm(e.getAttribute && e.getAttribute('title'));
|
||||
let hit = interactive.find(e => textOf(e) === w) || interactive.find(e => textOf(e).toLowerCase().includes(wl));
|
||||
if (hit) return hit;
|
||||
const leaves = Array.from(document.querySelectorAll('*')).filter(e => !e.children.length);
|
||||
return leaves.find(e => norm(e.textContent) === w) || leaves.find(e => norm(e.textContent).toLowerCase().includes(wl)) || null;
|
||||
}})()"#,
|
||||
sel = sel_json,
|
||||
want = want_json,
|
||||
force_text = force_text
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a JS expression that counts matching DOM elements by CSS selector or XPath.
|
||||
@@ -948,6 +984,268 @@ pub async fn get_element_text(
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Text content collected from a single frame of the page.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameText {
|
||||
pub frame_id: String,
|
||||
pub url: String,
|
||||
/// "top" | "inline" (same-process child frame) | "oopif" (out-of-process).
|
||||
pub kind: &'static str,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
// The expression we run in every frame to read its visible text. innerText
|
||||
// honors CSS visibility (skips display:none), textContent is the fallback.
|
||||
const FRAME_INNERTEXT_JS: &str = "(function(){try{var b=document.body||document.documentElement;return b?(b.innerText||b.textContent||''):'';}catch(e){return '';}})()";
|
||||
|
||||
async fn eval_text_default(client: &CdpClient, session_id: &str) -> String {
|
||||
let res = client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(serde_json::json!({
|
||||
"expression": FRAME_INNERTEXT_JS,
|
||||
"returnByValue": true,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
res.ok()
|
||||
.and_then(|v| v.get("result").and_then(|r| r.get("value")).cloned())
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// Same-process child frames share the top renderer but live in their own
|
||||
// execution context. Page.createIsolatedWorld hands us a context id bound to
|
||||
// that frame so Runtime.evaluate reads the child document, not the parent.
|
||||
async fn eval_text_in_frame(client: &CdpClient, session_id: &str, frame_id: &str) -> String {
|
||||
let ctx = client
|
||||
.send_command(
|
||||
"Page.createIsolatedWorld",
|
||||
Some(serde_json::json!({ "frameId": frame_id, "worldName": "chrome_use_text" })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("executionContextId").and_then(|c| c.as_i64()));
|
||||
let Some(ctx_id) = ctx else {
|
||||
return String::new();
|
||||
};
|
||||
let res = client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(serde_json::json!({
|
||||
"expression": FRAME_INNERTEXT_JS,
|
||||
"returnByValue": true,
|
||||
"contextId": ctx_id,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
res.ok()
|
||||
.and_then(|v| v.get("result").and_then(|r| r.get("value")).cloned())
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn flatten_frame_tree(node: &Value, is_top: bool, out: &mut Vec<(String, String, bool)>) {
|
||||
if let Some(frame) = node.get("frame") {
|
||||
if let Some(id) = frame.get("id").and_then(|v| v.as_str()) {
|
||||
let url = frame
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
out.push((id.to_string(), url, is_top));
|
||||
}
|
||||
}
|
||||
if let Some(children) = node.get("childFrames").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
flatten_frame_tree(child, false, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect visible text from every frame reachable in the active session,
|
||||
/// including out-of-process iframes (which never appear in the top frame's
|
||||
/// `Page.getFrameTree` and so are invisible to `document.body.innerText`).
|
||||
///
|
||||
/// Same-process child frames are read through `Page.createIsolatedWorld`;
|
||||
/// OOPIFs are read through their own auto-attached debugger session
|
||||
/// (`iframe_sessions`, keyed by frameId == targetId). This is the engine
|
||||
/// behind `get text --all-frames` and `chrome-use frames` — the fix for
|
||||
/// listing/marketplace pages whose description lives in a child frame (#27).
|
||||
pub async fn collect_all_frames_text(
|
||||
client: &CdpClient,
|
||||
top_session: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Vec<FrameText>, String> {
|
||||
let mut out: Vec<FrameText> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// 1. Top session: the top frame plus its same-process descendants. OOPIF
|
||||
// frames that happen to surface here are skipped — they're read via
|
||||
// their dedicated session in step 2 (cross-process isolated worlds fail).
|
||||
let tree = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(top_session))
|
||||
.await?;
|
||||
let mut frames: Vec<(String, String, bool)> = Vec::new();
|
||||
flatten_frame_tree(&tree["frameTree"], true, &mut frames);
|
||||
for (fid, url, is_top) in frames {
|
||||
if iframe_sessions.contains_key(&fid) {
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(fid.clone()) {
|
||||
continue;
|
||||
}
|
||||
let (kind, text) = if is_top {
|
||||
("top", eval_text_default(client, top_session).await)
|
||||
} else {
|
||||
(
|
||||
"inline",
|
||||
eval_text_in_frame(client, top_session, &fid).await,
|
||||
)
|
||||
};
|
||||
out.push(FrameText {
|
||||
frame_id: fid,
|
||||
url,
|
||||
kind,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Each out-of-process iframe, read through its own session.
|
||||
for (fid, sid) in iframe_sessions {
|
||||
if !seen.insert(fid.clone()) {
|
||||
continue;
|
||||
}
|
||||
let url = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(sid))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|t| {
|
||||
t.get("frameTree")
|
||||
.and_then(|ft| ft.get("frame"))
|
||||
.and_then(|f| f.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let text = eval_text_default(client, sid).await;
|
||||
out.push(FrameText {
|
||||
frame_id: fid.clone(),
|
||||
url,
|
||||
kind: "oopif",
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// Readability-lite: prefer the page's semantic main-content region over the
|
||||
// whole body so global header/nav/footer chrome (and, on many listing pages,
|
||||
// the "related items" sidebar) doesn't drown out the actual content. Runs on
|
||||
// the live, rendered tree (innerText needs layout — a detached clone returns
|
||||
// empty), so we pick the densest <main>/<article> region rather than cloning
|
||||
// and stripping. Falls back to <body> when no substantial main region exists.
|
||||
const MAIN_CONTENT_JS: &str = r#"(function(){
|
||||
function txt(el){try{return (el.innerText||'').trim();}catch(e){return '';}}
|
||||
var sels=['main','[role=main]','article','#main','#contents','#l-content'];
|
||||
var best=null,bestLen=0;
|
||||
for(var i=0;i<sels.length;i++){
|
||||
var els=document.querySelectorAll(sels[i]);
|
||||
for(var j=0;j<els.length;j++){var l=txt(els[j]).length;if(l>bestLen){bestLen=l;best=els[j];}}
|
||||
}
|
||||
if(best&&bestLen>200)return txt(best);
|
||||
return txt(document.body);
|
||||
})()"#;
|
||||
|
||||
/// Extract the page's main-content text (readability-lite), preferring a
|
||||
/// semantic `<main>`/`<article>` region over the full body. Used by
|
||||
/// `get text --main` to avoid header/nav/sidebar boilerplate (#27).
|
||||
pub async fn get_main_content_text(client: &CdpClient, session_id: &str) -> Result<String, String> {
|
||||
let res = client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(serde_json::json!({
|
||||
"expression": MAIN_CONTENT_JS,
|
||||
"returnByValue": true,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(res
|
||||
.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// Text nodes whose parent is one of these carry no visible content.
|
||||
fn is_noise_tag(name: &str) -> bool {
|
||||
matches!(name, "SCRIPT" | "STYLE" | "NOSCRIPT" | "TEMPLATE" | "HEAD")
|
||||
}
|
||||
|
||||
// Walk a CDP DOM.Node tree, collecting text-node values. Unlike `innerText`
|
||||
// (JS, blocked by CLOSED shadow roots), the CDP DOM tree from
|
||||
// `DOM.getDocument(pierce:true)` includes closed shadow roots and child
|
||||
// documents — so this reaches text JS can't. `parent_noise` carries whether an
|
||||
// ancestor was <script>/<style>/etc so their text is skipped.
|
||||
fn collect_dom_text(node: &Value, parent_noise: bool, out: &mut String) {
|
||||
let node_type = node.get("nodeType").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let node_name = node.get("nodeName").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if node_type == 3 {
|
||||
if !parent_noise {
|
||||
if let Some(t) = node.get("nodeValue").and_then(|v| v.as_str()) {
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let noise = parent_noise || is_noise_tag(node_name);
|
||||
if let Some(children) = node.get("children").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
collect_dom_text(child, noise, out);
|
||||
}
|
||||
}
|
||||
if let Some(shadow) = node.get("shadowRoots").and_then(|v| v.as_array()) {
|
||||
for sr in shadow {
|
||||
collect_dom_text(sr, noise, out);
|
||||
}
|
||||
}
|
||||
if let Some(doc) = node.get("contentDocument") {
|
||||
collect_dom_text(doc, noise, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract text from the page via the CDP DOM tree with `pierce:true`, which
|
||||
/// reaches into CLOSED shadow roots and child documents that `innerText`/`eval`
|
||||
/// cannot. Lets an agent read content rendered into a closed shadow DOM (e.g. an
|
||||
/// extension's injected debug panel) without any extra Chrome permission — it
|
||||
/// rides the per-tab debugger session that's already attached (#30).
|
||||
pub async fn get_pierced_text(client: &CdpClient, session_id: &str) -> Result<String, String> {
|
||||
let doc = client
|
||||
.send_command(
|
||||
"DOM.getDocument",
|
||||
Some(serde_json::json!({ "depth": -1, "pierce": true })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
let mut out = String::new();
|
||||
if let Some(root) = doc.get("root") {
|
||||
collect_dom_text(root, false, &mut out);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn get_element_attribute(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -1231,9 +1529,27 @@ pub async fn get_element_input_value(
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { return typeof this.value === 'string' ? this.value : ''; }"
|
||||
.to_string(),
|
||||
// Read rich-editor content too (issue #41): CodeMirror 5 / Monaco
|
||||
// keep their text in a model, not `.value`; contenteditable keeps
|
||||
// it as innerText. Falls back to `.value` for plain inputs.
|
||||
function_declaration: r#"function() {
|
||||
const el = this;
|
||||
const cm5 = el.closest && el.closest('.CodeMirror');
|
||||
if (cm5 && cm5.CodeMirror) return cm5.CodeMirror.getValue();
|
||||
if (window.monaco && monaco.editor) {
|
||||
try {
|
||||
const eds = monaco.editor.getEditors ? monaco.editor.getEditors() : [];
|
||||
const ed = eds.find(e => e.getDomNode && e.getDomNode().contains(el)) || eds[0];
|
||||
if (ed) return ed.getValue();
|
||||
const m = monaco.editor.getModels ? monaco.editor.getModels() : [];
|
||||
if (m[0]) return m[0].getValue();
|
||||
} catch (e) {}
|
||||
}
|
||||
if (typeof el.value === 'string') return el.value;
|
||||
if (el.isContentEditable) return el.innerText;
|
||||
return '';
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
@@ -1311,7 +1627,15 @@ pub async fn get_element_bounding_box(
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const r = this.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
const inViewport = r.bottom > 0 && r.right > 0
|
||||
&& r.top < (innerHeight || document.documentElement.clientHeight)
|
||||
&& r.left < (innerWidth || document.documentElement.clientWidth);
|
||||
return {
|
||||
x: r.x, y: r.y, width: r.width, height: r.height,
|
||||
centerX: Math.round(r.x + r.width / 2),
|
||||
centerY: Math.round(r.y + r.height / 2),
|
||||
inViewport,
|
||||
};
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
@@ -1421,6 +1745,31 @@ mod tests {
|
||||
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_dom_text_pierces_closed_shadow_and_skips_noise() {
|
||||
// A CDP DOM.Node tree: a host element whose CLOSED shadow root holds the
|
||||
// text, plus a <script> whose text must be skipped.
|
||||
let tree = serde_json::json!({
|
||||
"nodeType": 1, "nodeName": "BODY",
|
||||
"children": [
|
||||
{ "nodeType": 1, "nodeName": "SCRIPT",
|
||||
"children": [ { "nodeType": 3, "nodeName": "#text", "nodeValue": "var secret=1;" } ] },
|
||||
{ "nodeType": 1, "nodeName": "DIV",
|
||||
"shadowRoots": [
|
||||
{ "nodeType": 11, "nodeName": "#document-fragment",
|
||||
"children": [
|
||||
{ "nodeType": 1, "nodeName": "SPAN",
|
||||
"children": [ { "nodeType": 3, "nodeName": "#text", "nodeValue": "DECRYPTED 42" } ] }
|
||||
] }
|
||||
] }
|
||||
]
|
||||
});
|
||||
let mut out = String::new();
|
||||
collect_dom_text(&tree, false, &mut out);
|
||||
assert_eq!(out, "DECRYPTED 42");
|
||||
assert!(!out.contains("secret"), "script text must be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ref_equals_prefix() {
|
||||
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
|
||||
@@ -1452,10 +1801,30 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_selector_js_css() {
|
||||
let js = build_selector_js("#submit-btn");
|
||||
assert!(js.contains("document.querySelector(\"#submit-btn\")"));
|
||||
// CSS is now tried via a `sel` variable, with a visible-text fallback
|
||||
// appended (issue #24-B). It must still use querySelector (not xpath).
|
||||
assert!(js.contains("const sel = \"#submit-btn\""));
|
||||
assert!(js.contains("document.querySelector(sel)"));
|
||||
assert!(!js.contains("document.evaluate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_find_element_js_text_fallback() {
|
||||
// A bare label gets a text-matching fallback so `click "購入手続きへ"`
|
||||
// resolves by visible text, not just CSS (issue #24-B).
|
||||
let js = build_find_element_js("購入手続きへ");
|
||||
assert!(js.contains("購入手続きへ"));
|
||||
assert!(js.contains("interactive")); // the text-match branch
|
||||
assert!(js.contains("textOf"));
|
||||
// `text=` forces the text path (skips CSS).
|
||||
let forced = build_find_element_js("text=Buy now");
|
||||
assert!(forced.contains("true ? null")); // force_text => css skipped
|
||||
// xpath is unchanged.
|
||||
let xp = build_find_element_js("xpath=//button");
|
||||
assert!(xp.contains("document.evaluate"));
|
||||
assert!(!xp.contains("interactive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_selector_js_xpath() {
|
||||
let js = build_selector_js("xpath=//button[@id='ok']");
|
||||
|
||||
+446
-37
@@ -7,6 +7,17 @@ use super::cdp::types::*;
|
||||
use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap};
|
||||
use super::humanize;
|
||||
|
||||
/// Whether a pointer interaction should be DOM-dispatched (invoke the event on
|
||||
/// the element in its own session) rather than dispatched at a viewport
|
||||
/// coordinate via `Input.dispatchMouseEvent`. True when the target is inside an
|
||||
/// iframe (an OOPIF element's box can't be mapped to a top-viewport point) or we
|
||||
/// drive over the extension relay (a coordinate Input event isn't confined to the
|
||||
/// target tab on a busy real Chrome — it drifts onto the foreground tab; issues
|
||||
/// #31/#36). DOM-dispatch always hits the right element in the right tab.
|
||||
fn prefer_dom_dispatch(ref_map: &RefMap, selector_or_ref: &str) -> bool {
|
||||
ref_map.ref_is_in_iframe(selector_or_ref) || crate::connect::relay_url().is_some()
|
||||
}
|
||||
|
||||
pub async fn click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -45,6 +56,45 @@ pub async fn click(
|
||||
.await;
|
||||
}
|
||||
|
||||
// An element INSIDE an iframe needs a TRUSTED activation: a DOM `.click()` is
|
||||
// `isTrusted:false`, which security-sensitive embedded forms reject — Google
|
||||
// Payments' enabled `保存` button silently no-ops on a synthetic click (issue
|
||||
// #39). A coordinate `Input.dispatchMouseEvent` can't help either: `getBoxModel`
|
||||
// for a sub-frame node returns frame-local coordinates that don't compose the
|
||||
// iframe's offset, so the click lands in the wrong place. The frame-agnostic
|
||||
// trusted path is keyboard activation — focus the element in its own frame, then
|
||||
// dispatch a real Enter on the page session; Chrome routes the key to the
|
||||
// focused element regardless of frame (same as `type --focused`), and Enter on a
|
||||
// focused button/link fires a trusted `click`. `coord` mode opts out.
|
||||
let in_iframe = ref_map.ref_is_in_iframe(selector_or_ref);
|
||||
if mode != "coord" && button == "left" && click_count == 1 && in_iframe {
|
||||
return dom_activate(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// On the relay (the user's real Chrome) a TOP-document coordinate click used to
|
||||
// drift onto the foreground tab; that root cause is fixed (#5: the agent drives
|
||||
// its own pinned tab), but DOM-dispatch stays the conservative default here.
|
||||
if mode != "coord"
|
||||
&& button == "left"
|
||||
&& click_count == 1
|
||||
&& crate::connect::relay_url().is_some()
|
||||
{
|
||||
return dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let resolved = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
@@ -235,6 +285,112 @@ async fn dom_click(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trusted activation of an element inside an iframe (issue #39). Focuses the
|
||||
/// element in its own frame session, then dispatches a real Enter/Space on the
|
||||
/// page session — Chrome routes the key to the focused element across frames, and
|
||||
/// Enter/Space on a focused button/link/checkbox fires a `click` with
|
||||
/// `isTrusted: true`, which security-sensitive embedded forms (Google Payments
|
||||
/// `保存`) require. Non-activatable roles (a `div[onclick]`) can't be keyboard-
|
||||
/// activated, so they fall back to a DOM `.click()`.
|
||||
async fn dom_activate(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let role = parse_ref(selector_or_ref)
|
||||
.and_then(|r| ref_map.get(&r).map(|e| e.role.clone()))
|
||||
.unwrap_or_default();
|
||||
// Space toggles checkbox-like controls; Enter activates buttons/links/menus.
|
||||
let key = match role.as_str() {
|
||||
"checkbox" | "radio" | "switch" | "option" | "menuitemcheckbox" | "menuitemradio" => {
|
||||
Some("space")
|
||||
}
|
||||
"button" | "link" | "menuitem" | "tab" | "treeitem" => Some("enter"),
|
||||
_ => None,
|
||||
};
|
||||
let Some(key) = key else {
|
||||
// Not keyboard-activatable — best effort via DOM .click() (untrusted).
|
||||
return dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
// Focus the element in its OWN frame session so the keystroke lands on it.
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
// Trusted key on the page session — routed to the focused (in-frame) element.
|
||||
press_key(client, session_id, key).await?;
|
||||
wait_for_paint_settled(client, &effective_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// DOM-dispatch a double-click on the element in its own session (no coordinates)
|
||||
/// — the relay/iframe-safe counterpart to a coordinate dblclick. Fires the full
|
||||
/// click,click,dblclick sequence so handlers bound to any of them respond.
|
||||
async fn dom_dblclick(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const opts = { bubbles: true, cancelable: true, view: window };
|
||||
this.dispatchEvent(new MouseEvent('click', opts));
|
||||
this.dispatchEvent(new MouseEvent('click', { ...opts, detail: 2 }));
|
||||
this.dispatchEvent(new MouseEvent('dblclick', opts));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
wait_for_paint_settled(client, &effective_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dblclick(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -242,6 +398,20 @@ pub async fn dblclick(
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
// Same relay/iframe drift hazard as a single click — DOM-dispatch the
|
||||
// double-click there instead of a coordinate one (issues #31/#36).
|
||||
if std::env::var("AGENT_BROWSER_CLICK_MODE").as_deref() != Ok("coord")
|
||||
&& prefer_dom_dispatch(ref_map, selector_or_ref)
|
||||
{
|
||||
return dom_dblclick(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
click(
|
||||
client,
|
||||
session_id,
|
||||
@@ -254,6 +424,50 @@ pub async fn dblclick(
|
||||
.await
|
||||
}
|
||||
|
||||
/// DOM-dispatch a hover (pointer/mouse enter+move) on the element in its own
|
||||
/// session — reaches OOPIF elements and never drifts to the foreground tab over
|
||||
/// the relay, unlike a coordinate `mouseMoved` (issues #31/#36).
|
||||
async fn dom_hover(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const r = this.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||||
const base = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy };
|
||||
this.dispatchEvent(new PointerEvent('pointerover', base));
|
||||
this.dispatchEvent(new PointerEvent('pointerenter', { ...base, bubbles: false }));
|
||||
this.dispatchEvent(new MouseEvent('mouseover', base));
|
||||
this.dispatchEvent(new MouseEvent('mouseenter', { ...base, bubbles: false }));
|
||||
this.dispatchEvent(new MouseEvent('mousemove', base));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hover(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -261,6 +475,18 @@ pub async fn hover(
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
// Coordinate `mouseMoved` drifts to the foreground tab over the relay and
|
||||
// can't reach an OOPIF — DOM-dispatch the hover there (issues #31/#36).
|
||||
if prefer_dom_dispatch(ref_map, selector_or_ref) {
|
||||
return dom_hover(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
@@ -289,6 +515,63 @@ pub async fn hover(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// DOM-dispatch an HTML5 drag-and-drop from `source` to `target` in their shared
|
||||
/// session — the relay/iframe-safe counterpart to the coordinate drag, which
|
||||
/// drifts to the foreground tab over the relay and can't reach an OOPIF (issues
|
||||
/// #31/#36). Covers HTML5 DnD (sortable lists, file/card boards); pointer-driven
|
||||
/// drag (canvas, sliders) still needs the coordinate path. Errors if source and
|
||||
/// target live in different frames — a synthetic cross-frame DnD isn't reliable.
|
||||
pub async fn dom_drag(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
source: &str,
|
||||
target: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (src_obj, src_session) =
|
||||
resolve_element_object_id(client, session_id, ref_map, source, iframe_sessions).await?;
|
||||
let (tgt_obj, tgt_session) =
|
||||
resolve_element_object_id(client, session_id, ref_map, target, iframe_sessions).await?;
|
||||
if src_session != tgt_session {
|
||||
return Err(
|
||||
"drag source and target are in different frames; cross-frame drag-and-drop over the \
|
||||
relay isn't supported — drag within a single frame, or use a launched browser with \
|
||||
AGENT_BROWSER_CLICK_MODE=coord"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function(target) {
|
||||
const dt = new DataTransfer();
|
||||
const ev = (type, el) => el.dispatchEvent(
|
||||
new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||
ev('dragstart', this);
|
||||
ev('drag', this);
|
||||
ev('dragenter', target);
|
||||
ev('dragover', target);
|
||||
ev('drop', target);
|
||||
ev('dragend', this);
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(src_obj),
|
||||
arguments: Some(vec![CallArgument {
|
||||
value: None,
|
||||
object_id: Some(tgt_obj),
|
||||
}]),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&src_session),
|
||||
)
|
||||
.await?;
|
||||
wait_for_paint_settled(client, &src_session).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fill(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -296,7 +579,7 @@ pub async fn fill(
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<String, String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
@@ -306,32 +589,81 @@ pub async fn fill(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Focus the element
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id.clone()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
// Emulate a real edit so framework-controlled inputs (React/Vue) and
|
||||
// site-side listeners actually see the change (issue #25): set the value
|
||||
// through the element's PROTOTYPE setter (which React's _valueTracker hooks),
|
||||
// then dispatch input → change → blur/focusout. Beyond plain inputs, detect
|
||||
// rich editors and use their own API/events (issue #41): CodeMirror 5 and
|
||||
// Monaco have a model that `.value`/`textContent` can't touch; ProseMirror /
|
||||
// contenteditable need `execCommand('insertText')` so beforeinput/input fire
|
||||
// (a raw `textContent =` corrupts PM's doc and skips React composers).
|
||||
// Returns the engine used so the caller can report it. `type <sel> <text>`
|
||||
// remains for sites that need per-keystroke events.
|
||||
let fill_js = format!(
|
||||
r#"function() {{
|
||||
const el = this;
|
||||
const v = {val};
|
||||
try {{ el.focus(); }} catch (e) {{}}
|
||||
const tag = el.tagName;
|
||||
const fire = (type, ctor) => el.dispatchEvent(new (ctor || Event)(type, {{ bubbles: true }}));
|
||||
|
||||
// Select all + delete to clear
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
// CodeMirror 5: a hidden <textarea> inside .CodeMirror with a live instance.
|
||||
const cm5 = el.closest && el.closest('.CodeMirror');
|
||||
if (cm5 && cm5.CodeMirror) {{ cm5.CodeMirror.setValue(v); return 'codemirror5'; }}
|
||||
|
||||
// Monaco: global `monaco`; prefer the editor whose DOM contains el.
|
||||
if (window.monaco && monaco.editor) {{
|
||||
try {{
|
||||
const eds = monaco.editor.getEditors ? monaco.editor.getEditors() : [];
|
||||
const ed = eds.find(e => e.getDomNode && e.getDomNode().contains(el)) || eds[0];
|
||||
if (ed) {{ ed.setValue(v); return 'monaco'; }}
|
||||
const models = monaco.editor.getModels ? monaco.editor.getModels() : [];
|
||||
if (models[0]) {{ models[0].setValue(v); return 'monaco'; }}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
|
||||
if (tag === 'SELECT') {{ el.value = v; fire('input'); fire('change'); return 'select'; }}
|
||||
|
||||
if (el.isContentEditable) {{
|
||||
// ProseMirror / contenteditable: select-all then insertText fires
|
||||
// beforeinput/input that PM and React composers listen for.
|
||||
let ok = false;
|
||||
try {{
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
ok = document.execCommand('insertText', false, v);
|
||||
}} catch (e) {{}}
|
||||
if (!ok) {{ el.textContent = v; fire('input', window.InputEvent || Event); }}
|
||||
fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}}
|
||||
fire('focusout');
|
||||
return ok ? 'contenteditable' : 'contenteditable-fallback';
|
||||
}}
|
||||
|
||||
const proto = tag === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype;
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
const set = desc && desc.set ? (x) => desc.set.call(el, x) : (x) => {{ el.value = x; }};
|
||||
set(''); // reset the framework tracker
|
||||
fire('input', window.InputEvent || Event);
|
||||
set(v); // native setter → React/Vue registers
|
||||
fire('input', window.InputEvent || Event);
|
||||
fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}}
|
||||
fire('focusout'); // blur-triggered lookups/validation
|
||||
return 'input';
|
||||
}}"#,
|
||||
val = serde_json::to_string(value).unwrap_or_default()
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.select && this.select();
|
||||
this.value = '';
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}"#
|
||||
.to_string(),
|
||||
function_declaration: fill_js,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
@@ -341,18 +673,11 @@ pub async fn fill(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert text (keyboard input dispatched at page level, use parent session_id)
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.insertText",
|
||||
&InsertTextParams {
|
||||
text: value.to_string(),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| "input".to_string()))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -365,6 +690,7 @@ pub async fn type_text(
|
||||
clear: bool,
|
||||
delay_ms: Option<u64>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
key_events: bool,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
@@ -411,7 +737,7 @@ pub async fn type_text(
|
||||
.await?;
|
||||
}
|
||||
|
||||
type_text_into_active_context(client, session_id, text, delay_ms).await
|
||||
type_text_into_active_context(client, session_id, text, delay_ms, key_events).await
|
||||
}
|
||||
|
||||
pub async fn type_text_into_active_context(
|
||||
@@ -419,6 +745,7 @@ pub async fn type_text_into_active_context(
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
delay_ms: Option<u64>,
|
||||
key_events: bool,
|
||||
) -> Result<(), String> {
|
||||
// Per-character timing: an explicit `delay_ms` wins (caller asked for a
|
||||
// fixed cadence); otherwise fall back to humanize — variable, human-like
|
||||
@@ -468,6 +795,46 @@ pub async fn type_text_into_active_context(
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
} else if key_events {
|
||||
// Real keystrokes (keyDown+keyUp carrying `text`) for autocomplete /
|
||||
// combobox widgets that only react to key events and ignore the
|
||||
// `input` that `Input.insertText` fires — e.g. Google's address
|
||||
// postal-code → city/prefecture lookup (issue #36 / #4). The keyDown's
|
||||
// `text` still inserts the character, so the field also fills.
|
||||
let (key, code, key_code) = char_to_key_info(ch);
|
||||
let s = ch.to_string();
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: Some(s.clone()),
|
||||
unmodified_text: Some(s),
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyUp".to_string(),
|
||||
key: Some(key),
|
||||
code: Some(code),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
// VS Code/Electron webviews reject repeated dispatchKeyEvent calls
|
||||
// carrying printable `text`. Insert printable characters directly
|
||||
@@ -557,6 +924,48 @@ pub async fn press_key_with_modifiers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a SINGLE key event (`keyDown` or `keyUp`) carrying the full key
|
||||
/// descriptor — `key`, `code`, `windowsVirtualKeyCode`/`nativeVirtualKeyCode`,
|
||||
/// and (on key-down) printable `text`. Powers the `keydown`/`keyup` commands.
|
||||
///
|
||||
/// The previous implementation sent only `{key}`, so games and shortcut handlers
|
||||
/// that read `event.code` (e.g. `"KeyD"`, `"ArrowRight"`) or `event.keyCode` saw
|
||||
/// nothing — a held key set no movement flag and did nothing (dogfood: holding a
|
||||
/// direction in a canvas platformer barely nudged the player). Sending the same
|
||||
/// descriptor `press` uses makes hold-to-move work regardless of which field the
|
||||
/// page keys off.
|
||||
pub async fn dispatch_single_key(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
key: &str,
|
||||
event_type: &str,
|
||||
) -> Result<(), String> {
|
||||
let (key_name, code, key_code) = named_key_info(key);
|
||||
// Printable text is only meaningful on key-down; key-up never inserts.
|
||||
let text = if event_type == "keyDown" {
|
||||
key_text(&key_name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: event_type.to_string(),
|
||||
key: Some(key_name),
|
||||
code: Some(code),
|
||||
text: text.clone(),
|
||||
unmodified_text: text,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn scroll(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
|
||||
+273
-4
@@ -52,6 +52,22 @@ pub struct RelayState {
|
||||
pending: HashMap<i64, (ClientId, Value)>,
|
||||
/// monotonic source of relay-global command ids
|
||||
next_global_id: i64,
|
||||
/// Group-scoped isolation (issue #40). A tab group belongs to exactly one
|
||||
/// agent/session; the relay scopes `Target.getTargets` per client to its own
|
||||
/// group so the daemon can safely adopt new tabs (follow-popup, cross-session
|
||||
/// adopt) without ever seeing the user's or another agent's tabs.
|
||||
///
|
||||
/// clientId -> group name. A client that never announced a group (older
|
||||
/// daemon) is absent here and gets the full, UNSCOPED target list — so this
|
||||
/// is fully backward-compatible.
|
||||
client_groups: HashMap<ClientId, String>,
|
||||
/// targetId -> group name. Created tabs are tagged from `Target.createTarget`'s
|
||||
/// `agentGroup`; an explicitly adopted tab is tagged to the adopter; a pop-up
|
||||
/// inherits its opener's group (needs the extension to report `openerTargetId`).
|
||||
target_group: HashMap<String, String>,
|
||||
/// relay-global id of an in-flight `Target.createTarget` -> the `agentGroup`
|
||||
/// it carried, so the reply's `targetId` can be tagged with that group.
|
||||
pending_create: HashMap<i64, String>,
|
||||
}
|
||||
|
||||
/// What to do with a raw CDP command received from a `CdpClient`.
|
||||
@@ -95,6 +111,7 @@ impl RelayState {
|
||||
/// `pending` entries don't leak.
|
||||
pub fn drop_client(&mut self, client_id: ClientId) {
|
||||
self.pending.retain(|_, (cid, _)| *cid != client_id);
|
||||
self.client_groups.remove(&client_id);
|
||||
}
|
||||
|
||||
/// Route a raw CDP command `{id, method, params?, sessionId?}` from a
|
||||
@@ -123,12 +140,28 @@ impl RelayState {
|
||||
"jsVersion": ""
|
||||
}
|
||||
})),
|
||||
// Non-CDP control message: a daemon announces which tab group
|
||||
// (session) it owns, so getTargets can be scoped to it (issue #40).
|
||||
"ABRelay.setGroup" => {
|
||||
if let Some(g) = params.get("group").and_then(|g| g.as_str()) {
|
||||
if !g.is_empty() {
|
||||
self.client_groups.insert(client_id, g.to_string());
|
||||
}
|
||||
}
|
||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||
}
|
||||
// Discovery is best-effort and event-driven in real CDP; abs only
|
||||
// reads the getTargets result, so an empty ack is enough here.
|
||||
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
|
||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||
}
|
||||
"Target.getTargets" => {
|
||||
// Unscoped discovery for EXPLICIT cross-group adoption (`chrome-use
|
||||
// adopt`): returns every target the extension has attached, ignoring
|
||||
// group scoping, so an agent can find a specific pre-existing tab (the
|
||||
// user's, another session's) by URL/targetId and adopt it. Isolation
|
||||
// is preserved because the daemon only acts on the one tab it then
|
||||
// attaches (which the relay re-tags into the adopter's group).
|
||||
"ABRelay.getAllTargets" => {
|
||||
let infos: Vec<Value> = self
|
||||
.targets
|
||||
.values()
|
||||
@@ -136,15 +169,44 @@ impl RelayState {
|
||||
.collect();
|
||||
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
||||
}
|
||||
"Target.getTargets" => {
|
||||
// Scope to the client's own group when it announced one; an
|
||||
// un-announced (legacy) client gets the full list (back-compat).
|
||||
let scoped = self.client_groups.get(&client_id).cloned();
|
||||
let infos: Vec<Value> = self
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|(tid, _)| match &scoped {
|
||||
Some(g) => self
|
||||
.target_group
|
||||
.get(*tid)
|
||||
.map(|tg| tg == g)
|
||||
.unwrap_or(false),
|
||||
None => true,
|
||||
})
|
||||
.map(|(_, t)| t.target_info.clone())
|
||||
.collect();
|
||||
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
||||
}
|
||||
"Target.attachToTarget" => {
|
||||
let target_id = params
|
||||
.get("targetId")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
match self.targets.get(target_id) {
|
||||
Some(entry) => ClientRoute::Local(
|
||||
json!({ "id": id, "result": { "sessionId": entry.session_id } }),
|
||||
),
|
||||
Some(entry) => {
|
||||
let session_id = entry.session_id.clone();
|
||||
// Explicitly adopting a target makes it this client's
|
||||
// (cross-session adopt, #21) — tag it into the adopter's
|
||||
// group so it stays in that client's scoped getTargets and
|
||||
// isn't churn-pruned.
|
||||
if let Some(g) = self.client_groups.get(&client_id).cloned() {
|
||||
self.target_group.insert(target_id.to_string(), g);
|
||||
}
|
||||
ClientRoute::Local(
|
||||
json!({ "id": id, "result": { "sessionId": session_id } }),
|
||||
)
|
||||
}
|
||||
None => ClientRoute::Local(json!({
|
||||
"id": id,
|
||||
"error": { "code": -32602, "message": format!("No such target {target_id}") }
|
||||
@@ -157,6 +219,18 @@ impl RelayState {
|
||||
self.next_global_id += 1;
|
||||
let gid = self.next_global_id;
|
||||
self.pending.insert(gid, (client_id, id));
|
||||
// Remember the group a createTarget carries so the reply's
|
||||
// targetId can be tagged to the creating session (issue #40).
|
||||
if method == "Target.createTarget" {
|
||||
if let Some(g) = params.get("agentGroup").and_then(|g| g.as_str()) {
|
||||
if !g.is_empty() {
|
||||
self.pending_create.insert(gid, g.to_string());
|
||||
self.client_groups
|
||||
.entry(client_id)
|
||||
.or_insert_with(|| g.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientRoute::Forward(json!({
|
||||
"id": gid,
|
||||
"method": "forwardCDPCommand",
|
||||
@@ -201,6 +275,17 @@ impl RelayState {
|
||||
&& msg.get("method").is_none()
|
||||
{
|
||||
let gid = msg.get("id").and_then(|i| i.as_i64());
|
||||
// A createTarget reply: tag the new tab's targetId with the group the
|
||||
// command carried, so it lands in the creating session's scope (#40).
|
||||
if let Some(g) = gid.and_then(|g| self.pending_create.remove(&g)) {
|
||||
if let Some(tid) = msg
|
||||
.get("result")
|
||||
.and_then(|r| r.get("targetId"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
self.target_group.insert(tid.to_string(), g);
|
||||
}
|
||||
}
|
||||
let (to, orig_id) = match gid.and_then(|g| self.pending.remove(&g)) {
|
||||
Some((client_id, orig)) => (Some(client_id), orig),
|
||||
// No mapping (stale/unknown id) — fall back to broadcasting with
|
||||
@@ -241,6 +326,27 @@ impl RelayState {
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// Attribute the tab to a group for scoping (issue #40),
|
||||
// unless we already know it (createTarget tag). An
|
||||
// explicit `abGroup` from the extension wins; otherwise a
|
||||
// pop-up inherits its opener's group via `openerTargetId`.
|
||||
if !self.target_group.contains_key(tid) {
|
||||
if let Some(g) = info
|
||||
.get("abGroup")
|
||||
.and_then(|g| g.as_str())
|
||||
.filter(|g| !g.is_empty())
|
||||
{
|
||||
self.target_group.insert(tid.to_string(), g.to_string());
|
||||
} else if let Some(opener) = info
|
||||
.get("openerTargetId")
|
||||
.and_then(|o| o.as_str())
|
||||
.filter(|o| !o.is_empty())
|
||||
{
|
||||
if let Some(g) = self.target_group.get(opener).cloned() {
|
||||
self.target_group.insert(tid.to_string(), g);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.targets.insert(
|
||||
tid.to_string(),
|
||||
TargetEntry {
|
||||
@@ -255,6 +361,15 @@ impl RelayState {
|
||||
"Target.detachedFromTarget" => {
|
||||
let gone = inner_params.get("sessionId").and_then(|s| s.as_str());
|
||||
if let Some(gone) = gone {
|
||||
let gone_tids: Vec<String> = self
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|(_, e)| e.session_id == gone)
|
||||
.map(|(tid, _)| tid.clone())
|
||||
.collect();
|
||||
for tid in gone_tids {
|
||||
self.target_group.remove(&tid);
|
||||
}
|
||||
self.targets.retain(|_, e| e.session_id != gone);
|
||||
}
|
||||
return vec![];
|
||||
@@ -557,4 +672,158 @@ mod tests {
|
||||
_ => panic!("expected ToExt"),
|
||||
}
|
||||
}
|
||||
|
||||
// === Group-scoped isolation (issue #40) ===
|
||||
|
||||
/// Drive the real create path: announce group, createTarget(agentGroup), feed
|
||||
/// the ext reply (tags target→group) + the attachedToTarget event (creates the
|
||||
/// entry). Returns nothing; mutates `s`.
|
||||
fn create_in_group(s: &mut RelayState, client: ClientId, group: &str, tid: &str, sid: &str) {
|
||||
s.route_client_command(
|
||||
client,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": group } }),
|
||||
);
|
||||
let route = s.route_client_command(
|
||||
client,
|
||||
&json!({ "id": 2, "method": "Target.createTarget",
|
||||
"params": { "url": "about:blank", "agentGroup": group } }),
|
||||
);
|
||||
let gid = match route {
|
||||
ClientRoute::Forward(env) => env["id"].as_i64().unwrap(),
|
||||
_ => panic!("createTarget must forward"),
|
||||
};
|
||||
s.handle_ext_message(&json!({ "id": gid, "result": { "targetId": tid } }), "");
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": sid, "targetInfo": {
|
||||
"targetId": tid, "type": "page", "url": "about:blank", "attached": true } } } }),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
fn get_target_ids(s: &mut RelayState, client: ClientId) -> Vec<String> {
|
||||
match s.route_client_command(client, &json!({ "id": 9, "method": "Target.getTargets" })) {
|
||||
ClientRoute::Local(v) => v["result"]["targetInfos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["targetId"].as_str().unwrap().to_string())
|
||||
.collect(),
|
||||
_ => panic!("getTargets must be local"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_targets_is_scoped_to_each_clients_group() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// Each client sees ONLY its own group's tab — never the other agent's.
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||
assert_eq!(get_target_ids(&mut s, 2), vec!["tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_client_without_group_sees_all_targets() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// Client 3 never announced a group → full, unscoped list (back-compat).
|
||||
let mut all = get_target_ids(&mut s, 3);
|
||||
all.sort();
|
||||
assert_eq!(all, vec!["ta", "tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_inherits_opener_group_and_is_visible_to_that_client_only() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// A pop-up that agent-a's tab opened: extension reports openerTargetId=ta.
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": "sp", "targetInfo": {
|
||||
"targetId": "tp", "type": "page", "url": "https://oauth.example/",
|
||||
"attached": true, "openerTargetId": "ta" } } } }),
|
||||
"",
|
||||
);
|
||||
// Only agent-a sees the pop-up; agent-b never does.
|
||||
let mut a = get_target_ids(&mut s, 1);
|
||||
a.sort();
|
||||
assert_eq!(a, vec!["ta", "tp"]);
|
||||
assert_eq!(get_target_ids(&mut s, 2), vec!["tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_attach_tags_target_into_adopter_group() {
|
||||
let mut s = RelayState::new();
|
||||
// A pre-existing, ungrouped tab the extension reported (e.g. user's tab).
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": "su", "targetInfo": {
|
||||
"targetId": "tu", "type": "page", "url": "https://user.example/", "attached": true } } } }),
|
||||
"",
|
||||
);
|
||||
// Client 1 (group agent-a) explicitly adopts it by targetId (#21).
|
||||
s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": "agent-a" } }),
|
||||
);
|
||||
s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 2, "method": "Target.attachToTarget", "params": { "targetId": "tu" } }),
|
||||
);
|
||||
// Now it's in agent-a's scope and survives the scoped getTargets.
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["tu"]);
|
||||
// A different agent still doesn't see it.
|
||||
s.route_client_command(
|
||||
2,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": "agent-b" } }),
|
||||
);
|
||||
assert!(get_target_ids(&mut s, 2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_all_targets_is_unscoped() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// Client 1's scoped getTargets sees only its own group...
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||
// ...but ABRelay.getAllTargets returns EVERY target regardless of group
|
||||
// (for explicit cross-group adoption).
|
||||
let all = match s
|
||||
.route_client_command(1, &json!({ "id": 1, "method": "ABRelay.getAllTargets" }))
|
||||
{
|
||||
ClientRoute::Local(v) => {
|
||||
let mut ids: Vec<String> = v["result"]["targetInfos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["targetId"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
_ => panic!("getAllTargets must be local"),
|
||||
};
|
||||
assert_eq!(all, vec!["ta", "tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_clears_target_group() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.detachedFromTarget", "params": { "sessionId": "sa" } } }),
|
||||
"",
|
||||
);
|
||||
assert!(get_target_ids(&mut s, 1).is_empty());
|
||||
assert!(!s.target_group.contains_key("ta"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ pub struct ScreenshotOptions {
|
||||
pub quality: Option<i32>,
|
||||
pub annotate: bool,
|
||||
pub output_dir: Option<String>,
|
||||
/// Explicit pixel region (x, y, width, height) — `--clip` (issue #34). Takes
|
||||
/// precedence over selector/full_page.
|
||||
pub clip: Option<(f64, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotOptions {
|
||||
@@ -72,6 +75,7 @@ impl Default for ScreenshotOptions {
|
||||
quality: None,
|
||||
annotate: false,
|
||||
output_dir: None,
|
||||
clip: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +191,16 @@ async fn capture_screenshot_base64(
|
||||
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
|
||||
};
|
||||
|
||||
if options.full_page {
|
||||
if let Some((x, y, width, height)) = options.clip {
|
||||
// Explicit pixel region wins over selector/full_page (issue #34).
|
||||
params.clip = Some(Viewport {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
scale: 1.0,
|
||||
});
|
||||
} else if options.full_page {
|
||||
let metrics: Value = client
|
||||
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
|
||||
.await?;
|
||||
|
||||
@@ -330,6 +330,13 @@ impl RoleNameTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Max iframe nesting depth `take_snapshot` expands. Embedded payment/checkout
|
||||
/// widgets nest a few frames deep (e.g. AdSense → payments.google.com → an inner
|
||||
/// form frame); expanding past the first level is what gives those inner refs a
|
||||
/// `frame_id` so clicks resolve into the right frame (issue #36). Capped to keep
|
||||
/// a pathological frame tree from blowing up the snapshot.
|
||||
const MAX_IFRAME_DEPTH: usize = 3;
|
||||
|
||||
pub async fn take_snapshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -337,6 +344,28 @@ pub async fn take_snapshot(
|
||||
ref_map: &mut RefMap,
|
||||
frame_id: Option<&str>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
take_snapshot_at_depth(
|
||||
client,
|
||||
session_id,
|
||||
options,
|
||||
ref_map,
|
||||
frame_id,
|
||||
iframe_sessions,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn take_snapshot_at_depth(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
options: &SnapshotOptions,
|
||||
ref_map: &mut RefMap,
|
||||
frame_id: Option<&str>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
depth: usize,
|
||||
) -> Result<String, String> {
|
||||
client
|
||||
.send_command_no_params("DOM.enable", Some(session_id))
|
||||
@@ -606,10 +635,11 @@ pub async fn take_snapshot(
|
||||
}
|
||||
|
||||
// Recurse into child iframes: for each Iframe node with a backend_node_id,
|
||||
// resolve the child frame ID and take a snapshot of its content.
|
||||
// We only recurse from the main frame (frame_id == None) to avoid
|
||||
// unbounded depth; nested iframes within iframes are not expanded.
|
||||
if frame_id.is_none() {
|
||||
// resolve the child frame ID and snapshot its content. Recurse to
|
||||
// MAX_IFRAME_DEPTH (not just the main frame) so refs inside nested
|
||||
// payment/checkout widgets get a `frame_id` and clicks resolve into the right
|
||||
// frame (issue #36); the cap bounds a pathological frame tree.
|
||||
if depth < MAX_IFRAME_DEPTH {
|
||||
let mut iframe_snapshots: Vec<(String, String)> = Vec::new(); // (ref_id, child_snapshot)
|
||||
for node in tree_nodes.iter() {
|
||||
if node.role != "Iframe" || !node.has_ref {
|
||||
@@ -622,13 +652,14 @@ pub async fn take_snapshot(
|
||||
if let Ok(child_fid) = resolve_iframe_frame_id(client, session_id, bid).await {
|
||||
// Snapshot the child frame; errors are silently ignored
|
||||
// (e.g. cross-origin iframes)
|
||||
if let Ok(child_text) = Box::pin(take_snapshot(
|
||||
if let Ok(child_text) = Box::pin(take_snapshot_at_depth(
|
||||
client,
|
||||
session_id,
|
||||
options,
|
||||
ref_map,
|
||||
Some(&child_fid),
|
||||
iframe_sessions,
|
||||
depth + 1,
|
||||
))
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>iframe button probe</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>iframe button probe</h1>
|
||||
<iframe
|
||||
id="frame"
|
||||
width="320"
|
||||
height="140"
|
||||
srcdoc="
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style='margin:24px'>
|
||||
<button id='b' style='padding:24px;font-size:22px'>save</button>
|
||||
<script>
|
||||
document.getElementById('b').addEventListener('click', function (e) {
|
||||
this.textContent = 'clicked:' + e.isTrusted;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"
|
||||
></iframe>
|
||||
</body>
|
||||
</html>
|
||||
+314
-24
@@ -186,6 +186,123 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
}
|
||||
|
||||
if let Some(data) = &resp.data {
|
||||
// Auto-trigger: when you land on / read a page whose domain has site
|
||||
// adapters, surface them so the agent pulls structured data via
|
||||
// `chrome-use site <name>/<cmd>` instead of scraping the DOM. (In --json
|
||||
// mode this same info rides along in the `siteAdapters` field above.)
|
||||
if let Some(hint) = data.get("siteAdapters") {
|
||||
let domain = hint.get("domain").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let cmds: Vec<&str> = hint
|
||||
.get("commands")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
if !cmds.is_empty() {
|
||||
eprintln!("💡 site adapters for {domain} — prefer these for structured data:");
|
||||
eprintln!(" {}", color::dim(&cmds.join(", ")));
|
||||
eprintln!(
|
||||
" {}",
|
||||
color::dim(&format!("e.g. chrome-use site {} --json", cmds[0]))
|
||||
);
|
||||
}
|
||||
}
|
||||
// A click that opened a new tab: surface it so the agent doesn't read the
|
||||
// unchanged old page as a failed click (issue #24-A).
|
||||
if let Some(opened) = data.get("openedTab") {
|
||||
let tid = opened.get("tabId").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let url = opened.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let followed = data
|
||||
.get("followed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let verb = if followed {
|
||||
"switched to new tab"
|
||||
} else {
|
||||
"opened new tab"
|
||||
};
|
||||
eprintln!(
|
||||
"{} {} [{}] {}",
|
||||
color::cyan("→"),
|
||||
verb,
|
||||
tid,
|
||||
color::dim(url)
|
||||
);
|
||||
}
|
||||
|
||||
// `current`: the active tab's stable handle (#26).
|
||||
if data
|
||||
.get("current")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let tid = data.get("tabId").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let title = data.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = data.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let target = data.get("targetId").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!("{} [{}] {} - {}", color::cyan("→"), tid, title, url);
|
||||
println!(" {}", color::dim(&format!("target: {}", target)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloudflare challenge/clearance preflight (`cf-status`). Checked early
|
||||
// because its response carries `url`/`title`, which later generic
|
||||
// renderers would otherwise swallow.
|
||||
if action == Some("cf_status") {
|
||||
let challenged = data
|
||||
.get("challenged")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let rec = data
|
||||
.get("recommendation")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
let cl = data.get("clearance");
|
||||
let present = cl
|
||||
.and_then(|c| c.get("present"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let expired = cl
|
||||
.and_then(|c| c.get("expired"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let expires_in = cl.and_then(|c| c.get("expiresIn")).and_then(|v| v.as_i64());
|
||||
let device = data
|
||||
.get("deviceVerified")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let (icon, headline) = match rec {
|
||||
"proceed" => (color::success_indicator().to_string(), "cleared — no challenge, proceed"),
|
||||
"solve" => (color::warning_indicator().to_string(), "Cloudflare challenge active, no valid clearance — solve it"),
|
||||
"reissue" => (color::warning_indicator().to_string(), "challenge active but a clearance cookie exists — stale (IP/UA changed?), re-solve"),
|
||||
_ => (color::cyan("•").to_string(), "unknown"),
|
||||
};
|
||||
println!("{} {}", icon, headline);
|
||||
println!(
|
||||
" challenged: {}",
|
||||
if challenged { "yes" } else { "no" }
|
||||
);
|
||||
let cl_desc = if !present {
|
||||
"absent".to_string()
|
||||
} else if expired {
|
||||
"present but EXPIRED".to_string()
|
||||
} else if let Some(s) = expires_in {
|
||||
format!("valid, expires in {}m {}s", s / 60, s % 60)
|
||||
} else {
|
||||
"present (session)".to_string()
|
||||
};
|
||||
println!(" cf_clearance: {}", cl_desc);
|
||||
println!(
|
||||
" device trusted: {}",
|
||||
if device {
|
||||
"yes (CF_VERIFIED_DEVICE)"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dialog status response
|
||||
if action == Some("dialog") {
|
||||
if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {
|
||||
@@ -297,6 +414,43 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
// Snapshot
|
||||
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
|
||||
print_with_boundaries(snapshot, origin, opts);
|
||||
// Canvas-app hint: the tree was near-empty but the page paints to a
|
||||
// <canvas>, so refs are a dead end — point at the screenshot path.
|
||||
if let Some(note) = data.get("note").and_then(|v| v.as_str()) {
|
||||
eprintln!("{}", color::dim(note));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Frame list (`chrome-use frames`)
|
||||
if action == Some("frames") {
|
||||
if let Some(list) = data.get("frames").and_then(|v| v.as_array()) {
|
||||
let count = list.len();
|
||||
println!(
|
||||
"{}",
|
||||
color::bold(&format!(
|
||||
"{} frame{}",
|
||||
count,
|
||||
if count == 1 { "" } else { "s" }
|
||||
))
|
||||
);
|
||||
for f in list {
|
||||
let idx = f.get("index").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let kind = f.get("kind").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let url = f.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let len = f.get("textLen").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
println!(
|
||||
" [{}] {:<6} {} chars {}",
|
||||
idx,
|
||||
kind,
|
||||
len,
|
||||
color::dim(if url.is_empty() { "(about:blank)" } else { url })
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"{}",
|
||||
color::dim("read everything with: chrome-use get text --all-frames")
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Title
|
||||
@@ -335,6 +489,17 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
println!("y: {}", y);
|
||||
println!("width: {}", w);
|
||||
println!("height: {}", h);
|
||||
if let (Some(cx), Some(cy)) = (
|
||||
obj.get("centerX").and_then(|v| v.as_i64()),
|
||||
obj.get("centerY").and_then(|v| v.as_i64()),
|
||||
) {
|
||||
// Echoed in click-ready CSS px so the agent can paste straight
|
||||
// into `click <centerX> <centerY>` (issue #43).
|
||||
println!("center: {} {}", cx, cy);
|
||||
}
|
||||
if let Some(iv) = obj.get("inViewport").and_then(|v| v.as_bool()) {
|
||||
println!("inViewport: {}", iv);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -477,6 +642,9 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
}
|
||||
// Tabs
|
||||
if let Some(tabs) = data.get("tabs").and_then(|v| v.as_array()) {
|
||||
// `tab list --full` prints untruncated URLs so a long SSO/redirect
|
||||
// URL can actually be re-opened after a stale session (issue #19).
|
||||
let full = data.get("full").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
for tab in tabs {
|
||||
let tab_id = tab.get("tabId").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let tab_label = tab.get("label").and_then(|v| v.as_str());
|
||||
@@ -491,8 +659,13 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
let title = title.as_str();
|
||||
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
|
||||
// the list stays readable instead of flooding the terminal.
|
||||
let url = truncate_middle(url, 120);
|
||||
// the list stays readable instead of flooding the terminal —
|
||||
// unless `--full` was asked for (to re-open the exact URL).
|
||||
let url = if full {
|
||||
url.to_string()
|
||||
} else {
|
||||
truncate_middle(url, 120)
|
||||
};
|
||||
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let marker = if active {
|
||||
color::cyan("→")
|
||||
@@ -504,25 +677,35 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
} else {
|
||||
println!("{} [{}] {} - {}", marker, tab_id, title, url);
|
||||
}
|
||||
// `--full` also surfaces the stable cross-session CDP targetId so
|
||||
// a stranded tab can be adopted from another session via
|
||||
// `tab <targetId>` (issue #21).
|
||||
if full {
|
||||
if let Some(target_id) = tab.get("targetId").and_then(|v| v.as_str()) {
|
||||
println!(" {}", color::dim(&format!("target: {}", target_id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Tab switch
|
||||
if action == Some("tab_switch") {
|
||||
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) {
|
||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||
println!(
|
||||
"{} Switched to tab [{}] ({})",
|
||||
color::success_indicator(),
|
||||
tab_id,
|
||||
url
|
||||
);
|
||||
let warning = data.get("warning").and_then(|v| v.as_str());
|
||||
// A non-responding session isn't a real success — show a warning
|
||||
// indicator instead of the green ✓ (issue #29.3).
|
||||
let indicator = if warning.is_some() {
|
||||
color::warning_indicator()
|
||||
} else {
|
||||
println!(
|
||||
"{} Switched to tab [{}]",
|
||||
color::success_indicator(),
|
||||
tab_id
|
||||
);
|
||||
color::success_indicator()
|
||||
};
|
||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||
println!("{} Switched to tab [{}] ({})", indicator, tab_id, url);
|
||||
} else {
|
||||
println!("{} Switched to tab [{}]", indicator, tab_id);
|
||||
}
|
||||
if let Some(w) = warning {
|
||||
eprintln!("{}", color::dim(w));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1318,9 +1501,20 @@ Examples:
|
||||
chrome-use fill - Clear and fill an input field
|
||||
|
||||
Usage: chrome-use fill <selector> <text>
|
||||
chrome-use fill <selector> --file <path>
|
||||
chrome-use fill <selector> --stdin
|
||||
|
||||
Clears the input field and fills it with the specified text.
|
||||
This replaces any existing content in the field.
|
||||
Clears the field and fills it with the text, replacing existing content.
|
||||
Works on rich editors too (issue #41): CodeMirror 5, Monaco, ProseMirror and
|
||||
plain contenteditable are detected and set via their own API / input events,
|
||||
not a raw `.value` write — and the response echoes which `engine` was used.
|
||||
For framework inputs (React/Vue/Angular) the value goes through the native
|
||||
setter so the form registers it (no more "pristine" Save no-ops).
|
||||
|
||||
Options:
|
||||
--file <path> Read the value from a UTF-8 file (large/multiline text,
|
||||
backticks/quotes/newlines/non-ASCII — no shell escaping)
|
||||
--stdin Read the value from stdin
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1329,7 +1523,8 @@ Global Options:
|
||||
Examples:
|
||||
chrome-use fill "#email" "user@example.com"
|
||||
chrome-use fill @e3 "Hello World"
|
||||
chrome-use fill "input[name='search']" "query"
|
||||
chrome-use fill ".CodeMirror" --file ./article.md # set a CodeMirror editor
|
||||
cat post.md | chrome-use fill @e7 --stdin
|
||||
"##
|
||||
}
|
||||
"type" => {
|
||||
@@ -1341,6 +1536,12 @@ Usage: chrome-use type <selector> <text>
|
||||
Types text into the specified element character by character.
|
||||
Unlike fill, this does not clear existing content first.
|
||||
|
||||
Options:
|
||||
--key-events Send real per-character keyDown/keyUp instead of
|
||||
(alias --keys) Input.insertText. Use for autocomplete / combobox fields
|
||||
that only react to key events — e.g. a postal-code box
|
||||
that auto-fills city/prefecture, or Google Places.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
@@ -1348,6 +1549,7 @@ Global Options:
|
||||
Examples:
|
||||
chrome-use type "#search" "hello"
|
||||
chrome-use type @e2 "additional text"
|
||||
chrome-use type @e5 "201-0001" --key-events # trigger the address autocomplete
|
||||
|
||||
See Also:
|
||||
For typing into contenteditable editors (Lexical, ProseMirror, etc.)
|
||||
@@ -1612,12 +1814,23 @@ Usage: chrome-use scroll [direction] [amount] [options]
|
||||
|
||||
Scrolls the page or a specific element in the specified direction.
|
||||
|
||||
Without --selector, scroll dispatches a real (isTrusted) mouse wheel at a
|
||||
viewport coordinate, so it scrolls whatever container is under the pointer —
|
||||
including cross-origin iframes (Google Payments, Stripe, embedded checkout/KYC)
|
||||
that plain page scroll can't reach.
|
||||
|
||||
Arguments:
|
||||
direction up, down, left, right (default: down)
|
||||
amount Pixels to scroll (default: 300)
|
||||
|
||||
Options:
|
||||
-s, --selector <sel> CSS selector for a scrollable container
|
||||
-s, --selector <sel> CSS selector for a scrollable container (same-origin)
|
||||
--at <x,y> Dispatch the wheel at this viewport pixel (read it from a
|
||||
screenshot) — precise way into a cross-origin iframe
|
||||
--frame <n> Scroll the n-th frame from `chrome-use frames` (wheel at
|
||||
that frame's center)
|
||||
|
||||
Without --selector/--at/--frame the wheel lands at the viewport center.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1629,6 +1842,8 @@ Examples:
|
||||
chrome-use scroll up 200
|
||||
chrome-use scroll left 100
|
||||
chrome-use scroll down 500 --selector "div.scroll-container"
|
||||
chrome-use scroll down 700 --at 640,400 # wheel at a pixel over an iframe
|
||||
chrome-use scroll down 700 --frame 2 # scroll frame 2 from `frames`
|
||||
"##
|
||||
}
|
||||
"scrollintoview" | "scrollinto" => {
|
||||
@@ -1709,6 +1924,15 @@ Pass --hide-scrollbars false when launching to keep native scrollbars visible.
|
||||
|
||||
Options:
|
||||
--full, -f Capture full page (not just viewport)
|
||||
[selector] Capture just an element (CSS or @ref), e.g. `screenshot ".header" h.png`
|
||||
--clip <x,y,w,h> Capture a pixel region, e.g. `screenshot --clip 0,0,200,40 corner.png`
|
||||
--max-width <px> Downscale so the image's width ≤ px (preserves aspect)
|
||||
--max-height <px> Downscale so the image's height ≤ px
|
||||
--scale <0..1> Downscale by a factor, e.g. 0.5 (DPR-1, so screenshot px
|
||||
line up 1:1 with `click x y`)
|
||||
Default: capped at 2000px longest edge unless overridden
|
||||
(AGENT_BROWSER_SCREENSHOT_MAX_EDGE; 0 disables). Annotated
|
||||
shots are never downscaled, so ref overlays stay aligned.
|
||||
--annotate Overlay numbered labels on interactive elements.
|
||||
Each label [N] corresponds to ref @eN from snapshot.
|
||||
Prints a legend mapping labels to element roles/names.
|
||||
@@ -1729,6 +1953,10 @@ Examples:
|
||||
chrome-use screenshot
|
||||
chrome-use screenshot ./screenshot.png
|
||||
chrome-use screenshot --full ./full-page.png
|
||||
chrome-use screenshot ".header .indicator" corner.png # just one element
|
||||
chrome-use screenshot --clip 1600,0,200,40 corner.png # a pixel region
|
||||
chrome-use screenshot --scale 0.5 ./half.png # DPR-1: screenshot px == click px
|
||||
chrome-use screenshot --max-width 1400 ./shot.png # cap width for image readers
|
||||
chrome-use screenshot --annotate # Labeled screenshot + legend
|
||||
chrome-use screenshot --annotate ./page.png # Save annotated screenshot
|
||||
chrome-use screenshot --annotate --json # JSON output with annotations
|
||||
@@ -1870,7 +2098,9 @@ Usage: chrome-use get <subcommand> [args]
|
||||
Retrieves various types of information from elements or the page.
|
||||
|
||||
Subcommands:
|
||||
text <selector> Get text content of element
|
||||
text [selector] Element text; no selector = WHOLE PAGE, all frames
|
||||
text --main Main-content text only (skip nav/header/sidebar)
|
||||
text --pierce Read through CLOSED shadow DOM (injected panels)
|
||||
html <selector> Get inner HTML of element
|
||||
value <selector> Get value of input element
|
||||
attr <selector> <name> Get attribute value
|
||||
@@ -1886,7 +2116,10 @@ Global Options:
|
||||
--session <name> Use specific session
|
||||
|
||||
Examples:
|
||||
chrome-use get text @e1
|
||||
chrome-use get text # whole page across ALL frames (default)
|
||||
chrome-use get text @e1 # one element
|
||||
chrome-use get text --main # main content, no nav/sidebar boilerplate
|
||||
chrome-use frames # list frames + where the text lives
|
||||
chrome-use get html "#content"
|
||||
chrome-use get value "#email-input"
|
||||
chrome-use get attr "#link" href
|
||||
@@ -2757,6 +2990,20 @@ Notes:
|
||||
- Streaming is always enabled. Set AGENT_BROWSER_STREAM_PORT to bind to a
|
||||
specific port instead of the default OS-assigned port.
|
||||
|
||||
The WS is BIDIRECTIONAL — the high-throughput way to drive a live/real-time page
|
||||
(games, canvas apps) instead of one screenshot + one CLI call per action:
|
||||
- Server -> client (JSON text frames):
|
||||
{"type":"frame","data":"<base64 jpeg>"} live screencast (~60fps)
|
||||
plus status / tabs messages.
|
||||
- Client -> server (send JSON text):
|
||||
{"type":"input_keyboard","eventType":"keyDown|keyUp","key":" ","code":"Space",
|
||||
"windowsVirtualKeyCode":32}
|
||||
{"type":"input_mouse","eventType":"mousePressed|mouseReleased|mouseMoved",
|
||||
"x":640,"y":360,"button":"left","clickCount":1}
|
||||
{"type":"input_touch","eventType":"touchStart|touchEnd","touchPoints":[...]}
|
||||
Connect once and run a tight local loop: read frames, send timed input — no
|
||||
per-action process spawn, no round-trip. Works over the extension relay too.
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
--session <name> Use specific session
|
||||
@@ -3048,8 +3295,14 @@ Core Commands:
|
||||
click <sel|x y> Click element/@ref, or a viewport coordinate
|
||||
dblclick <sel> Double-click element
|
||||
type <sel> <text> Type into element
|
||||
fill <sel> <text> Clear and fill
|
||||
press <key> Press key (Enter, Tab, Control+a)
|
||||
fill <sel> <text> Clear and fill (handles CodeMirror/Monaco/ProseMirror/
|
||||
contenteditable; `--file <path>`/`--stdin` for large text)
|
||||
press <key> [--hold <ms>] Press key (Enter, Tab, Control+a). --hold keeps it
|
||||
down <ms> then releases — precise (in-daemon), for
|
||||
games/charge: `press d --hold 800`
|
||||
keydown <key> Hold a key down (no auto-release) — for games/shortcuts
|
||||
keyup <key> Release a held key. Pair with keydown to hold-to-move:
|
||||
`keydown d` … `keyup d`
|
||||
keyboard type <text> Type text with real keystrokes (no selector)
|
||||
keyboard inserttext <text> Insert text without key events
|
||||
hover <sel> Hover element
|
||||
@@ -3063,8 +3316,13 @@ Core Commands:
|
||||
scroll <dir> [px] Scroll (up/down/left/right)
|
||||
scrollintoview <sel> Scroll element into view
|
||||
wait <sel|ms> Wait for element or time
|
||||
screenshot [path] Take screenshot
|
||||
screenshot [path] Take screenshot (auto-downscaled to ≤2000px long edge;
|
||||
--max-width/--max-height/--scale to override)
|
||||
pdf <path> Save as PDF
|
||||
canvas list List <canvas> elements (size, type) on the page
|
||||
canvas capture [sel] [path] Save a canvas's rendered pixels to PNG — for
|
||||
WebGL/canvas apps (Figma, games, maps, charts) that
|
||||
expose no DOM. toDataURL, with a screenshot fallback.
|
||||
snapshot Accessibility tree with refs (for AI)
|
||||
eval <js> Run JavaScript
|
||||
connect <port|url> Connect to browser via CDP
|
||||
@@ -3077,10 +3335,17 @@ Navigation:
|
||||
|
||||
Get Info: chrome-use get <what> [selector]
|
||||
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
|
||||
box <sel> → x,y,width,height,centerX,centerY,inViewport in CSS px (feed
|
||||
centerX/centerY into `click x y`); value reads CodeMirror/Monaco too
|
||||
text (no selector = whole page, all frames), text --main, frames (list)
|
||||
|
||||
Check State: chrome-use is <what> <selector>
|
||||
visible, enabled, checked
|
||||
|
||||
Anti-bot: chrome-use stealth | cf-status
|
||||
stealth stealth self-check (webdriver/UA/plugins + overrides)
|
||||
cf-status Cloudflare challenge + cf_clearance preflight (skip re-solving)
|
||||
|
||||
Find Elements: chrome-use find <locator> <value> <action> [text]
|
||||
role, text, label, placeholder, alt, title, testid, first, last, nth
|
||||
|
||||
@@ -3108,7 +3373,16 @@ Storage:
|
||||
storage <local|session> Manage web storage
|
||||
|
||||
Tabs:
|
||||
tab [new|list|close|<n>] Manage tabs
|
||||
tab [new|list|close|<ref>] Manage tabs (<ref> = t<N>, a label, or a CDP targetId)
|
||||
tab list --full Full URLs + stable cross-session targetId per tab
|
||||
tab <targetId> Adopt a specific tab (incl. another session's) by its
|
||||
stable targetId, no reload — preserves in-page state
|
||||
open <url> --reuse-tab Reuse an existing tab on that URL instead of spawning
|
||||
a duplicate (matches origin+path; preserves state)
|
||||
adopt <url|targetId> Read a PRE-EXISTING tab (the user's own, or another
|
||||
session's) WITHOUT opening a new one — matches by URL
|
||||
substring or stable targetId, then drives it. e.g.
|
||||
`adopt "github.com/owner/repo"`
|
||||
|
||||
Diff:
|
||||
diff snapshot Compare current vs last snapshot
|
||||
@@ -3156,6 +3430,14 @@ Batch:
|
||||
batch [--bail] ["cmd" ...] Execute multiple commands sequentially (args or stdin)
|
||||
--bail stops on first error (default: continue all)
|
||||
|
||||
Site adapters: turn a website into a structured-data CLI (runs as you, in your tab)
|
||||
site update Fetch the community adapter pack into ~/.chrome-use/sites
|
||||
site list List installed adapters (name/cmd)
|
||||
site info <name>/<cmd> Show an adapter's @meta (args, domain, capabilities)
|
||||
site <name>/<cmd> [args] Run an adapter: navigate to its site + return JSON
|
||||
e.g. site github/issues epiral/repo, site reddit/search rust
|
||||
Positional args fill declared args in order; --key value overrides
|
||||
|
||||
Auth Vault:
|
||||
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
|
||||
auth login <name> Login using saved credentials (waits for form fields)
|
||||
@@ -3170,6 +3452,10 @@ Confirmation:
|
||||
Sessions:
|
||||
session Show current session name
|
||||
session list List active sessions
|
||||
sessions List running session daemons (alias of daemon status)
|
||||
daemon status List running session daemons (+ relay state)
|
||||
daemon restart Kill all session daemons; keeps the extension relay
|
||||
up. Clears stale/cross-leaked state after an upgrade.
|
||||
|
||||
Chat (AI):
|
||||
chat <message> Send a natural language instruction (single-shot)
|
||||
@@ -3363,6 +3649,9 @@ iOS Simulator (requires Xcode and Appium):
|
||||
chrome-use -p ios device list # List simulators
|
||||
chrome-use -p ios swipe up # Swipe gesture
|
||||
chrome-use -p ios tap @e1 # Touch element
|
||||
|
||||
Hit a bug or rough edge? A 30-second issue genuinely sharpens this tool:
|
||||
https://github.com/leeguooooo/chrome-use/issues
|
||||
"#
|
||||
);
|
||||
}
|
||||
@@ -3445,6 +3734,7 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
|
||||
|
||||
pub fn print_version() {
|
||||
println!("chrome-use {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("report bugs / rough edges: https://github.com/leeguooooo/chrome-use/issues");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
//! Site adapters: turn any website into a structured-data CLI by running a small
|
||||
//! per-command JS adapter inside your real, logged-in browser tab (it reuses the
|
||||
//! site's cookies / same-origin fetch / its own webpack modules — the site thinks
|
||||
//! it's you, because it is).
|
||||
//!
|
||||
//! The adapter format is the community **bb-sites** convention
|
||||
//! (<https://github.com/epiral/bb-sites>): one `.js` file per command, a
|
||||
//! `/* @meta {...} */` JSON header (name, description, domain, args), then an
|
||||
//! `async function(args){ ... return {...} }`. chrome-use ships none of those
|
||||
//! adapters — `chrome-use site update` fetches the upstream repo at runtime into
|
||||
//! `~/.chrome-use/sites` (like a package manager pulling a dependency), so the
|
||||
//! adapters stay the property of their authors. Running an adapter navigates to
|
||||
//! its `@meta.domain` and `eval`s the function in the site's own logged-in page.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SITES_ZIP_URL: &str = "https://github.com/epiral/bb-sites/archive/refs/heads/main.zip";
|
||||
|
||||
/// `~/.chrome-use/sites` — where synced adapters live.
|
||||
pub fn sites_dir() -> Option<PathBuf> {
|
||||
dirs_home().map(|h| h.join(".chrome-use").join("sites"))
|
||||
}
|
||||
|
||||
fn dirs_home() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Parsed adapter: its `@meta` JSON and the raw `async function(args){...}` source.
|
||||
pub struct Adapter {
|
||||
pub meta: Value,
|
||||
pub func_src: String,
|
||||
/// The adapter's declared `args` keys in DECLARATION order. Parsed from the
|
||||
/// raw @meta text because `serde_json` sorts object keys alphabetically, which
|
||||
/// would otherwise scramble positional-arg mapping for multi-arg adapters.
|
||||
pub arg_order: Vec<String>,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
pub fn domain(&self) -> Option<&str> {
|
||||
self.meta.get("domain").and_then(|v| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Load `<sites>/<name>/<cmd>.js`, splitting the `/* @meta {...} */` header from
|
||||
/// the function body. `spec` is `name/cmd`.
|
||||
pub fn load_adapter(spec: &str) -> Result<Adapter, String> {
|
||||
let (name, cmd) = spec
|
||||
.split_once('/')
|
||||
.ok_or_else(|| format!("site: expected <name>/<command>, got `{spec}`"))?;
|
||||
if name.is_empty()
|
||||
|| cmd.is_empty()
|
||||
|| name.contains("..")
|
||||
|| cmd.contains("..")
|
||||
|| name.contains('/')
|
||||
|| cmd.contains('/')
|
||||
{
|
||||
return Err(format!("site: invalid adapter spec `{spec}`"));
|
||||
}
|
||||
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||
let path = dir.join(name).join(format!("{cmd}.js"));
|
||||
if !path.exists() {
|
||||
return Err(format!(
|
||||
"site: adapter `{spec}` not found. Run `chrome-use site update` to sync adapters, \
|
||||
or `chrome-use site list` to see what's installed."
|
||||
));
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| format!("site: read {spec}: {e}"))?;
|
||||
parse_adapter(&raw, spec)
|
||||
}
|
||||
|
||||
/// Split the `@meta` JSON block and the function source from an adapter file.
|
||||
pub fn parse_adapter(raw: &str, spec: &str) -> Result<Adapter, String> {
|
||||
let start = raw
|
||||
.find("@meta")
|
||||
.and_then(|i| raw[i..].find('{').map(|j| i + j))
|
||||
.ok_or_else(|| format!("site: {spec} missing /* @meta {{...}} */ header"))?;
|
||||
// Find the matching close brace for the @meta object (brace-count, string-aware).
|
||||
let bytes = raw.as_bytes();
|
||||
let mut depth = 0i32;
|
||||
let mut in_str = false;
|
||||
let mut esc = false;
|
||||
let mut end = None;
|
||||
for (k, &b) in bytes.iter().enumerate().skip(start) {
|
||||
if in_str {
|
||||
if esc {
|
||||
esc = false;
|
||||
} else if b == b'\\' {
|
||||
esc = true;
|
||||
} else if b == b'"' {
|
||||
in_str = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_str = true,
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = Some(k + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let end = end.ok_or_else(|| format!("site: {spec} @meta header has no closing brace"))?;
|
||||
let meta: Value = serde_json::from_str(&raw[start..end])
|
||||
.map_err(|e| format!("site: {spec} @meta is not valid JSON: {e}"))?;
|
||||
// The function is everything after the meta comment's closing `*/`.
|
||||
let after = raw[end..].find("*/").map(|i| end + i + 2).unwrap_or(end);
|
||||
let func_src = raw[after..].trim().to_string();
|
||||
if func_src.is_empty() {
|
||||
return Err(format!("site: {spec} has no function body after @meta"));
|
||||
}
|
||||
let arg_order = arg_order_from_meta(&raw[start..end]);
|
||||
Ok(Adapter {
|
||||
meta,
|
||||
func_src,
|
||||
arg_order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the `args` object's keys in DECLARATION order from the raw @meta JSON
|
||||
/// text (serde sorts them, losing order). Brace/string-aware: finds the `"args"`
|
||||
/// value object and collects only its top-level keys.
|
||||
fn arg_order_from_meta(meta_json: &str) -> Vec<String> {
|
||||
let bytes = meta_json.as_bytes();
|
||||
// Locate the `"args"` key, then the `{` that opens its value object.
|
||||
let Some(args_pos) = meta_json.find("\"args\"") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(brace_off) = meta_json[args_pos..].find('{') else {
|
||||
return Vec::new();
|
||||
};
|
||||
let open = args_pos + brace_off;
|
||||
let mut keys = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut in_str = false;
|
||||
let mut esc = false;
|
||||
let mut cur = String::new();
|
||||
let mut last_str: Option<String> = None;
|
||||
for &b in bytes.iter().skip(open) {
|
||||
if in_str {
|
||||
if esc {
|
||||
esc = false;
|
||||
} else if b == b'\\' {
|
||||
esc = true;
|
||||
} else if b == b'"' {
|
||||
in_str = false;
|
||||
last_str = Some(std::mem::take(&mut cur));
|
||||
} else {
|
||||
cur.push(b as char);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_str = true,
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break; // end of the args object
|
||||
}
|
||||
}
|
||||
// A `:` at depth 1 means the preceding string was a key of `args`.
|
||||
b':' if depth == 1 => {
|
||||
if let Some(k) = last_str.take() {
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Build the JS to eval: `(<adapter function>)(<args JSON>)`. The adapter's
|
||||
/// `async function(args)` returns a promise; chrome-use's eval awaits it.
|
||||
pub fn build_eval(adapter: &Adapter, args: &Value) -> String {
|
||||
let args_json = serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string());
|
||||
format!("({})({})", adapter.func_src, args_json)
|
||||
}
|
||||
|
||||
/// List installed adapters as `name/cmd` strings (sorted).
|
||||
pub fn list_adapters() -> Result<Vec<String>, String> {
|
||||
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for site in std::fs::read_dir(&dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
{
|
||||
if !site.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = site.file_name().to_string_lossy().to_string();
|
||||
for cmd in std::fs::read_dir(site.path())
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
{
|
||||
let p = cmd.path();
|
||||
if p.extension().and_then(|e| e.to_str()) == Some("js") {
|
||||
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
|
||||
out.push(format!("{name}/{stem}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Download the bb-sites repo zip and extract its adapters into `~/.chrome-use/sites`.
|
||||
pub async fn update() -> Result<usize, String> {
|
||||
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("chrome-use")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let bytes = client
|
||||
.get(SITES_ZIP_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("site update: download failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("site update: {e}"))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("site update: read body: {e}"))?;
|
||||
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut zip = zip::ZipArchive::new(cursor).map_err(|e| format!("site update: bad zip: {e}"))?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let mut count = 0usize;
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let Some(enclosed) = f.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
// Strip the top-level `bb-sites-main/` component from the archive path.
|
||||
let rel: PathBuf = enclosed.components().skip(1).collect();
|
||||
if rel.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let out = dir.join(&rel);
|
||||
if f.is_dir() {
|
||||
let _ = std::fs::create_dir_all(&out);
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
std::io::copy(&mut f, &mut buf).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&out, &buf).map_err(|e| e.to_string())?;
|
||||
if out.extension().and_then(|e| e.to_str()) == Some("js") {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
// Build the domain→adapters index and stamp the sync time so navigation can
|
||||
// suggest adapters (auto-trigger) and `needs_refresh` can pace re-syncs.
|
||||
write_domain_index(&dir);
|
||||
if let Some(p) = last_update_path() {
|
||||
let _ = std::fs::write(p, now_secs().to_string());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// `~/.chrome-use/sites/.last_update` — unix-seconds marker of the last sync.
|
||||
fn last_update_path() -> Option<PathBuf> {
|
||||
sites_dir().map(|d| d.join(".last_update"))
|
||||
}
|
||||
|
||||
/// `~/.chrome-use/sites/.index.json` — `{ "github.com": ["github/issues", …], … }`,
|
||||
/// built on `update` so navigation can look up adapters by domain without parsing
|
||||
/// all ~145 adapter files on every command.
|
||||
fn index_path() -> Option<PathBuf> {
|
||||
sites_dir().map(|d| d.join(".index.json"))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Parse every installed adapter and write the domain→adapters index. Within a
|
||||
/// domain, read-only adapters are listed first (then alphabetical) so the
|
||||
/// auto-suggested example leads with a safe read, not a write action.
|
||||
fn write_domain_index(dir: &std::path::Path) {
|
||||
let mut by_domain: std::collections::BTreeMap<String, Vec<(bool, String)>> = Default::default();
|
||||
for spec in list_adapters().unwrap_or_default() {
|
||||
if let Ok(a) = load_adapter(&spec) {
|
||||
if let Some(d) = a.domain() {
|
||||
let read_only = a
|
||||
.meta
|
||||
.get("readOnly")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
by_domain
|
||||
.entry(d.to_string())
|
||||
.or_default()
|
||||
.push((read_only, spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ordered: std::collections::BTreeMap<String, Vec<String>> = by_domain
|
||||
.into_iter()
|
||||
.map(|(domain, mut v)| {
|
||||
// read-only (true) first, then by spec name
|
||||
v.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
|
||||
(domain, v.into_iter().map(|(_, s)| s).collect())
|
||||
})
|
||||
.collect();
|
||||
if let Ok(json) = serde_json::to_string(&ordered) {
|
||||
let _ = std::fs::write(dir.join(".index.json"), json);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_DAYS: u64 = 7;
|
||||
|
||||
/// Whether the adapter pack should be (re)synced: true on first use (nothing
|
||||
/// installed) or when the last sync is older than the TTL. Disabled by
|
||||
/// `AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1`; TTL overridable via
|
||||
/// `AGENT_BROWSER_SITES_TTL_DAYS` (0 = always).
|
||||
pub fn needs_refresh() -> bool {
|
||||
if std::env::var_os("AGENT_BROWSER_SITES_NO_AUTO_UPDATE").is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(dir) = sites_dir() else {
|
||||
return false;
|
||||
};
|
||||
// First use: no adapters installed yet.
|
||||
if list_adapters().map(|l| l.is_empty()).unwrap_or(true) {
|
||||
let _ = &dir;
|
||||
return true;
|
||||
}
|
||||
let ttl_days = std::env::var("AGENT_BROWSER_SITES_TTL_DAYS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_TTL_DAYS);
|
||||
let ttl = ttl_days.saturating_mul(86_400);
|
||||
match last_update_path().and_then(|p| std::fs::read_to_string(p).ok()) {
|
||||
Some(s) => match s.trim().parse::<u64>() {
|
||||
Ok(ts) => now_secs().saturating_sub(ts) >= ttl,
|
||||
Err(_) => true,
|
||||
},
|
||||
None => true, // no marker → treat as stale
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapters whose `@meta.domain` matches `host` (exact, or `host` is a subdomain
|
||||
/// of it) — for auto-suggesting `site` commands when you land on a known site.
|
||||
/// Reads the prebuilt `.index.json`; empty if the pack isn't synced yet.
|
||||
pub fn adapters_for_domain(host: &str) -> Vec<String> {
|
||||
let host = host.trim_start_matches("www.");
|
||||
let Some(raw) = index_path().and_then(|p| std::fs::read_to_string(p).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(idx) = serde_json::from_str::<std::collections::BTreeMap<String, Vec<String>>>(&raw)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Preserve the index's per-domain ordering (read-only adapters first); just
|
||||
// dedup if a host somehow matches multiple domain keys.
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (domain, specs) in idx {
|
||||
let d = domain.trim_start_matches("www.");
|
||||
if host == d || host.ends_with(&format!(".{d}")) {
|
||||
for s in specs {
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Map CLI args to the adapter's `args` object. Positional args fill the adapter's
|
||||
/// declared `args` keys in order; `--key value` overrides by name. The adapter
|
||||
/// validates required args itself.
|
||||
pub fn map_args(adapter: &Adapter, positional: &[String], named: &[(String, String)]) -> Value {
|
||||
let mut obj = serde_json::Map::new();
|
||||
// Positional args fill the adapter's declared args in DECLARATION order
|
||||
// (`arg_order`), not serde's alphabetized key order — otherwise a 2-arg
|
||||
// adapter like `{projectId, path}` would map positionals to `{path, projectId}`.
|
||||
for (i, val) in positional.iter().enumerate() {
|
||||
if let Some(k) = adapter.arg_order.get(i) {
|
||||
obj.insert(k.clone(), Value::String(val.clone()));
|
||||
}
|
||||
}
|
||||
for (k, v) in named {
|
||||
obj.insert(k.clone(), Value::String(v.clone()));
|
||||
}
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"/* @meta
|
||||
{
|
||||
"name": "github/issues",
|
||||
"domain": "github.com",
|
||||
"args": { "repo": {"required": true}, "state": {"required": false} }
|
||||
}
|
||||
*/
|
||||
|
||||
async function(args) { return { repo: args.repo }; }"#;
|
||||
|
||||
#[test]
|
||||
fn parses_meta_and_function() {
|
||||
let a = parse_adapter(SAMPLE, "github/issues").unwrap();
|
||||
assert_eq!(a.domain(), Some("github.com"));
|
||||
assert!(a.func_src.starts_with("async function(args)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_eval_wraps_and_passes_args() {
|
||||
let a = parse_adapter(SAMPLE, "github/issues").unwrap();
|
||||
let args = map_args(
|
||||
&a,
|
||||
&["owner/repo".into()],
|
||||
&[("state".into(), "closed".into())],
|
||||
);
|
||||
let js = build_eval(&a, &args);
|
||||
assert!(js.contains("async function(args)"));
|
||||
assert!(js.contains("\"repo\":\"owner/repo\""));
|
||||
assert!(js.contains("\"state\":\"closed\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_spec() {
|
||||
assert!(load_adapter("noslash").is_err());
|
||||
assert!(load_adapter("../etc/passwd").is_err());
|
||||
}
|
||||
|
||||
// Regression: positional args must follow DECLARATION order, not serde's
|
||||
// alphabetical key order. With `{projectId, path}` (not alphabetical),
|
||||
// `<uuid> <file>` must map projectId←uuid, path←file — not swapped.
|
||||
#[test]
|
||||
fn positional_args_follow_declaration_order_not_alphabetical() {
|
||||
let raw = r#"/* @meta
|
||||
{
|
||||
"name": "claude-design/get-file",
|
||||
"domain": "claude.ai",
|
||||
"args": { "projectId": {"required": true}, "path": {"required": true} }
|
||||
}
|
||||
*/
|
||||
async function(args){ return args; }"#;
|
||||
let a = parse_adapter(raw, "claude-design/get-file").unwrap();
|
||||
assert_eq!(a.arg_order, vec!["projectId", "path"]);
|
||||
let args = map_args(&a, &["the-uuid".into(), "misonote.dc.html".into()], &[]);
|
||||
assert_eq!(args["projectId"], "the-uuid");
|
||||
assert_eq!(args["path"], "misonote.dc.html");
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -52,6 +52,27 @@ fn is_newer(latest: &str, current: &str) -> bool {
|
||||
matches!((parse_version(latest), parse_version(current)), (Some(l), Some(c)) if l > c)
|
||||
}
|
||||
|
||||
/// Public semver-ish comparison (`latest` strictly newer than `current`), so
|
||||
/// `doctor` can flag a stale extension/CLI without re-implementing parsing.
|
||||
pub fn version_is_newer(latest: &str, current: &str) -> bool {
|
||||
is_newer(latest, current)
|
||||
}
|
||||
|
||||
/// The latest CLI version recorded by the background update check, if any.
|
||||
/// `doctor` uses it to show "a newer chrome-use is available" without a network
|
||||
/// call (the `__update-check` worker refreshes the cache out of band).
|
||||
pub fn cached_latest_version() -> Option<String> {
|
||||
std::fs::read_to_string(update_cache_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|j| {
|
||||
j.get("latest")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Hidden `__update-check` subcommand: fetch the latest release tag and cache it.
|
||||
/// Spawned detached by [`maybe_notify_update`] so the network call never blocks a
|
||||
/// real command. Uses `curl` (no extra deps, matches `upgrade`).
|
||||
@@ -100,7 +121,7 @@ pub fn maybe_notify_update() {
|
||||
if first.starts_with("__")
|
||||
|| matches!(
|
||||
first.as_str(),
|
||||
"upgrade" | "install" | "doctor" | "dashboard"
|
||||
"upgrade" | "install" | "doctor" | "dashboard" | "daemon"
|
||||
)
|
||||
{
|
||||
return;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -30,6 +30,16 @@ const tabs = new Map()
|
||||
const sessionToTab = new Map()
|
||||
/** child (OOPIF/worker) sessionId -> tabId */
|
||||
const childSessionToTab = new Map()
|
||||
/** sessionId -> CDP targetId, kept ACROSS detach so a dead `cb-tab-<oldTabId>`
|
||||
* session can be recovered by its stable targetId when the cross-process nav
|
||||
* gave the tab a new Chrome tabId (issue #24). Capped to bound memory. */
|
||||
const sessionTargets = new Map()
|
||||
function rememberSessionTarget(sessionId, targetId) {
|
||||
if (!sessionId || !targetId) return
|
||||
sessionTargets.delete(sessionId)
|
||||
sessionTargets.set(sessionId, targetId)
|
||||
if (sessionTargets.size > 256) sessionTargets.delete(sessionTargets.keys().next().value)
|
||||
}
|
||||
/** tab-group name -> chrome tabGroups id (best-effort cache) */
|
||||
const groupIdByName = new Map()
|
||||
|
||||
@@ -71,6 +81,35 @@ async function groupTabInto(tabId, name) {
|
||||
groupIdByName.set(name, gid)
|
||||
}
|
||||
|
||||
// Group-scoped relay isolation hints (issue #40). The relay scopes
|
||||
// Target.getTargets per agent by tab group; report two things in the synthesized
|
||||
// targetInfo so it can attribute each tab:
|
||||
// - abGroup: the tab's Chrome tab-group TITLE (= the owning session name), so
|
||||
// the relay can re-attribute existing tabs after a restart (createTarget
|
||||
// tagging won't re-run for already-open tabs).
|
||||
// - openerTargetId: the targetId of the tab that opened this one, so a pop-up
|
||||
// (window.open / target=_blank / OAuth result) inherits its opener's group
|
||||
// and the agent that opened it can follow it — without foreign tabs leaking.
|
||||
// Best-effort: any failure yields empty strings, which the relay ignores.
|
||||
async function tabScopeHints(tabId) {
|
||||
let openerTargetId = ''
|
||||
let abGroup = ''
|
||||
try {
|
||||
const t = await chrome.tabs.get(tabId)
|
||||
if (t) {
|
||||
if (typeof t.openerTabId === 'number') {
|
||||
const op = tabs.get(t.openerTabId)
|
||||
if (op) openerTargetId = op.targetId
|
||||
}
|
||||
if (t.groupId != null && t.groupId >= 0 && chrome.tabGroups) {
|
||||
const g = await chrome.tabGroups.get(t.groupId).catch(() => null)
|
||||
if (g && g.title) abGroup = g.title
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return { openerTargetId, abGroup }
|
||||
}
|
||||
|
||||
function postToHost(msg) {
|
||||
try {
|
||||
if (port) port.postMessage(msg)
|
||||
@@ -108,9 +147,15 @@ function connectHost() {
|
||||
// reconnect. Keep chrome.debugger attached so reconnect is cheap.
|
||||
for (const tabId of tabs.keys()) setBadge(tabId, 'connecting')
|
||||
})
|
||||
// Report our version so the host can tell the CLI/`doctor` which extension
|
||||
// build is live (otherwise the extension version is a black box — the user
|
||||
// can't tell they're on an old one). Best-effort; ignored by older hosts.
|
||||
try {
|
||||
postToHost({ method: 'hello', version: chrome.runtime.getManifest().version })
|
||||
} catch {}
|
||||
// Tell the daemon about everything we already have attached, then attach
|
||||
// anything new.
|
||||
reannounceAttachedTabs()
|
||||
void reannounceAttachedTabs()
|
||||
void attachAllTabs()
|
||||
}
|
||||
|
||||
@@ -124,7 +169,7 @@ async function onHostMessage(msg) {
|
||||
// Daemon (re)connected — (re)attach and announce every tab so it discovers
|
||||
// the user's existing tabs rather than racing an empty target list.
|
||||
if (msg.method === 'attachAll') {
|
||||
reannounceAttachedTabs()
|
||||
void reannounceAttachedTabs()
|
||||
await attachAllTabs()
|
||||
return
|
||||
}
|
||||
@@ -149,6 +194,90 @@ function tabForTarget(targetId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// The STABLE Chrome tabId encoded in a `cb-tab-<tabId>` session id (#17), or
|
||||
// null for any other session shape (child/iframe sessions). The tabId is the
|
||||
// real source of truth: it survives the renderer-process swaps (cross-origin
|
||||
// OAuth/SSO navs) that tear down the page's CDP target — which is why binding to
|
||||
// it (like claude-in-chrome) rides through the hop that killed the old
|
||||
// target/sessionId binding (issue #23).
|
||||
function tabIdFromSession(sessionId) {
|
||||
const m = /^cb-tab-(\d+)$/.exec(sessionId || '')
|
||||
return m ? Number(m[1]) : null
|
||||
}
|
||||
|
||||
// Ensure the debugger is attached to a `cb-tab-<tabId>` session's tab, re-attaching
|
||||
// across the transient window of a process swap (with a couple of short retries).
|
||||
// Returns the tabId on success, or null when the tab is genuinely gone
|
||||
// (closed / restricted). (issues #20.1, #23)
|
||||
async function recoverSessionTab(sessionId) {
|
||||
const tabId = tabIdFromSession(sessionId)
|
||||
// 1) Fast path: the encoded Chrome tabId still exists — re-attach it (covers
|
||||
// the common renderer-process swap where the tabId is preserved, #23).
|
||||
if (tabId != null) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
||||
if (!eligible(tab)) break // tabId is gone — fall through to targetId recovery
|
||||
try {
|
||||
await attachTab(tabId)
|
||||
if (tabs.has(tabId)) return tabId
|
||||
} catch {
|
||||
// mid-swap: tab exists but isn't attachable yet — back off and retry.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 120 + i * 150))
|
||||
}
|
||||
}
|
||||
// 2) The Chrome tabId is gone, but the CDP targetId is STABLE across the nav.
|
||||
// Some cross-process hops (Mercari's signin token exchange) give the tab a
|
||||
// NEW tabId while keeping the same target, so `cb-tab-<oldTabId>` can't be
|
||||
// recovered by tabId. Find the tab now hosting our remembered targetId via
|
||||
// chrome.debugger.getTargets(), attach it, and ALIAS the dead session to it
|
||||
// so the daemon's session id keeps resolving. Longer window: this hop can
|
||||
// take several seconds to settle (issue #24).
|
||||
const targetId = sessionTargets.get(sessionId)
|
||||
if (targetId) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const targets = await chrome.debugger.getTargets().catch(() => null)
|
||||
const t = targets && targets.find((x) => x.id === targetId && x.tabId != null)
|
||||
if (t && t.tabId != null) {
|
||||
const tab = await chrome.tabs.get(t.tabId).catch(() => null)
|
||||
if (eligible(tab)) {
|
||||
try {
|
||||
await attachTab(t.tabId)
|
||||
if (tabs.has(t.tabId)) {
|
||||
sessionToTab.set(sessionId, t.tabId) // alias dead session -> live tab
|
||||
return t.tabId
|
||||
}
|
||||
} catch {
|
||||
// not attachable yet — keep waiting for the swap to settle.
|
||||
}
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300 + i * 300))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Send a CDP command to a tab, riding a debugger detach that can happen between
|
||||
// our attach check and the command itself (a renderer-process swap mid-flight).
|
||||
// On a detached-style failure, drop the stale handle, re-attach the stable tab,
|
||||
// and retry once — so a cross-process nav never surfaces as a hard error (#23).
|
||||
async function sendCdpToTab(tabId, method, params) {
|
||||
const dbg = { tabId }
|
||||
try {
|
||||
return await chrome.debugger.sendCommand(dbg, method, params)
|
||||
} catch (e) {
|
||||
const msg = String((e && e.message) || e)
|
||||
if (!/detached|not attached|target.*(closed|gone)|no target|cannot access|frame.*detached/i.test(msg)) {
|
||||
throw e
|
||||
}
|
||||
detachTab(tabId, false)
|
||||
const ok = await recoverSessionTab(`cb-tab-${tabId}`)
|
||||
if (!ok) throw e
|
||||
return await chrome.debugger.sendCommand(dbg, method, params)
|
||||
}
|
||||
}
|
||||
|
||||
function anyConnectedTab() {
|
||||
const it = tabs.keys().next()
|
||||
return it.done ? null : it.value
|
||||
@@ -207,13 +336,26 @@ async function handleForwardCdpCommand(msg) {
|
||||
// Fail loudly instead so the agent sees an actionable error, not bad data.
|
||||
let tabId
|
||||
if (sessionId) {
|
||||
tabId = tabForSession(sessionId)
|
||||
if (!tabId) {
|
||||
throw new Error(
|
||||
`stale sessionId ${sessionId} for ${method}: its tab is gone (closed, ` +
|
||||
`navigated across processes, or lost after an extension restart). ` +
|
||||
`Re-attach by re-opening your target URL before retrying.`,
|
||||
)
|
||||
// The stable Chrome tabId encoded in `cb-tab-<tabId>` is the source of truth
|
||||
// (it survives renderer-process swaps; the CDP target/sessionId does not).
|
||||
// Resolve via it primarily — don't depend on a session→tab map entry that the
|
||||
// detach handler may have cleared — and ensure the debugger is attached,
|
||||
// re-attaching across a cross-process nav before failing (issues #20.1, #23).
|
||||
// `tabForSession` still covers child/iframe sessions that aren't `cb-tab-*`.
|
||||
tabId = tabIdFromSession(sessionId) ?? tabForSession(sessionId)
|
||||
if (tabId == null) {
|
||||
throw new Error(`unknown sessionId ${sessionId} for ${method}`)
|
||||
}
|
||||
if (!tabs.has(tabId)) {
|
||||
const recovered = await recoverSessionTab(sessionId)
|
||||
if (!recovered) {
|
||||
throw new Error(
|
||||
`stale sessionId ${sessionId} for ${method}: its tab is gone (closed, ` +
|
||||
`navigated across processes, or lost after an extension restart). ` +
|
||||
`Re-attach by re-opening your target URL before retrying.`,
|
||||
)
|
||||
}
|
||||
tabId = recovered
|
||||
}
|
||||
} else if (typeof params?.targetId === 'string') {
|
||||
tabId = tabForTarget(params.targetId)
|
||||
@@ -223,18 +365,17 @@ async function handleForwardCdpCommand(msg) {
|
||||
// applies to any attached tab.
|
||||
tabId = anyConnectedTab()
|
||||
}
|
||||
if (!tabId) throw new Error(`no attached tab for ${method}`)
|
||||
const dbg = { tabId }
|
||||
if (tabId == null) throw new Error(`no attached tab for ${method}`)
|
||||
|
||||
// Re-enabling Runtime can leave a stale state; bounce it (matches upstream).
|
||||
if (method === 'Runtime.enable') {
|
||||
try {
|
||||
await chrome.debugger.sendCommand(dbg, 'Runtime.disable')
|
||||
await sendCdpToTab(tabId, 'Runtime.disable', undefined)
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
} catch {}
|
||||
return await chrome.debugger.sendCommand(dbg, 'Runtime.enable', params)
|
||||
return await sendCdpToTab(tabId, 'Runtime.enable', params)
|
||||
}
|
||||
return await chrome.debugger.sendCommand(dbg, method, params)
|
||||
return await sendCdpToTab(tabId, method, params)
|
||||
}
|
||||
|
||||
// ---- attach / detach ------------------------------------------------------
|
||||
@@ -273,13 +414,18 @@ async function attachTab(tabId) {
|
||||
const entry = { sessionId, targetId }
|
||||
tabs.set(tabId, entry)
|
||||
sessionToTab.set(sessionId, tabId)
|
||||
rememberSessionTarget(sessionId, targetId)
|
||||
setBadge(tabId, port ? 'on' : 'connecting')
|
||||
const { openerTargetId, abGroup } = await tabScopeHints(tabId)
|
||||
postToHost({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId,
|
||||
method: 'Target.attachedToTarget',
|
||||
params: { sessionId, targetInfo: { ...targetInfo, attached: true } },
|
||||
params: {
|
||||
sessionId,
|
||||
targetInfo: { ...targetInfo, attached: true, openerTargetId, abGroup },
|
||||
},
|
||||
},
|
||||
})
|
||||
return entry
|
||||
@@ -321,14 +467,32 @@ async function attachAllTabs() {
|
||||
}
|
||||
}
|
||||
|
||||
function reannounceAttachedTabs() {
|
||||
for (const [, entry] of tabs.entries()) {
|
||||
async function reannounceAttachedTabs() {
|
||||
for (const [tabId, entry] of tabs.entries()) {
|
||||
// Re-send the group hint too (issue #40) so the relay can rebuild its
|
||||
// targetId→group map after its own restart (createTarget tagging won't
|
||||
// re-run for tabs that are already open). Include the live url/title so the
|
||||
// relay's target list stays matchable by URL after a reconnect (otherwise a
|
||||
// reannounced tab shows a blank url and `adopt <url>` can't find it).
|
||||
const { openerTargetId, abGroup } = await tabScopeHints(tabId)
|
||||
let url = ''
|
||||
let title = ''
|
||||
try {
|
||||
const t = await chrome.tabs.get(tabId)
|
||||
if (t) {
|
||||
url = t.url || t.pendingUrl || ''
|
||||
title = t.title || ''
|
||||
}
|
||||
} catch {}
|
||||
postToHost({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId: entry.sessionId,
|
||||
method: 'Target.attachedToTarget',
|
||||
params: { sessionId: entry.sessionId, targetInfo: { targetId: entry.targetId, type: 'page', attached: true } },
|
||||
params: {
|
||||
sessionId: entry.sessionId,
|
||||
targetInfo: { targetId: entry.targetId, type: 'page', url, title, attached: true, openerTargetId, abGroup },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -355,9 +519,33 @@ chrome.debugger.onEvent.addListener((source, method, params) =>
|
||||
}),
|
||||
)
|
||||
|
||||
chrome.debugger.onDetach.addListener((source) =>
|
||||
void whenReady(() => {
|
||||
if (source.tabId) detachTab(source.tabId, true)
|
||||
chrome.debugger.onDetach.addListener((source, reason) =>
|
||||
void whenReady(async () => {
|
||||
const tabId = source.tabId
|
||||
if (!tabId) return
|
||||
detachTab(tabId, true)
|
||||
// A cross-process navigation (e.g. an SSO redirect like
|
||||
// login.account.rakuten.com that swaps the render process / spawns OOPIFs)
|
||||
// detaches the debugger, but the TAB survives. Without re-attaching, the
|
||||
// session goes permanently stale and even open/navigate fails — exactly the
|
||||
// #19 follow-up. So proactively re-attach (the stable `cb-tab-<tabId>`
|
||||
// session id then restores the daemon's binding). Don't fight a detach the
|
||||
// user or DevTools initiated.
|
||||
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') return
|
||||
if (!port) return
|
||||
// The swapped-in process needs a moment to settle; retry with backoff.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await new Promise((r) => setTimeout(r, 250 + i * 200))
|
||||
if (tabs.has(tabId)) return // already re-attached (e.g. via onUpdated)
|
||||
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
||||
if (!tab || !eligible(tab)) return // tab gone or now a restricted page
|
||||
try {
|
||||
await attachTab(tabId)
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn(`ab-connect: reattach attempt ${i + 1} for tab ${tabId} failed:`, e)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "chrome-use",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.11",
|
||||
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||
"icons": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chrome-use",
|
||||
"version": "1.2.2",
|
||||
"version": "1.5.24",
|
||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
+258
-8
@@ -36,6 +36,41 @@ Refs (`@e1`, `@e2`, ...) are assigned fresh on every snapshot. They become
|
||||
submits, dynamic re-renders, dialog opens. Always re-snapshot before your
|
||||
next ref interaction.
|
||||
|
||||
> **Hard rule: snapshot-first, never screenshot-to-locate.** For form fields and
|
||||
> buttons, ALWAYS `snapshot -i` and act on refs/selectors. Do **not** reach for
|
||||
> `screenshot` + coordinate clicks to find or hit an element — `snapshot -i` now
|
||||
> pierces **cross-origin iframes** (embedded Google Payments / Stripe / checkout /
|
||||
> KYC forms) and lists their elements by `@ref`, including input values. Use
|
||||
> coordinates only for canvas/WebGL, or when `snapshot` genuinely returns nothing
|
||||
> for your target. Screenshots are for *visual verification you report*, never the
|
||||
> agent's own input — and a full-page `screenshot` of a real retina browser is
|
||||
> often too large for an image reader anyway. (If you ever feel you *need* a
|
||||
> screenshot to read state or locate something, that's a bug — please file it.)
|
||||
|
||||
> **Snapshot-first, always. Never default to `screenshot` + coordinate clicking
|
||||
> for form fields or buttons.** Run `snapshot -i` and act on `@refs`. Use
|
||||
> coordinates only for canvas/WebGL, or when `snapshot` genuinely returns nothing
|
||||
> for your target. This holds **even inside cross-origin embedded iframes** —
|
||||
> since v1.5.12 `snapshot -i` pierces out-of-process iframes (Google Payments,
|
||||
> Stripe, embedded checkout/KYC) and lists their elements with refs, so
|
||||
> `click @e` / `type @e` / `fill @e` work directly. A screenshot is for a genuine
|
||||
> *visual* check you report to the user — not your own input. (Full-page
|
||||
> screenshots of a real retina Chrome are often too large for the image reader
|
||||
> anyway.) Driving off pixels on the relay also risks a coordinate event drifting
|
||||
> onto the user's foreground tab — refs never do. See issue #37.
|
||||
|
||||
> **Two different intents — only one is discouraged.** The rule above is about
|
||||
> *screenshot-to-locate* (using a picture to find/hit an element) — that's the bug.
|
||||
> *screenshot-to-capture* — saving a region or element to a file as a **reusable
|
||||
> image asset** (maps, charts, og-images, visual-diff baselines, report figures) —
|
||||
> is fully supported and encouraged: `screenshot [selector] [--clip x,y,w,h] <file>`.
|
||||
> Capturing a rendered map region to a PNG for a blog post is the right tool, not a
|
||||
> smell. Screenshots are auto-downscaled to ≤2000px (longest edge) so they fit an
|
||||
> image reader and their pixels line up with `click x y`; override with
|
||||
> `--max-width`/`--max-height`/`--scale`. To click something you couldn't hit by
|
||||
> ref, `box @ref` gives the element's CSS-px box + `centerX/centerY` to feed
|
||||
> straight into `click <centerX> <centerY>` — no screenshot needed.
|
||||
|
||||
## Before you automate: pick the cheapest tool
|
||||
|
||||
Driving a browser is the heavy option. chrome-use earns its keep when you
|
||||
@@ -45,6 +80,7 @@ need a **real, logged-in browser** — not for reading text off a public page.
|
||||
|---|---|
|
||||
| Discover what exists / find sources | `WebSearch` |
|
||||
| Specific facts from a static or public page | `WebFetch` or `curl` (no browser) |
|
||||
| **Structured data from a known site** (GitHub issues, Reddit/HN search, Bilibili/Twitter feed, …) — esp. behind login | `chrome-use site <name>/<cmd>` (see below) — skip snapshot+click entirely |
|
||||
| Login state, interaction, JS-rendered or anti-bot pages | **chrome-use** (this skill) |
|
||||
| A page the user saved before / an internal system | `chrome-use find-url <keywords>` (their bookmarks), then open it |
|
||||
| The user's **own already-open, logged-in** Chrome window | the **extension connect** flow (below) |
|
||||
@@ -108,7 +144,27 @@ Each `--session` that connects gets its **own colored Chrome tab group** (named
|
||||
after the session) and drives only its own tabs — multiple agents share the one
|
||||
real browser without cross-talk, and the user's own tabs are never grouped. CDP
|
||||
drives the page without moving the user's mouse/keyboard, so it doesn't fight
|
||||
them for control. **Anti-detection ranking: this real logged-in Chrome (extension
|
||||
them for control.
|
||||
|
||||
**Strict multi-agent isolation.** A session over the relay tracks and drives
|
||||
**only the tabs it created** (its own group). It does **not** adopt the user's
|
||||
existing tabs, other agents' tabs, or pop-ups (e.g. an OAuth/login window — that's
|
||||
the user's), so several agents (and other tools opening tabs) can work in the same
|
||||
real Chrome concurrently without ever dropping or stealing each other's tabs —
|
||||
another agent's tab churn can't make your bound tab vanish or drift your commands
|
||||
onto the wrong page. Consequence: `tab list` shows only *your* session's tabs; to
|
||||
drive a specific page, navigate to it in your own tab instead of expecting a
|
||||
pre-existing or popped-up tab to appear in the list.
|
||||
|
||||
> **Need to read a tab the user already has open?** Use `chrome-use adopt
|
||||
> <url-substring|targetId>` — it finds that pre-existing tab (the user's own, or
|
||||
> another session's) across groups and drives it **without opening a new tab**.
|
||||
> e.g. `adopt "claude.ai/design"` then `snapshot`/`eval`/`get text` on it. On no
|
||||
> match it errors and lists the tabs it can see. This is the explicit, opt-in way
|
||||
> through the isolation above (it tags the adopted tab into your group). Great for
|
||||
> "read/extract from the page I'm looking at" without disturbing it.
|
||||
|
||||
**Anti-detection ranking: this real logged-in Chrome (extension
|
||||
connect) > a headed launched browser > headless (forbidden).** A genuine human
|
||||
browser has no headless/automation tells at all, so prefer it for anything
|
||||
anti-bot-sensitive.
|
||||
@@ -127,6 +183,19 @@ cadence, and scroll/drag ease. Default `off`; a per-navigation detector
|
||||
auto-escalates pages guarded by Akamai/PerimeterX/DataDome to `human`. Leave it
|
||||
on auto; force `human` only when you already know the target scores behaviour.
|
||||
|
||||
**Cloudflare clearance — solve once, reuse.** Passing a Cloudflare challenge
|
||||
mints a `cf_clearance` cookie (HttpOnly — invisible to `eval`/`document.cookie`;
|
||||
read it via `chrome-use cookies`). It's bound to your **IP + User-Agent**: reuse
|
||||
the same exit IP and UA and you skip the challenge until it expires. Driving the
|
||||
user's real Chrome (relay) persists it natively; for isolated sessions,
|
||||
`--session-name <name>` save/restores it. Before spending effort solving, run
|
||||
`chrome-use cf-status` (aliases `cf`, `clearance`): it reports whether the page
|
||||
is *currently* a Cloudflare challenge and whether a still-valid `cf_clearance`
|
||||
exists, with a recommendation — `proceed` (already cleared, don't re-solve),
|
||||
`solve` (challenge up, no clearance), or `reissue` (clearance present but page
|
||||
still blocks → IP/UA drifted, re-solve). Use it as a preflight to avoid
|
||||
re-solving what you already cleared.
|
||||
|
||||
## Two ways to drive a page — and when to drop to `eval`
|
||||
|
||||
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
||||
@@ -150,6 +219,34 @@ chrome-use eval "[...document.forms[0].elements].filter(e=>!e.validity.valid).ma
|
||||
chrome-use eval "document.querySelector('#stubborn').click()" # direct DOM click, bypasses overlays
|
||||
```
|
||||
|
||||
## Site adapters — the cheapest path for "read structured data from site X"
|
||||
|
||||
Before you `open` + `snapshot` + click your way through GitHub/Reddit/Bilibili/etc.,
|
||||
check whether a **site adapter** already exists. An adapter is a community-written JS
|
||||
function that hits the site's own JSON API *from inside your logged-in tab* and returns
|
||||
clean structured data — no clicking, no scraping, no screenshots. It's the same idea as
|
||||
`eval`, packaged per-site.
|
||||
|
||||
```bash
|
||||
chrome-use site update # one-time: fetch the adapter pack (~145 cmds)
|
||||
chrome-use site list # what's installed (github/issues, reddit/search, …)
|
||||
chrome-use site info github/issues # an adapter's args + which domain it runs on
|
||||
chrome-use site github/issues owner/repo --json # run it → JSON (navigates there for you)
|
||||
```
|
||||
|
||||
- Positional args fill the adapter's declared args **in order**; `--key value` overrides by name.
|
||||
- It navigates to the adapter's domain (reusing the current tab if you're already on it), so
|
||||
login-gated feeds (`bilibili/feed`, `twitter/...`) work because they run as *you*.
|
||||
- If no adapter fits, fall back to the normal `snapshot`/`eval` loop. Adapters come from the
|
||||
[bb-sites](https://github.com/epiral/bb-sites) community pack; chrome-use fetches & runs them.
|
||||
|
||||
> **Auto-trigger — act on it.** chrome-use keeps the pack synced automatically (first use +
|
||||
> weekly), and when you `open`/`navigate`/`snapshot` a page whose domain has adapters it tells
|
||||
> you: a `💡 site adapters for <domain>` line on stderr, and a `siteAdapters: {domain, commands}`
|
||||
> field in `--json`. **When you see that, prefer the listed `site <name>/<cmd>` over snapshot+click
|
||||
> for reading data** — it's the cheaper, more reliable path and it's already installed. You don't
|
||||
> need to run `site update` yourself; just use the command it names.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
@@ -207,7 +304,11 @@ assigned fresh on every snapshot.
|
||||
For unstructured reading (no refs needed):
|
||||
|
||||
```bash
|
||||
chrome-use get text @e1 # visible text of an element
|
||||
chrome-use get text # WHOLE PAGE — all frames by default (see below)
|
||||
chrome-use get text @e1 # visible text of one element (or a CSS selector)
|
||||
chrome-use get text --main # main content only — skip nav/header/sidebar
|
||||
chrome-use get text --pierce # read through CLOSED shadow DOM (injected panels)
|
||||
chrome-use frames # list every frame + where the text lives
|
||||
chrome-use get html @e1 # innerHTML
|
||||
chrome-use get attr @e1 href # any attribute
|
||||
chrome-use get value @e1 # input value
|
||||
@@ -216,6 +317,27 @@ chrome-use get url # current URL
|
||||
chrome-use get count ".item" # count matching elements
|
||||
```
|
||||
|
||||
**Whole-page text is cross-frame by default.** `chrome-use get text` with no
|
||||
selector aggregates visible text across **every** frame — top document plus
|
||||
same-process child frames plus cross-origin iframes — so you never silently miss
|
||||
content that lives in an iframe (Yahoo Auctions / Rakuten / Mercari shop
|
||||
descriptions, embedded checkout/spec frames). Each child frame is delimited with
|
||||
a `----- frame [kind] url -----` marker. You do **not** need to remember a flag —
|
||||
the default already reads all frames. (`--all-frames` is still accepted as an
|
||||
explicit alias.)
|
||||
|
||||
So: when text looks missing or wrong, you don't have to guess — just
|
||||
`chrome-use get text` reads everything. To **see** the structure (which frame
|
||||
holds what), run `chrome-use frames`. To **cut boilerplate** (global nav/header/
|
||||
footer, "related items" sidebars), use `chrome-use get text --main`. If content
|
||||
is lazy-loaded, `scroll` it into view first, then read.
|
||||
|
||||
**Closed shadow DOM.** Some injected UI (browser-extension debug panels, web
|
||||
components) renders into a *closed* shadow root that `eval`/`innerText` cannot
|
||||
read. `chrome-use get text --pierce` reads through closed shadow roots and child
|
||||
documents via the CDP DOM tree — use it when content is clearly on screen (you
|
||||
see it in a screenshot) but `get text`/`eval` come back empty.
|
||||
|
||||
## Interacting
|
||||
|
||||
```bash
|
||||
@@ -226,8 +348,15 @@ chrome-use hover @e1 # hover
|
||||
chrome-use focus @e1 # focus (useful before keyboard input)
|
||||
chrome-use fill @e2 "hello" # clear then type
|
||||
chrome-use type @e2 " world" # type without clearing
|
||||
chrome-use press Enter # press a key at current focus
|
||||
chrome-use type @e5 "201-0001" --key-events # real keystrokes (not insertText) —
|
||||
# use for autocomplete/combobox fields that
|
||||
# only react to key events (e.g. a postal box
|
||||
# that auto-fills city/prefecture, Google Places)
|
||||
chrome-use press Enter # press a key at current focus (down+up)
|
||||
chrome-use press Control+a # key combination
|
||||
chrome-use keydown d # HOLD a key down (no auto-release)
|
||||
chrome-use keyup d # release it — pair them to hold-to-move
|
||||
# in a game: `keydown d; sleep; keyup d`
|
||||
chrome-use check @e3 # check checkbox
|
||||
chrome-use uncheck @e3 # uncheck
|
||||
chrome-use select @e4 "option-value" # native <select> only
|
||||
@@ -240,16 +369,38 @@ chrome-use pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
|
||||
# (no silent no-op). Use this for custom
|
||||
# dropdowns where `select` returns ✓ but
|
||||
# changes nothing.
|
||||
chrome-use upload @e5 file1.pdf # upload file(s) — NOTE: needs a --launch/direct-CDP
|
||||
# session. Over the extension relay it CANNOT work
|
||||
# (Chrome's chrome.debugger forbids it); chrome-use
|
||||
# errors with a hint. Carry your login into a launched
|
||||
# session via `cookies export` | `cookies set --curl`.
|
||||
chrome-use upload @e5 file1.pdf # upload file(s) — works over the extension relay too:
|
||||
# chrome.debugger forbids setFileInputFiles, so the
|
||||
# file's bytes are streamed into the page and rebuilt as
|
||||
# a File there (chunked under native-messaging's 1 MiB cap).
|
||||
# Works on file <input>s and drop/paste composers (e.g. X).
|
||||
chrome-use scroll down 500 # scroll page (up/down/left/right)
|
||||
chrome-use scroll down 700 --at 640,400 # wheel at a pixel — scrolls a cross-origin
|
||||
# iframe (Payments/Stripe/checkout/KYC) that
|
||||
# plain page scroll can't reach
|
||||
chrome-use scroll down 700 --frame 2 # scroll frame 2 from `chrome-use frames`
|
||||
chrome-use scrollintoview @e1 # scroll element into view
|
||||
chrome-use drag @e1 @e2 # drag and drop
|
||||
```
|
||||
|
||||
**Cross-origin iframes (embedded payment / checkout / KYC widgets — Google
|
||||
Payments, Stripe, etc.) — drive them by ref, never by screenshot.** `snapshot -i`
|
||||
pierces these out-of-process iframes and lists their elements by `@ref`
|
||||
(including input values); `get text --all-frames` reads their text. Then just act
|
||||
on the refs: `click @e`, `type @e`, `hover @e`, `dblclick @e`, `drag @a @b` all
|
||||
work into the iframe. Over the extension relay these are dispatched through the
|
||||
DOM (in the element's own frame), so they hit the right element in the right tab
|
||||
— a coordinate click/scroll there can drift onto whatever tab is in the
|
||||
foreground, so prefer refs. For below-the-fold content in such a frame, scroll it
|
||||
with `scroll down N --at x,y` (a pixel over the frame) or `--frame n`. For a
|
||||
postal/autocomplete box inside the frame, `type @e "…" --key-events`.
|
||||
|
||||
> **Caveat: `find text "…"` can't reach into a cross-origin iframe** — it errors
|
||||
> "Element not found" even though `snapshot -i` lists those nodes and
|
||||
> `get text` reads them. Inside cross-origin iframes, target elements by their
|
||||
> **snapshot `@ref`**, not by `find`. (`box @ref` also works on iframe refs when
|
||||
> you need a coordinate fallback.)
|
||||
|
||||
### When refs don't work or you don't want to snapshot
|
||||
|
||||
Use semantic locators:
|
||||
@@ -296,6 +447,61 @@ chrome-use click --coords 449,320 # same, explicit flag
|
||||
|
||||
A bare-number argument is always a coordinate, never a selector.
|
||||
|
||||
### Canvas / WebGL apps (games, map & 3D viewers, drawing tools)
|
||||
|
||||
These paint everything to a `<canvas>` and expose **almost no accessibility
|
||||
tree**, so `snapshot` comes back near-empty and refs are a dead end. `snapshot`
|
||||
detects this and prints a one-line hint. Drive them the screenshot way:
|
||||
|
||||
```bash
|
||||
chrome-use canvas list # enumerate <canvas> elements (size, type)
|
||||
chrome-use canvas capture out.png # save the canvas's RENDERED pixels to PNG —
|
||||
# toDataURL (full backing-store res, e.g.
|
||||
# Figma 2522x1904), screenshot fallback for
|
||||
# WebGL w/o preserveDrawingBuffer / tainted.
|
||||
# Gets the RENDER, not hidden source data
|
||||
# (those live in the app's binary store/API).
|
||||
chrome-use screenshot /tmp/s.png # SEE the state (your only read path —
|
||||
# eval/get text return nothing useful)
|
||||
chrome-use click 640 360 # interact by viewport coordinate
|
||||
chrome-use press d --hold 800 # hold-to-move, precise (timed in-daemon —
|
||||
# NOT keydown+shell-sleep+keyup, which
|
||||
# adds ~250ms jitter per round-trip)
|
||||
chrome-use press Space # discrete actions (jump/attack/confirm)
|
||||
```
|
||||
|
||||
**Don't drive frame-by-frame with one CLI call per action** — that's the slowest,
|
||||
lowest-fidelity way (each call is a process spawn + round-trip). Script a *timed
|
||||
sequence in a single round-trip* with `batch` (it sends each step to the running
|
||||
daemon; `press --hold` and `wait` block in-daemon, so timing is precise):
|
||||
|
||||
```bash
|
||||
chrome-use batch "press d --hold 900" "press j" "press j" "wait 200" "press d --hold 500"
|
||||
```
|
||||
|
||||
Also try reading real state instead of pixels: `eval` runs in the page's main
|
||||
world, so for a framework/engine game you can often reach its globals (e.g. a
|
||||
Phaser/PIXI/Three instance, a store, `window.__GAME__`) and read positions/score
|
||||
directly — far better than guessing from a screenshot.
|
||||
|
||||
**For genuinely real-time driving, drop the CLI entirely and use the WebSocket.**
|
||||
`chrome-use stream enable` opens a bidirectional WS (`stream status` prints the
|
||||
`ws://127.0.0.1:<port>`). Connect once and you get a live ~60fps screencast AND
|
||||
can send input on the same socket — no per-action process spawn, no round-trip,
|
||||
works over the extension relay:
|
||||
|
||||
```js
|
||||
// node (global WebSocket): live frames + locally-timed input
|
||||
const ws = new WebSocket("ws://127.0.0.1:PORT")
|
||||
ws.onmessage = e => { const m = JSON.parse(e.data); if (m.type==="frame") {/* base64 jpeg */} }
|
||||
const k = (eventType,key,code,vk) => ws.send(JSON.stringify({type:"input_keyboard",eventType,key,code,windowsVirtualKeyCode:vk}))
|
||||
k("keyDown"," ","Space",32); setTimeout(()=>k("keyUp"," ","Space",32), 80) // a jump
|
||||
// also: {type:"input_mouse",eventType:"mousePressed",x,y,button:"left",clickCount:1}
|
||||
```
|
||||
|
||||
This is the difference between watching a slideshow and playing the game. Reserve
|
||||
screenshots for one-off checks; use the WS for any sustained real-time control.
|
||||
|
||||
## Waiting (read this)
|
||||
|
||||
Agents fail more often from bad waits than from bad selectors. Pick the
|
||||
@@ -501,6 +707,43 @@ the same browser's existing targets, so a second session's first `open` can
|
||||
navigate a sibling's tab. For concurrent agents on one real Chrome, use the
|
||||
extension (each with a distinct `--session`), not raw `--cdp`.
|
||||
|
||||
Each session owns its own tab group and assigns its own `t<N>` indices (the same
|
||||
physical tab is `t8` in one session, `t1` in another), so `t<N>` is **not** a
|
||||
stable cross-session handle. To reach a *specific* tab from another session — e.g.
|
||||
a tab that was filled in a session whose handle later died — use the **stable CDP
|
||||
`targetId`**:
|
||||
|
||||
```bash
|
||||
chrome-use tab list --full --session B # re-syncs live tabs; prints `target: <id>` per row
|
||||
chrome-use tab <targetId> --session B # adopt that exact tab, NO reload (state preserved)
|
||||
```
|
||||
|
||||
`tab list` re-discovers the live tab set on every call, so a fresh session sees
|
||||
tabs other sessions opened (and re-attached ones), not just its own. Adopting by
|
||||
`targetId` lands session B on the stranded tab without reloading it, so a
|
||||
half-filled form survives. Still, the simplest recovery for a session whose own
|
||||
tab died is to recover *that* session (reload / re-`open` / `daemon restart`).
|
||||
|
||||
To avoid piling up duplicate tabs when you re-`open` the same entry URL on
|
||||
rebind, pass **`--reuse-tab`**: if a tab already shows that URL (matched by
|
||||
origin+path), it switches to it instead of spawning a new one.
|
||||
|
||||
### Reset stuck daemon state
|
||||
|
||||
Each session runs a background daemon worker that holds the page handles. If a
|
||||
session starts misbehaving — commands hit the wrong tab, refs/handles look stale,
|
||||
or you upgraded `chrome-use` mid-session and old workers linger — restart the
|
||||
daemons instead of hunting PIDs with `pgrep`/`kill`:
|
||||
|
||||
```bash
|
||||
chrome-use daemon status # list running session daemons (+ relay state)
|
||||
chrome-use daemon restart # kill every session daemon worker
|
||||
```
|
||||
|
||||
`daemon restart` leaves the extension's native-messaging bridge (`__nm-host`)
|
||||
alone, so the relay to your live Chrome stays up — the next command just spins up
|
||||
a fresh, clean daemon against the same browser. It does **not** close any tabs.
|
||||
|
||||
### Mock network requests
|
||||
|
||||
```bash
|
||||
@@ -610,6 +853,13 @@ forbids debugging). The session no longer has a live tab — re-run
|
||||
replaces the old silent behaviour where the command ran on some *other*
|
||||
tab and returned wrong data.
|
||||
|
||||
To recover, you need the tab's **exact** URL (query params and all — a long
|
||||
SSO/redirect link breaks if truncated). `tab list` shortens long URLs with
|
||||
`…`; use **`tab list --full`** to print them untruncated, then re-`open` the
|
||||
right one. For multi-redirect SSO flows, re-open the **stable entry URL**
|
||||
(not the mid-redirect one) and `wait` a few seconds for the SPA to settle
|
||||
before snapshotting.
|
||||
|
||||
**Reads landing on the wrong page**
|
||||
`eval`, `screenshot`, and `network requests` print the page they ran
|
||||
against to stderr: `eval @ <url>`, `screenshot @ <url>`, `network @ <url>`.
|
||||
|
||||
Reference in New Issue
Block a user