mcpField
S
SAIR

SAIR

本地服务
sair.run开发工具12 天前更新

关于 SAIR

SAIR 为 CI 流水线协调共享的真实 Android 设备,通过设备锁定、ADB 协议代理和任务隔离避免测试互相干扰。它还提供面向编码 Agent 的 MCP 服务,可运行仪器测试或租用设备并执行设备操作。

主要功能

  1. 01锁定设备并排队等待
  2. 02隔离 CI 任务的 ADB 设备访问
  3. 03运行真实设备仪器测试
  4. 04安装 APK 并执行 shell 命令
  5. 05获取截图与 logcat

适用场景

  1. 01在 CI 中协调多个任务使用手机
  2. 02让编码 Agent 在真实设备运行测试
  3. 03在租用设备上安装 APK 并检查应用
  4. 04通过截图和 logcat 诊断测试问题

MCP 相关

SAIR 提供 stdio MCP 服务,供编码 Agent 运行测试、列出和租用设备、安装 APK、执行 shell、获取截图及读取 logcat。服务通过 API 密钥连接编排服务,Agent 所在机器无需代理或 adb。

配置

本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。

暂未收录安装方式

我们还没有获取到这个服务的安装包或远程地址,请按项目 README 中的说明安装。

代码仓库
安装包
mcpbsair-mcp.mcpb

工具

还没有获取到工具列表

本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。

详细介绍

SAIR — 共享 Android 仪器测试运行器

SAIR 让 CI 流水线可以安全地共享实体 Android 设备。它提供设备锁定、ADB 协议转换和按运行器隔离,确保多个任务不会争用同一台设备。

原因

没有 SAIR 时,所有能连接到手机的 CI 任务都会同时使用它:

CI job 1 ──┐
           ├──▶ adb ──▶ Phone     ✗ both jobs install / uninstall the same APK
CI job 2 ──┘                      ✗ one job's reboot or settings change breaks
                                    the other's tests mid-run
                                  ✗ connectedCheck runs on every attached
                                    phone, including ones another job is using

使用 SAIR 后,任务必须先持有锁才能访问设备,等待中的任务则会排队:

CI job 1 ──┐                 ┌──▶ Phone A (locked by job 1)
           ├──▶ Proxy ───────┤
CI job 2 ──┘      ▲          └──▶ Phone B (locked by job 2)
                  │ locks
CI job 3 ···  Orchestrator: job 3 waits in the queue until
                            a phone is released

GitHub Action

此仓库同时也是一个 GitHub Action,会将 sair-acquire、sair-release、 sair-wifi、sair-bluetooth、sair-locks 和 sair-label 放到 PATH 中。这些工具需要一个编排器账号:在 sair.run 注册以获取 API 密钥,将其保存为仓库机密 SAIR_API_KEY,并将仓库变量 SAIR_PROXY_URL 设置为你设备台的代理地址(参见 运行)。然后:

steps:
  - uses: actions/checkout@v4
  - uses: compscidr/[email protected]
  - run: sair-acquire --count 1 > /dev/null
    env:
      SAIR_PROXY_URL: ${{ vars.SAIR_PROXY_URL }}
      SAIR_API_KEY: ${{ secrets.SAIR_API_KEY }}
  - run: ./gradlew connectedCheck
  - if: always()
    run: sair-release --status ${{ job.status }}
    env:
      SAIR_API_KEY: ${{ secrets.SAIR_API_KEY }}

完整工作流示例 说明了每个步骤。

没有自有代理的运行器(例如 GitHub 托管的运行器,使用其他账号共享的手机)可以让该 Action 启动一个代理,且代理版本与 Action 版本相同。保持 SAIR_PROXY_URL 未设置;代理会监听默认地址 localhost:8550,日志位于 $RUNNER_TEMP/sair-proxy.log:

  - uses: compscidr/[email protected]
    with:
      proxy: true
    env:
      SAIR_API_KEY: ${{ secrets.SAIR_API_KEY }}

如果使用分支或 SHA,而不是发布标签,该 Action 会发出警告,并改为启动最新发布版本。

AI Agent(MCP)

sair-mcp 是一个面向编码 Agent 的 MCP 服务(stdio)。它可以通过一次调用,在真实手机上运行应用的仪器测试;也可以租用手机、安装 APK、运行 shell 命令、截屏并读取 logcat。它使用来自 sair.run 的 API 密钥与编排器的 Agent API 通信。Agent 所在机器无需代理或 adb。

可以使用 go install github.com/compscidr/sair/cmd/sair-mcp@latest 安装,从发布 tarball 中获取 sair-mcp,或使用发布包中的 sair-mcp.mcpb(适用于 Claude Desktop 等客户端的 MCP Bundle)。然后将其添加到你的 Agent 中,例如 Claude Code:

claude mcp add sair -e SAIR_API_KEY=sair_... -- sair-mcp

或添加到任意客户端的 JSON 配置中:

{ "mcpServers": { "sair": { "command": "sair-mcp", "env": { "SAIR_API_KEY": "sair_..." } } } }
工具功能
run_tests上传 app_apk 和 test_apk(Agent 所在机器上的路径),等待运行结束,并返回逐项测试结果、instrument 输出和 logcat 尾部内容
list_devices列出密钥可使用的手机:序列号、型号、sdk(API 级别)、是否共享、是否正在使用
lease_device租用一台手机(serial,可选 wait_s),并返回租约 ID
install_apk在租用的手机上安装 APK,并返回其软件包名
shell运行命令,并返回输出和退出代码
screenshot返回屏幕的 PNG 图片
logcat返回最近的 lines 行(默认 500 行)
release_device结束租约:卸载已安装的内容并释放手机

服务会定期发送租约心跳,因此即使 Agent 在两次调用之间思考一段时间,也能继续使用手机。租约仍会在计划规定的最长时限后结束(免费版 15 分钟、solo 版 1 小时、team 版 2 小时、enterprise 版 4 小时)。如果手机由其他用户共享,shell 命令会被限制在安全范围内(参见编排器 README)。SAIR_API_URL 用于指定其他编排器(默认为 https://api.sair.run)。

它已作为 io.github.compscidr/sair 列入 官方 MCP 注册表。每次发布都会包含它:发布工作流会构建 sair-mcp.mcpb,填入 server.json 中的版本、下载 URL 和 SHA-256(仓库中的值是占位符),然后通过 GitHub OIDC 登录并使用 mcp-publisher 发布。可使用以下命令检查:

curl "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.compscidr/sair"。

快速入门

安装
curl -fsSL https://raw.githubusercontent.com/compscidr/sair/main/install.sh | bash

此命令会检测你的操作系统和架构,下载最新发布版本,并将所有二进制文件安装到 ~/.local/bin。

前置条件
  • 在连接 Android 设备的机器上安装了 adb
  • 通过 USB 连接了一台或多台 Android 设备,并已启用 USB 调试 (请勿使用个人账号登录:参见 保护测试台)
运行
# 1. Start a real ADB server on a non-default port (5038), so it doesn't
#    conflict with the proxy which will own the standard port (5037).
adb -P 5038 start-server

# 2. Set your API key (shared by proxy and device-source)
export SAIR_API_KEY=your-api-key

# 3. Start the proxy and device source
sair-proxy &
sair-device-source

默认情况下,代理会通过 TLS 连接到托管的编排器 orchestrator.sair.run。本地开发时,可使用 ORCHESTRATOR_ADDR 覆盖此地址。

使用

获取设备锁(会阻塞,直到有设备可用),运行测试,然后释放锁:

ACQUIRE_OUTPUT=$(sair-acquire)
eval "$ACQUIRE_OUTPUT"
./gradlew connectedCheck
sair-release

评估结束后,原生 adb 会自动通过指定范围的端口与代理通信,并且只能看到已锁定的设备。

当测试台在设备来源中设置 WIFI_SSID 时,Wi-Fi 将由系统为你管理:

  • 获取时,每部手机都会加入测试台 Wi-Fi,且只有在手机处于在线状态(见下文)时才会返回;否则获取失败。--no-wifi 会让手机以离线状态交接,并以相同方式验证,而不是信任上一次释放时的状态。--wifi-band 2.4|5|6(也可用 sair-wifi on --band)会让手机加入指定 GHz 频段:手机会扫描,将建议固定到该频段内检测到的 WIFI_SSID 中信号最强的 BSSID;如果没有检测到任何 BSSID,则加入失败。手机的 cmd wifi status 会显示其加入的 BSSID 和频率。
  • 释放时,以及锁过期或被取消时,每部手机都会重置:通过 adb shell 添加的所有网络建议和所有已保存网络都会被遗忘,并切换无线电状态;重置会等待手机离线且无线电重新开启。下一个任务绝不会继承上一个任务的网络。随后手机会重新加入测试台 Wi-Fi,因此在租约之间保持在线,并可安装应用更新(见下文的 Play Store);下一次获取会按任务要求将手机置于相应状态,无论任务要求哪种状态。
  • 释放时还会,卸载手机的设备源首次发现该设备时不存在的所有第三方软件包(包括测试 APK 和 .test 软件包,以及它们的应用数据和已登录账户),并强行停止浏览器和邮件应用;见 BASELINE_PACKAGES、RESET_FORCE_STOP 和 RESET_CLEAR_PACKAGES。被移除的内容会写入代理日志和编排器上的锁日志,其中会列出与软件包关联的任何 Wi-Fi 建议。此操作在 Wi-Fi 重置前运行,因此测试应用为测试台网络泄漏的建议会在重置等待手机离线前被移除。
  • 获取时会先执行相同的清理(反向端口转发、软件包),然后才检查 Wi-Fi。如果任务从未释放,例如没有 if: always() 释放步骤、运行器退出,或锁过期时代理已停止运行,那么下一个任务会进行清理,其锁日志会显示 sair: uninstalled … (left by an earlier job)。
  • 不会遗留 UiAutomation:释放和获取时都会检查。被杀掉的 am instrument 或遗留的 uiautomator 服务进程可能会留下已注册的 UiAutomation,导致后续所有插桩测试都失败,并显示 UiAutomationService … already registered!。uiautomator dump 探测会检测到此情况;随后 sair 会杀掉 shell 用户的 app_process/uiautomator 进程,以注销 UiAutomation;只有探测仍然失败时才会重启手机(绝不会重启设有安全锁的手机)。锁日志会记录 sair: cleared a leaked UiAutomation registration (killed … | rebooted)。
  • 唤醒并解锁:获取时,以及释放重置后,都会唤醒屏幕并解除锁屏,然后验证状态(dumpsys power 显示 Awake,dumpsys window policy 显示锁屏未显示);最多尝试三次,因为在某些手机上,一次性解除锁屏可能会遇到竞态。如果手机始终无法解除锁屏,获取会失败并显示 screen locked;这表示手机设置了 PIN、图案或密码,sair 无法解除,因此请将手机的屏幕锁设为 Swipe 或 None。成功唤醒前,设备的 warning(代理 /status、设备报告)也会报告此原因。如果手机的 stay_on_while_plugged_in 已关闭,日志会将其记录为警告,因为手机会在步骤之间休眠并重新锁屏。Android 4.x 手机(没有 wm)会通过自身的 mShowingLockscreen 字段进行验证;如果 sair 不认识转储的格式,会记录一次并将其视为已解锁。
  • 释放时重启:手机未被获取且运行时间超过 REBOOT_UPTIME_HOURS 时重启(默认值为 24);重置会等待启动完成并执行上述唤醒操作,然后该手机才能进行下一次获取;手机停机期间不会被分配。锁日志会记录 sair: rebooted after release (uptime …),代理的 /status 会列出每部手机的 last_reboot。此操作在 Wi-Fi 重置前运行,因此不会重复等待离线。设有 PIN、图案或密码的手机绝不会以这种方式重启,因为重启后会停在“解锁以启动”界面,需要人工处理;设备源会记录原因及修复方法。无论测试台 Wi-Fi 是否可用,都会执行唤醒和重启。
  • 释放时循环重启 Bluetooth,在 Wi-Fi 重置后执行:svc bluetooth disable,等待 dumpsys bluetooth_manager 显示适配器完全处于 OFF 状态,再执行 svc bluetooth enable,并等待其显示 ON(每次最多 30 秒)。上一个任务卡住的 BLE 堆栈(例如卡住的广播器、未能重启的扫描器)不会被带到下一个任务中,从而避免出现“未发现对等设备”。仅禁用通常会停在 BLE_ON(经典 Bluetooth 已关闭,但 BLE 仍因“附近设备扫描”或某个请求使用 BLE 的应用而运行),因此 sair 会在循环过程中关闭 ble_scan_always_enabled;如果 BLE 仍被占用,还会强行停止转储中列为 BLE 应用的软件包(在 One UI 上为 com.samsung.android.beaconmanager 和 com.samsung.android.mdx.kit)。Bluetooth 重新开启前会恢复此设置。若因运行时间过长而重启,则跳过此操作;如果自上次任务后(或自代理启动后)未执行释放重置,则改在获取时执行。锁日志会记录 sair: Bluetooth cycled off and on (…)。如果无线电未能完全关闭或重新开启,相关信息会写入锁日志,并作为设备的 warning 记录,直到某次循环成功;但不会导致获取失败,因为大多数任务都不会使用 Bluetooth。Bluetooth 始终保持开启:即使无线电始终无法完全关闭,也会重新开启,并在日志行中说明(Bluetooth back on but never fully off: ... adapter is BLE_ON)。没有适配器转储信息的手机(Android 4.x)会跳过此操作。
  • 任务进行期间,sair-bluetooth restart 会在任务使用的手机上执行相同的循环,并在适配器处于 ON 状态时返回,供测试套件验证 Bluetooth 堆栈重启后的恢复情况(sair#156)。如果测试本身强行停止 com.android.bluetooth,就会与 sair 一分钟前执行的循环发生竞态,并获取过时的适配器状态。锁日志会记录 sair: Bluetooth cycled off and on (…) (requested by the job);失败时会向调用方返回错误,并保留锁。手机上的测试可像调用 Wi-Fi 一样,使用锁的设备令牌来调用此功能;见设备端控制。
  • 租约期间不安装 Play Store 更新:获取时会停用 Play Store(pm disable-user com.android.vending),释放时会重新启用,因此更新会在租约之间、手机重新连接测试台 Wi-Fi(或移动数据)后安装。Google Play 服务更新会强行停止所有使用它的应用,包括正在进行插桩测试的应用,导致任务失败并显示 Process crashed。锁日志会记录 sair: Play Store disabled for the lease。如果手机拒绝停用(Xiaomi:Cannot disable system packages),锁日志会说明情况并保留锁。没有 Play Store 的手机,以及 Android 4.x 手机(其 pm 会因此崩溃)会跳过此操作。需要使用 Play Store 本身的任务(结算、应用内更新、Play Integrity)无法在这些手机上使用它。
  • 无线电卡死时重启:每次 svc wifi / svc bluetooth 切换和无线电状态读取都设有超时(15 秒)。如果 Wi-Fi 重置或 Bluetooth 循环失败,设备源会探测无线电服务(dumpsys wifi / dumpsys bluetooth_manager);只有该探测也卡住时,才会重启手机(规则与运行时间重启相同),然后重新执行重置。若 svc 卡住但无线电正常,则不会重启手机。锁日志会记录 sair: radio wedged (wifi|bluetooth), rebooted;如果重启后无线电仍有故障,则像之前一样使重置失败。 已经运行过任务的测试台上,手机可能还留有上一个任务的包,因此“首次发现”会把它们记录到基线中。对于测试包及其目标包,如果 pm list instrumentation 同时列出两者,且两者都已安装,则绝不会将它们加入基线;这样可以处理 CI 遗留包,同时不会影响仅仅自带 instrumentation 的应用。对于其他情况,或者为了确保无误,请先在每部手机上查看一次 pm list packages -3,然后显式设置 BASELINE_PACKAGES(设为空则不保留任何第三方包)。设备源会按名称记录所记录的基线,因此升级后的第一条日志就能看出不需要的条目。
  • 任务进行期间,sair-wifi off / sair-wifi on 会断开和恢复网络,而无需让任务知道凭据,并且只有在手机离线或在线后才会返回。每部手机各自切换,因此两部手机可以同时请求相反的状态。
  • 在手机上,测试可以使用锁的设备令牌,在两条断言之间切换自身的 Wi-Fi;请参阅设备端控制。

在线意味着同时满足以下所有条件:连接的是 WIFI_SSID 本身(而不是 SIM 卡网络或其他残留网络),该 Wi-Fi 是 Android 已验证的默认网络(与被测应用得到的判定相同),并且 WIFI_PING_HOST 有响应;使用默认主机名时,这也能验证 DNS。该 Wi-Fi 上使用 VPN 没有问题。离线意味着未连接 WIFI_SSID,且 WIFI_PING_HOST 无响应;其他没有上行链路的网络(例如测试自身的 Wi-Fi Direct 组网)仍算离线。每次等待都有时间上限(WIFI_CONNECT_TIMEOUT_SECONDS、WIFI_DISCONNECT_TIMEOUT_SECONDS),超时会指出失败环节,例如 associated to "Cafe", not "bench"、connected but not VALIDATED 或 still associated to "bench"。不管理移动数据:按此定义,装有 SIM 卡的手机永远不会离线。

设备源会统计每部手机启动后尝试开启 Wi-Fi 的情况:按原因记录失败次数(not_associated、other_ssid、not_validated、no_route、other)、最后一次失败,以及连接上线所需时间的最新值、中位数和最长值。代理的 /status 会按手机列出这些信息,字段为 wifi;当最慢的连接时间接近 WIFI_CONNECT_TIMEOUT_SECONDS(应调高该值)或大多数失败都因为未能连接上(应移动手机或 AP)时,还会提供 hint。至少尝试五次后,如果某部手机每十次连接中有一次失败,也会记录为警告。

测试应用以自身 UID 注册的测试台网络建议,sair 无权移除,但可以移除该应用:释放时重置包会卸载它,Android 也会随之删除其网络建议。只有基线中的包(设备源首次发现手机时已存在的测试应用)才可能在每次忘记网络后重新连接测试台 Wi-Fi;--no-wifi 在该手机上获取锁后会失败,并提示 still associated to "bench",直到卸载该应用或将其从 BASELINE_PACKAGES 中排除。

Wi-Fi 重置不会触及测试应用以自身 UID 注册的网络建议;重置包卸载应用后,这些建议就会消失。通过另一个代理中继的设备不会受到影响。

密码不会出现在 adb 命令行中:adbd 会将发送给手机的每个 shell 服务记录到 logcat 中,后续任务可能会读取这些记录,因此设备源会通过 stdin 将命令传给 sh。通过受限端口自行运行 cmd wifi add-suggestion 的任务,其命令会在设备源日志和锁日志中进行脱敏。 当手机上的 acquire 或 release 重置失败时,锁日志会记录代理和设备源中与该手机及锁相关的近期日志行(sair: proxy log before the failure,设备源也同样如此),因此无需通过 ssh 连接到设备台架,就能在门户中查看故障原因。设备台架上其他手机和锁的日志行不会包含在内。代理还会通过其会话向编排器发送简短事件(acquire 结果和等待时间、Wi-Fi 加入时间或失败原因、已移除的软件包、重启、版本、设备源变为无响应),供门户的按手机查看页面使用。

架构

         ┌──────────────────────────┐
         │  DeviceSource            │
         │  + Phone                 │
         │  + real adb (port 5038)  │
         └────────────▲─────────────┘
                      │  gRPC
                      ▼
               ┌──────────────┐       ┌──────────────┐
               │    Proxy     │◄gRPC─▶│ Orchestrator │
               │  (port 5037) │       └──────────────┘
               └──────────────┘        (locks & sessions)
                  ▲       ▲
           ADB    │       │  HTTP
        (port 5037│       │(port 8550)
                  │       │
  ----------------+-------+---------------- CI runner --
                  ▼       │
         ┌────────────┐   │   ┌─────────────────┐
         │ adb client │   └───│ sair-acquire /  │
         │ (thinks it │       │ sair-release    │
         │  talks to  │       └─────────────────┘
         │  real adb) │
         └────────────┘

DeviceSource 在每台通过 USB 连接了 Android 设备的机器上运行。它通过真实的 adb 服务端(在 5038 等非标准端口上运行)发现设备,并通过 gRPC 向代理注册。你可以在任意数量的机器上运行设备源。

Proxy 是中央枢纽。设备源向它注册,由它发现设备并通过设备源路由命令。它通过 gRPC 与编排器通信,以管理锁和会话。它在 5037 端口(标准 ADB 端口)上监听,因此 CI 运行器上的原版 adb 会认为自己正在与真实的 ADB 服务端通信。

Orchestrator 管理设备锁、会话和协调工作。代理通过 gRPC 与它通信。它不会连接设备源,也不会直接使用 ADB。可通过 sair.run 使用托管的编排器。

Tools(sair-acquire / sair-release)是轻量级的 bash 封装脚本,会调用代理的 HTTP API(端口 8550)来获取和释放设备锁。

ADB client — CI 运行器上的原版 adb 连接到 5037 端口上的代理(标准 ADB 端口)。代理会将 ADB 协议消息转换为 gRPC 调用,因此 ./gradlew connectedCheck 等 CI 工具无需任何修改即可运行。

配置

设备源
变量默认值说明
DEVICE_SOURCE_PORT8080gRPC 监听端口
ADB_PORT5038真实 ADB 服务的端口
DEVICE_SERIALS—仅管理这些序列号(以逗号分隔);工作台上的其余手机对 sair 不可见。设置但留空:不管理任何手机
EXCLUDE_SERIALS—管理除这些序列号外的所有已连接手机。不可与 DEVICE_SERIALS 同时使用
WIFI_SSID—获取手机时连接工作台 Wi-Fi,释放时重置;未设置 = 不管理 Wi-Fi
WIFI_PASSPHRASE—WPA2 密码(未设置 = 开放网络)
WIFI_PING_HOSTdns.google手机在线时必须能 ping 通,离线时则不能;主机名也会检查 DNS,设置为 IP 即可跳过该检查
WIFI_CONNECT_TIMEOUT_SECONDS60Wi-Fi 开启 / 获取手机时,等待手机上线的最长时间
WIFI_DISCONNECT_TIMEOUT_SECONDS20Wi-Fi 关闭 / 释放手机 / 使用 --no-wifi 获取手机时,等待手机离线的最长时间
BASELINE_PACKAGES—释放时保留的第三方软件包,以逗号分隔。未设置:首次发现每部手机时记录其软件包,减去已安装的测试软件包 / 目标软件包对。设置但留空:不保留任何软件包。在此版本之前已投入使用的工作台上,请明确设置此项
RESET_FORCE_STOPcom.android.chrome,com.google.android.gm释放时强制停止的应用
RESET_CLEAR_PACKAGES—释放时清除数据(pm clear)的应用。默认关闭:清除已登录手机上的 Chrome 数据会使其退出登录
REBOOT_UPTIME_HOURS24手机运行达到此时长后,在下一个作业获取它之前,于释放时重启。0 表示永不重启
SAIR_LOG_LEVELinfodebug 会记录每次转发的分块字节数,用于追踪传输停滞

被 DEVICE_SERIALS / EXCLUDE_SERIALS 排除的手机绝不会被报告、锁定或重置(包括 Wi-Fi、反向转发和软件包),因此可以让几部手机留在 CI 工作台上供本地开发使用。筛选器决定 sair 管理哪些手机,而非谁占用 USB 端口:工作台的 sair-adb-server 仍会占用所有手机,因此应通过它访问被排除的手机(使用 ANDROID_ADB_SERVER_PORT=5038,或对应的 ADB_PORT),不要另行启动 ADB 服务与之争抢设备。设备源会记录被筛选出的已连接手机,并在 DEVICE_SERIALS 指定了未连接的手机时发出警告。

验证是否正常运行:

grpcurl -plaintext localhost:8080 devicesource.DeviceSource/GetDevices
代理
变量默认值描述
ORCHESTRATOR_ADDRorchestrator.sair.run:9090Orchestrator gRPC 地址(锁管理)
ORCHESTRATOR_TLSfalse(托管环境中会自动启用)与 orchestrator 的连接使用 TLS
SAIR_API_KEYdev-key-123用于身份验证的 API 密钥
SAIR_GUEST_API_KEYS空允许锁定此代理设备的其他账户 API 密钥,以逗号分隔。此代理所属账户的所有密钥,以及已列出的访客所属账户的所有其他密钥,无需列出即可使用。请参阅下文的“访客租户”。
ADB_PROXY_PORT5037ADB 协议监听端口
PROXY_HTTP_PORT8550HTTP API 监听端口
PROXY_HTTP_HOST0.0.0.0HTTP API 绑定地址
HEARTBEAT_INTERVAL_SECONDS60锁心跳间隔
LOCK_IDLE_TIMEOUT_MINUTES20如果某个作用域端口在此时长内没有 adb 客户端,则释放该锁(持锁退出的任务);设为 0 可禁用
PROXY_ID主机名在账户内标识此代理,以便多台机器共用同一个 API 密钥。仅当此代理上报过的设备才会获准加锁。
SAIR_LOG_LEVELinfodebug、info、warn 或 error

代理会公开两个端口:

  • 5037(ADB 协议)— 标准 adb 会连接到此处,但在获取锁之前看不到设备
  • 8550(HTTP API)— sair-acquire 和 sair-release 会调用此接口来管理锁

代理会与 orchestrator 保持一条长期打开的流,用于传输其版本、设备报告、锁心跳,以及运行中任务的 ADB 日志(在一秒内刷新)。如果 orchestrator 版本较旧、不支持该流,代理会自动回退到定期的一元调用。

锁可以包含连接到你其他代理的设备(或由其他账户与你共享的设备)。Orchestrator 会在两个代理之间转发这些 ADB 连接,因此运行器上的 adb 会像看到本地设备一样看到它们。代理只会建立出站连接,因此位于 NAT 后的代理也能工作;只有远程设备会经过 orchestrator 转发。

访客租户

代理自身账户下的任何密钥都会被直接接受:你创建的第二个密钥,或受邀团队成员创建的密钥,都可以在你的测试台上使用,无需更改配置。对于未列出的密钥,代理会向编排器查询该密钥所属的账户,并缓存结果 30 秒。对于不支持此检查的旧版编排器,只有 SAIR_API_KEY 和下面的访客密钥可用。

一个测试台可以为两个账户提供服务。典型场景是:一台手机注册在个人账户下的机器,同时也为公司账户运行 CI,而公司账户希望拥有自己的锁、队列、日志和账单。在代理上将公司的 API 密钥设为访客密钥:

SAIR_API_KEY=owner-key                 # the account the phones belong to
SAIR_GUEST_API_KEYS=company-key,other  # accounts that may lock them here

使用访客密钥的作业与其他作业一样使用代理(使用 SAIR_API_KEY=company-key 和代理的 URL 调用 sair-acquire)。代理会在锁定请求中转发访客密钥,因此编排器会将锁记录在访客账户下,而手机仍连接在所有者的代理上。ADB 流量绝不会离开这台机器。

信任模型:列出密钥即表示所有者同意。 访客密钥存放在代理自身的配置中,只有能够访问该机器的人才能编辑。将某个账户的密钥放在那里,就表示“该账户可以使用这些手机”,因此无需在门户中将设备标记为共享。设备不会进入公共池,访客仍然可以锁定这些设备。这两种机制彼此独立。无论哪种方式,持有者都可以使用手机上登录的任何账户,而不仅仅是测试 APK(参见保护测试台的安全):

代理上的访客密钥在门户中共享
谁可以锁定仅列出的账户所有 SAIR 账户
锁在哪儿运行在所有者的代理上本地运行通过编排器中继运行
--count / all此代理上的所有手机仅借用者自己的手机
如何授权编辑代理的环境在门户中切换每台设备的设置

两种方式可以同时启用。所有者、访客和公共借用者都在同一个锁定域中锁定同一部实体手机,因此不能有两方同时持有它;手机空闲时,也会优先处理所有者的作业。

访客密钥可用于代理的所有端点(/acquire、/release、/wifi、/label、/status),与所有者的密钥相同。列出的访客账户下的其他所有密钥也同样可用:列出一个密钥即授权该账户,因此访客可以添加或轮换密钥,而无需编辑代理的环境(列出的密钥必须保持有效,因为编排器靠它查找账户)。其他任何密钥都会被拒绝。 访客密钥需要由支持访客密钥的编排器处理;旧版编排器会忽略该字段,并将锁记录在所有者名下。

工具

在 GitHub Actions 中,uses: compscidr/[email protected](或任何更新的发布标签)会将工具加入 PATH;请参阅下方的工作流示例。其他情况下,将 tools/sair-acquire、tools/sair-release、tools/sair-wifi、tools/sair-bluetooth、tools/sair-locks 和 tools/sair-label 复制到 CI 项目中,或将此仓库的 tools/ 目录加入 PATH。

获取设备锁(阻塞,直到设备可用):

eval $(sair-acquire)            # all devices
eval $(sair-acquire --count 1)  # any one free device

只需要一台手机的 CI 作业应使用 --count 1,以便池中的其余设备可供其他作业使用。

执行 eval 后,会设置以下环境变量:

变量描述
SAIR_LOCK_ID锁 ID(传递给 sair-release)
SAIR_SERIALS已获取设备序列号的逗号分隔列表
ANDROID_ADB_SERVER_PORT作用域限定的 ADB 端口——标准 adb 会自动读取此变量
ANDROID_HOME / ANDROID_SDK_ROOT$RUNNER_TEMP(或 $TMPDIR)下的影子 SDK,其中的 platform-tools/adb 会通过 -P <scoped port> 调用真正的 adb;仅当此前已设置 SDK 时才会设置。此变量会保留,以便 sair-release 之后的步骤继续正常运行
ANDROID_SERIAL第一个序列号(仅在获取单台设备时设置)
SAIR_PROXY_URL代理 URL(用于 sair-release)
SAIR_DEVICE_TOKEN / SAIR_DEVICE_API_PORT锁作用域限定的令牌,以及每部手机上可访问代理 /wifi 和 /bluetooth 的端口;仅当代理已设置这些值时才会设置。请参阅设备端控制

完成后释放设备锁:

sair-release

常用选项:

# Acquire specific device(s)
eval $(sair-acquire --serial DEVICE_A)
eval $(sair-acquire --serial DEVICE_A,DEVICE_B)

# Acquire any N free devices (mutually exclusive with --serial)
eval $(sair-acquire --count 2)

# Take every device from one proxy, for phones that must sit together (BLE,
# Wi-Fi Direct, adb reverse); waits until one proxy has enough free
eval $(sair-acquire --count 2 --same-proxy)

# Queue behind every default-priority job while devices are busy (higher
# goes first, ties are FIFO, default 0) — e.g. so bot PRs don't hold up humans
eval $(sair-acquire --priority -1)

# Phones verified offline (default: joined to the bench Wi-Fi and online)
eval $(sair-acquire --no-wifi)

# Join the bench Wi-Fi on one band of a dual/tri-band AP (2.4, 5 or 6 GHz)
eval $(sair-acquire --wifi-band 2.4)

# Tell matrix legs of one job apart (default: $SAIR_STAGE), and label the
# lease or one of its devices for the job views
eval $(sair-acquire --stage api-34 --label suite=smoke --label DEVICE_A:role=group-owner)

# Labels only known once the serials are granted; a key labeled again takes
# the new value (needs a proxy with /label)
sair-label DEVICE_A:role=group-owner DEVICE_B:role=client

# Drop and restore the network mid-job (all locked devices, or --serial)
sair-wifi off
sair-wifi on
sair-wifi on --band 5

# Restart the Bluetooth stack mid-job, sair's own off-then-on cycle; returns
# once the adapter is back on (all locked devices, or --serial)
sair-bluetooth restart

# Which release the tools (or a bench's binaries) came from
sair-acquire --version
sair-proxy --version   # also in the proxy's GET /status as "version"

# Point to a remote proxy
eval $(sair-acquire --url http://proxy-host:8550 --api-key my-key)

# Release with explicit lock ID
sair-release --lock-id <lock-id>

# Every lock your account holds (all proxies, all keys) and the ones that
# ended in the last hour, with how; asks the orchestrator, not the proxy
# (SAIR_ORCHESTRATOR_URL, default https://orchestrator.sair.run)
sair-locks
# Release every lock this key holds, e.g. after a crashed agent or killed job
sair-locks --release-mine
设备端控制

有些测试最清楚自己何时需要网络:一个测试需要互联网,下一个测试不能联网,而且必须在手机上的两条断言之间切换网络。租户 API 密钥绝不能传到手机上(它能为每个作业解锁获取和释放操作,而且下一个作业可能读取遗留的数据),因此每个锁都会拥有一个独立的短期令牌。

获取设备时,代理会生成一个随机的设备令牌。该令牌仅可用于 POST /wifi 和 POST /bluetooth,仅适用于该锁中的序列号,并且仅在锁有效期间有效。代理还会在每部本地手机上运行 adb reverse tcp:<port> tcp:<port>,其中 <port> 是代理的 HTTP API 端口,这样手机上的 127.0.0.1:<port> 就能访问代理(这要求代理的 HTTP API 位于设备源所在的主机上,这是常规测试台的布局)。sair-acquire 会将这两项分别导出为 SAIR_DEVICE_TOKEN 和 SAIR_DEVICE_API_PORT;将它们与序列号一起传递给 instrumentation:

./gradlew connectedCheck \
  -Pandroid.testInstrumentationRunnerArguments.sairDeviceToken="$SAIR_DEVICE_TOKEN" \
  -Pandroid.testInstrumentationRunnerArguments.sairDeviceApiPort="$SAIR_DEVICE_API_PORT" \
  -Pandroid.testInstrumentationRunnerArguments.sairLockId="$SAIR_LOCK_ID" \
  -Pandroid.testInstrumentationRunnerArguments.sairSerial="$ANDROID_SERIAL"

随后,手机上的测试只需发起一次普通 HTTP 调用,无需任何库。手机连上互联网后,on 就会返回,因此测试可以在调用返回后立即继续;off 则会断开网络:

fun sairWifi(on: Boolean) {
    val args = InstrumentationRegistry.getArguments()
    val url = "http://127.0.0.1:${args.getString("sairDeviceApiPort")}/wifi" +
        "?lock_id=${args.getString("sairLockId")}" +
        "&serial=${Uri.encode(args.getString("sairSerial"))}" +
        "&state=${if (on) "on" else "off"}"
    val conn = URL(url).openConnection() as HttpURLConnection
    conn.requestMethod = "POST"
    conn.setRequestProperty("x-device-token", args.getString("sairDeviceToken"))
    conn.readTimeout = 90_000 // "on" waits for Internet, up to the bench's timeout
    check(conn.responseCode == 200) { "sair wifi: HTTP ${conn.responseCode}" }
}

使用相同的标头调用 POST /bluetooth?lock_id=…&serial=…&action=restart,会像 sair 在释放时一样重启蓝牙协议栈,并在适配器恢复为 ON 后返回(sair#156);恢复测试会调用此接口,而不是自行强制停止 com.android.bluetooth。

这是对 127.0.0.1 的普通 HTTP 请求,因此被测应用必须允许此处使用明文流量:从 targetSdk 28 开始,平台默认会拒绝此类请求,并提示 Cleartext HTTP traffic to 127.0.0.1 not permitted。可在调试版本的网络安全配置中允许明文流量,或者通过原始 Socket 写入请求行,因为该策略不涵盖这种方式。

如果一个锁包含多部手机,请为每部手机传递各自的序列号(SAIR_SERIALS 会列出这些序列号)。使用设备令牌时必须提供序列号:代理无法判断反向转发连接来自哪部手机。从其他代理中继过来的设备无法使用令牌;它们的 Wi-Fi 完全不受管理。释放锁时会移除此转发,与作业留下的其他所有 adb reverse 一样。

保护测试台

持有锁的作业拥有手机的完整 adb shell 和插桩访问权限。通过 UI 自动化,这足以使用手机上已登录的任何应用:邮件、浏览器中已登录的会话、Play Store。代理无法可靠地过滤这些操作,因为正规的 UiAutomator 测试也会执行相同的操作。在测试台手机上登录的任何账户,都可以被能够锁定该手机的人使用:你账户中的所有密钥(你自己的以及受邀团队成员的)、SAIR_GUEST_API_KEYS 中的每个密钥,以及在门户中共享手机时的每个 SAIR 账户。

  • 手机需要登录账户吗? adb 安装、插桩、BLE、Wi-Fi Direct 和 Nearby Connections 都无需账户。通常需要登录的原因是从 Play Store 安装应用和测试 Google Sign-In。如果不需要这些功能,请让手机保持未登录状态。
  • 如果需要登录,请使用专用的测试台账户,绝不要使用个人账户。 不绑定付款方式,将 Play 购买设置为始终需要身份验证,不关联真实邮件或联系人,并将账户恢复方式指向别名。假设任何能够锁定手机的人都能使用该账户。
  • 次要 Android 用户或工作资料不构成隔离边界。 shell 用户可以跨用户执行操作,因此不要依赖它来隐藏账户。
  • 访客密钥和门户共享交出去的是整部手机, 不只是测试 APK。持有者可以使用手机上已登录的任何账户。

部署

用于生产时,请将 SAIR 组件作为 systemd 服务或 Docker 容器运行。

Systemd 服务

安装二进制文件和 systemd 单元:

curl -fsSL https://raw.githubusercontent.com/compscidr/sair/main/install.sh | bash -s -- --systemd

设备源机器 — 编辑 /etc/sair/device-source.env,然后:

sudo systemctl enable --now sair-adb-server sair-device-source

这会在每次启动时启动真实的 ADB 服务器(端口 5038)和设备源。

代理机器 — 编辑 /etc/sair/proxy.env(设置 ORCHESTRATOR_ADDR、SAIR_API_KEY 等),然后:

sudo systemctl enable --now sair-proxy

检查状态和日志:

sudo systemctl status sair-device-source
sudo journalctl -u sair-proxy -f

如果代理或设备源循环重启,并出现 fatal error: removed GODEBUG ... set to old value,说明主机操作系统将过时的 GODEBUG 导出到了每个服务中(UGOS 设置了 tlskyber=0)。随附的单元会通过 UnsetEnvironment=GODEBUG 清除此变量(systemd ≥ 235);请重新安装这些单元,或在 drop-in 文件中添加该行(sudo systemctl edit sair-proxy)。

Docker 容器

每次发布时,都会将预构建镜像发布到 GitHub Container Registry:

ghcr.io/compscidr/sair-device-source:latest
ghcr.io/compscidr/sair-proxy:latest

设备源机器 — 真实的 ADB 服务器必须在主机上运行(不能在容器中运行)。使用 systemd 单元让它在启动时运行,或手动启动:

# Option A: Install the ADB systemd service (starts on boot)
curl -fsSL https://raw.githubusercontent.com/compscidr/sair/main/install.sh | bash -s -- \
  --systemd-adb-only

# Option B: Start manually
adb -P 5038 start-server

设备源容器需要能够访问主机的 ADB 服务器:

docker run -d --name sair-device-source \
  --network host \
  -e ADB_PORT=5038 \
  ghcr.io/compscidr/sair-device-source:latest

--network host 是最简单的选项——它允许容器通过 localhost:5038 访问主机的 ADB 服务器,并直接公开 gRPC 端口。

代理机器:

docker run -d --name sair-proxy \
  -p 5037:5037 \
  -p 8550:8550 \
  -e ORCHESTRATOR_ADDR=your-orchestrator:9090 \
  -e SAIR_API_KEY=your-api-key \
  ghcr.io/compscidr/sair-proxy:latest

将 latest 替换为发布标签(例如 v0.1.0),即可固定到特定版本。

示例:GitHub Actions 工作流

name: Android Tests

on: [push, pull_request]

jobs:
  connected-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21

      # Puts sair-acquire and sair-release on PATH, pinned to a release tag
      # (the action ships from v0.0.21 on). Bump the tag to upgrade; the
      # release notes list breaking changes.
      - uses: compscidr/[email protected]

      - name: Acquire device
        env:
          SAIR_PROXY_URL: ${{ vars.SAIR_PROXY_URL }}
          SAIR_API_KEY: ${{ secrets.SAIR_API_KEY }}
        run: |
          # Also appends every variable to $GITHUB_ENV for the later steps
          sair-acquire --count 1 > /dev/null

      - name: Run connected tests
        run: ./gradlew connectedCheck

      - name: Release device
        if: always()
        env:
          SAIR_API_KEY: ${{ secrets.SAIR_API_KEY }}
        run: sair-release --status ${{ job.status }}

关于此工作流的要点:

  • sair-acquire 会一直阻塞,直到有设备可用;因此,所有设备都繁忙时,作业会自然排队。--priority N 用于排列队列(优先级高的排在前面;相同优先级按 FIFO 排列;默认值为 0);例如,传入 --priority ${{ github.actor == 'dependabot[bot]' && -1 || 0 }},让机器人 PR 排在人工 PR 后面等待。
  • --count 1 会锁定一台空闲设备,而不是整个设备池,从而让其他设备可供并发作业使用。它还会设置 ANDROID_SERIAL,让 Gradle 面向该设备运行。
  • ANDROID_ADB_SERVER_PORT 会告知标准 adb 连接代理的专用端口,该端口只会暴露已锁定的设备。
  • ANDROID_HOME 会被重定向到一个影子 SDK,其中的 adb 已内置专用端口。AGP 9.4+ 会在 Gradle 工作进程守护进程中运行插桩测试,而该进程启动时环境为空,因此 ANDROID_ADB_SERVER_PORT 不会传递给它调用的 adb;这个封装脚本也能让该调用路径正常工作。运行 sair-acquire 时需要设置 ANDROID_HOME 或 ANDROID_SDK_ROOT;若未设置,它会向 stderr 发出警告。
  • 在 GitHub Actions 中(已设置 GITHUB_ENV),sair-acquire 会自行将它导出的每个变量以原始值追加到 $GITHUB_ENV,因此后续步骤无需解析 eval 输出即可读取这些变量。
  • sair-release 放在 if: always() 步骤中,这样即使测试失败也会释放锁。--status ${{ job.status }} 会在仪表板中记录作业结果(成功、失败、已取消);若不传此参数,已释放的作业会显示为未知,而未释放且过期的锁会显示为超时。
  • 持有锁期间,代理会记录通过专用端口发出的每个 ADB 请求(服务字符串、时间、设备会话持续时长、返回的字节数),并在每次心跳和释放时将记录发送给编排器,因此作业页面会显示设备上实际运行的内容,即使作业超时也不例外。裸端口不会记录任何内容。
  • 在 GitHub Actions 中,sair-acquire 还会发送仓库、运行链接、作业 ID、运行尝试次数、提交、分支和工作流(分别来自 GITHUB_REPOSITORY、GITHUB_RUN_ID、GITHUB_JOB、GITHUB_RUN_ATTEMPT、GITHUB_SHA、GITHUB_HEAD_REF 或 GITHUB_REF_NAME、GITHUB_WORKFLOW),仪表板会按作业显示这些信息。在其他环境中,请传入 --repo、--run-url、--job、--run-attempt、--sha、--branch 和 --workflow。矩阵作业的每个分支都会使用相同的 GITHUB_JOB,因此请设置 SAIR_STAGE(例如 SAIR_STAGE: api-${{ matrix.api }})或传入 --stage,以区分各个分支。较旧的代理会忽略这些字段。
  • 在 Actions 之外,请使用 ACQUIRE_OUTPUT=$(sair-acquire),而不要使用 eval $(sair-acquire),这样获取设备失败时,此步骤也会失败;成功后再对输出使用 eval。

多机器示例

设备分布在多台机器上的典型生产环境配置:

┌──────────────────────────┐   ┌──────────────────────────┐
│  Machine A               │   │  Machine B               │
│  DeviceSource            │   │  DeviceSource            │
│  + Phone A, Phone B      │   │  + Phone C               │
│  + real adb (port 5038)  │   │  + real adb (port 5038)  │
└────────────▲─────────────┘   └────────────▲─────────────┘
             │  gRPC                        │  gRPC
             └──────────┬───────────────────┘
                        ▼
                 ┌──────────────┐       ┌──────────────┐
                 │    Proxy     │◄gRPC─▶│ Orchestrator │
                 │  (port 5037) │       └──────────────┘
                 └──────────────┘        (locks & sessions)
                    ▲       ▲
             ADB    │       │  HTTP
          (port 5037│       │(port 8550)
                    │       │
  ------------------+-------+-------------- CI runner --
                    ▼       │
           ┌────────────┐   │   ┌─────────────────┐
           │ adb client │   └───│ sair-acquire /  │
           │ (thinks it │       │ sair-release    │
           │  talks to  │       └─────────────────┘
           │  real adb) │
           └────────────┘

从 CI 的角度来看,全部三台设备都属于同一个设备池。调用 sair-acquire 会锁定任意可用设备,无论设备实际连接在哪台机器上。

从源代码构建

需要 Go 1.24+。

go build ./cmd/sair-device-source
go build ./cmd/sair-proxy

或者使用 Docker:

# Device source image
docker build --target device-source -t sair-device-source .

# Proxy image
docker build --target proxy -t sair-proxy .

许可证

详情请参阅 LICENSE。

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。