Skip to content

Latest commit

 

History

History
332 lines (233 loc) · 35.5 KB

task-control.md

File metadata and controls

332 lines (233 loc) · 35.5 KB

任务控制:多个调用方共用一台手机

这份文档记录「AI 任务怎么被人和程序监管」的目标架构、它和现状的差距,以及分步落地的顺序。为什么要把任务放进守护进程见 D17。缘起是 #50:外部 Agent 平台想用控制台的步骤显示、暂停、接管和确认。

1. 要解决的问题

一台手机同一时间只能有一个操控者(D11),想用它的却很多:控制台里的 AI 任务、外部 Agent 平台、看着页面的人、命令行、定时任务。要做到:

  1. 独占:同一时刻只有一方在操作手机。这一点要强制执行,不能靠各方自觉。
  2. 归属:每个动作都能追到是哪个任务、哪个调用方做的,基于的是哪次观察。
  3. 监管:暂停、接管、确认、取消在执行点生效,谁也绕不过去。
  4. 出错就停:调用方不见了、设备变了、服务重启了,任务都停下来,不能没人看着继续操作。

2. 目标架构

 控制台(人)   命令行    外部平台适配层    内建 runner(Claude/Codex)
     │         │            │                 │
     └─────────┴─── 控制面:任务 · 租约 · 确认 · 事件 ───┘
                            │
       MCP 适配层(凭任务口令绑定到某个任务)
                            │ 数据面:设备动作
          ┌─────────────────┴─────────────────┐
          │ 守护进程:唯一的执行点                  │
          │ 租约 · 任务状态机 · 策略 · 确认          │
          │ 步骤日志(落盘) · 事件流               │
          └─────────────────┬─────────────────┘
                     设备(WDA / HID)

2.1 原则:监管和执行在同一个地方

守护进程本来就是唯一持有手机的进程(D2),所有动作都要在它那里排队拿锁。监管也应该放在这里:动作要经过准入检查(有没有租约、任务处于什么状态、设备对不对、策略允不允许)才执行。控制台、命令行、MCP 都只是客户端,谁都不能自己决定「现在可以操作」。

如果监管放在某个客户端里(比如控制台),不经过它的调用方照样能操作手机。这种监管只拦得住愿意配合的调用方。

2.2 概念

概念 含义
主体(principal) 调用方:人、外部平台、内建 runner
租约(lease) 谁正在占用哪台设备。可以转移:接管时转给人,交还时还回来
任务(task) 一件事从开始到结束的全过程。属于某个主体,绑定一台设备
步骤(step) 一次动作:提出、准入、执行、结果,归在某个任务名下
确认(approval) 一个单独的待决定事项,有自己的编号和决定权

2.3 状态机

Running ⇄ Paused
Running / Paused → TakenOver → Running(交还后先重新看界面)
Running → Waiting(等确认)→ Running
终态:completed · failed · cancelled · rejected · abandoned(租约断了) · interrupted(设备变了或服务重启)

每次状态变化都追加写入任务日志。守护进程重启后,没结束的任务记成 interrupted,不会悄悄消失。审计记录也一直在。

2.4 租约:用连接是否还在判断调用方是否还在

开任务的调用方保持一条连接,这条连接就是租约。连接断了(进程退出、崩溃、被杀),任务就变成 abandoned,之后这个任务的动作一律拒绝。不用心跳和超时,也就没有心跳超时的误判。

2.5 口令

  • 执行口令(actor):交给 Agent。凭它操作手机、报告步骤、请求确认。
  • 控制口令(controller):给任务的所有者,用来暂停、取消、确认。
  • 人:对任何任务都能接管、停止。

口令还可以限定范围:哪台设备、哪些 App、最多几步、最长多久。

边界要说清楚:同一个系统用户下,进程能读到彼此的文件和环境,口令只是约定,不构成安全边界(和 D13 同理)。要让「只有所有者能确认」真正成立,必须让 Agent 换一个 uid 运行或者放进沙箱,同时守护进程用 SO_PEERCRED 认调用方。

2.6 敏感动作:由策略拦截,不靠 Agent 自觉

只看点击坐标,没法识别所有敏感操作。最有效的办法是给任务划定范围:开任务时声明它只在哪些 App 里操作。守护进程知道前台是哪个 App,任务越界就停下来等人。iphone_confirm(Agent 主动请求确认)作为补充。

2.7 观察版本:人和 Agent 都动过手机时,动作建立在旧画面上怎么办

每次观察(截图、读元素)都对应一个版本。别人(人或者其他调用方)动过手机之后,再来的动作如果不是基于新的观察,就拒绝执行,让 Agent 先重新看一遍。

2.8 子步骤

iphone_batch 这类一次发多个动作的请求,在每个动作之间都检查暂停和接管,而不是把整批当成一步。

2.9 设备绑定

开任务时绑定 UDID,之后每个动作都带上它。切换设备或拔掉设备时,任务停下(interrupted),需要人重新开始。

2.10 只有一套接口

  • 控制面只有一套接口:本机用 Unix socket,以后需要远程时再加带认证的 HTTP。事件带编号,断线后能从断点接着收。
  • 内建的 Claude/Codex runner 也走这套接口。外部平台用的就是内建功能自己在用的那套,不另开一条路。
  • MCP 只是一层适配:靠任务口令绑定到某个任务。

2.11 兼容

不带任务口令的普通 MCP 调用,没有任务在跑时和以前完全一样。有任务在跑时,看界面(截图、读元素、状态)照常可以,操作手机会被拒绝,并说明原因。

3. 现状与差距(2026-11-12,0.2.2 之后的 main)

做对了、不用动的:

  • 守护进程独占设备,是唯一的排队点(D2)。准入时检查期望的设备(expect_udid),排队太久的动作丢掉,调用方走了的请求也丢掉(core.rs 的 admit)。
  • 任务只要出问题就拒绝:gate 不在、任务不在,一律拒绝。
  • 步骤由 gate 记录,不解析 Agent 的输出,所以和用哪个 Agent 无关。
  • 接管后要求先重新看界面(recheck),是观察版本的雏形。
  • socket 权限 0600;每个请求自带语言。

差距(越靠前越根本):

# 差距 现状 后果
1 监管不在执行点 任务、暂停、确认都在 viewer 进程里,MCP 主动来问 gate;守护进程不知道有任务这回事 普通 MCP、命令行在任务进行中照样能操作;不开控制台就没有任务
2 没有租约 独占只靠 viewer 里的 start() 和 manual_allowed() 检查 互斥只在控制台里有效
3 任务不落盘 只在内存里,保留最近 4 个 一重启就没了,没有审计记录
4 状态不全 Running/Paused/Takeover/Waiting/Done,再加 ok、stopped 两个标志 分不清放弃、中断和失败
5 只有一个口令 每个任务一个口令,交给 Agent;控制靠「能访问本机 HTTP」 执行方和控制方分不开
6 敏感动作靠 Agent 自觉 只有 iphone_confirm Agent 不主动报,就拦不住
7 设备绑定只做了一半 页面上的动作带 expect_udid,gate 下 MCP 的调用不带 任务进行中在命令行 select,任务就去操作另一台设备
8 观察版本只覆盖一种情况 recheck 只在控制台接管时触发 别人从命令行或其他 MCP 动过手机,Agent 不知道
9 批量算一步 iphone_batch 只过一次 gate 暂停要等整批跑完才生效
10 没有公共控制面 任务接口只有面向浏览器的 HTTP;内建 runner 的参数写死在 agent_args 外部平台没有接入点
11 只能靠子进程判断存活 只适用于 viewer 自己启动的 Agent 外部 Agent 无法判断是否还活着
12 单设备 和 D11 一致 有同时驱动多台的需求时再说

4. 落地顺序

差距 1 是根本:租约和任务状态机放进守护进程以后,2、3、4、7、8、9、10、11 都自然解决,或者变成小改动。差距 5、6 要等 Agent 在沙箱里运行、需要无人值守时才有实际意义,单用户本机部署时只会增加复杂度。

阶段 内容 状态
一 任务和租约放进守护进程:控制面请求、准入检查、状态机、落盘、设备绑定、观察版本(简化版)、子步骤;内建 runner 和 MCP 改走守护进程;iphone-control task 命令给外部平台用 已完成(2026-11-12)
二 控制台接上正在进行的任务(刷新后、在别处开的);命令行查看事件 已完成(2026-11-12)
三 任务范围(App 白名单)和越界确认 已完成(2026-11-13,见 5.5)
三 执行方不能控制自己的任务(2.5 的落地版,见 5.6) 已完成(2026-11-13)
四 局域网访问控制台(7.2)、控制台后台服务、HTTPS(7.3) 已完成(2026-11-13)
四 HTTP MCP(7.4) 已完成(2026-11-13)
四 登录(7.5,一个令牌) 已完成(2026-11-13)
四 有名字的令牌和只读令牌、跨主机开任务(7.6) 已完成(2026-11-13)
四 远端 stdio 转接、task --url(7.7) 已完成(2026-11-13)
四 更细的权限、多设备租约 等需要时

5. 阶段一的具体设计

5.1 请求

都是控制接口上的普通请求(每行一个 JSON),任务口令用请求键 task 传递,和 lang、expect_udid 一样:

请求 谁发 作用
task_open { title, owner } 开任务的一方 绑定当前控制的设备,开一个任务。先回一行 {id, token},然后这条连接就是租约,持续推送任务事件,直到任务结束;连接断了,任务就是 abandoned
task_events { id } 任何人 先重放已有事件,再接着推送新事件,任务结束时连接关闭
task_list 任何人 最近的任务(含正在进行的)
task_control { id, action, step?, approve? } 人、所有者 pause、resume、takeover、handback、stop、confirm
task_step { tool, args } 执行方(带口令) 一个工具调用开始:任务不在 running 时一直等,然后记一步,返回步骤号
task_step_done { step, ok, ms, text } 执行方 一个工具调用的结果
task_confirm { action, detail } 执行方 等人确认
task_note { text } 执行方、所有者 Agent 说的话,归到下一步
task_finish { ok, summary } 执行方、所有者 任务结束

5.2 准入规则(Daemon::handle)

有任务在进行(不在终态)时:

  • 带这个任务口令的请求:任务不在 running 就一直等,调用方走了就放弃;等完后排队时间重新计算。请求固定发往任务绑定的设备。不能切换设备。交还控制或者别人动过手机之后,先要有一次观察,才接受下一个动作。
  • 不带口令的请求:查看类(截图、读元素、状态、列 App 等)照常执行。动作类只在 takeover(人接管)时接受,接受后给任务记上「需要重新观察」;其他状态下一律拒绝并说明原因(抬起手指除外)。切换设备、手动重签同样拒绝。
  • 带的口令属于已结束或不存在的任务:任何请求都拒绝,提示「任务已结束,停下来」。

请求放行之后,还要排队拿设备锁(可能排在 wait_for、巡检后面),这期间人可能暂停、接管或交还了。所以拿到锁之后再按上面的规则查一次(Tasks::still):任务的请求遇到暂停、接管、等待确认就放下锁接着等,用户的请求在交还之后就拒绝。「需要重新观察」只有观察成功(截图、读元素、等文字没有出错)才清掉。

没有任务在进行时,和以前完全一样。

5.3 落盘

每个任务一个 tasks/<id>.jsonl,每行一个事件,最多保留 50 个。守护进程启动时把没有 done 的任务补成 interrupted。

5.4 给外部平台用

iphone-control task run --title … -- <命令>:开任务、保持租约,带着 IPHONE_CONTROL_TASK_TOKEN(以及 IPHONE_CONTROL_TASK_ID)启动命令,命令里的 iphone-control mcp 会自动加入任务。命令结束时任务随之结束,结果取命令的退出码;命令想写总结时自己调 iphone-control task finish …。任务在别处被停止时,结束这个命令的整个进程组。Ctrl-C 或 SIGTERM 等于停止任务。适配层只需要包一层,不用自己实现协议。

5.5 任务范围(阶段三的前一半)

开任务时可以带 apps(App 名或 Bundle ID;iphone-control task run --app … 可以写多次;控制台上是 Agent 旁边的 App 按钮,传 Bundle ID)。开任务时就解析成 Bundle ID,名字写错直接开不了任务。不带就是不限制,和以前一样。

有范围的任务,每个操作执行之前守护进程检查它会碰到哪个 App(tasks::scope_check):

操作 看什么
启动 App 要启动的那个
打开链接 http(s) 算 Safari;别的 scheme(tel:、sms:、App 自己的)不知道会打开什么、做什么,一律先问,同意也只放行这一次
点击、按名字点、长按、滑动、输入、按下和移动手指、在网页里执行 JS 当时的前台 App(问 WDA 的 activeAppInfo)
安装、卸载 App 不管范围,一律先问
按键、结束 App、看屏幕 不检查:被拒绝之后靠它们回到范围内
  • 主屏幕(SpringBoard,含锁屏和通知中心)总在范围内。
  • 超出范围时任务进入 waiting,和 iphone_confirm 用同一套确认:页面和 task approve/reject 都能回答。同意后这个 App 加入本任务的范围,之后不再问;拒绝只拒这一步,Agent 收到「回到范围内的 App 再继续」,任务不会因此记成 rejected。
  • 看不出前台是哪个 App(WDA 不可用、读失败)时不操作,返回错误让 Agent 稍后再试,不在不知道是哪个 App 的界面上盲点。
  • 查前台只短暂拿设备锁,等人回答时不持锁(人可以先接管);调用方中途断开,问题从页面上撤掉。
  • 问人(越界确认和 iphone_confirm)和一个步骤一样,只在任务运行时开始:暂停、接管时先等着,不会把人的暂停或接管改成「等待确认」;同时只有一个问题,第二个等第一个答完。回答之后只有任务还在「等待确认」时才回到运行。
  • 局限:检查的是动作之前的前台。在范围内的 App 里点一下跳进了别的 App,这一下本身不会被拦,进去之后的下一步才会。

5.6 执行方不能控制自己的任务(2.5 的落地版)

问题:task_control 原先只看任务 ID。Agent 用自己的 Bash 跑一句 iphone-control task approve <id>,就能批准自己的越界确认,或者在用户暂停后自己继续,任务范围和 iphone_confirm 都形同虚设。

为什么不发控制口令:命令行和控制台要能控制任务,就得能拿到控制口令,比如放在 0600 的文件里;Agent 和用户是同一个系统用户,同样读得到。在同一个 uid 下,口令分不开执行方和控制方。

做法:暂停、继续、接管、交还、确认这几种控制只能由人来做;停止不限,Agent 可以放弃。下面两种请求会被拒绝:

  1. 带任务口令的控制请求:iphone-control task … 会自动带上环境里的 IPHONE_CONTROL_TASK_TOKEN,所以 Agent 在自己的环境里执行就会被拒绝。
  2. Agent 进程发来的控制请求:开任务的进程(控制台、task run)在启动 Agent 之前发 task_agent(带口令;守护进程用 SO_PEERCRED 核对它就是开任务的进程),说明「我之后启动的进程都是 Agent」。之后,控制请求如果来自它的子孙进程(SO_PEERCRED 取 pid,再沿父进程链往上找),就拒绝;开任务的进程自己发的不拒绝(控制台转发页面上的点击,task run 收到 Ctrl-C 后停止任务)。控制台的接口也一样:本机来的请求,控制台在 Linux 上通过 /proc/net/tcp 和 /proc/*/fd 查出是哪个进程连过来的,作为 for_pid 一起交给守护进程判断。在 macOS 上查不出,这条路只能靠第 1 条。

成功的控制会记一条 control 事件 {action, by, step?, approve?},by 是 viewer(局域网来的带上 IP)、cli 或 owner。

局限:这不是安全边界。Agent 如果故意把进程挂到别处,比如两次 fork 后被 init 或 systemd 收养,或者用 systemd-run 启动,就不再是开任务进程的子孙。这里拦住的是最常见的情况:Agent 用自己的 Shell 去批准。要真正分开,需要 Agent 换一个 uid 运行或放进沙箱(2.5)。

6. 阶段一、二之后还差什么

和第 2 节的目标架构相比:

  • 口令只有一种(2.5):同一台机器上,开任务的一方拿到的口令同时交给 Agent,没有单独的控制口令;改为按口令和进程拒绝执行方的控制请求(5.6)。跨主机时登录令牌就是控制口令,Agent 只拿任务口令(7.6)。要在同一台机器上真正隔离,仍要 Agent 换 uid 或进沙箱。
  • 任务范围已有,策略还没有(2.6):限定 App 后越界会停下来问人(5.5);但范围内的敏感动作(在微信里发消息)仍然靠 Agent 调 iphone_confirm。控制台上开任务时也能设范围(Agent 旁边的 App 按钮,2026-11-13)。
  • 观察版本是简化版(2.7):只有一个「需要重新看」的标记,在交还、或别人在接管期间动过手机后置上,Agent 截图、读元素或等文字之后清掉。动作不带「基于哪次观察」,所以 Agent 自己连续操作时不做这项检查。
  • 事件不带编号(2.10):重连时从头重放,没有从断点继续。任务事件全是文字,从头重放代价不大。
  • 设备拔掉时(2.9):任务不中断,它的请求会被拒绝(找不到绑定的设备),插回同一台手机后可以继续;只有换成另一台设备才中断。
  • 单设备(D11):仍是。只在本机已经不是:控制台就是带登录的 HTTP 控制面,局域网和别的机器都能开任务、控制任务、用 MCP(第 7 节)。

7. 阶段四的需求

7.1 局域网访问控制台(2026-11-13 提出)

场景:守护进程和手机在一台机器上(比如 NAS),人在局域网里另一台电脑的浏览器上看控制台、操作手机、开 AI 任务,不想每次先建 SSH 隧道。

现状(7.2 之前):控制台只绑 127.0.0.1,没有改的参数;请求的 Host 必须是 127.0.0.1/localhost/[::1](防 DNS rebinding),操作的 Origin 必须是页面自己的这几个地址;没有登录(D3)。局域网里用 SSH 隧道:ssh -L 8202:127.0.0.1:8202 <用户>@<主机>,浏览器开 http://127.0.0.1:8202/。在做之前,这是推荐的办法。

要求:

  1. 显式开启,默认不变:默认仍然只监听本机(D3)。开局域网要明确配置监听地址(例如 viewer --listen 0.0.0.0:8202,或设置文件里的一项),启动时提示谁能连上。
  2. 必须认证:开了局域网监听就必须配访问令牌(或密码),没配就拒绝启动,不能退回「谁都能连」。令牌存在设置文件里(0600);浏览器登录一次后用 HttpOnly、SameSite=Strict 的 cookie;输错限速。MCP、命令行等非浏览器客户端用 Authorization: Bearer。
  3. Host / Origin 检查照样做:认可的从「只认回环」改成「回环 + 配置的主机名或 IP」;不在名单里的 Host 一律拒绝(DNS rebinding 依然要防),操作的 Origin 必须是这些地址之一。
  4. HTTPS:WebCodecs 只在安全上下文(HTTPS,或 127.0.0.1/localhost)里可用,用普通 HTTP 从局域网 IP 打开时,H.264 / 手机自己的 HEVC 画面都解不了,只能退回每秒 4~10 MB 的 MJPEG。所以局域网访问要支持 HTTPS:用户提供证书,或者自动生成自签证书并显示指纹供第一次核对。令牌在 HTTP 上明文传输也是一个理由。
  5. 能做什么分开:至少区分「只看画面和状态」和「操作手机、开 AI 任务、确认」两种令牌(对应 2.5 的口令分开);任务事件里记下是哪个令牌(哪个人)暂停、接管、确认的。
  6. 和控制面是同一件事:局域网里的控制台用的就是第 2.10 节的 HTTP 控制面,外部 Agent 平台跨主机接入(HTTP MCP)用同一套认证,不另做一套。

验收:

  • 默认安装不监听任何网卡(和现在一样)。
  • 开了局域网监听、没配令牌:拒绝启动并说明原因。
  • 局域网另一台电脑:用令牌登录后能看画面(H.264 或 HEVC,不退回 MJPEG)、操作、开 AI 任务、暂停、确认。
  • 没有令牌、令牌错误、Host 不在名单里、来自别的网页的 Origin:一律拒绝。
  • 只读令牌:能看画面和状态,操作、开任务、确认都被拒绝。

7.2 局域网访问:第一版(2026-11-13)

和 7.1 不同,第一版按用户的决定先不做登录,登录(一个令牌)放到以后。所以它只能是显式开启的,默认和现在完全一样。

  • 开启:iphone-control viewer --lan(设置文件里 IPHONE_CONTROL_VIEWER_LAN=1)。还是同一个端口、同样是 HTTP,只是从 127.0.0.1 改成监听所有 IPv4 网卡。不加 --lan 时只绑 127.0.0.1,和以前一样。
  • 只接受局域网来的连接:对端地址必须是回环、私有网段(10/8、172.16/12、192.168/16)、链路本地(169.254/16)或 CGNAT(100.64/10,Tailscale 用它)。别的一律直接断开,机器万一暴露在公网也不会被外网操作。
  • Host 检查保留(防 DNS rebinding):认可的是回环名、本机主机名和 <主机名>.local、启动时各网卡的 IPv4 地址,以及 --lan-host 额外写的名字(可写多次,给 DNS 名用)。
  • Origin 检查保留(防别的网页借浏览器操作):操作的 Origin 必须是 http://<认可的 Host>:<端口>。Sec-Fetch-Site 检查照旧。
  • 启动时提醒:打印每个局域网地址,并说明「局域网里任何人都能打开它操作手机、让 AI 执行任务,没有登录,只在可信的网络里开」(7.5 加了登录以后,这句提醒去掉了)。
  • 画面:浏览器只在 HTTPS 或 localhost 上提供 WebCodecs,从局域网 IP 打开时,页面自动退回 MJPEG(每秒 4~10 MB)。手机自己的 HEVC 画面也用不了,所以系统弹窗直接在 MJPEG 画面里看得到。
  • 代价:局域网里能连上这台机器的人都能操作手机、用本机登录的 Claude Code / Codex 开任务。这是有意的权衡,等加了登录再收紧。决策记录见 D3 的补充。

之后:

  1. 控制台做成后台服务(iphone-control service viewer install,开机自启,跟着设置文件里的 IPHONE_CONTROL_VIEWER_*):已完成(2026-11-13)。升级时先停下它(它是常驻客户端,会让升级一直 busy),升级完再启动;有 AI 任务在跑时自动升级推迟。
  2. 登录:一个令牌,存在数据目录,登录后用 HttpOnly; SameSite=Strict; Secure 的 cookie,程序用 Authorization: Bearer,失败限速;局域网口子开了登录才放行。HTTP MCP 届时再定。(已在 7.5 做完;cookie 最后用的是 SameSite=Lax,见 7.5。)
  3. HTTPS:已完成(2026-11-13),见 7.3。

7.3 HTTPS(2026-11-13)

7.1 第 4 条的落地。目的是让局域网里打开的控制台和本机一样用 WebCodecs(H.264、手机自己的 HEVC),并且加密;它不是登录。

  • 同一个端口:看连接的第一个字节,0x16(TLS 握手)走 TLS,否则是明文 HTTP。明文从本机(回环)来照常服务;从局域网来的回 307 跳到 https://<它请求的 Host>:<端口><路径>(Host 已经过白名单,不会成为开放跳转;用 307 而不是 308,免得浏览器记住,之后 --tls off 时打不开)。握手在每个连接自己的任务里做,最多等 10 秒。
  • 什么时候开:--tls auto(默认)在 --lan 或给了证书时开;on、off 强制。设置文件:IPHONE_CONTROL_VIEWER_TLS、_TLS_CERT、_TLS_KEY。
  • 证书:用户给的(PEM,可带证书链;mkcert、tailscale cert、内部 CA),或者本机生成的自签证书:ECDSA P-256,800 天(低于 Apple 的 825 天上限),EKU serverAuth,SAN 是回环名、主机名和 .local、各网卡 IP、--lan-host。存在数据目录 viewer-tls/(key.pem 0600),旁边 names.json 记下覆盖的名字和到期时间。名字都覆盖、离到期还有 30 天以上就沿用;出现新名字(DHCP 换了地址)或快到期就重新生成,新证书保留旧名字,序列号取当前时间(Firefox 拒绝同一签发者重复的序列号)。
  • 指纹:SHA-256,按浏览器显示的 AB:CD:… 格式,在启动输出和 service status 里,供第一次核对浏览器的警告。
  • Origin:操作的 Origin 接受 http:// 和 https:// 两种,名字和端口照旧要对。
  • 写出:TLS 会把最后一段留在缓冲里,所以每个响应、视频头、每条任务事件、每帧画面之后都 flush(明文 TCP 上 flush 什么也不做)。
  • 依赖:tokio-rustls、rcgen 原本就在依赖树里(idevice、reqwest、plume_core),只用 ring,没有新增 crate。
  • 局限:自签证书第一次要手动点「继续」;之后页面按规范是安全上下文,但各浏览器对点过警告的证书仍有限制(比如 Chrome 不注册 Service Worker),WebCodecs 能不能用以真机浏览器实测为准。

7.4 HTTP MCP(2026-11-13)

7.1 第 6 条的一部分:外部 Agent 跨主机接入,用和控制台同一个端口、同一套访问规则,不另开一个口子。

  • 在哪:控制台的 /mcp,Streamable HTTP(rmcp 的服务端:会话、SSE 都由它做)。控制台照旧一个连接一个请求:读完请求头和请求体交给 rmcp,再把它的回应(JSON 或 SSE)一段段写回去、每段 flush,直到结束或客户端断开。POST 必须带 Content-Length(否则 411),请求体最多 1 MB。
  • 工具:和 stdio 的 iphone-control mcp 是同一个 McpServer,没有任何差别(D10)。
  • 任务:stdio 靠进程的环境变量 IPHONE_CONTROL_TASK_TOKEN 加入任务;HTTP 是一个进程服务许多客户端,所以口令放在每个请求的头 Iphone-Control-Task-Token 里,McpServer 按请求读取,用 as_task 让这次调用发出的所有请求都带上它。「是否在任务里」也改成按请求判断(protocol::in_task)。带了口令就列出 iphone_confirm、每次调用报告步骤,和 stdio 一样。
  • 访问:和控制台页面一样,对端地址、Host、Sec-Fetch-Site 先查;带 Origin 的请求(浏览器)必须来自控制台自己,命令行客户端不带 Origin。rmcp 自己的 Host 白名单设为不检查,免得两套名单不一致。--lan 时局域网的明文请求 307 到 HTTPS。
  • 开关:--mcp on|off(IPHONE_CONTROL_VIEWER_MCP),默认开:它能做的事控制台的接口本来就能做,没有多开口子。
  • 还没有的:从别的机器开任务(任务的租约还是本机 socket 上的连接,外部平台目前在本机用 task run 开任务、把口令交给远端的 agent)。已在 7.6 做完。

7.5 登录(2026-11-13)

7.1 第 2 条的落地,按用户的决定做了一个令牌(只读和可操作分开留到以后)。和 7.1 不同的一点:本机也要登录。frp、nginx、cloudflared、tailscale serve 这类反向代理或隧道跑在本机时,公网上每个访问者的连接都来自 127.0.0.1,控制台分不清是用户本人还是别人,所以不能「本机免登录」。

  • 范围:经 HTTP 的一切(页面、接口、画面、任务事件、/mcp)。Unix socket 上的命令行、stdio 的 iphone-control mcp、task run 不受影响(socket 是 0600,本来就只有这个用户能连)。
  • 令牌:第一次启动时用系统随机数生成 256 位,十六进制存在 <数据目录>/viewer-token(0600,先写临时文件再改名;7.6 起改为 viewer-tokens.json)。iphone-control viewer token 显示令牌、本机和局域网的登录链接(/?token=…)、claude mcp add … --header "Authorization: Bearer …";--new 换新令牌,控制台服务在运行就重启它。令牌不写进启动输出和日志(journal 可能有别人能读)。
  • 浏览器:没登录时 / 返回登录页(401);登录页的表单 POST /login(带 Origin 时必须是控制台自己),或者点开 /?token=… 的链接,成功后 303 回 /,地址栏里不留令牌。会话 cookie ictl_viewer_<端口>:值是令牌对固定文本的 HMAC-SHA256,看不出令牌,换令牌后自动失效;HttpOnly、SameSite=Lax(从别处点链接进来也认得,CSRF 靠原有的 Origin 和 Sec-Fetch-Site 检查)、HTTPS 时加 Secure、30 天。页面上的请求遇到 401 就刷新,回到登录页。
  • 程序:Authorization: Bearer <令牌>。/mcp 的 401 带 WWW-Authenticate: Bearer(RFC 6750)。
  • 限速:按全局算,不按地址(经反向代理时地址都是 127.0.0.1)。一分钟里错 20 次后,这一分钟内错的令牌一律 429;已经登录的会话不受影响。令牌是 256 位随机数,限速只是多一层保险。(最初这一分钟内连正确的令牌也不校验,结果谁能连上页面,就能靠刷错令牌让 mcp --url、远端任务都连不上;0.2.4 之后改成正确的令牌总能进。)
  • 比较:令牌和 cookie 都用与内容无关的时间比较。
  • 还没有的:页面上的退出登录。只读令牌和记下是哪个令牌见 7.6。

7.6 只读令牌与跨主机开任务(2026-11-13)

7.1 第 5、6 条,以及 2.5「真正的控制口令」在跨主机时的落地。

有名字的令牌

  • 令牌改存 <数据目录>/viewer-tokens.json(0600),每个有名字(字母、数字、-_.,最多 40 个字)和权限:operate 什么都能做,view 只能看。第一次启动时把原来的 viewer-token 变成 owner(同一个令牌,会话 cookie 也不变),再删掉旧文件。
  • viewer token / --new 仍然是 owner;viewer token add <名字> [--read-only]、list(不显示令牌本身)、show <名字> [--new]、remove <名字>(最后一个能操作的令牌不能删)。
  • 控制台每次校验前看文件的修改时间,变了就重读:增删、换令牌马上生效,不用重启;被删或被换掉的令牌,它的会话随之失效。
  • 每个令牌的会话 cookie 值不同(各自的 HMAC),所以知道是谁;页面上的控制记成 viewer:<名字>(局域网来的再带 IP)。
  • 只读:任何 GET(/mcp 除外)和 POST /api/video/ack(报告画面读到哪里)放行,其余一律 403。/api/info 带上 login: {name, role},页面据此隐藏输入框、底栏按键和确认按钮,act() 也直接拦下。读元素、查系统弹窗会顺带自动解锁手机,算「看」,只读也能用。
  • Origin:带会话 cookie 的写请求(浏览器)照旧要 Origin 是控制台自己;用 Authorization: Bearer 或任务口令的(程序)可以不带 Origin,网页没法让浏览器带上这两种请求头。

跨主机开任务

  • POST /api/agent/tasks/open(能操作的令牌,{title, owner?, apps?}):控制台替平台在守护进程的 socket 上 task_open,把 {id, token} 作为第一条 opened 事件发回,之后转发任务事件。这个 HTTP 响应就是租约:客户端断开,控制台丢掉 socket 上的租约,任务记为 abandoned。和本机一样不用心跳;每 15 秒一条注释防代理掐断空闲连接。控制台重启时这些任务也会中断。
  • 执行和控制分开:平台留着登录令牌(控制方:/api/agent/tasks/<id>/<动作>),只把任务口令交给 agent(执行方)。/mcp 和 POST /api/agent/tasks/finish 认进行中任务的口令(请求头 Iphone-Control-Task-Token,控制台用新的守护进程请求 task_check 核对),别的路径不认。所以远端 agent 能为任务操作手机、结束任务,碰不到暂停、继续、接管、确认。这是 5.6 的进程检查在跨主机时做不到的部分;错的任务口令和错的登录令牌一起计入限速。
  • task run --url <控制台> [--ca <证书>]:登录令牌从 IPHONE_CONTROL_VIEWER_TOKEN 读,不传给命令(启动前从环境里去掉);命令拿到 IPHONE_CONTROL_TASK_TOKEN、IPHONE_CONTROL_TASK_ID、IPHONE_CONTROL_URL、IPHONE_CONTROL_MCP_URL(和 IPHONE_CONTROL_CA)。命令里的 task finish 看到 IPHONE_CONTROL_URL 就经控制台结束任务。其余行为(Ctrl-C 停止、命令结束任务结束、在别处停止时停掉命令、task finish 后给 5 秒退出)和本机一样。
  • 局限:同一个控制台上的令牌都是全权或只读,还没有「只能开任务」这类更细的权限。远端的 stdio 客户端和其他 task 命令见 7.7。

7.7 远端的 stdio 转接和 task --url(2026-11-13)

  • iphone-control mcp --url <控制台> [--ca <证书>]:stdin/stdout 上是 MCP(每行一条 JSON-RPC),另一边是控制台的 /mcp(Streamable HTTP)。在 JSON-RPC 这一层转发,不用 rmcp 的 HTTP 客户端(免得多带一个 reqwest 版本):每条消息 POST 过去,回来的 JSON 或 SSE(收到这个请求的回复就停)写回 stdout;initialize 单独先发,之后带上它给的 Mcp-Session-Id 和协议版本;请求并发(工具调用可能等人很久),通知和对服务端请求的回复按顺序发(notifications/initialized 先到);HTTP 出错时给这条请求回一个 JSON-RPC 错误,客户端不会一直等;stdin 关了就 DELETE 会话。
  • 凭据:有 IPHONE_CONTROL_TASK_TOKEN 就只带任务口令(不带登录,所以不能控制任务),否则用 IPHONE_CONTROL_VIEWER_TOKEN 登录。--url 默认取 IPHONE_CONTROL_URL、--ca 取 IPHONE_CONTROL_CA,正是 task run --url 给命令设的:里面的 agent 照常配置 iphone-control mcp,不用改。
  • task … --url:--url、--ca 放到 task 这一层(各子命令都能写,也从 IPHONE_CONTROL_URL、IPHONE_CONTROL_CA 取)。list、events、各种控制经控制台的 GET /api/agent/tasks、GET /api/agent/tasks/<id>/events、POST /api/agent/tasks/<id>/<动作>,用登录令牌;finish 用任务口令。task run --url 启动的命令有 IPHONE_CONTROL_URL 但没有登录令牌,所以 agent 在里面执行 task approve 之类会在本地就失败,即使绕过去也会被控制台 401。
  • SSE 解析:在字节里找事件边界(\n\n 或 \r\n\r\n),再把完整的事件转成文字,免得一块数据断在中文字符中间。