这份文档记录「AI 任务怎么被人和程序监管」的目标架构、它和现状的差距,以及分步落地的顺序。为什么要把任务放进守护进程见 D17。缘起是 #50:外部 Agent 平台想用控制台的步骤显示、暂停、接管和确认。
一台手机同一时间只能有一个操控者(D11),想用它的却很多:控制台里的 AI 任务、外部 Agent 平台、看着页面的人、命令行、定时任务。要做到:
- 独占:同一时刻只有一方在操作手机。这一点要强制执行,不能靠各方自觉。
- 归属:每个动作都能追到是哪个任务、哪个调用方做的,基于的是哪次观察。
- 监管:暂停、接管、确认、取消在执行点生效,谁也绕不过去。
- 出错就停:调用方不见了、设备变了、服务重启了,任务都停下来,不能没人看着继续操作。
控制台(人) 命令行 外部平台适配层 内建 runner(Claude/Codex)
│ │ │ │
└─────────┴─── 控制面:任务 · 租约 · 确认 · 事件 ───┘
│
MCP 适配层(凭任务口令绑定到某个任务)
│ 数据面:设备动作
┌─────────────────┴─────────────────┐
│ 守护进程:唯一的执行点 │
│ 租约 · 任务状态机 · 策略 · 确认 │
│ 步骤日志(落盘) · 事件流 │
└─────────────────┬─────────────────┘
设备(WDA / HID)
守护进程本来就是唯一持有手机的进程(D2),所有动作都要在它那里排队拿锁。监管也应该放在这里:动作要经过准入检查(有没有租约、任务处于什么状态、设备对不对、策略允不允许)才执行。控制台、命令行、MCP 都只是客户端,谁都不能自己决定「现在可以操作」。
如果监管放在某个客户端里(比如控制台),不经过它的调用方照样能操作手机。这种监管只拦得住愿意配合的调用方。
| 概念 | 含义 |
|---|---|
| 主体(principal) | 调用方:人、外部平台、内建 runner |
| 租约(lease) | 谁正在占用哪台设备。可以转移:接管时转给人,交还时还回来 |
| 任务(task) | 一件事从开始到结束的全过程。属于某个主体,绑定一台设备 |
| 步骤(step) | 一次动作:提出、准入、执行、结果,归在某个任务名下 |
| 确认(approval) | 一个单独的待决定事项,有自己的编号和决定权 |
Running ⇄ Paused
Running / Paused → TakenOver → Running(交还后先重新看界面)
Running → Waiting(等确认)→ Running
终态:completed · failed · cancelled · rejected · abandoned(租约断了) · interrupted(设备变了或服务重启)
每次状态变化都追加写入任务日志。守护进程重启后,没结束的任务记成 interrupted,不会悄悄消失。审计记录也一直在。
开任务的调用方保持一条连接,这条连接就是租约。连接断了(进程退出、崩溃、被杀),任务就变成 abandoned,之后这个任务的动作一律拒绝。不用心跳和超时,也就没有心跳超时的误判。
- 执行口令(actor):交给 Agent。凭它操作手机、报告步骤、请求确认。
- 控制口令(controller):给任务的所有者,用来暂停、取消、确认。
- 人:对任何任务都能接管、停止。
口令还可以限定范围:哪台设备、哪些 App、最多几步、最长多久。
边界要说清楚:同一个系统用户下,进程能读到彼此的文件和环境,口令只是约定,不构成安全边界(和 D13 同理)。要让「只有所有者能确认」真正成立,必须让 Agent 换一个 uid 运行或者放进沙箱,同时守护进程用 SO_PEERCRED 认调用方。
只看点击坐标,没法识别所有敏感操作。最有效的办法是给任务划定范围:开任务时声明它只在哪些 App 里操作。守护进程知道前台是哪个 App,任务越界就停下来等人。iphone_confirm(Agent 主动请求确认)作为补充。
每次观察(截图、读元素)都对应一个版本。别人(人或者其他调用方)动过手机之后,再来的动作如果不是基于新的观察,就拒绝执行,让 Agent 先重新看一遍。
iphone_batch 这类一次发多个动作的请求,在每个动作之间都检查暂停和接管,而不是把整批当成一步。
开任务时绑定 UDID,之后每个动作都带上它。切换设备或拔掉设备时,任务停下(interrupted),需要人重新开始。
- 控制面只有一套接口:本机用 Unix socket,以后需要远程时再加带认证的 HTTP。事件带编号,断线后能从断点接着收。
- 内建的 Claude/Codex runner 也走这套接口。外部平台用的就是内建功能自己在用的那套,不另开一条路。
- MCP 只是一层适配:靠任务口令绑定到某个任务。
不带任务口令的普通 MCP 调用,没有任务在跑时和以前完全一样。有任务在跑时,看界面(截图、读元素、状态)照常可以,操作手机会被拒绝,并说明原因。
做对了、不用动的:
- 守护进程独占设备,是唯一的排队点(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 一致 | 有同时驱动多台的需求时再说 |
差距 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) |
| 四 | 更细的权限、多设备租约 | 等需要时 |
都是控制接口上的普通请求(每行一个 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 } |
执行方、所有者 | 任务结束 |
有任务在进行(不在终态)时:
- 带这个任务口令的请求:任务不在 running 就一直等,调用方走了就放弃;等完后排队时间重新计算。请求固定发往任务绑定的设备。不能切换设备。交还控制或者别人动过手机之后,先要有一次观察,才接受下一个动作。
- 不带口令的请求:查看类(截图、读元素、状态、列 App 等)照常执行。动作类只在 takeover(人接管)时接受,接受后给任务记上「需要重新观察」;其他状态下一律拒绝并说明原因(抬起手指除外)。切换设备、手动重签同样拒绝。
- 带的口令属于已结束或不存在的任务:任何请求都拒绝,提示「任务已结束,停下来」。
请求放行之后,还要排队拿设备锁(可能排在 wait_for、巡检后面),这期间人可能暂停、接管或交还了。所以拿到锁之后再按上面的规则查一次(Tasks::still):任务的请求遇到暂停、接管、等待确认就放下锁接着等,用户的请求在交还之后就拒绝。「需要重新观察」只有观察成功(截图、读元素、等文字没有出错)才清掉。
没有任务在进行时,和以前完全一样。
每个任务一个 tasks/<id>.jsonl,每行一个事件,最多保留 50 个。守护进程启动时把没有 done 的任务补成 interrupted。
iphone-control task run --title … -- <命令>:开任务、保持租约,带着 IPHONE_CONTROL_TASK_TOKEN(以及 IPHONE_CONTROL_TASK_ID)启动命令,命令里的 iphone-control mcp 会自动加入任务。命令结束时任务随之结束,结果取命令的退出码;命令想写总结时自己调 iphone-control task finish …。任务在别处被停止时,结束这个命令的整个进程组。Ctrl-C 或 SIGTERM 等于停止任务。适配层只需要包一层,不用自己实现协议。
开任务时可以带 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,这一下本身不会被拦,进去之后的下一步才会。
问题:task_control 原先只看任务 ID。Agent 用自己的 Bash 跑一句 iphone-control task approve <id>,就能批准自己的越界确认,或者在用户暂停后自己继续,任务范围和 iphone_confirm 都形同虚设。
为什么不发控制口令:命令行和控制台要能控制任务,就得能拿到控制口令,比如放在 0600 的文件里;Agent 和用户是同一个系统用户,同样读得到。在同一个 uid 下,口令分不开执行方和控制方。
做法:暂停、继续、接管、交还、确认这几种控制只能由人来做;停止不限,Agent 可以放弃。下面两种请求会被拒绝:
- 带任务口令的控制请求:
iphone-control task …会自动带上环境里的IPHONE_CONTROL_TASK_TOKEN,所以 Agent 在自己的环境里执行就会被拒绝。 - 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)。
和第 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 节)。
场景:守护进程和手机在一台机器上(比如 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/。在做之前,这是推荐的办法。
要求:
- 显式开启,默认不变:默认仍然只监听本机(D3)。开局域网要明确配置监听地址(例如
viewer --listen 0.0.0.0:8202,或设置文件里的一项),启动时提示谁能连上。 - 必须认证:开了局域网监听就必须配访问令牌(或密码),没配就拒绝启动,不能退回「谁都能连」。令牌存在设置文件里(0600);浏览器登录一次后用
HttpOnly、SameSite=Strict的 cookie;输错限速。MCP、命令行等非浏览器客户端用Authorization: Bearer。 - Host / Origin 检查照样做:认可的从「只认回环」改成「回环 + 配置的主机名或 IP」;不在名单里的
Host一律拒绝(DNS rebinding 依然要防),操作的Origin必须是这些地址之一。 - HTTPS:WebCodecs 只在安全上下文(HTTPS,或
127.0.0.1/localhost)里可用,用普通 HTTP 从局域网 IP 打开时,H.264 / 手机自己的 HEVC 画面都解不了,只能退回每秒 4~10 MB 的 MJPEG。所以局域网访问要支持 HTTPS:用户提供证书,或者自动生成自签证书并显示指纹供第一次核对。令牌在 HTTP 上明文传输也是一个理由。 - 能做什么分开:至少区分「只看画面和状态」和「操作手机、开 AI 任务、确认」两种令牌(对应 2.5 的口令分开);任务事件里记下是哪个令牌(哪个人)暂停、接管、确认的。
- 和控制面是同一件事:局域网里的控制台用的就是第 2.10 节的 HTTP 控制面,外部 Agent 平台跨主机接入(HTTP MCP)用同一套认证,不另做一套。
验收:
- 默认安装不监听任何网卡(和现在一样)。
- 开了局域网监听、没配令牌:拒绝启动并说明原因。
- 局域网另一台电脑:用令牌登录后能看画面(H.264 或 HEVC,不退回 MJPEG)、操作、开 AI 任务、暂停、确认。
- 没有令牌、令牌错误、
Host不在名单里、来自别的网页的Origin:一律拒绝。 - 只读令牌:能看画面和状态,操作、开任务、确认都被拒绝。
和 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 的补充。
之后:
- 控制台做成后台服务(
iphone-control service viewer install,开机自启,跟着设置文件里的IPHONE_CONTROL_VIEWER_*):已完成(2026-11-13)。升级时先停下它(它是常驻客户端,会让升级一直 busy),升级完再启动;有 AI 任务在跑时自动升级推迟。 - 登录:一个令牌,存在数据目录,登录后用
HttpOnly; SameSite=Strict; Secure的 cookie,程序用Authorization: Bearer,失败限速;局域网口子开了登录才放行。HTTP MCP 届时再定。(已在 7.5 做完;cookie 最后用的是SameSite=Lax,见 7.5。) - HTTPS:已完成(2026-11-13),见 7.3。
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.pem0600),旁边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.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.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 回/,地址栏里不留令牌。会话 cookieictl_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.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。
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),再把完整的事件转成文字,免得一块数据断在中文字符中间。