Friend 桌面端 E2E Bundle Pool 实战指南:面向 Headless 测试的预授权具名 Bundle 池
2026/9/16 11:37:04 网站建设 项目流程

Friend 桌面端 E2E Bundle Pool 实战指南:面向 Headless 测试的预授权具名 Bundle 池

【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend

本文基于 Friend(Omi)桌面端仓库中的desktop/macos/docs/e2e-bundle-pool.mdscripts/omi-e2e-pool源码,系统讲解如何用"固定命名的预授权 Bundle 池"替代"每任务新建 Bundle",解决 macOS 无头(headless)端到端测试中 TCC 权限必须由人工授予这一根本矛盾。读完本文,你将掌握池的搭建、槽位租约(lease)机制、fail-closed 启动策略、共享/隔离两种登录态模式,以及它在run.sh与自动化测试中的真实调用链。

背景:为什么 macOS 无头测试需要"池"而不是"每任务一个 Bundle"

macOS 将每一项 TCC 授权(Microphone、Screen Recording、Accessibility、System Audio、Notifications、Automation、文件夹访问)绑定到Bundle ID 加签名证书这一组合上。由此产生两个直接后果:

  1. 授权在重建后存活:只要 Bundle ID 与签名身份不变,一次人工授权可以跨任意 worktree、任意 commit 反复复用——这正是池槽位可以"建一次、用很久"的根基;
  2. 只有人类能创建授权:任何 Agent、规则或脚本都无法点击系统对话框;在开启 SIP 的机器上,Screen Recording 与 Accessibility 既不能由tccutil授予,也不能通过 MDM 配置描述文件或直接改数据库绕过。

run.sh的默认行为是按链接的 worktree 推导omi-<worktree>具名 Bundle——这对交互式开发没问题,但对无头任务却是灾难:每个任务都从"零授权"起步,在第一个无法点击的系统对话框前戛然而止。池的设计把"人工介入"从"每个任务一次"压缩到"每个槽位一次"。

从 scripts/omi-e2e-pool 源码头部的注释可以看到这一动机被量化过:在一台宿主上实测曾出现过 12 个已安装具名 Bundle、TCC 中约 30 个 Bundle ID、78 个签名印记,全部由人工逐个授予后即被废弃。池正是对这一浪费的逆转。

一次性搭建:人类在机器的 GUI 会话中完成

池大小由"实际同时运行的 lane 数"决定——每个槽位都需要一次人工授权过程,默认值为 3,任意正整数均可:

export OMI_E2E_POOL_SIZE=3 # 建议写入宿主机的 shell profile cd desktop/macos ./scripts/omi-e2e-pool setup # 打印每个槽位的授权清单

setup命令本身不执行授权,而是打印每个槽位的人工步骤清单(见 cmd_setup)。对每个槽位,依次完成:

第 1 步:从任意 checkout 构建并启动一次,且必须带--full

槽位的首次构建没有可复用的产物,后续启动则默认走--fast-only。池在首次使用时固定该槽位的签名身份——默认是Omi Local Dev Signing,这是run.sh无需 GUI 即可自动创建的稳定自签名身份(详见 local-code-signing.md)。若想改用 Apple 身份,必须在首次 acquire 之前设置OMI_E2E_POOL_SIGN_IDENTITY;事后更改会重置该槽位持有的全部授权:

./scripts/omi-e2e-pool run --slot 1 -- ./run.sh --yolo --full --no-wait

第 2 步:打开槽位的 Permissions 页面,在 macOS 弹窗出现时逐项授权

Screen Recording 与 Accessibility 位于「系统设置 › 隐私与安全性」中;若系统提示应用"退出并重新打开",照做即可:

OMI_AUTOMATION_PORT=47701 ./scripts/omi-ctl navigate settings permissions --show

第 3 步:验证后释放槽位

./scripts/omi-e2e-pool check --slot 1 # 每个必需项都必须显示 granted ./scripts/omi-e2e-pool release --slot 1

周期性维护:macOS 15 及更新版本会定期重新询问应用是否可继续录制屏幕,check会将这种情况报告为screen_recording=stale,人工点击一次即可清除。

关于签名身份,还有两点值得注意(源码与文档双重印证):

  • Omi Local Dev Signingrun.sh按需自动创建的,无需 GUI、无密码交互,在 CI 与 ssh 会话中可用;
  • 自签名身份不会出现在security find-identity -v -p codesigning列表中,但codesign -s "Omi Local Dev Signing"依然有效——判断可用性的方式是实际签名一个探针文件,而不是查看列表。

登录态:shared(共享)还是 isolated(隔离)

模式机制适用场景
shared(默认)完整启动前克隆 Omi Dev 会话,与任何具名 Bundle 行为一致。开发者凭据以 JSON 文件形式存放在 Application Support 下的developer-secrets/<bundle-id>.json,因此从后台 Agent shell(launchdBackground会话、ssh)也能克隆,重建过程不会弹出 Keychain 访问请求。只需在 Omi Dev 中登录一次,后续槽位克隆该文件式会话即可测试以开发者本人账号身份跑在 dev 后端上,写入是真实数据
isolated槽位保留自己的会话。在槽位应用内用专用测试账号登录一次,会话持久化在槽位自己的developer-secrets文件中,跨重建存活;Rewind 历史也不会被克隆需要隔离测试账号、不希望污染开发者的 Rewind 历史
./scripts/omi-e2e-pool acquire --slot 2 --auth isolated

auth 模式绑定在槽位上而不是设置它的 lane 上,status会展示它。源码中 env_lines 对 isolated 槽位会额外导出OMI_SKIP_AUTH_SEED='1'OMI_SKIP_REWIND_SEED='1',分别跳过 Omi Dev 会话克隆与 Rewind 历史克隆。

无头默认值:开发者 Bundle 的 dump/seed 不再依赖登录 keychain,所以--auth shared在 Background 会话中也能工作。但池依然规定:非 Aqua 会话下的acquire,若未显式传--auth默认降级为 isolated(因为后台会话无法 dump Omi Dev 的 keychain 会话,shared 会种入空 dump、导致"冷启动")。显式传入的--auth永远优先;GUI(Aqua)会话的 acquire 保持 shared 默认。判断依据是 pool_manager_name 对launchctl managername的探测。

从任务中使用槽位

一个 lane 的完整生命周期如下:

cd desktop/macos ./scripts/omi-e2e-pool acquire # 取第一个空闲槽位,打印其编号 eval "$(./scripts/omi-e2e-pool env)" # 导出 OMI_APP_NAME、端口、签名身份、auth 模式 ./run.sh --yolo --fast-only --no-wait # 构建进已租用的槽位 ./scripts/omi-e2e-pool check # 缺少授权或槽位登出时 fail closed ./scripts/omi-ctl wait-ready && ./scripts/omi-ctl navigate rewind … ./scripts/omi-e2e-pool release # lane 结束时释放

也可一步完成:./scripts/omi-e2e-pool run -- ./run.sh --yolo——该包装器会在调用方未选 lane 时自动注入--fast-only(见 cmd_run)。

acquire还会把环境写入<worktree>/.dev/e2e-pool.env,因此同一 worktree 中后续的任何 shell 都能通过env找到自己的槽位,无需重复 acquire。每次envverifycheckrun都会刷新租约的心跳(heartbeat)。

env实际导出的完整变量集(见 env_lines):

环境变量含义
OMI_APP_NAME槽位应用名,如omi-e2e-1
OMI_E2E_POOL_SLOT槽位编号
OMI_E2E_POOL_TOKEN槽位租约令牌,跨目录证明持有权
OMI_AUTOMATION_PORT桥接端口
PORT桌面后端端口
PYTHON_PORTPython 后端端口
OMI_SIGN_IDENTITY槽位固定的签名身份
OMI_SKIP_AUTH_SEED/OMI_SKIP_REWIND_SEED仅 isolated 槽位导出,值为 1

启动策略:fail closed,而不是 fail cold

原本只写在本文档中的无头规则,现在由启动路径强制实施:

  • --fast-only是池的默认值omi-e2e-pool run会把它注入裸./run.sh调用;而run.sh自身会在快速 Bundle 指纹判定需要完整重建时(首次构建、输入变更、运行时载荷不完整)自行执行完整重建。
  • 已租用槽位上显式--full/OMI_FORCE_FULL_BUNDLE=1会被拒绝(退出码 2),只要已安装的 Bundle 仍可快速复用。你永远不需要它:真正需要重建的场景不会被拦截。仅OMI_FORCE_REWIND_SEED=1这类内部强制完整重建仍会放行。
  • 空 auth dump 绝不抹除任何东西。当种子化无法进行(源developer-secrets文件缺失或无令牌)时,槽位保留现有会话并在日志中说明,池槽位不存在"冷启动"路径。
  • 绝不重置池槽位的密钥存储。omi-local-profile-keychain-reset.sh 对com.omi.omi-e2e-*(任意池大小、任意OMI_E2E_POOL_PREFIX)直接拒绝:池槽位不是本地模拟器 profile,其developer-secrets文件是人类登录进去的持久化会话。
  • 登出的槽位是硬性失败check对登出槽位退出码为 2——与缺失 TCC 授权同级——并指向一次性人工修复。仅做健康检查不够:就绪门槛是先用omi-e2e-pool check确认授权与登录态,再用omi-ctl wait-ready等待已登录的 owner-ready 快照。

run.sh侧的强制执行点分别在 run.sh 第 253 行(构建前调用omi-e2e-pool verify "$APP_SLUG")与 omi_pool_refuses_explicit_full 纯函数([ -n "$1" ] && [ "$3" = "1" ] && [ "$2" = "reusable" ]为真时拒绝),拒绝信息在 run.sh 第 1273-1276 行 输出。

固定端口:无需手工穿线

每个槽位拥有固定端口,无需手工传递任何端口参数:

槽位 NBundle桥接端口桌面后端Python 后端
Ncom.omi.omi-e2e-N47700 + N10100 + N8300 + N

三个端口基址都位于scripts/dev-instance.sh推导的每 worktree 范围之外——桥接与桌面后端基址在其之下,Python 基址高于有界的8080 + offset范围(最大 8279)——因此池槽位永远不会与自动隔离的 worktree 发生端口冲突。./scripts/omi-e2e-pool slots会按配置的池大小打印完整端口表;测试中甚至验证了动态扩容(OMI_E2E_POOL_SIZE=7时出现omi-e2e-7 … 47707)与自定义前缀(omi-lab-1)行为。

租约:谁拥有槽位,何时让出

租约归属于 worktreerun.sh在触碰/Applications之前调用omi-e2e-pool verify <slug>:只有持有该槽位的调用 worktree(或携带槽位OMI_E2E_POOL_TOKEN的调用方)才能继续构建;其他人会立刻被拒绝,并被告知持有者名称与 worktree——而不是在 30 秒后、一个看起来像产品 bug 的路由里超时。

租约失效(defunct),且下次acquire会大声回收(loudly reclaim)的条件包括:

  • 持有者的 worktree 目录已不存在(omi-lane finishgit worktree remove、删除的 checkout);
  • worktree 仍在但持有者的.dev/e2e-pool.env已消失——删除池状态的 lane 视为已让出槽位;
  • --pid记录的持有者进程已死(存在 harness 时,传入 Agent 运行或 harness 的 pid);
  • 仅作兜底:超过OMI_E2E_POOL_STALE秒(默认 6 小时)没有任何池命令触碰它。

判定逻辑集中在 lease_defunct_reason:worktree 消失、env 文件缺失或令牌不匹配、pid 死亡、心跳过期,任一命中即失效。

存活的持有者绝不会被驱逐:池满时acquire会列出各持有者并停止,同时提示如何扩容。reap释放失效租约并报告——但不杀死被消失 lane 遗留的槽位应用,下次启动自然会替换它。

并发安全方面,acquirereleasereap都是"先读后写"租约文件,因此整个决策序列在同一把池锁下执行(pool_lock,基于mkdir的原子性):同一时刻两个 lane 并发 acquire 会被串行化,最终各持有一个互不相同的槽位——测试用三路并发 acquire 验证了"恰好两路获胜且槽位互异"。

其他细节:

  • release --slot N仍先解析调用方 worktree 再释放;即使调用方点名槽位,另一个 worktree 持有的存活槽位也会被拒绝。--worktree PATH为在 checkout 外调用的 harness 提供调用方身份。失效租约仍可释放,便于清理已消失的 lane。
  • 所有权按规范化 worktree 路径比较(canonical_worktree 解析符号链接),所以通过相对路径或 symlink acquire 的租约,持有者默认解析即可释放,无需重复--worktree
  • 兜底到期后归来的持有者只需刷新自己的租约:兜底回收的是"消失 lane"的槽位,不会把存活的 lane 锁在自己的槽位之外。
  • status展示每个槽位:是否安装、应用是否运行、桥接端口是否被占用、持有者、心跳年龄、auth 模式。

池不覆盖的边界

  • 共享硬件。槽位可以共存,但机器只有一支麦克风、一个 ScreenCaptureKit 实例。断言采集到音频或帧的测试需要单独的 capacity-1 采集租约;两个 lane 同时灌入音频会产生"指向错误的失败",而不是争用错误。
  • GUI 会话。从后台 Agent shell 启动 GUI 应用,仅在控制台已有用户登录且 WindowServer 运行时才有效。这是宿主前置条件,不是槽位能提供的能力。
  • Onboarding 与授权流程本身。池槽位已经越过 onboarding 且已授权。要测试这些流程,需按 AGENTS.md 所述使用一次性具名 Bundle。

契约测试:可验证的行为保证

tests/test-omi-e2e-pool.sh 是该工具的封闭(hermetic)契约测试:无应用、无 TCC、无/Applications——池目录、"worktree"与应用目录全部放在临时目录中,可在任意宿主(包括 Linux CI)运行,并纳入 launcher 测试发现循环,每次 CI 都会执行。它覆盖的行为包括:端口表派生、前缀校验(必须为omi-开头且已是 slug 形式)、非池 slug 直通、acquire 幂等与并发串行化、verify 拒绝并点名持有者、release 所有权保护、失效租约的三种判定与reap、心跳刷新、isolated/shared 模式粘附、身份固定、run注入--fast-only的策略矩阵,以及check对登出/缺授权槽位退出码 2、对无响应槽位退出码 1 的 fail-closed 语义。

此外,测试还直接驱动run.sh中的omi_pool_refuses_explicit_full纯函数,验证其决策矩阵:可复用槽位上显式--full被拒、指纹不符与首次构建放行、载荷不完整放行、rewind reseed 强制完整放行、非池具名 Bundle 按需重建放行——这些共同构成了池启动策略的可验证保证。

【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询