feat: let the agent show and drive the guest browser - #222
Conversation
- Enable WebView CDP debugging so the in-guest agent can attach to the Guest Browser WebView through the devtools abstract socket. - Guest browser route accepts an optional url argument. - GuestBrowserCommandWatcher polls the active workspace for .and-code/browser-command.json and opens the guest browser at the requested URL, so the agent can put the browser in front of the user. - Add guest-tools/andcode-browser-mcp: an MCP server exposing browser_show/status/navigate/click/type/screenshot/info to the agent (CDP client implemented over AF_UNIX, no extra dependencies).
|
🔍 OpenCodeReview found 10 issue(s) in this PR.
|
| if (!commandFile.exists()) { | ||
| return null | ||
| } |
There was a problem hiding this comment.
[maintainability · low]
runCatching は inline 関数のため、この return null は非ローカル return となり consumeOpenCommand 全体から抜けます(getOrNull() もスキップ)。動作はしますが意図が分かりにくく、将来このブロックを inline でない関数に抽出した際にコンパイルエラーになる等、壊れやすい構造です。return null ではなく return@runCatching null を使うか、明示的な return を持つ関数本体に書き換えると読みやすくなります。
| val text = commandFile.readText() | ||
| commandFile.delete() | ||
| JSONObject(text).optString("url").takeIf { it.isNotBlank() } |
There was a problem hiding this comment.
[bug · medium]
commandFile.delete() の戻り値が無視されています。削除に失敗した場合(権限不足・ファイルロック等)は同じコマンドファイルが残るため、ポーリングのたびに同じ URL が再処理され onOpenUrl(および navController.navigate)が毎秒繰り返し呼ばれます。また、readText → delete → パースの順序のため、エージェントの書き込み途中(JSON 未完成)を読み込んだケースではパース前にファイルが削除され、コマンドが静かに失われます(エージェントには「要求を受け付けた」と返っているのにブラウザが開かない)。パースを先に行い、パース成功かつ delete() が true を返した場合のみ消費する形にすると、両方の問題を回避できます。
Suggestion:
| val text = commandFile.readText() | |
| commandFile.delete() | |
| JSONObject(text).optString("url").takeIf { it.isNotBlank() } | |
| private fun consumeOpenCommand(commandFile: File): String? { | |
| if (!commandFile.exists()) return null | |
| return runCatching { | |
| val text = commandFile.readText() | |
| val url = JSONObject(text).optString("url").takeIf { it.isNotBlank() } | |
| if (url != null && commandFile.delete()) url else null | |
| }.getOrNull() | |
| } |
| // Lets the in-guest agent drive this WebView over CDP (the devtools abstract | ||
| // socket is reachable from the guest, which shares the app's UID), so a page | ||
| // can be shown to the user and operated by human and agent at the same time. | ||
| WebView.setWebContentsDebuggingEnabled(true) |
There was a problem hiding this comment.
[security · medium]
WebView.setWebContentsDebuggingEnabled(true) はプロセス全体に効くグローバル設定です。有効にすると、webview_devtools_remote_<pid> 抽象ソケットへ接続できるすべてのプロセスが、アプリ内の全 WebView の内容を CDP 経由で読み取り・書き換え・JS インジェクション・フォーム入力/認証情報の傍受ができるようになり、Android 公式ガイダンスでも本番ビルドでの有効化は推奨されていません。ゲストエージェントからの CDP 接続が本機能の意図だとしても、BuildConfig のビルドタイプや設定フラグで有効/無効を切り替えられるようにし、リリース配布時に意図せずデバッグ機能が有効にならないよう制御することを推奨します。
| // Lets the in-guest agent drive this WebView over CDP (the devtools abstract | ||
| // socket is reachable from the guest, which shares the app's UID), so a page | ||
| // can be shown to the user and operated by human and agent at the same time. | ||
| WebView.setWebContentsDebuggingEnabled(true) |
There was a problem hiding this comment.
[maintainability · low]
この設定はプロセスグローバルな静的設定であり、WebView インスタンスごとに呼ぶ必要はありません。AndroidView の factory は再コンポジションや画面再生成のたびに再実行され得るため、この場所に置くと冗長で、グローバル設定が UI の詳細実装に埋もれてしまいます。Application.onCreate() などで一度だけ呼び出す方が、設定の意図と適用タイミングが明確になります。
| GuestBrowserCommandWatcher( | ||
| workspacePath = chatState.selectedWorkspacePath, | ||
| onOpenUrl = { url -> | ||
| navController.navigate(guestBrowserRoute(url)) { launchSingleTop = true } | ||
| }, | ||
| ) |
There was a problem hiding this comment.
[performance · low]
このウォッチャーはアプリのルート AndCodeApp に常時マウントされ、BrowserCommandWatcher 内の while(true) ループがワークスペース選択中は 1 秒間隔のファイルポーリングをアプリのライフサイクル全体にわたって実行し続けます。ゲストブラウザを実際に使っていない時やアプリがバックグラウンドにある間も継続するため、定常的なディスクI/O(バッテリー・CPU消費)になります。
機能上はエージェントがバックグラウンドで動作している前提なので常時監視自体は意図的と思われますが、ローカルランタイムが起動中の時だけ監視する、コマンドファイルを置くディレクトリの変更を FileObserver 等でイベント駆動に切り替える、といった形でポーリングを限定することを検討してください。
|
|
||
| composable(ROUTE_GUEST_BROWSER) { | ||
| composable(GUEST_BROWSER_ROUTE_PATTERN) { backStack -> | ||
| val requestedUrl = backStack.arguments?.getString(GUEST_BROWSER_ARG_URL)?.let { decodeRouteArg(it) } |
There was a problem hiding this comment.
[bug · medium]
decodeRouteArg は内部で Base64.getUrlDecoder().decode() を実行するため、base64url として不正な文字列が渡されると IllegalArgumentException を投げてコンポーズ時にクラッシュします。同じファイルの CODE_VIEWER ルート(runCatching で包んでいる)や他サイトと異なり、この新規コードは防御なしで呼び出しています。url 引数が不正な場合(例: ディープリンクや非エンコード経路からの遷移)にクラッシュし得るため、runCatching { ... }.getOrNull() で包んで不正引数を吸収することを推奨します。
Suggestion:
| val requestedUrl = backStack.arguments?.getString(GUEST_BROWSER_ARG_URL)?.let { decodeRouteArg(it) } | |
| val requestedUrl = | |
| backStack.arguments?.getString(GUEST_BROWSER_ARG_URL)?.let { arg -> | |
| runCatching { decodeRouteArg(arg) }.getOrNull() | |
| } |
| mcp = FastMCP("and-code-browser") | ||
|
|
||
| COMMAND_FILE = Path(".and-code") / "browser-command.json" | ||
| CDP_BRIDGE_PORT_NOTE = "abstract socket only; no TCP bridge needed" |
There was a problem hiding this comment.
[maintainability · low]
定数 CDP_BRIDGE_PORT_NOTE は定義されているだけでコード内のどこからも参照されておらず、未使用のデッドコードです。削除するか、実コメントとして活用してください。
| hexname = parts[-1] | ||
| try: | ||
| name = bytes.fromhex(hexname).decode("ascii") | ||
| except (ValueError, UnicodeDecodeError): | ||
| continue | ||
| if name.startswith("webview_devtools_remote_"): | ||
| return name | ||
| return None |
There was a problem hiding this comment.
[bug · high]
/proc/net/unix の Path 列は abstract socket 名を @webview_devtools_remote_<pid> のようなリテラル文字列で表示します(16進数エンコードではありません)。そのため bytes.fromhex(hexname) は必ず ValueError を投げ、except で握りつぶされて continue するため、この関数は常に None を返します。結果として browser_navigate / browser_click / browser_type / browser_screenshot / browser_info はすべて「socket not found」で失敗し、browser_status も常に未接続を返すことになります。さらに、先頭の @ を除去せずに connect("\0" + name) へ渡すと誤った abstract アドレスへ接続するため、@ を除いた名前を返す必要があります。
Suggestion:
| hexname = parts[-1] | |
| try: | |
| name = bytes.fromhex(hexname).decode("ascii") | |
| except (ValueError, UnicodeDecodeError): | |
| continue | |
| if name.startswith("webview_devtools_remote_"): | |
| return name | |
| return None | |
| path = parts[-1] | |
| # /proc/net/unix では abstract socket 名は "@webview_devtools_remote_<pid>" のように | |
| # リテラル文字列で表示される(16進数ではない)。先頭の "@" を取り除いて返す。 | |
| if path.startswith("@") and path[1:].startswith("webview_devtools_remote_"): | |
| return path[1:] | |
| return None |
| resp = b"" | ||
| while b"\r\n\r\n" not in resp: | ||
| chunk = sock.recv(4096) | ||
| if not chunk: | ||
| raise CdpError("socket closed during ws handshake") | ||
| resp += chunk |
There was a problem hiding this comment.
[bug · medium]
ハンドシェイクで \r\n\r\n が見つかった時点で読み込みを終了し、resp に受信済みの余剰バイト(同じ TCP セグメントに同梱された最初の WebSocket フレームなど)がすべて破棄されます。フレームの途中で切れていた場合は以降のフレーム境界がずれ、recv_text() が不正なデータを返して CDP セッションが壊れる可能性があります。受信バッファを保持して次の読み込みで使い回すバッファリングに変更してください。
| """開いているゲストブラウザで URL へ遷移します。""" | ||
| page = _session().open_page_session() | ||
| try: | ||
| page.call("Page.navigate", url=url) |
There was a problem hiding this comment.
[security · medium]
browser_navigate は引数 URL を無検証で Page.navigate に渡し、browser_show も同様にコマンドファイルへそのまま書き込みます。WebView は javaScriptEnabled = true で、アプリ側の normalizeUrl は http/https に正規化しますが、このツールはそれを迂回します。エージェントがページ内コンテンツ(プロンプトインジェクション等)に誘導された場合、file:// や javascript: URL によってローカルリソースの読み込みや WebView 内でのコード実行につながる可能性があります。http/https のみ許可するスキーム検証を追加してください。
The runtime installer now seeds /usr/local/bin/andcode-browser-mcp.py (stdlib-only MCP server, no third-party deps) into both the Alpine and Antigravity rootfses and registers it in each agent's config: - OpenCode: ~/.config/opencode/opencode.json (mcp.and-code-browser) - Claude: ~/.claude.json (mcpServers.and-code-browser) - Antigravity: ~/.gemini/config/mcp_config.json Provisioning is idempotent, preserves user-added servers, and also runs on startup for runtimes installed before this change, so OpenCode, Claude Code and Antigravity all expose the same browser_* tools. Supersedes the guest-tools script from #222.
Makes the in-app Guest Browser a shared surface for human and agent:
WebView.setWebContentsDebuggingEnabled(true)so the in-guest agent can attach over CDP (devtools abstract socket; reachable from the guest, which shares the app UID).urlargument.GuestBrowserCommandWatcherpolls the active workspace for.and-code/browser-command.jsonand opens the guest browser at the requested URL.guest-tools/andcode-browser-mcp: MCP server withbrowser_show/status/navigate/click/type/screenshot/info; CDP client implemented over AF_UNIX sockets (no extra deps).Flow: agent calls
browser_show(url)→ app opens the screen for the user → agent drives the same WebView via CDP while the user watches/operates.