AIエージェントニュース編集部

OpenClaw×Xvfb:Computer Useの座標ズレと描画遅延を防ぐ基盤

CIやバックグラウンドジョブでLLMにGUI操作(Computer Use)をさせると、手元では動いたのに実行基盤に載せた途端クリック位置がズレたり、描画前の画面を見て空振りしたりします。原因の多くはモデルではなく、実行環境の解像度やレンダリングのタイミングが固定されていないことです。この記事では、OpenClawをXvfb上で動かすヘッドレス環境を前提に、座標の正規化、画面変化の検知、隔離とフォールバックまでを、明日から試せる形で整理します。

ブラウザ操作ツールの限界とOpenClawによるOSネイティブ自動化の役割

PlaywrightやPuppeteerのようなブラウザ操作ツールは、DOMやアクセシビリティツリーを使えるため、Web画面の自動化では最も堅牢な選択肢です。まずはそちらで解けるかを確認してください。ただし対象がブラウザの外に出ると使えません。ネイティブのデスクトップアプリ、ファイル選択ダイアログ、Electronアプリの独自UI、ブラウザ拡張のポップアップなどが該当します。

こうした領域では、画面全体のスクリーンショットを見てマウスとキーボードを操作する、OSレベルのGUI自動化が必要になります。OpenClawはこの役割を担うソフトウェアとして使えます。DOMを経由せず、画面のピクセルと入力イベントだけで動くため、対象アプリの実装に依存しません。

その代わり、DOMの安定性は失われます。ボタンの位置は画面解像度、DPIスケーリング、ウィンドウ配置、フォント描画に左右されます。ヘッドレス環境ではこれらを誰も固定してくれません。したがって実行基盤側で「画面を決定論的にする」ことが、この構成の設計の中心になります。

Xvfb環境のセットアップ:ヘッドレスコンテナでOpenClawを立ち上げる

Xvfbは、物理ディスプレイなしでX11のフレームバッファをメモリ上に作る仮想ディスプレイです。コンテナ内でXvfbを起動して DISPLAY を指定すれば、GUIアプリもGUI自動化ツールも、通常のデスクトップと同じ前提で動きます。ウィンドウマネージャを入れ忘れると、ダイアログの位置やフォーカスの挙動が不定になるため、軽量なものを一緒に起動します。

FROM debian:12-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    xvfb openbox xdotool x11-utils imagemagick \
    fonts-noto-cjk fonts-dejavu-core \
    python3 python3-pip python3-pil python3-numpy \
    && rm -rf /var/lib/apt/lists/*

ENV DISPLAY=:99 \
    GDK_SCALE=1 GDK_DPI_SCALE=1 \
    QT_AUTO_SCREEN_SCALE_FACTOR=0

COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

エントリポイントでは、Xvfbの起動完了を待ってからOpenClawを起動します。sleep で固定待ちにせず、xdpyinfo で接続できるまでポーリングするほうが確実です。

#!/usr/bin/env bash
set -euo pipefail

Xvfb "$DISPLAY" -screen 0 1280x800x24 -nolisten tcp &

for i in $(seq 1 50); do
  xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break
  sleep 0.1
done

openbox &
exec "$@"

-nolisten tcp は、X サーバへのネットワーク経由の接続を閉じるための指定です。OpenClawの起動コマンドや設定項目はバージョンによって異なる可能性があるため、そこは公式ドキュメントで確認してください。重要なのは、OpenClawのプロセスが DISPLAY=:99 を見ていることです。日本語UIを扱うなら fonts-noto-cjk を入れておかないと、文字が豆腐になってモデルの認識精度が落ちます。

座標ズレを根絶する仮想ディスプレイ解像度の固定と正規化処理

座標ズレの原因は大きく二つあります。ひとつは実画面の解像度がジョブごとに違うこと、もうひとつはモデルに渡す画像が途中で縮小されることです。後者は見落とされがちです。AnthropicのComputer Useのドキュメントでも、大きな画像はAPI側でリサイズされうるため、クライアント側で縮小して座標を逆変換することが推奨されています。縮小の挙動はモデルやAPIの仕様に依存するので、自分の使うプロバイダの記述を確認してください。

対策は、Xvfbの解像度を固定し、モデルに見せる画像サイズも固定し、返ってきた座標を実画面座標へ逆変換する、という三点です。実画面を1280x800、モデルへ渡す画像を1024x640(同じアスペクト比)にするなら、変換は次の関数で済みます。

from dataclasses import dataclass

@dataclass(frozen=True)
class Geometry:
    screen_w: int = 1280
    screen_h: int = 800
    model_w: int = 1024
    model_h: int = 640

    def to_screen(self, x: float, y: float) -> tuple[int, int]:
        sx = round(x * self.screen_w / self.model_w)
        sy = round(y * self.screen_h / self.model_h)
        # 画面外クリックを防ぐクランプ
        sx = min(max(sx, 0), self.screen_w - 1)
        sy = min(max(sy, 0), self.screen_h - 1)
        return sx, sy

アスペクト比が一致していない設定は、縦横の倍率が食い違ってズレの原因になります。起動時に xdpyinfo | grep dimensions の結果と Geometry の値を突き合わせ、不一致ならジョブを即座に失敗させるアサーションを入れてください。「気づかないままズレたクリックを続ける」ことが最も高くつきます。

補足として、GTKやQtアプリが独自にスケーリングをかけるケースがあります。前節の環境変数で抑えられますが、アプリごとに挙動が違うため、最初に対象アプリで基準スクリーンショットを撮り、ボタン位置が想定どおりかを目視確認しておくと安全です。

描画遅延による空振りを防ぐ「画面変化検知(Visual Settle)」の実装

クリックの直後にスクリーンショットを撮ると、遷移途中や読み込み中の画面をモデルに見せてしまいます。モデルは古い画面を前提に次の操作を出し、空振りします。固定の sleep 2 は、遅い環境では足りず、速い環境では無駄になります。そこで、画面が落ち着いたことをピクセル差分で判定する「Visual Settle」を挟みます。

考え方は単純です。短い間隔でスクリーンショットを取り、直前フレームとの差分が閾値以下の状態が一定回数続いたら「安定」とみなします。カーソルの点滅やスピナーのように常に動く領域は、マスクして除外します。

import subprocess, time, io
import numpy as np
from PIL import Image

def grab() -> np.ndarray:
    png = subprocess.check_output(
        ["import", "-window", "root", "png:-"])
    img = Image.open(io.BytesIO(png)).convert("L")
    return np.asarray(img, dtype=np.int16)

def wait_settle(interval=0.15, stable_frames=3,
                diff_ratio=0.001, timeout=10.0,
                mask=None) -> bool:
    prev = grab()
    stable = 0
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        time.sleep(interval)
        cur = grab()
        d = np.abs(cur - prev) > 8   # 微小なノイズは無視
        if mask is not None:
            d &= ~mask
        stable = stable + 1 if d.mean() < diff_ratio else 0
        if stable >= stable_frames:
            return True
        prev = cur
    return False

この関数を、入力操作の直後とスクリーンショット送信の直前に必ず呼びます。diff_ratio や stable_frames は対象アプリで調整してください。値が小さすぎるとアニメーションの多いUIで永遠に安定しません。大きすぎると読み込み中を見逃します。

注意点が一つあります。画面が静止していても、バックグラウンドで処理が進んでいることがあります。進捗バーのないネットワーク待ちなどです。その場合はVisual Settleだけに頼らず、「特定のテキストが現れるまで待つ」といったアプリ固有の条件を併用してください。Settleは「見ても安全な状態か」の下限を保証するもので、「処理が終わったか」の判定とは別物です。

予期せぬ外部操作を遮断するネットワーク隔離とフォールバック設計

GUI操作を任されたエージェントは、画面に映った任意のテキストを読み、そのとおりに動く可能性があります。Webページ内の悪意ある指示(プロンプトインジェクション)が、別サイトへのアクセスや情報送信につながる恐れがあります。ヘッドレス実行では人が画面を見ていないため、被害に気づく機会も減ります。

最初の防御はネットワーク隔離です。Dockerなら、外部へ出られない --internal ネットワークにコンテナを置き、必要な通信だけを許可リスト付きのプロキシ経由にします。LLM APIへの通信と、対象アプリが必要とする宛先のみを許可してください。

docker network create --internal gui-isolated

docker run --rm \
  --network gui-isolated \
  --read-only --tmpfs /tmp \
  --cap-drop ALL --security-opt no-new-privileges \
  -e HTTPS_PROXY=http://allowlist-proxy:3128 \
  openclaw-xvfb:latest

資格情報はコンテナに最小権限のものだけを渡し、成果物の書き出し先も限定します。ファイルシステムを読み取り専用にしておくと、想定外の書き込みも抑えられます。

もう一つはフォールバック設計です。失敗時の動きをあらかじめ決めておきます。

  1. Visual Settleがタイムアウトしたら、一度だけ再取得して再判定する
  2. 同じ操作が3回連続で画面変化を起こさなかったら、そのステップを失敗として打ち切る
  3. 失敗時は、直前のスクリーンショットと操作ログをアーティファクトとして保存し、ジョブを異常終了させる
  4. 副作用の大きい操作(送信、削除、購入など)は、実行前に人の承認を要求するか、そもそもヘッドレスジョブから外す

リトライを無限に許すと、誤操作が積み重なります。「止まって証拠を残す」ほうが、CIの運用では復旧が容易です。保存したスクリーンショットは、座標ズレや描画待ちの不具合を再現する材料にもなります。

関連記事