やってみた 2026年11月5日

agent-browser を試したら、Node 22 では半年前の版が入り、最新版でも古い ref が別ページのボタンを押した

XECIN RustAIエージェントブラウザ自動化テスト

GitHubのトレンドを見ていたら、10位に vercel-labs/agent-browser が入っていました。説明は「Browser automation CLI for AI agents」。Rust 製で、スターは45,616、ライセンスは Apache-2.0 です。AI エージェントがシェルから1コマンドずつブラウザを動かすための CLI で、ページをアクセシビリティツリーの文字列で読み、@e1 のような ref を指定して操作します。

目に留まったのは README のこの一文でした。

A click fails, instead of reporting success, when the target is hidden, disabled, outside the viewport, or covered by another element such as a consent banner.

私はバックエンドの仕事が長いので、「成功を返したのに、実は何も起きていない」という挙動に後から苦しめられた経験が何度かあります。人が画面を見ながら操作しているなら気づけますが、エージェントは返ってきた文字列を信じて次の手に進みます。失敗すべきときに本当に失敗するのか、✓ Done はどこまで信じてよいのか。ここを自分の手で確かめたくなりました。

先に書いておくと、README の約束そのものは最新版で守られていました。ただ、そこにたどり着くまでの道と、約束の外側に、知っておきたいことがいくつかありました。

試した環境

  • 使い捨ての Docker コンテナ(上限は CPU 2・メモリ 4GB・PID 512)。ホストには何も入れていません
  • node:24-bookworm(Node 24.21.0 / npm 11.19.0)に agent-browser 0.41.2(2026-11-03 公開の最新版)
  • node:22-bookworm(Node 22.23.1 / npm 10.9.8)に agent-browser 0.27.0(2026-05-07 公開。こうなった理由は次の節で)
  • ブラウザ: Chrome for Testing 157.0.8103.38
  • 操作する相手: コンテナの中に自作したページ。site.test と other.test を /etc/hosts で 127.0.0.1 に向け、要求を1行ずつログに残す Node の HTTP サーバーで配りました。外のサイトは README の手順どおりに開いた example.com だけです
  • ソースの確認: GitHub の main(1f66e398d8)

検証のコンテナを立ててから全部捨てるまでは23分8秒でした。

最初のアプローチ: 手元の Node 22 に入れたら 0.27.0 が入った

最初は深く考えず、手元にあった node:22-bookworm で README のとおりに入れました。

# node:22-bookworm(Node v22.23.1 / npm 10.9.8)
$ npm install -g agent-browser

added 1 package in 1s
$ agent-browser --version
agent-browser 0.27.0

$ npm view agent-browser@latest version engines --json
{
  "version": "0.41.2",
  "engines": {
    "node": ">=24.0.0",
    "pnpm": ">=11.0.0"
  }
}
$ npm view agent-browser@0.27.1 engines.node
>=24.0.0

$ agent-browser install --with-deps          # 終了コードは 0
Installing system dependencies...
Running: sudo apt-get update
⚠ Could not run apt-get update: No such file or directory (os error 2)
(略)
✓ Chrome 157.0.8103.38 installed successfully

$ agent-browser open http://site.test:8080/click.html      # 終了コードは 1
✗ Auto-launch failed: Chrome exited early (exit code: 127) without writing DevToolsActivePort
(略)
  /root/.agent-browser/browsers/chrome-157.0.8103.38/chrome: error while loading shared libraries: libnspr4.so: cannot open shared object file: No such file or directory

詰まりどころが二段重なっていました。

一段目は版です。最新の 0.41.2 は engines で Node 24 以上を求めていて、0.27.1 以降はどれも同じ指定でした。npm は latest がこの条件に合わないと、合う版の中で一番新しいものを選ぶようで、指定の無い 0.27.0 が入りました。出力は added 1 package in 1s だけで、警告は1行もありません。README に書かれた Node の要件は「Building from source requires Node.js 24 or later」なので、配布物を入れるだけの人は気づきにくいと思います。

二段目は依存です。0.27.0 の --with-deps は sudo apt-get を呼びますが、公式の Node イメージに sudo はありません。警告を出して先へ進み、終了コードは 0 のまま、システムのライブラリは1つも入りませんでした。apt-get install sudo してから入れ直すと70パッケージ入り、Chrome も起動します。なお 0.41.2 は同じ root の状態で71パッケージを入れ、Chrome のダウンロードまで込みで29.3秒で終わりました。

もう一つ、0.27.0 のまま agent-browser doctor を実行すると、結果は Summary: 8 pass, 0 warn, 0 fail でした。半年前の版であることは診断の対象ではないようです。

出口は簡単でした。agent-browser upgrade を実行すると、npm install -g agent-browser@latest が走って ✓ Done! v0.27.0 → v0.41.2 と出ます。tag を明示すると、npm は EBADENGINE の警告を出しつつ入れてくれます。実体は Rust のバイナリなので Node 22 のままでも動き、後述の24ケースは Node 24 と同じ結果になりました。

Node 24 のコンテナでは README のクイックスタートがそのまま通りました。open example.com が1.858秒、snapshot -i の出力は README と同じ - link "Learn more" [ref=e1] で8ミリ秒、click @e1 が24ミリ秒です。

せっかく両方の版が手元にあるので、ここからは比べながら進めます。

クリックの約束を24ケースで確かめる

ボタンを1つだけ置いたページを条件ごとに作り、click '#t' の終了コードと、ボタンの click ハンドラが本当に動いたかを1ケース1ページで記録しました。

ボタンの状態0.27.00.41.2
display:none / visibility:hidden / 幅と高さが0(3件)Done・発火なし失敗(not visible)
disabled / fieldset の disabled(2件)Done・発火なし失敗(disabled)
left:-9999px で画面の外(1件)Done・発火なし失敗(outside the viewport)
同意バナー / 透明な全面 div / 中央だけ覆う箱 / 1秒後に出るバナー(4件)Done・発火なし失敗(covered by <div#banner> など)
1.5秒後に有効になる(1件)Done・発火なし失敗(disabled。3ミリ秒で即答)
スクロールしないと見えない(1件)Done・発火なしDone・発火
普通 / opacity:0 / aria-disabled / 子の span / pointer-events:none の膜越し(5件)Done・発火Done・発火
inert の中(1件)Done・発火なしDone・発火なし
本人に pointer-events:none(1件)Done・発火なしDone・発火なし
overflow:hidden の親に隠れている(1件)Done・発火なしDone・発火なし
閉じた details の中(1件)Done・発火なしDone・発火なし
hover で逃げる / mousedown で消える(2件)Done・発火なしDone・発火なし
click ハンドラを付けていない対照(1件)DoneDone

0.27.0 は24ケースすべて終了コード 0 で、✓ Done なのに発火しなかったケースが18件ありました。同意バナーの裏のボタンも、disabled のボタンも ✓ Done です。リポジトリの履歴を見ると、覆われた要素の検出が入ったのは6月10日(#1434)、hidden と disabled と画面外で失敗するようにしたのは10月20日(#2173)でした。README の一文は、できてまだ2週間ほどの約束です。

0.41.2 では、README が挙げた4つの条件はどれも終了コード 1 で止まり、バナーの場合は覆っている要素の id まで教えてくれました。約束は守られています。✓ Done なのに発火しないものは18件から6件に減りました。

ただ、この6件のうち4件は同じ根を持っていそうでした。エッジケースの話は好きなので、つい掘ってしまいます。

# 0.41.2。click の直前に、ボタンの中心の elementFromPoint を聞いた結果
inert           {"rect":[16,89,56,36],"hit":"div#stage","hitIsAncestor":true,"hitIsSelf":false}
pointer_none    {"rect":[16,89,56,36],"hit":"div.row","hitIsAncestor":true,"hitIsSelf":false}
clipped         {"rect":[216,89,56,36],"hit":"div#stage","hitIsAncestor":true,"hitIsSelf":false}
details_closed  {"rect":[16,119,56,36],"hit":"html","hitIsAncestor":true,"hitIsSelf":false}

# cli/src/native/element.rs の重なり判定(hit がボタンの先祖なら「届く」扱い)
for (let n = hit; n; n = up(n)) { if (n === el) return null; }
for (let n = el; n; n = up(n)) { if (n === hit) return null; }

重なりの判定は、クリック位置の要素がボタン自身か、その先祖か子孫なら「届く」とみなします。子の span に当たる場合を救うには子孫の方向が必要ですが、先祖の方向も同じ扱いです。上の4件は、ブラウザの当たり判定がボタンを素通りして親を返すので、ここに入ってしまいます。入力は親に届き、ボタンの click は動かず、✓ Done が返ります。コメントにも「ancestors/descendants in either direction」と明記されているので、隠れた欠陥というより割り切りだと読みました。残りの2件(hover で逃げる、mousedown で消える)は、判定のあとでページ側が動くケースです。

事前の確認でも見分けは付きませんでした。4件とも is visible は true、is enabled も true です。このうち inert と閉じた details は snapshot -i に出てこないので、ref で操作するエージェントなら踏みにくいはずです。一方で本人に pointer-events:none のボタンは button "実行" [ref=e9] と普通に出て、ref で押しても ✓ Done で発火しませんでした。

細かいところでは、overflow:hidden の親に隠れたボタンは、セレクターで押すと発火せず、ref で押すと発火しました。ref の経路は押す前に DOM.scrollIntoViewIfNeeded を呼ぶのに対し、セレクターの経路は要素が画面の外にあるときだけスクロールするためです。同じボタンでも指定のしかたで結果が変わります。

1.5秒後に有効になるボタンが3ミリ秒で失敗したのも、人によっては意外かもしれません。私は他の自動操作ツールの感覚で少し待ってくれるものと思っていました。ここは好みが分かれるところですが、待つかどうかをエージェント側で決めさせる設計なのだと思います。

古い ref は何を押すのか

README は「After navigation or a DOM change, take a new snapshot before using refs again.」と書いています。では、取り直さなかったらどうなるのか。正しい使い方ではありませんが、エージェントはときどきこれをやるはずです。

受注一覧の表を作り、各行に同じ名前の「削除」ボタンを置きました。どの行が押されたかは data-id で記録します。snapshot -i で1行目のボタンの ref を取り、ページ側の再描画を eval で起こしてから、その古い ref を押しました。

スナップショットのあとに起きたこと0.27.00.41.2
何も変わらない(対照)A を押すA を押す
同じノードのまま行を並べ替えるA を押すA を押す
「Aを削除」のように名前が一意のまま描き直すA を押すA を押す
同名3個を同じ内容で描き直すA を押す失敗(the snapshot had several matching elements)
先頭に新しい行を足して描き直すNEW を押す失敗(同上)
1個だった「削除」が2個に増えるX を押す失敗(several elements now match)
行が消える失敗失敗
1個だった「削除」が別の注文 Z の「削除」に入れ替わるZ を押すZ を押す
もう一度 snapshot を取ってから古い番号を押すDone・どのボタンも発火なし失敗(Unknown ref)

0.27.0 の「NEW を押す」は、正直なところ背筋が冷えました。行が1つ増えただけで、古い ref が別の行の削除を押します。0.41.2 はここを止めます(10月20日の #2145)。下から2行目の Z だけは 0.41.2 でも押しますが、これはドキュメントに書かれた動きで、スナップショットの中で一意だった ref は「同じ役割と名前の1個」へ移ると説明されています。

ここまでは納得できました。本題はこの次です。ページをまたいだ場合を、リンクを押す普通の手順で試しました。

# 0.41.2。実際は --session を付けて実行。ページはどちらも自作のダミー
$ agent-browser open http://site.test:8080/nav_a.html
$ agent-browser snapshot -i
- heading "注文 A の詳細" [level=1, ref=e1]
- link "次の注文へ" [ref=e2]
- button "この注文を削除" [ref=e3]
- textbox "メモ" [ref=e4]
- button "送信して次へ" [ref=e5]
$ agent-browser click @e2
✓ Done
$ agent-browser get title
注文 B の詳細
$ agent-browser click @e3             # 古い ref。エラーを期待していた
✓ Done
rc=0
$ agent-browser eval 'JSON.stringify({hits:window.hits,downs:window.downs,...})'
"{\"hits\":[\"B-CONFIRM-PAYMENT\"],\"downs\":[[79,150,\"BUTTON\",\"pay\"]],\"pay_center\":[100.7421875,150.875]}"

# 同じ命令を CDP で直接送って、移動前のノード番号がどう答えるかを見る
before nav: backendNodeId 12 center [ 79, 150.875 ]
after +1000ms: path=/nav_b.html getBoxModel= center 79,150.875 resolveNode= ERROR Node with given id does not belong to the document
after +15000ms: path=/nav_b.html getBoxModel= center 79,150.875 resolveNode= ERROR Node with given id does not belong to the document

「この注文を削除」の ref を押したのに、押されたのは次のページの「注文 B の支払いを確定」でした。終了コードは 0、--json でも "success":true です。

左は注文 A のページを screenshot --annotate で撮ったもので、3番の枠が「この注文を削除」。右はリンクで注文 B へ移動したあとに古い ref の @e3 を押した直後の画面で、押された位置に足した赤い丸が「注文 B の支払いを確定」のボタンの上にある

左が移動前で、3番の枠が @e3 です。右が移動後で、赤い丸が実際に押された位置です。上の帯と赤い丸は説明のために後から足しましたが、画面そのものは本物です。名前も役割も違うボタンなので、ドキュメントにある「同じ役割と名前へ移る」動きではありません。移動前のボタンの中心が (79, 150.875) で、押された座標が (79, 150)。古い座標をそのまま押しています。

CDP に直接聞いた結果と、ソースを合わせると筋が通りました。移動のあとでも DOM.getBoxModel は古いノードの座標を返し、15秒待っても変わりません。一方で DOM.resolveNode はエラーになります。agent-browser の重なり判定はこの resolveNode の失敗を let Ok(resolved) = resolved else { return Ok(()); }; で素通りさせるので、判定が走らないまま古い座標へクリックが送られます。ドキュメントには「navigating its page or iframe invalidates its refs」とありますが、ページ自身が動く移動では、少なくとも click には効いていませんでした。

どこまで広がるのかも確かめました。

  • フォームの送信で移動した場合も同じで、B-CONFIRM-PAYMENT が押されました
  • 移動先の同じ位置に全面ボタンを置くと、名前の違うそのボタンが押されました。同じ位置に何も無ければ、✓ Done のまま何も起きません
  • Cache-Control: no-store で配っても同じでした。bfcache のせいではなさそうです
  • 古い ref の fill と get text は Could not locate element with role=... で正しく失敗しました。click の経路だけの話です
  • 移動のあとに snapshot -i を1回挟むか、agent-browser open で移動すると、Unknown ref: e3 で止まります

つまり、README のとおりに移動のたび取り直していれば起きません。同梱のスキルにも「Take a fresh snapshot after navigation」とあります。ただ、守らなかったときの結果がエラーではなく別のボタンの ✓ Done なので、私はここを一番気にしています。

許可ドメインは固かった

ここからは良かった話です。ドキュメントの Security の節に --allowed-domains があり、ナビゲーションだけでなくサブリソースや WebSocket も止めると書かれています。許可していないホスト(主に other.test)へ、28通りの手段で1回ずつ届こうとするページを作り、サーバー側のログで数えました。

フラグなしで届いた手段は25通り(画像、スクリプト、fetch、XHR、sendBeacon、WebSocket、EventSource、Worker の中の fetch と WebSocket、prefetch、ping 属性、フォーム送信、meta refresh、302 の転送、window.open など)。--allowed-domains site.test を付けると、この25通りがすべて0件になりました。iframe の中から素の WebSocket を取り出す手も WebSocket blocked: other.test で止まりました。IP アドレス直書きも、evil.site.test のようなサブドメインも止まります。

CLI 側も同じです。open、tab new、read、a11y、record start で許可外を指定すると、終了コード 1 で Domain 'other.test' is not in the allowed domains list が返り、サーバーには1件も届きません。走っているセッションにあとからフラグを付けても効き、以後フラグを外しても制限は残りました。vitals は終了コード 0 で数字を返し、diff url も終了コード 0 でしたが、どちらもサーバーには届いていません。

一つだけ引っかかったのが iframe です。

開いたページフラグなし—allowed-domains site.test
iframe なし1,528 ms1,491 ms
同一オリジンの iframe1,409 ms1,502 ms
srcdoc の iframe761 ms1,471 ms
別オリジンの iframe1,470 ms失敗(30,634 ms)
別オリジンの iframe(other.test も許可)未実施失敗(30,491 ms)

別オリジンの iframe があるページは、許可の有無に関係なく open が30秒ほど止まり、Failed to read: Resource temporarily unavailable (os error 11) で失敗しました。次のコマンドではブラウザが立ち上げ直され、about:blank に戻っています。原因までは追えていません。動画や決済の埋め込みがあるページをこのフラグ付きで開く予定があるなら、先に確かめた方がよさそうです。

読む経路によって、見える文字が違う

エージェントがページを読む経路は複数あります。人の画面に出ない文字がどこに混ざるかを、目印の文字列を仕込んだページで数えました(0.41.2)。

目印を置いた場所snapshotsnapshot -iget text bodyread
画面に見えている本文出る-出る出る
display:none / visibility:hidden---出る
aria-hidden=“true”--出る出る
opacity:0 / 画面外 / font-size:0 / 白地に白 / sr-only出る-出る出る
CSS の ::before / img の alt出る---
ボタンの aria-label / 入力欄の placeholder出る出る--
template の中---出る

read は描画を見ずに HTML から変換しているような出方で、display:none の文字も拾います。snapshot はアクセシビリティツリーなので display:none は消えますが、白地に白の文字や font-size:0 は残ります。どの経路でも、人の見ている画面と同じにはなりません。画面では「閉じる」と書かれたボタンも、snapshot -i では aria-label の文字で出ます。

--content-boundaries を付けると、ページ由来の出力がランダムな nonce 付きのマーカーで包まれます。ページに偽の終了マーカーを書いても nonce が違うので見分けられ、nonce は実行のたびに変わっていました。ただ、包まれるのは snapshot、get text、get html、eval、console、read で、次のものはそのまま出ました。

  • open の1行目に出るページのタイトル
  • get title、get value、get attr
  • cookies、storage、network requests

どれもページが自由に決められる文字です。ドキュメントの冒頭は「all page-sourced output is wrapped」ですが、続けて載っている対象の一覧の方が実態に近いです。タイトルに指示文を書いたページを開いた瞬間、マーカーの外にその文字が出ることになります。

押したあとの確かめ方

✓ Done だけでは足りないなら、押す前と後を比べればよさそうです。ドキュメントにも diff snapshot は「agents verifying that an action had the intended effect」のためのものとあります。保存回数を表示するページで試しました。

# 何も操作していないのに、前回のスナップショットとの差分が全行追加になる
$ agent-browser snapshot > /dev/null
$ agent-browser diff snapshot
--- before
+++ after
@@ -0,0 +1,4 @@
(略)
4 additions, 0 removals, 0 unchanged

# 基準を自分で保存して渡すと動く。押しても何も起きないボタン(pointer-events:none)
$ agent-browser snapshot > before.txt
$ agent-browser click '#t'
✓ Done
$ agent-browser diff snapshot --baseline before.txt
✓ No changes detected

# 普通のボタン
$ agent-browser snapshot > before.txt
$ agent-browser click '#t'
✓ Done
$ agent-browser diff snapshot --baseline before.txt
 - heading "押したあとの確かめ方" [level=1]
 - paragraph
-  - StaticText "保存した回数: 0"
+  - StaticText "保存した回数: 1"
 - button "保存"
(略)
1 additions, 1 removals, 3 unchanged

ドキュメントには、--baseline が無ければセッションの直前のスナップショットと比べるとあります。ただ手元では毎回「全行追加」で、--json の changed も常に true でした。ソースでも baseline が無いときの基準は None => String::new() と空文字になっています。--baseline を渡せば、✓ Done なのに何も起きていないケースをきちんと見分けられました。

毎回 before.txt を書き出すこの段取りは不格好で、ここは改善の余地がありそうです。私なら薄いラッパーに包んで、副作用のある操作のときだけ差分を見ると思います。このあたりは使い方次第でしょう。

脱線ついでに、errors はページの例外があると ✗ とだけ表示し、本文が出ませんでした(終了コードは 0)。--json なら "text":"Error: PAGE-ERROR-TEXT..." と入っているので、読むなら JSON の方です。

運用を考えて測ったこと

速さは申し分ありません。3回測って、最初の open が524〜1,503ミリ秒、以後の open が12〜16ミリ秒、snapshot -i が7〜8ミリ秒、get title が1〜2ミリ秒、screenshot が50〜62ミリ秒、close が155〜156ミリ秒でした。常駐の daemon は PSS で12〜14MiB、Chrome は15プロセスの合計で約490〜500MiB。つまりメモリの大半はブラウザの分で、2セッションだと Chrome が約760MiBになりました。セッションどうしの localStorage と Cookie は分かれていて、close --all のあとにプロセスは残りません。

ここで一度、自分のコンテナの作り方でつまずきました。検証の途中で docker exec が OCI runtime exec failed: ... read init-p: connection reset by peer で落ち始め、原因は PID の上限でした。PID 1 を sleep infinity にしていたので、終わった Chrome の子プロセスを誰も回収せず、この時点でゾンビが376個溜まっていました。1回の open と close で約11個増えます(69→80→91→101→112→123)。docker run --init で作り直すと0個のままなので、対象の不具合ではありません。コンテナの中で長く回すなら --init は付けておくべきです。

もう一つ、agent-browser dashboard start --port 4848 で管理画面が立ち上がります。

agent-browser dashboard の画面。左にセッション dash1 と dash2、中央に dash1 が開いている注文 A のページのライブ表示、右に Chat や Activity、Console、Network などのタブが並ぶ

これも agent-browser 自身で撮りました。左にセッションの一覧、中央に操作中のページのライブ表示が出ます。エージェントが何をしているかを人が横から覗くのに良さそうです。右のチャット欄は試していません。

試してみての所感

決まった手順を繰り返すだけなら、私はいままでどおりテストコードを書く方を選ぶと思います。agent-browser が活きるのは、手順が決まっていないページをエージェントに探索させる場面でしょう。1コマンドが数ミリ秒で、出力も短いので、その用途にはよく合っています。

自分で使うなら、次の4つは最初に決めておきます。

  • 入れた直後に --version を見る。Node 24 未満の環境なら agent-browser@latest と明示する
  • ref を押す前に必ず snapshot を取り直す。規約に頼らず、ラッパー側で強制したい(このラッパーはまだ書いていません)
  • 副作用のある操作のあとは --baseline 付きの差分で確かめる
  • --allowed-domains は常用する。ただ別オリジンの iframe があるページは様子見

今思えば、最初に Node 22 のまま 0.27.0 で検証を始めていたら、「クリックは全部成功と言うツール」という的外れな結論を書くところでした。--version を一度見たことに救われた形です。0.27.0 から 0.41.2 まで半年で進み、README の約束も2週間前にできたばかりなので、古い ref の click と iframe の件は、次に見るときには変わっているかもしれません。--confirm-actions で click を人の承認に回す手も今回は試していないので、そこはまた確かめたいと思います。