第 1 课

装好 agent-desktop,在无显示器的 Linux 上拉起无头桌面并读懂引用

用 npm 安装 agent-desktop 1.2.0;看懂纯终端里 status 为什么是 denied,按官方 Docker 指南的 agent-desktop-run 脚本拉起 Xvfb、openbox 和会话总线;打开 GNOME 计算器,确认它登上无障碍总线;用骨架快照和整窗快照认识引用,并亲眼看到两块一模一样的区域为什么钻取会被拒。

图文20 分钟agent-desktop 官方文档 ↗

课程目录第 1 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

图文教材

本课对照的官方页面:Installation 、Linux 、Run agent-desktop in a Docker container 、Quick start 。测试环境:2026-11-13,Asia/Shanghai,Debian 13 x86_64 云主机(无显卡、无显示器),Node.js 20.19.2,npm 11.21.1,agent-desktop 1.2.0(2026-11-05 发布)。下面所有「对照结果」都是这台机器的真实输出,只做了节选。

agent-desktop 是 Vercel Labs 开源的桌面「电脑操作」命令行工具。它不截图、不认像素,而是读取操作系统给读屏软件用的无障碍树(Linux 上是 AT-SPI),把每个能点、能填的控件编成一个引用(ref),以 JSON 返回。AI 智能体拿着引用去点击或填值,再读一次界面核对结果。它本身不带大模型,循环由你的智能体或脚本来跑,所以本系列课程全程不需要任何 API Key。

这一课先在一台没有显示器的 Linux 机器上把它装好,按官方 Docker 指南里的启动脚本拉起一个无头桌面,打开 GNOME 计算器,看懂快照里的引用长什么样。如果你本来就坐在 Ubuntu 桌面前,可以跳过第 2 步,直接把后面命令里的 agent-desktop-run 换成 agent-desktop。

第 1 步:安装 agent-desktop

官方要求 Node.js 18 或更高。Linux 版从 1.0.0(2026-10-28)开始提供,需要 glibc 2.35 以上,官方实测的是 Ubuntu 24.04 及以后版本。先装好命令行、无障碍总线和两个练习用的桌面程序(Debian/Ubuntu):

sudo apt-get install -y --no-install-recommends \
  xvfb openbox dbus x11-utils at-spi2-core jq gnome-calculator mousepad
npm install -g agent-desktop --allow-scripts=agent-desktop
agent-desktop version

对照结果:

changed 1 package in 1s
{"version":"2.6","ok":true,"command":"version","data":{"os":"linux",
"target":"x86_64","version":"1.2.0"}}

--allow-scripts 让 npm 执行包里的 postinstall,把 Node 启动器换成原生二进制的链接。被拦下也能用,只是每条命令都要先启动一次 Node,官方说会多出 100 毫秒以上。每条命令都只输出一个 JSON「信封」:ok 表示成没成,成功时结果在 data 里,失败时在 error 里。

第 2 步:用官方启动脚本拉起一个无头桌面

在 SSH 或云主机的终端里直接查状态,会被拒绝:

agent-desktop status | jq -c '.data.permissions.accessibility'

对照结果:

{"state":"denied","suggestion":"No D-Bus session bus is reachable. Run inside a desktop
session, or start one with 'dbus-run-session -- <command>'."}

原因是它要通过 D-Bus 会话总线和 AT-SPI 无障碍总线找到桌面上的程序,而纯终端里既没有 X 显示,也没有会话总线。官方 Docker 指南给了一个包装脚本 agent-desktop-run:第一次调用时启动 1280×800 的 Xvfb 虚拟显示(:99)、openbox 窗口管理器和会话总线,把地址记进 ~/.agent-desktop-session/env,以后每条命令都在这个会话里执行。它在容器外的普通 Linux 主机上一样能用。把下面的内容保存为 ~/.local/bin/agent-desktop-run(与官方原文相同,只把一行过长的 echo 拆成了两行):

#!/bin/sh
set -eu

state_dir="${AGENT_DESKTOP_SESSION_DIR:-$HOME/.agent-desktop-session}"
env_file="$state_dir/env"
lock_dir="$state_dir/starting"

mkdir -p "$state_dir"
chmod 700 "$state_dir"

display_live() {
  [ -s "$env_file" ] || return 1
  . "$env_file"
  xdpyinfo >/dev/null 2>&1
}

bus_live() {
  [ -s "$env_file" ] || return 1
  . "$env_file"
  dbus-send --session --dest=org.freedesktop.DBus --type=method_call \
    --print-reply / org.freedesktop.DBus.GetId >/dev/null 2>&1
}

desktop_ready() {
  display_live && bus_live
}

start_desktop() {
  display_live || {
    rm -f /tmp/.X99-lock
    export DISPLAY=:99
    Xvfb :99 -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 &
    i=0
    until xdpyinfo >/dev/null 2>&1; do
      i=$((i + 1))
      [ "$i" -le 50 ] || { echo "agent-desktop-run: Xvfb did not start" >&2; exit 1; }
      sleep 0.2
    done
    openbox >/dev/null 2>&1 &
  }
  bus_live || {
    DBUS_SESSION_BUS_ADDRESS=$(dbus-daemon --session --fork --print-address)
    export DBUS_SESSION_BUS_ADDRESS
  }
  printf "export DISPLAY=%s\nexport DBUS_SESSION_BUS_ADDRESS='%s'\n" \
    "$DISPLAY" "$DBUS_SESSION_BUS_ADDRESS" > "$env_file"
}

if ! desktop_ready; then
  if mkdir "$lock_dir" 2>/dev/null; then
    trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT INT TERM
    start_desktop
    rmdir "$lock_dir"
    trap - EXIT INT TERM
  else
    i=0
    until desktop_ready; do
      i=$((i + 1))
      [ "$i" -le 150 ] || {
        echo "agent-desktop-run: the desktop session did not start;" \
          "remove $lock_dir and run again" >&2
        exit 1
      }
      sleep 0.2
    done
  fi
fi

. "$env_file"
exec agent-desktop "$@"

加上执行权限,再查一次状态:

chmod +x ~/.local/bin/agent-desktop-run
agent-desktop-run status | jq -c '{a: .data.permissions.accessibility.state,
  s: .data.supported_surfaces}'
cat ~/.agent-desktop-session/env

对照结果:

{"a":"granted","s":["window","sheet","alert","menu"]}
export DISPLAY=:99
export DBUS_SESSION_BUS_ADDRESS='unix:path=/tmp/dbus-lVka6HkFhq,guid=0fc9...'

granted 加上非空的 supported_surfaces,说明无障碍总线能应答了。Linux 没有 macOS 那样的权限弹窗,总线通不通就决定了一切。

第 3 步:打开计算器,看它有没有登上无障碍总线

agent-desktop-run launch gnome-calculator \
  | jq -c '{ok, app: .data.app, title: .data.window.title,
           focused: .data.window.is_focused}'
agent-desktop-run list-apps | jq -c '.data.apps[] | {name, bundle_id, pid}'

对照结果:

{"ok":true,"app":"gnome-calculator","title":"Calculator","focused":true}
{"name":"gnome-calculator","bundle_id":"org.gnome.Calculator","pid":3037682}

后面所有命令里的 --app 都要用 list-apps 打印出来的这个名字(gnome-calculator),不能用窗口标题 Calculator。GTK3、GTK4 程序不用额外设置;Qt 程序要设 QT_ACCESSIBILITY=1,Chromium 系浏览器要加 --force-renderer-accessibility。

第 4 步:读快照,认识引用

官方建议先用骨架快照(--skeleton)只读三层,摸清结构:

agent-desktop-run snapshot --app gnome-calculator --skeleton -i --compact > skel.json
jq -c '{snapshot_id: .data.snapshot_id, ref_count: .data.ref_count}' skel.json
jq -c '.. | objects | select(.ref_id?) | {ref_id, role, children_count}' skel.json

对照结果:

{"snapshot_id":"s1329k5pdh61hn","ref_count":2}
{"ref_id":"@s1329k5pdh61hn:e1","role":"scrollarea","children_count":1}
{"ref_id":"@s1329k5pdh61hn:e2","role":"scrollarea","children_count":1}

引用的格式是 @快照ID:e序号,必须原样传给后面的命令。按官方流程,下一步是用 snapshot --root 钻进某个区域(把下面命令里的快照 ID 换成你自己输出里的,用别人的 ID 会得到 SNAPSHOT_NOT_FOUND)。但在这台机器的 GNOME 计算器 48 上,两块区域都是没有名字的滚动区,长得一模一样,钻取会被拒绝:

agent-desktop-run snapshot --root @s1329k5pdh61hn:e1 -i --compact | jq -c '.error.code'

对照结果:

"AMBIGUOUS_TARGET"

错误详情里写的是 identity_not_unique:保存这个引用时无法证明它唯一,所以不敢重新定位。官方的观察规则是:控件不多的小程序直接读整个窗口,骨架加钻取留给控件很多的复杂程序。读整个窗口:

agent-desktop-run snapshot --app gnome-calculator -i --compact > calc.json
jq -c '{snapshot_id: .data.snapshot_id, ref_count: .data.ref_count}' calc.json
jq -r '.. | objects | select(.role? == "button") | .name' calc.json | paste -sd' '
jq -c '.. | objects | select(.role? == "textfield")
       | {ref_id, role, a: .available_actions}' calc.json

对照结果:

{"snapshot_id":"s4a0o5pvfos9p","ref_count":41}
4 7 8 9 5 6 mod ÷ 1 2 0 . 3 × − + π √ x2 C ( ) % = Undo Basic Main Menu ...
{"ref_id":"@s4a0o5pvfos9p:e9","role":"textfield","a":["RightClick","SetValue",
"TypeText","SetFocus"]}

-i 只保留可交互的元素,--compact 去掉多余字段。刚打开的计算器是 36 个引用,算过几次后历史记录也会进树,数字会变多。按钮的 name 就是上面印的字,注意乘号是 ×、减号是 −(都不是键盘上的符号)。显示屏是一个 textfield,available_actions 告诉你它支持 SetValue(直接写值)。下一课就用这些引用来按按钮、读结果。

常见坑

  • status 显示 denied:当前终端没有会话总线。在桌面会话里的终端运行,或者像本课这样通过 agent-desktop-run 执行每一条命令;直接敲 agent-desktop 不会自动用上包装脚本的会话。
  • 程序在跑,list-apps 里却没有:程序比无障碍总线先启动就不会登记。关掉重开;Qt、Chromium 程序还要加上面说的变量或参数。
  • 报 ACTION_NOT_SUPPORTED,说无法证明窗口类型:没有窗口管理器。包装脚本会启动 openbox,自己搭环境时别漏掉。
  • 报 INTERNAL,提示私有文件可被其他用户访问:~/.agent-desktop 的权限太宽,执行 chmod 700 ~/.agent-desktop。
  • 骨架快照钻取报 AMBIGUOUS_TARGET:区域里有两个一模一样、没有名字的元素。小程序改读整个窗口,或者用下一课的 find 按角色和名字直接找。
  • 快照里有隐私:官方提醒快照、截图、剪贴板内容可能带出桌面上的私人信息,别贴进公开的日志或工单。

小结

这一课装好了 agent-desktop 1.2.0,用官方的 agent-desktop-run 脚本在没有显示器的 Linux 上拉起了无头桌面,确认无障碍总线是 granted,打开计算器并读出了它的引用。下一课进入核心的「观察、操作、验证」循环:按按钮算出 6×7,往编辑器里写字,再处理关闭程序时弹出的保存对话框。