102. Remote Session 方案
状态:Phase 0–2 完成,Phase 3 Desktop 第一片 + Android 第一片 + 第二片完成(2026-07-03)
1. 目标
让运行在用户本机的 Coding Agent 会话可以由 Desktop、IDE、Web 或移动端共同观察和控制。模型调用、文件、MCP、工具和审批状态仍留在本机;远端只是会话窗口。
2. 安全边界
- CLI runtime 是会话授权的唯一真相。
- signaling-server 仅负责寻址和密文/协议帧转发,peerId 不等于授权身份。
- 配对令牌使用 256-bit 随机值,本地仅保存 SHA-256 摘要,恒定时间比较。
- 配对令牌单次使用,默认 5 分钟失效。
- Remote Session 默认 12 小时失效;主机连接断开时立即关闭。
- 每个远端成员使用最小权限 Scope:
observe、prompt、approve、interrupt。 - 非主机不能伪造 runtime/output 事件。
3. WebSocket 协议 v1.0
所有请求沿用 CLI WS 的 { id, type, ... } 信封,并先通过服务器级 token 认证。
| 请求类型 | 用途 |
|---|---|
remote-session-create | 主机为已有 Agent Session 创建远程入口 |
remote-session-pairing-token | 主机为新设备签发一次性令牌 |
remote-session-join | 远端设备消费令牌并加入 |
remote-session-publish | 主机广播 runtime 事件,或远端发送受 Scope 约束的控制事件 |
服务端推送使用 remote-session-event,包含 remoteSessionId、agentSessionId 和原始 event。
4. 已落地代码
packages/cli/src/harness/remote-session-registry.js:会话、成员、令牌和 Scope 授权。packages/cli/src/gateways/ws/remote-session-protocol.js:WS 请求处理和成员广播。packages/cli/src/gateways/ws/message-dispatcher.js:协议路由。packages/cli/src/gateways/ws/ws-server.js:生命周期接入,客户端断开时清理授权。
4.1 Phase 1:真实 Agent Session 接入
- CLI 发给主机的统一 Session 事件会按
agentSessionId + hostClientId自动镜像到具备observeScope 的配对设备。 prompt复用现有handleSessionMessage,进入同一个 Agent handler 和持久化链路。approval.resolve复用现有handleSessionAnswer,解析正在等待的交互请求。interrupt复用现有handleSessionInterrupt,中断同一个本地运行实例。- 控制事件不会经 signaling 身份直接调用 runtime;必须先通过本地 Remote Session 成员和 Scope 校验。
- 远端包装事件不会被再次镜像,防止广播环路;无
observeScope 的控制设备不接收会话内容。
5. 后续阶段
5.1 Phase 2:加密中继与二维码配对
- X25519 生成设备临时密钥并执行 ECDH。
- HKDF-SHA256 使用一次性配对令牌作为 salt,将共享秘密绑定到 Remote Session ID。
- AES-256-GCM 加密每个会话事件;协议版本、Session ID、发送设备和序号进入 AAD。
- 每个发送者使用单调序号,接收端拒绝重复或乱序信封;认证失败不会推进序号。
- 配对载荷使用
chainlesschain://remote-session/pair#<base64url>。敏感载荷位于 URI fragment,避免普通 HTTP 请求自动发送给服务器。 - signaling adapter 只发送
remote-session.encrypted密文载荷,并兼容在线消息和服务器暂存的离线消息。 - CLI 配置
remoteSessionRelayUrl与remoteSessionPeerId时,创建会话或重新签发令牌会返回可直接生成二维码的 URI。 - 私钥和派生会话密钥只驻留本地进程内存;主机会话关闭时清理。
相关实现:
packages/cli/src/harness/remote-session-crypto.jspackages/cli/src/gateways/remote-session-relay.js
5.2 后续阶段
Phase 3 Desktop 第一片(已完成)
- Relay 支持指数退避自动重连、有界离线发送队列、重连后自动 flush,以及显式关闭不重连。
- CLI
serve新增--remote-session-relay-url/--remote-session-peer-id,并透传至 WS runtime。 - Desktop bridge、Session Service、IPC 与 preload 已支持创建、刷新配对令牌和关闭 Remote Session。
- AI Chat 的 Agent 工具栏新增 Remote 面板:二维码、过期时间、复制链接、刷新一次性令牌和主动关闭。
- Desktop 通过
CHAINLESSCHAIN_REMOTE_SESSION_RELAY_URL和CHAINLESSCHAIN_REMOTE_SESSION_PEER_ID配置 relay。
仍待完成
Android 原生客户端第一片(已完成)
- 目标明确为
android-app/原生 Kotlin/Compose,不走 uni-app。 - 已实现原生配对 URI 解析、
ws/wssrelay 校验和过期拒绝。 - 已实现 BouncyCastle X25519、HKDF-SHA256、AES-256-GCM、AAD、篡改检测及按发送者序号防重放。
- Android raw X25519 公钥与 Node SPKI 公钥互操作;CLI 同时接受两种编码。
- OkHttp WebSocket 完成 signaling 注册、加密 pair request、事件接收及 Prompt / approval / interrupt 控制。
- EncryptedSharedPreferences 使用 Android Keystore 保存待配对 URI;配对成功后仅保留非敏感元数据并删除一次性 token。
- Compose 控制页已接入 ZXing 原生扫码、事件列表、消息输入、审批和中断操作。
- Remote Control 首页新增不依赖既有 P2P 连接的“远程编码会话”入口,NavGraph 路由已接通。
- CLI relay 已完成 Android 配对握手:接收 raw X25519 公钥、解密一次性 token、加入成员并返回加密
pair.accepted。 - 验证:Android
:app:compileDebugKotlin成功(317 tasks);RemoteSessionCryptoTest定向 JVM 测试成功(393 tasks);CLI relay/crypto/protocol/server 75 tests passed。
Phase 3 第二片(已完成)
- Registry 新增
listDevices(sessionId, hostClientId)(host-only 设备清单,含 host 标记)和revokeMember(sessionId, hostClientId, clientId)(host 不能撤销自身)。 - WS 协议新增
remote-session-devices与remote-session-revoke;撤销时向被撤销设备推送通知——本地客户端收明文remote-session-revoked,relay 配对的移动端收加密session.revoked控制事件以停止自动重连。 - Desktop bridge / session-service / IPC / preload 打通设备列表与撤销;AI Chat 的 Remote 面板新增「已配对设备」清单(scope 展示 + host 标记)和单设备撤销(二次确认)。
- Android
RemoteSessionClient增加指数退避自动重连:瞬断在同一进程内重新注册并复用已派生的共享密钥(不消耗一次性令牌),显式断开或收到session.revoked时停止重连并进入REVOKED状态。 - Android 新增自包含
RemoteSessionNotifier:审批事件到达时发系统高优先级通知(remote_session_approvals渠道),点按打开 App;批准/拒绝后撤销通知。 - 验证:CLI
remote-session-registry/remote-session-protocol定向单测 18 通过(含 relay 撤销加密通知路径)+ ws-server/pair-token 集成 16 通过;Android 新增RemoteSessionClientReconnectTest(退避 1s/2s/4s、显式断开不重连、重连次数上界)。
Phase 3 第三片:Web Remote Session 客户端(已完成)
packages/web-panel新增「远程会话」页:粘贴/扫码桌面端配对链接后,直接连信令 relay(不走本地 CLI WS),端到端加密接管会话。src/utils/remote-session-crypto.js:纯 JS@noble(X25519 + HKDF-SHA256 + AES-256-GCM)移植,与 Node host / Android 逐字节互通——刻意避开浏览器 WebCrypto 的 X25519 兼容性问题,同一份代码在 Vite bundle 与 Node 单测中运行。接受 host 的 SPKI 公钥与移动端 raw 32B 公钥两种编码。src/stores/remoteSession.js:relay 客户端 Pinia store,镜像 AndroidRemoteSessionClient状态机——注册、加密配对、事件镜像、Scope 受限控制(prompt/approve/interrupt),指数退避自动重连(瞬断在同一 store 生命周期内复用已派生密钥不重配对),收到 hostsession.revoked即停连并置revoked。RemoteSession.vue:状态标签、事件流、审批「批准/拒绝」、提示输入、中断/断开;复用QrScannerModal摄像头扫码。路由remote-session+ 侧栏菜单项已接通。- 验证:
remote-session-crypto5 单测(与真实 Node host 模块加解密互通 + URI 解析 + 重放拒绝)+remote-session-store4 单测(假 relay 跑真加密:配对→连接→事件镜像→受限控制→自动重连恢复→撤销)全通过;vite build干净(RemoteSession chunk 47KB)。
Phase 4:审计日志(已完成)
packages/cli/src/harness/remote-session-audit.js:有界(默认 1000 条环形缓冲)内存审计日志RemoteSessionAuditLog,与远程会话「内存态 + 12h TTL」的短生命周期一致。record/list/stats/clear+ 可插拔sink缝(供日后接 JSONL/SQLite 持久化,不改调用点)。- 记录九类动作:
session.created/pairing-token.issued/device.joined(direct + relay 两路)/device.revoked/device.disconnected/session.closed/control.prompt/control.approval/control.interrupt。隐私优先:prompt 只记{chars}长度不记内容;approval 记{requestId, approved};符合「数据主权」不过度采集原则。 - WS
remote-session-audit查询(host-only:authorize 证明成员身份 + hostClientId 证明所有权)返回{entries, stats}。ws-server 构造this.remoteSessionAudit,并在断开清理(removeClient)与 relay 配对成功处补记。 - Desktop:bridge/service/IPC/preload 打通
getRemoteSessionAudit;Remote 面板新增「审计日志」时间线(动作彩色标签 + 操作者 + 时间 + 明细,创建/撤销时自动刷新)。 - 验证:
remote-session-audit6 单测(seq/时钟/detail 深拷贝/过滤/stats/环形缓冲上限/sink 容错)+remote-session-protocol扩测(生命周期与控制事件入账 + 断言 prompt 内容不入账 + host-only 查询);registry/protocol/audit 三件 26 单测 + mirroring/ws-server/pair-token 集成 18 全通过。
Phase 4:组织策略(已完成)
RemoteSessionPolicy(与 registry 同文件,规避循环依赖):管理员可约束远程会话——allowedScopes(收窄 Scope 白名单)/maxDevices(非 host 设备上限)/maxSessionTtlMs+maxTokenTtlMs(会话/令牌时长上限)/allowRelayPairing(是否允许 relay 配对)。默认全空 = 无约束 no-op。- 单一执行点在 registry:
create封顶会话时长;issuePairingToken按策略收窄 Scope(返回scopes+policyNarrowed)并封顶令牌时长;join校验设备上限 + relay 开关(direct/relay 两路都过同一检查,令牌验签通过后才判策略,避免向未授权探测泄露拒因)。host 本身始终保留全 Scope,只约束远端设备。 RemoteSessionPolicy.fromEnv:从CHAINLESSCHAIN_REMOTE_SESSION_{ALLOWED_SCOPES,MAX_DEVICES,MAX_SESSION_TTL_MS,MAX_TOKEN_TTL_MS,ALLOW_RELAY}装配;ws-server 用它构造 registry。WSremote-session-policy查询(任意已认证客户端可读,非敏感——便于配对前预检)。- Desktop:bridge/service/IPC/preload
getRemoteSessionPolicy;Remote 面板顶部展示生效策略摘要(收窄的 Scope / 设备上限 / 会话时长 / relay 是否禁用)。 - 验证:
remote-session-policy9 单测(applyScopes 收窄/不相交抛错/未知 Scope 拒绝、TTL 封顶、设备上限、relay 开关、fromEnv 解析)+ registry 扩测 4(issue 收窄 + host 不受限、TTL 封顶、设备上限、relay 禁用 direct 仍可)+ protocol 查询 1。远程会话全量 50 单测(8 文件)通过。
Phase 4:推送通知(vendor push,已完成)
packages/cli/src/harness/remote-session-push.js:RemoteSessionPushDispatcher在 relay WebSocket 被系统挂起(后台应用)时唤醒已配对设备处理审批请求——前台本地通知覆盖不到的场景。实际投递(FCM / APNs / 小米 / 华为 / OPPO)是宿主注入的可插拔sender,本层零凭据且完全可单测;无 sender 时降级为记录型 no-op。载荷不含任何会话内容,只带路由唤醒所需的形状(与审计日志的隐私优先一致)。30s 去重窗口按clientId:dedupeKey抑制重复唤醒;isApprovalRequestEvent只匹配 approval/permission 请求,排除其resolved对应事件。- Registry:
join接受pushToken/pushProvider并存于成员(无 token 则忽略 provider);配对后设备可用registerPush自行注册/刷新/清空自己的 token(null 清空即退出唤醒目标);pushTargets列出携带 token 的非 host 成员;listDevices暴露hasPush/pushProvider。 - WS:
ws-server构造this.remoteSessionPush(fromEnv+ 注入remoteSessionPushSender);_mirrorRemoteSessionEvent在镜像 approval 事件时,对每个带 push token 的观察成员 fire-and-forget 唤醒(独立于本地/relay 可达性——后台应用可能报「socket 活着」但进程已挂起,永远读不到镜像事件直到用户点推送),并记push.sent/push.failed/push.skipped审计。relay 配对握手的pair.join也透传pushToken/pushProvider。新增 WSremote-session-push-register(设备只能注册自己的 token)。 - Desktop:Remote 面板设备列表对带推送的远端设备显示绿色「Push ·
<provider>」标签(悬浮说明后台可被唤醒)。 - Android:
RemoteSessionClient.setPushCredentials(token, provider)缝——设置后随加密pair.join上送;真实 token 取值走多 provider 有序解析(FCM→vivo→OPPO→Xiaomi→Huawei),见下方「Android FirebaseMessaging 集成」与「Android vivo push 集成」节(FcmTokenProvider/VivoTokenProvider/OppoTokenProvider/XiaomiTokenProvider/HuaweiTokenProvider反射取值 +RemoteSessionPushTokenResolver)。 - 验证:
remote-session-push9 单测(isApprovalRequestEvent 匹配/无 sender 跳过/无 token 跳过/投递 sent/provider 覆盖/去重窗口 + 跨 client 不去重 + 过窗重发/sender 抛错不 throw 记 failed/fromEnv)+ registry 扩测 5(join 存 token、无 token 忽略 provider、register 注册刷新清空、非成员拒绝、pushTargets 排除 host 与指定 client)+ protocol 扩测 3(join 带 token + 审计、注册 + 审计、非成员拒绝)+ mirroring 扩测 2(approval 唤醒且记 push.sent、无 sender 不唤醒不记)。
Phase 4:真实 vendor push sender — FCM HTTP v1(已完成)
packages/cli/src/harness/remote-session-push-fcm.js:sender契约的真实实现,走 FCM HTTP v1 API(legacy server-key API 已停用)。鉴权用 Google 服务账号:GoogleServiceAccountTokenProvider用私钥 RS256 签一个 JWT 断言、到oauth2.googleapis.com/token换取短时 OAuth2 access token(本地缓存至到期前 60s skew),磁盘上不落长期 server key。FcmV1PushSender.sendPOSTmessages:send,body 为高优先级{notification, data, android.priority=high, apns-priority=10}——data只带路由字段(type/sessionId/clientId),无会话内容。- 全链路可注入(
fetch/时钟/签名器/fs),用生成的密钥对 + 假 transport 即可单测,无需真凭据或联网。createFcmPushSender(env)从..._FCM_SERVICE_ACCOUNT(JSON 路径)或..._FCM_SERVICE_ACCOUNT_JSON(内联)+ 可选..._FCM_PROJECT_ID造 FCM sender;provider 分派由下方 senders dispatcher 负责。 - 死 token 剪除闭环:FCM 对已卸载/轮换的 token 返回 404 或
UNREGISTERED/NOT_FOUND→ sender 抛PushTokenUnregisteredError(code=PUSH_TOKEN_UNREGISTERED);dispatcher 把code透传到 outcome;ws-server._dispatchApprovalPush收到即registerPush(...,{token:null})清掉该设备 token 并记push.unregistered审计——避免对死 token 无限重试。 - WS 接线:
RemoteSessionPushDispatcher.fromEnv的 sender 优先取注入的remoteSessionPushSender(测试/自定义 transport),否则createRemoteSessionPushSender(process.env)按 env 造真 FCM sender;两者皆无则 null。 - 验证:
remote-session-push-fcm13 单测(token provider:缺字段抛错 / JWT 真 RS256 签名 + claims 校验 + 换 token / 缓存 + 过期刷新 / 端点失败或缺 token 抛错;FCM sender:选项校验 / 成功 POST 带 bearer + body 形状 + 返回 id / 缺 token / 404 + UNREGISTERED body → PushTokenUnregisteredError / 5xx 通用错;createFcmPushSender:未配置 → null / 畸形服务账号 → null / 内联 JSON 造 sender 端到端发送 / 文件路径经注入 fs 读 / project id 覆盖)+ dispatcher 扩测 1(code 透传)+ mirroring 扩测 1(unregistered 剪除 token + 记 push.unregistered)。
Phase 4:APNs sender(Apple 推送,已完成)
packages/cli/src/harness/remote-session-push-apns.js:sender契约的 iOS/macOS 实现,走 APNs HTTP/2 provider API + token 鉴权:ApnsTokenProvider用.p8授权密钥 ES256(原始 R‖S)签 JWT(header{alg:ES256,kid}、claims{iss:teamId,iat}),本地缓存 ~50min。ApnsPushSender.sendPOST/3/device/{token},headers 带authorization: bearer <jwt>+apns-topic(bundle id)+apns-push-type:alert+apns-priority:10,body{aps:{alert,sound}, "remote-session":{type,sessionId,clientId}}——只带路由 id,无会话内容。- APNs 强制 HTTP/2(
fetch/undici 不支持),故网络 transport 是可注入request(默认node:http2);连同时钟/签名器/fs全可注入,用生成的 EC P-256 密钥 + 假 transport 单测,无需真凭据或联网。createApnsPushSender(env)从..._APNS_KEY(.p8 路径)或..._APNS_KEY_P8(内联)+..._APNS_KEY_ID+..._APNS_TEAM_ID+..._APNS_TOPIC+..._APNS_PRODUCTION(true→生产 host,否则 sandbox)装配;缺任一必需项 → null。 - 死 token 剪除复用同一
PushTokenUnregisteredError(提到共享remote-session-push-errors.js):APNs 410Unregistered或 400BadDeviceToken→ 抛该错 → dispatcher 透传 code → ws-server 清 token + 记push.unregistered(与 FCM 同一闭环)。 - Provider 分派:新
remote-session-push-senders.js的createRemoteSessionPushSender(env)按..._PUSH_PROVIDER(默认 fcm)switch 到createFcmPushSender/createApnsPushSender/createWebPushSender/createXiaomiPushSender(xiaomi|mi)/createHuaweiPushSender(huawei|hms)/createOppoPushSender(oppo|heytap)/createVivoPushSender(vivo);未知/未配置 → null。ws-server 改从此 dispatcher 取 sender(fcm 行为不变)。加新 provider = 一 import + 一 case。 - 验证:
remote-session-push-apns14 单测(token provider:缺字段抛错 / ES256 JWT header+claims+真签名验证 / 缓存 + TTL 刷新;APNs sender:选项校验 / HTTP/2 POST 带 bearer+APNs headers+body 形状+apns-id / 生产 host / 缺 token / 410+BadDeviceToken → PushTokenUnregisteredError / 5xx 通用错;createApnsPushSender:缺必需项 → null / 内联凭据端到端发送 / 生产 host / .p8 文件经注入 fs 读)+remote-session-push-senders(默认 fcm / apns / web 路由 / 不跨 provider / 未知 provider → null / 空配置 → null)。
Phase 4:Web push(service worker,已完成)
- 服务端 sender(
packages/cli/src/harness/remote-session-push-web.js):sender契约的浏览器实现,走 Web Push 协议——VAPID(RFC 8292) ES256 JWT(按 push 端点 origin 缓存 12h)鉴权 + RFC 8291(aes128gcm) 载荷加密(ECDH-P256 + HKDF-SHA256 + AES-128-GCM,绑定订阅的 p256dh 公钥 + auth secret)。"token" 是浏览器PushSubscription(endpoint + keys)的 JSON。Web Push 端点收普通 HTTPS,故网络 transport 是可注入fetch(默认全局);加解密用 node:crypto,测试里真做 encrypt→decrypt 往返,无需真凭据/联网。载荷只带路由 id。createWebPushSender(env)从..._VAPID_PUBLIC_KEY(base64url 65B)+..._VAPID_PRIVATE_KEY(base64url 32B)+..._VAPID_SUBJECT装配;缺任一 → null。404/410 →PushTokenUnregisteredError(复用同一剪除闭环)。 - 浏览器 service worker(
packages/web-panel/public/remote-session-sw.js):push事件解密后的 JSON →showNotification(按 sessionId tag、requireInteraction);notificationclick→ 聚焦/打开面板。 - web 客户端订阅(
packages/web-panel/src/utils/webPush.js):subscribeWebPush({vapidPublicKey})注册 SW +pushManager.subscribe+ 序列化订阅为 pushToken;serializeSubscription/urlBase64ToUint8Array为纯函数(可测);不支持/拒权时返 null,配对照常无推送降级。stores/remoteSession.js:connect(uri,{pushCredentials})把订阅带进加密pair.join;updatePushCredentials(token)配对后经push.register控制事件转发(与 Android 对齐)。 - 验证:
remote-session-push-web12 单测(RFC 8291 加密往返 + 每次新 ephemeral 密文不同;VAPID:缺字段抛错 / ES256 JWT 真签名验证 + aud/sub / 按 origin 缓存;sender:缺订阅 / POST 加密 body 订阅方可解密 + VAPID+aes128gcm headers / 410 → unregistered / 5xx 通用错;工厂:缺 VAPID → null / env 造 sender 端到端)+remote-session-push-sendersweb 路由 1 + web-panelweb-push5(序列化 + base64url 解码 + 支持性)+remote-session-store扩测 3(pair.join 带订阅 / 无订阅省略 /updatePushCredentials→ push.register)。CLI 远程会话全量 129 单测(14 文件)通过;web-panel 远程会话 12 单测通过。
Phase 4:小米 push sender(MIUI,已完成)
packages/cli/src/harness/remote-session-push-xiaomi.js:sender契约的 MIUI 实现,走小米推送服务端 API。与 FCM/APNs 不同无 JWT——鉴权是静态 AppSecret 放Authorization: key=<AppSecret>header,故就是一次 form-encoded POST(普通 HTTPS,可注入fetch)。XiaomiPushSender.sendPOST/v3/message/regid,form 带registration_id(token)+restricted_package_name+pass_through=0(弹通知)+title/description+payload(只带路由 id JSON,无会话内容)。响应result==="ok" && code===0→ 成功(data.id)。createXiaomiPushSender(env)从..._XIAOMI_APP_SECRET+..._XIAOMI_PACKAGE_NAME(+ 可选..._XIAOMI_HOST切海外 host)装配;缺任一 → null。死 token 剪除复用同一PushTokenUnregisteredError:小米无单一「未注册」码,故按 reason/description 文本匹配(invalid/not valid/unregist/不存在…)判失效 → 抛该错 → ws-server 清 token + 记push.unregistered。dispatcher 加xiaomi|mi两 case。- 验证:
remote-session-push-xiaomi8 单测(选项校验 / form POST 带 key= 头 + registration_id + package + payload 形状 / 自定义 host / 缺 token / 无效 regid → PushTokenUnregisteredError / 其它 error → 通用错 / 工厂缺配置 → null / env 造 sender 端到端)+remote-session-push-sendersxiaomi+mi 路由 1。
Phase 4:华为 HMS push sender(Push Kit,已完成)
packages/cli/src/harness/remote-session-push-huawei.js:sender契约的华为/鸿蒙实现,走 HMS Push Kit。鉴权是 OAuth2 client_credentials(app id + secret 换短时 access token,比 FCM 的签名 JWT 简单)——HuaweiTokenProviderPOSToauth2/v3/token拿 token(缓存至到期前 60s skew),HuaweiPushSender.send带BearerPOST/v1/{appId}/messages:send,body{message:{notification, android.urgency=HIGH, data, token:[<token>]}}——data只带路由 id JSON,无会话内容。响应code==="80000000"→ 成功(requestId)。createHuaweiPushSender(env)从..._HUAWEI_APP_ID+..._HUAWEI_APP_SECRET装配;缺任一 → null。死 token 剪除复用同一PushTokenUnregisteredError:80300007(全部 token 无效)/80100000(部分无效,单 token 即该 token 失效)或 msg 匹配 invalid/unregist → 抛该错 → ws-server 清 token + 记push.unregistered。dispatcher 加huawei|hms两 case。- 验证:
remote-session-push-huawei11 单测(token provider:缺字段抛错 / client_credentials 换 token + form 参数 / 缓存 + 过期刷新 / 端点失败或缺 token 抛错;sender:选项校验 / Bearer POST + body 形状 + token 数组 + requestId / 缺 token / 80300007+80100000 → PushTokenUnregisteredError / 其它 code → 通用错;工厂:缺配置 → null / env OAuth+send 端到端)+remote-session-push-sendershuawei+hms 路由 1。
Phase 4:OPPO push sender(HeyTap / ColorOS,已完成)
packages/cli/src/harness/remote-session-push-oppo.js:sender契约的 OPPO/OnePlus/realme 实现,走 OPPO Push(HeyTap)server API。鉴权是两步握手:sign = SHA256(app_key + timestamp + master_secret)换短时auth_token(有效 ~24h)——OppoAuthProviderPOST/server/v1/auth拿 token(缓存至到期前 5min skew),OppoPushSender.send带auth_token(header + form 双写)POST/server/v1/message/notification/unicast,message为{target_type:2, target_value:<regid>, notification:{title, content, action_parameters}}——action_parameters只带路由 id JSON,无会话内容。响应code===0→ 成功(data.messageId)。createOppoPushSender(env)从..._OPPO_APP_KEY+..._OPPO_MASTER_SECRET(+ 可选..._OPPO_HOST)装配;缺任一 → null。死 token 剪除复用同一PushTokenUnregisteredError:OPPO 无单一「未注册」码,故按返回 message 文本匹配(invalid registration/token、unregist、not exist、无效/不存在…)判失效 → 抛该错 → ws-server 清 token + 记push.unregistered。dispatcher 加oppo|heytap两 case。- 验证:
remote-session-push-oppo10 单测(auth provider:缺字段抛错 / SHA256 sign + form 参数 + auth_token / 缓存 + 24h 后刷新 / 端点失败或缺 token 抛错;sender:缺 authProvider 抛错 / unicast POST + message 形状 + target_type=2 + messageId / 缺 regid / invalid message → PushTokenUnregisteredError / 其它 code → 通用错;工厂:缺配置 → null / env auth+send 端到端)+remote-session-push-sendersoppo+heytap 路由 1。
Phase 4:vivo push sender(vpush,已完成)
packages/cli/src/harness/remote-session-push-vivo.js:sender契约的 vivo/iQOO 实现,走 vivo Push(vpush)server API。鉴权同为两步握手但用 MD5 sign + JSON body(异于 OPPO 的 SHA256+form):sign = MD5(appId + appKey + timestamp + appSecret)换短时authToken(有效 ~24h)——VivoAuthProviderPOST/message/auth(JSON)拿 token(缓存至到期前 5min skew),VivoPushSender.send带authTokenheader JSON POST/message/send,body 为{regId, notifyType:1, title, content, skipType:1, requestId, clientCustomMap}——clientCustomMap只带路由 id,无会话内容。requestId每条唯一(默认randomUUID,可注入)。响应result===0→ 成功(taskId)。createVivoPushSender(env)从..._VIVO_APP_ID+..._VIVO_APP_KEY+..._VIVO_APP_SECRET(+ 可选..._VIVO_HOST)装配;缺任一 → null。死 token 剪除复用同一PushTokenUnregisteredError:result码 10302/10303(regId 非法/不属本 app)或 desc 文本匹配(invalid reg/illegal/unregist/无效/不存在/非法…)判失效 → 抛该错 → ws-server 清 token + 记push.unregistered。dispatcher 加vivo一 case。- 验证:
remote-session-push-vivo11 单测(auth provider:缺三字段各自抛错 / MD5 sign + JSON body + authToken / 缓存 + 24h 后刷新 / 端点失败或坏 result 抛错;sender:缺 authProvider 抛错 / JSON POST + authToken 头 + body 形状 + requestId 注入 + taskId / 缺 regId / 码 10302 → PushTokenUnregisteredError / 其它码但 desc 命中文本 → 同错 / 其它失败 → 通用错;工厂:缺配置 → null / env auth+send 端到端)+remote-session-push-sendersvivo 路由 1。CLI 远程会话全量 175 单测(18 文件)通过。
Phase 4:Android FirebaseMessaging 集成(app 侧取 token,已完成)
android-app/.../remote/session/FcmTokenProvider.kt:反射式取设备 FCM 注册 token——沿用AppInitializer对 Crashlytics 的「google-services.json 可选」范式:Class.forName("com.google.firebase.messaging.FirebaseMessaging")+getInstance().getToken()(kotlinx-coroutines-play-services的Task.await())。Firebase 不在 classpath / 无 google-services.json 时返回 null 不崩,宿主自动回退 relay + 本地通知;无硬依赖、无需 google-services.json 也能编译运行。可注入fetcher供测试。RemoteSessionViewModel.pair(uri):改为协程,配对前先 best-effort 取 token(withTimeoutOrNull(3s)封顶,避免慢网阻塞配对)→client.setPushCredentials(token, "fcm")→connect(uri),使 token 随加密pair.join上送到宿主(relay 侧唯一注册路径)。app/build.gradle.kts:hasGoogleServices守卫块内追加firebase-messaging-ktx(仅当 google-services.json 存在才引入;CI 当前无该文件,构建不受影响)。- 验证:
FcmTokenProviderTest4(注入 fetcher 取值 / 空白与 null 归一化 / 抛错降级 null / 默认反射路径无 Firebase → null)+RemoteSessionClientPushTest(配对前设 token → 宿主解密pair.join得 pushToken+pushProvider / 无 token 则不带这两字段 / 清 token 后丢弃字段)——纯 JVM 框架无关,用真 X25519+AES-GCM 往返解密验证。
Phase 4:FirebaseMessagingService onNewToken + 配对后 token 刷新(已完成)
- 配对后刷新路径(宿主,纯逻辑可测):
handleRemoteSessionPublish新增push.register事件——已配对设备(含仅 relay 的手机)可自助刷新自己的 vendor token:authorize证成员身份 +registerPush以认证clientId为键(设备只能改自己的 token;不带 token 即清空),记push.registered(via:"relay")审计。这补上了配对后(一次性pair.join之外)唯一缺失的 token 更新通道。 - Android 客户端:
RemoteSessionClient.updatePushCredentials(token, provider)——更新字段供下次pair.join,且已配对时立即经加密push.register控制事件转发给宿主,使在途会话即刻改用新 token。 - 进程级桥:
RemoteSessionPushBridge(@Volatile activeClient,纯 Kotlin 无 Android/Firebase 类型)——让FirebaseMessagingService(在 ViewModel 之外)把刷新 token 交给活跃客户端;无活跃会话则 no-op(下次配对反射取新 token)。RemoteSessionViewModel在 init 注册 / onCleared 注销activeClient。 RemoteSessionFirebaseService(app/src/firebase/java/,条件源集):onNewToken→RemoteSessionPushBridge.onNewToken;onMessageReceived→ 前台/纯 data 的审批推送本地弹通知(data 只带路由字段)。因子类继承 firebase-messaging 类型,build.gradle.kts仅在hasGoogleServices时把该源集加入main——无 google-services.json 时整类不编译不进 APK;manifest 中<service>用tools:ignore="MissingClass"(Firebase-less 构建下永不实例化)。- 验证:
RemoteSessionClientPushTest扩测 2(配对前 update 只随下次pair.join/ 配对后 update 经解密push.register到宿主)+RemoteSessionPushBridgeTest3(无活跃客户端 no-op / 空白 token 忽略 / onNewToken 经桥入客户端并随pair.join上送)+ CLIremote-session-protocol扩测 3(relaypush.register注册 + 审计 / 省略 token 即清空 / 非成员拒绝)。CLI 远程会话全量 90 单测通过;Android push 三类 JVM 测试全绿。RemoteSessionFirebaseService因需 google-services.json 才编译,随真机接入验证(Win 不可跑)。
Phase 4:Android vivo push 集成(多 provider 取 token,已完成)
- provider 抽象:新
RemoteSessionPushTokenProvider接口(val provider标签 +suspend fun getToken())——FcmTokenProvider与新VivoTokenProvider均实现之。配对不再写死 FCM,而是走有序解析。 android-app/.../remote/session/VivoTokenProvider.kt:反射式取设备 vivo regId,与FcmTokenProvider同范式——Class.forName("com.vivo.push.PushClient")+getInstance(context).getRegId()。vivo Push SDK 是手动 AAR(不在 Maven),不在 classpath / push 未 turnOn(getRegId()返 "")时返回 null 不崩;宿主回退 relay + 本地通知。异于 FCM 静态单例,vivoPushClient需Context,故构造带 context。可注入fetcher供测试。android-app/.../remote/session/OppoTokenProvider.kt:反射式取设备 OPPO(HeyTap)regId——Class.forName("com.heytap.msp.push.HeytapPushManager")+ 静态getRegisterID()。OPPO Push SDK 是 OPPO-Maven 依赖(com.heytap.msp:push);不在 classpath / 未注册(返 "")时返回 null 不崩。异于 vivo,OPPO 的getRegisterID()是静态方法,故 provider 无需 Context(与 FCM 同)。可注入fetcher供测试。android-app/.../remote/session/XiaomiTokenProvider.kt:反射式取设备 Xiaomi(MIUI)regId——Class.forName("com.xiaomi.mipush.sdk.MiPushClient")+ 静态getRegId(Context)。MiPush SDK 是手动 AAR;不在 classpath / 未注册(返 null)时返回 null 不崩。同 vivo,getRegId是带 Context 的静态方法,故 provider 需 Context。可注入fetcher供测试。android-app/.../remote/session/HuaweiTokenProvider.kt:反射式取设备 Huawei(HMS)token——AGConnectServicesConfig.fromContext(ctx).getString("client/app_id")(appId 来自 agconnect-services.json,缺则 null)+HmsInstanceId.getInstance(ctx).getToken(appId, "HCM")。HMS 独特处:getToken是阻塞网络调用,故反射路径 offload 到Dispatchers.IO(不阻塞主线程);SDK/配置不在 / getToken 抛 ApiException 时返回 null 不崩。需 Context。可注入fetcher供测试(fetcher 路径直跑,跳过 IO offload)。RemoteSessionPushTokenResolver:按有序 provider 列表解析首个可用 token(FCM 先——海外/Pixel/三星国际;vivo、OPPO、Xiaomi、Huawei 后——国内 ROM;Huawei 因阻塞 getToken 排最后);某 provider 抛错/返 null/空白即跳过,一个缺 SDK 不阻塞其余;全不可用才返 null。纯 Kotlin 无 Android 类型,全可测。RemoteSessionViewModel.pair(uri):fcmTokenProvider.getToken()换为pushTokenResolver.resolve()(列表[FcmTokenProvider(), VivoTokenProvider(application), OppoTokenProvider(), XiaomiTokenProvider(application), HuaweiTokenProvider(application)],仍withTimeoutOrNull(3s)封顶——阻塞的 Huawei getToken 也受此约束),命中即client.setPushCredentials(resolved.token, resolved.provider)。- 桥泛化:
RemoteSessionPushBridge.onNewToken(token, provider = FcmTokenProvider.PROVIDER)——加 provider 参数(默认 fcm 保持RemoteSessionFirebaseService调用不变),vivo 接收器可传VivoTokenProvider.PROVIDER把刷新的 regId 路由进活跃客户端。 - 验证:
VivoTokenProviderTest5 +OppoTokenProviderTest5 +XiaomiTokenProviderTest5 +HuaweiTokenProviderTest5(注入 fetcher 取值 / provider 标签 / 空白与 null 归一化 / 抛错降级 null / 默认反射路径无 SDK → null;Huawei 额外覆盖 IO offload 路径)+RemoteSessionPushTokenResolverTest6(首个命中 / 前者不可用则下沉 / 抛错跳过 / 空白视为不可用 / 全不可用 → null / 空列表 → null)+RemoteSessionPushBridgeTest扩测 1(onNewToken 带非默认 provider 标签经解密pair.join得 vivo)+FcmTokenProviderTest4(实现接口后不回归)。Android remote-session 七类 JVM 测试全绿(5+5+5+5+6+4+4=34,gradlew :app:testDebugUnitTest)。至此 resolver 五 provider(FCM/vivo/OPPO/Xiaomi/Huawei)全接。
Phase 4:Android vivo push 真机集成(AAR 门控 + 接收器 + 反射 turnOn,已完成骨架)
- 构建门控(
app/build.gradle.kts,镜像 Firebasegoogle-services.json范式):hasVivoPush = !fileTree("libs"){include("vivo*push*.aar"…)}.isEmpty——AAR 一落进app/libs/就自动 (a)src/vivo/java加进main源集、(b)implementation(vivoPushAars)。无 AAR 时源集排除,默认构建行为不变(RemoteSessionVivoReceiver不编译不进 APK;VivoTokenProvider仍反射降级)。 RemoteSessionVivoReceiver(src/vivo/java,条件源集,vivo 的RemoteSessionFirebaseService对位):继承com.vivo.push.sdk.OpenClientPushMessageReceiver;onReceiveRegId→RemoteSessionPushBridge.onNewToken(regId, VivoTokenProvider.PROVIDER)(首注册/轮换即推给活跃会话);onNotificationMessageClicked→ 本地弹审批通知(params只带路由 id)。- AndroidManifest:
<receiver ... RemoteSessionVivoReceiver android:exported="true">+com.vivo.pushclient.action.RECEIVEintent-filter +com.vivo.push.app_id/api_keymeta-data(值走manifestPlaceholders,空默认保证无凭证也 merge;-PvivoPushAppId=… -PvivoPushApiKey=…注入)。tools:ignore="MissingClass"(无 AAR 构建下类不存在但永不实例化)。 VivoPushService反射真集成(push/vendor/OtherVendorStubs.kt,脱 stub):initialize()反射PushClient.getInstance(ctx).initialize()+turnOnPush(动态代理 IPushActionListener);currentToken()反射getRegId();isIntegrated()探Class.forName——全 runCatching 兜底,无 SDK 即返 false/null 不崩,无编译期硬依赖。- 启动触发(
AppInitializer异步块 #10):按Build.MANUFACTURER自动选 vendor 并initialize()——vivo 设备启动即 turnOn 铸 regId 供唤醒(FCM 设备 route 到 no-op;小米/华为 仍 stub 返 false;OPPO 见下)。 - 验证:
VivoPushServiceTest5(vendor 标签 / 无 SDK isIntegrated=false / initialize 降级 false 且幂等 / currentToken=null / shutdown 安全 no-op)+AppInitializerTest2(新增pushVendorRegistry依赖后不回归);gradlew :app:kspDebugKotlin(主 Hilt DI 图含新依赖编译通过)+:app:processDebugMainManifest(vivo receiver + meta-data 成功 merge 进merged_manifest)+:app:testDebugUnitTest过。门控日志确认无 AAR 时 vivo 源集排除。剩真机项(Win 不可跑):接入者放 AAR + 传凭证(docs/guides/Vendor_Push_Setup.md§3.4 三步)+ 真机上架 vivo 市场端到端验证。
Phase 4:Android OPPO push 真机集成(Maven 门控 + 回调 register,已完成骨架)
- 架构差异:OPPO(HeyTap)push 与 vivo 形态不同——(1) SDK 是 OPPO-Maven 依赖(
com.heytap.msp:push)非手动 AAR;(2) regId 不走 manifest 广播 receiver,而是HeytapPushManager.register(ctx, appKey, appSecret, ICallBackResultService)的回调onRegister递送。故 OPPO 真机集成无 conditional 源集、无 manifest receiver,纯反射即可。 - 构建门控(
app/build.gradle.kts+settings.gradle.kts):-PoppoPush=true一开即 (a) settings 加 HeyTap Maven repo(exclusiveContent限定com.heytap.mspgroup)、(b)implementation("com.heytap.msp:push:3.5.3")。默认(无 flag)构建 CI byte-identical(门控日志实测 "OPPO Push disabled")。因 Maven 依赖无本地文件可探,故用显式 opt-in flag 而非 vivo 的 AAR 探测。 OppoPushService反射真集成(push/vendor/OtherVendorStubs.kt,脱 stub):initialize()反射HeytapPushManager.init(ctx, DEBUG)+register(…, 动态代理 ICallBackResultService);回调onRegister(跨版本 arg 数不定,取首个非空 String 作 regId)→RemoteSessionPushBridge.onNewToken(regId, "oppo")直接路由进活跃会话。currentToken()反射getRegisterID();isIntegrated()探Class.forName——全 runCatching 兜底。- 凭证:
appKey/appSecret走BuildConfig.OPPO_PUSH_APP_KEY/_APP_SECRET(-PoppoPushAppKey/Secret,空则initialize()跳过)——异于 vivo 走 manifest meta-data,因 OPPO register() 以参数收。 - 启动触发同 vivo(
AppInitializer按 manufacturer 自动initialize();OPPO/OnePlus/realme 命中)。 - 验证:
OppoPushServiceTest5(vendor 标签 / 无 SDK isIntegrated=false / 无凭证+SDK initialize 降级 false 且幂等 / currentToken=null / shutdown 安全 no-op);gradlew :app:kspDebugKotlin(主 Hilt DI +BuildConfig.OPPO_PUSH_*字段 + vendor→remote.session import 编译通过)+:app:testDebugUnitTest过;门控日志确认默认无 OPPO 依赖。剩真机项(Win 不可跑):接入者传-PoppoPush=true+ 凭证(docs/guides/Vendor_Push_Setup.md§3.3 三步)+ 真机上架 OPPO 商店端到端验证;through/data 静默消息(CompatibleDataMessageCallbackService)defer(本期通知消息够唤醒审批)。
Phase 4:Android Xiaomi push 真机集成(AAR 门控 + 接收器 + 反射 registerPush,已完成骨架)
- 形态:小米 MiPush 是手动 AAR(同 vivo),且 regId 经 manifest 声明的
PushMessageReceiver子类递送(同 vivo 的广播 receiver 模型)——故走 vivo 式条件源集 + manifest receiver;凭证走 BuildConfig(同 OPPO)。是 vivo + OPPO 两种模式的合体。 - 构建门控(
app/build.gradle.kts):hasXiaomiPush = !fileTree("libs"){include("MiPush*.aar"…)}.isEmpty——AAR 落进app/libs/即自动加src/xiaomi/java源集 +implementation(xiaomiPushAars);BuildConfig 注XIAOMI_PUSH_APP_ID/_APP_KEY(-PxiaomiAppId/Key)。默认无 AAR → 源集排除,构建 byte-identical(门控日志实测 "Xiaomi Push disabled")。 RemoteSessionXiaomiReceiver(src/xiaomi/java,条件源集,继承com.xiaomi.mipush.sdk.PushMessageReceiver):onCommandResult(MiPushClient.COMMAND_REGISTER+resultCode==0L)取commandArguments[0]作 regId →RemoteSessionPushBridge.onNewToken(regId, XiaomiTokenProvider.PROVIDER);onNotificationMessageClicked→ 本地弹审批通知(extra只带路由 id)。- AndroidManifest:
${applicationId}.permission.MIPUSH_RECEIVE自定义权限 + MiPush 全套 service/receiver(XMJobService/XMPushService/PushMessageHandler/MessageHandleService/NetworkStatusReceiver/PingReceiver)+RemoteSessionXiaomiReceiver(RECEIVE_MESSAGE/MESSAGE_ARRIVED/ERROR 三 intent-filter)。全tools:ignore="MissingClass"——无 AAR 构建下 SDK 类不存在但 manifest 照常 merge(processDebugMainManifest实测MIPUSH_RECEIVE/RemoteSessionXiaomiReceiver/XMPushService进merged_manifest)。 XiaomiPushService反射真集成(脱 stub):initialize()反射MiPushClient.registerPush(ctx, appId, appKey);currentToken()反射getRegId(ctx);shutdown()反射unregisterPush;isIntegrated()探Class.forName——全 runCatching,无 SDK/凭证即 false/null 不崩。启动触发同 vivo(AppInitializer按 manufacturer 自动initialize();小米/红米命中)。- 验证:
XiaomiPushServiceTest5(vendor 标签 / 无 SDK isIntegrated=false / 无凭证+SDK initialize 降级 false 且幂等 / currentToken=null / shutdown 安全 no-op);gradlew :app:kspDebugKotlin(主 Hilt DI +BuildConfig.XIAOMI_PUSH_*字段编译通过)+:app:processDebugMainManifest(MiPush 全套 + receiver 成功 merge)+:app:testDebugUnitTest过;门控日志确认默认无 AAR 时 Xiaomi 源集排除。剩真机项(Win 不可跑):接入者放 AAR + 传凭证(docs/guides/Vendor_Push_Setup.md§3.1 三步)+ 真机上架小米商店端到端验证。
Phase 4:Android Huawei HMS push 真机集成(agconnect 门控 + HmsMessageService,已完成骨架——push 全家收口)
- 形态:华为最独特——除 Maven 依赖外还需 AGConnect gradle 插件(
com.huawei.agconnect,处理agconnect-services.json成资源)+ 华为 Maven repo。故门控与 Firebase 完全同构:以agconnect-services.json存在为闸(华为版google-services.json),且 regId 经HmsMessageService.onNewToken递送(FCM 的FirebaseMessagingService对位)。 - 构建门控(
app/build.gradle.kts+ rootbuild.gradle.kts+settings.gradle.kts,三处同一闸):hasAgconnect = file("agconnect-services.json").exists()→ (a) appapply(plugin="com.huawei.agconnect")+src/hms/java源集 +implementation("com.huawei.hms:push:6.11.0.300");(b) root buildscript 加agcp插件 classpath;(c) settings 加华为 Maven repo(pluginManagement 供 agcp classpath + dependencyResolutionManagementexclusiveContent限com.huawei.*group 供 hms:push)。无 json 时三处全不激活,默认/CI 构建 byte-identical(门控日志实测 "Huawei HMS disabled";含 settings/root 改动后完整 config+compile 仍过)。 RemoteSessionHmsService(src/hms/java,条件源集,继承com.huawei.hms.push.HmsMessageService):onNewToken→RemoteSessionPushBridge.onNewToken(token, HuaweiTokenProvider.PROVIDER);onMessageReceived→ 本地弹审批通知(dataOfMap只带路由 id)。与RemoteSessionFirebaseService逐行对位。- AndroidManifest:
<service RemoteSessionHmsService>+com.huawei.push.action.MESSAGING_EVENTintent-filter +tools:ignore="MissingClass"(镜像 FCM service;实测RemoteSessionHmsService/MESSAGING_EVENT进merged_manifest)。 HuaweiPushService反射真集成(脱 stub):initialize()反射HmsMessaging.getInstance(ctx).turnOnPush()(token 经 serviceonNewToken异步递送);shutdown()反射turnOffPush;isIntegrated()探Class.forName;currentToken()返 null(阻塞取 token 由HuaweiTokenProvideroffload 到 IO)。全 runCatching 兜底。启动触发同其它(AppInitializer按 manufacturer 自动initialize())。- 验证:
HuaweiPushServiceTest5(vendor 标签 / 无 SDK isIntegrated=false / initialize 降级 false 且幂等 / currentToken=null / shutdown 安全 no-op);gradlew :app:kspDebugKotlin+:app:processDebugMainManifest(HMS service 成功 merge)+:app:testDebugUnitTest过;门控日志确认默认无 json 时 HMS 源集排除、settings/root gradle 改动不破默认构建。剩真机项(Win 不可跑):接入者放agconnect-services.json+ 按华为控制台核对插件/SDK/repo 版本 + release keystore SHA256 绑定 + 真机上架华为应用市场端到端验证(docs/guides/Vendor_Push_Setup.md§3.2)。 - 至此 Android/桌面 push 全家收口:桌面 sender 7 家(FCM/APNs/Web/小米/华为/OPPO/vivo)+ Android 取 token resolver 5 家(FCM/vivo/OPPO/Xiaomi/Huawei)+ Android 真机集成 4 家国内(vivo/OPPO/Xiaomi/Huawei)全落骨架,FCM 真机早已就绪。
Phase 4:iOS 多 provider push token resolver(CoreP2P,已完成)
- 形态:iOS 原生 push 只有 APNs 一条通道(无国内厂商 SDK),故「多 provider」= APNs 为唯一真 provider,但 resolver 保持 provider 无关,为将来通道(VoIP/PushKit、web-push relay 兜底)留口——与 Android 多 provider 设计同构。provider 标签
apns对齐桌面 APNs sender(CHAINLESSCHAIN_REMOTE_SESSION_PUSH_PROVIDER=apns)。 ios-app/Modules/CoreP2P/Sources/Pairing/RemoteSessionPushToken.swift(纯 Swift,可测,CoreP2P 模块):RemoteSessionPushTokenProvider协议(var provider标签 +func token() async -> String?)——Android 同名接口的 iOS 镜像。非抛错设计:provider 失败返 nil 而非 throw,故 resolver 无需 try/catch(Swift 惯用法对应 Android「抛错则跳过」)。APNsPushTokenProvider:读设备 APNs token。CoreP2P 不能引用 app-target 的PushNotificationManager,故经注入的@Sendable () async -> String?source 闭包取值(app 侧接PushNotificationManager.shared.deviceToken/UserDefaults "device_push_token");空白/nil → nil。RemoteSessionPushTokenResolver:按有序 provider 列表解析首个非空 token(Resolved{token,provider});某 provider 返 nil/空白即跳过,全不可用才 nil——AndroidRemoteSessionPushTokenResolver的逐行镜像。
- 验证:
RemoteSessionPushTokenResolverTests9(resolver:首个命中 / 前者不可用则下沉 / 空白视为不可用 / 全不可用 → nil / 空列表 → nil;APNs provider:标签+读 source / 空白与 nil → nil / 经 resolver 端到端)。Swift 无 Windows 工具链,随 iOS CI 真编译(同既往 iOS 分片)。 - 待接入点:iOS 尚无 Android 那种 relay
pair.join客户端(iOS 配对走 WebRTC/signaling,iOS Phase 6 真机 E2E defer);本片是已单测的取 token 基座,待 iOS RemoteSession relay 客户端落地后接入配对(同 Android 先建 provider 再接全流程的顺序)。
Phase 4:iOS RemoteSession relay 客户端 — E2EE 基座(配对解析 + 加密信封,已完成)
- 定位:iOS 版
RemoteSessionClient(对标 AndroidRemoteSessionClient.kt)的第一片——纯加密/解析基座,无网络,全单测。后续片:WebSocket relay 连接 + 配对握手 + 重连 + events/status 流。分片顺序同 Android(crypto/parser 先,networked client 后)。 ios-app/Modules/CoreP2P/Sources/Pairing/RemoteSessionCrypto.swift(CryptoKit,逐字节兼容桌面 host + Android mobile):RemoteSessionPairingParser.parse:解析chainlesschain://remote-session/pair#<base64url(JSON)>——校验v==1、relay scheme ∈ {ws,wss}、必填字段、expiresAt过期;错误经RemoteSessionCryptoError强类型枚举返回。RemoteSessionCrypto:X25519 ECDH → HKDF-SHA256 → AES-256-GCM。pair()用Curve25519.KeyAgreement算 shared secret,salt=SHA256(pairingToken)、info=<protocol>:<sessionId>派生 32B key(与 Android/桌面 HKDF 参数逐一对齐);encrypt/decrypt12B nonce + 128-bit tag,AAD=<protocol>\n<sessionId>\n<senderId>\n<sequence>;send sequence 自增、receive 严格递增防重放/乱序。RemoteEncryptedEnvelopeJSON 形状{v,sessionId,senderId,sequence,nonce,ciphertext,tag},binary 字段全 base64url 无 pad(RemoteSessionBase64对齐Base64.getUrlEncoder().withoutPadding())。
- 验证:
RemoteSessionCryptoTests15(parser 8:合法/错前缀/坏 payload/错版本/过期/未过期/缺字段/非 ws relay;crypto 7:双实例派生同 key 双向往返 / 重放乱序拒绝 / 未配对 encrypt 抛 notPaired / 错 token 派生异 key 解密失败(token 绑定)/ 公钥 32B + base64 往返 / 信封 JSON 往返)。Swift 无 Windows 工具链,随 iOS CI 真编译。协议改动必与RemoteSessionCrypto.kt+ 桌面 harness 同步。
Phase 4:iOS RemoteSession relay 客户端 — WebSocket 连接 + 配对握手(已完成)
- 定位:iOS
RemoteSessionClient的第二片——在 E2EE 基座之上补上 networked 层:relay 连接、register、加密配对握手、加密控制/事件收发。对标 AndroidRemoteSessionClient.kt的 connect→register→sendPairRequest→pair.accepted→CONNECTED 主序。剩余片:自动重连(一次性令牌用尽 → 复用共享密钥恢复)、status/events 独立流化。 ios-app/Modules/CoreP2P/Sources/Pairing/RemoteSessionClient.swift(transport 注入的协议驱动核心,全单测):- transport 抽象
RemoteSessionWebSocket(send/close)+RemoteSessionWebSocketListener(open/text/close/failure)+RemoteSessionWebSocketFactory——镜像 okhttpWebSocket/WebSocketListener,让协议逻辑与真 socket 解耦、可用 fake 单测。 - 连接:
connect(uri)解析配对 URI、以ios-<UUID>peerId 建RemoteSessionCrypto并pair(),置connecting后开 socket;webSocketDidOpen发{type:register, peerId, deviceType:mobile, deviceInfo:{protocol:remote-session.e2ee.v1}}。 - 握手:收
registered→ 置pairing并发{type:message, to:hostPeerId, payload:{type:remote-session.pair, mobilePeerId, mobilePublicKey, envelope}},envelope 加密{type:pair.join, remoteSessionId, token, pushToken?, pushProvider?}(push 凭据经setPushCredentials由 app 层从RemoteSessionPushTokenResolver取值后注入);宿主解密回pair.accepted→ 置connected。 - 控制/事件:
sendPrompt/resolveApproval/interrupt/updatePushCredentials(已配对时即发push.register)经加密remote-session.encrypted上送(未配对返 false);入站remote-session.encrypted解密 →pair.accepted/session.revoked(置revoked并关闭)/其余经onEvent回调发出;offline-message解包originalMessage。状态经onStatusChange/onError回调暴露(IDLE/CONNECTING/PAIRING/CONNECTED/DISCONNECTED/REVOKED/ERROR)。
- transport 抽象
ios-app/Modules/CoreP2P/Sources/Pairing/URLSessionRemoteSessionWebSocket.swift:URLSessionWebSocketTask支撑的生产 transport——delegate 报 open/close、自重排的 receive loop 收帧,全部汇入同一 listener 面;factory(configuration:)直接喂RemoteSessionClient(webSocketFactory:)。- 验证:
RemoteSessionClientTests11(fake socket + 宿主侧RemoteSessionCrypto完成真 ECDH 解密,对标 AndroidRemoteSessionClientPushTest):connect→connecting、open→register 形状、registered→pair 请求且宿主可解密pair.join、push 凭据随pair.join上送、pair.accepted→connected、配对前sendPrompt拒绝/配对后加密上送宿主可解、入站事件解密经onEvent发出、session.revoked→revoked+关闭、updatePushCredentials→push.register、offline-message解包、disconnect→关闭+disconnected。Swift 无 Windows 工具链,随 iOS CI 真编译。协议改动必与RemoteSessionClient.kt+ 桌面 relay 同步。
Phase 4:审计日志持久化 sink(JSONL,已完成)
packages/cli/src/harness/remote-session-audit-sink.js:RemoteSessionAuditFileSink接到审计日志的sink缝,把(已隐私脱敏的)审计流持久化为换行分隔 JSON(JSONL)文件,让取证记录跨宿主重启存活。每条一行;文件按大小上限滚动(audit.jsonl→audit.jsonl.1→ …,backups可配,默认保 1 份;backups=0即到顶截断),永不无界增长。读取容忍撕裂的末行(崩溃时半截 append)——解析失败的行直接跳过。零依赖(仅 node:fs)且可注入fs,日后换 SQLite 后端只需实现同样的{ handler, readAll }形状,不动审计日志与调用点。- 审计日志 hydration:
RemoteSessionAuditLog新增initialEntries构造项——从持久化 sink 预填内存环并沿用 seq 高水位(新条目严格单调递增),且不把已持久化条目回写 sink(它们已在文件里)。重启后list/stats查询即含历史。 - WS:
ws-server用RemoteSessionAuditFileSink.fromEnv(CHAINLESSCHAIN_REMOTE_SESSION_AUDIT_FILE启用 +CHAINLESSCHAIN_REMOTE_SESSION_AUDIT_MAX_BYTES调滚动上限)构造 sink,注入审计日志并用readAll({limit:1000})hydrate。未配置文件时 sink 为 null,审计日志保持纯内存(现有行为不变)。 REMOTE_SESSION_AUDIT_ACTIONS补齐 push 四类动作(push.registered/sent/failed/skipped)。- 验证:
remote-session-audit-sink8 单测(缺 path 抛错、JSONL 追加 + 懒建父目录、撕裂行跳过、readAll limit、滚动跨 backup 读回、backups=0 截断、fromEnv、经审计日志 persist→hydrate 往返)+remote-session-audit扩测 2(hydration 沿用 seq 不回写 sink + malformed 行跳过、hydration 封顶 maxEntries 留最新)+ ws-server 集成 2(注入 sink 持久化 + 新 server hydrate、无 sink 保持纯内存)。远程会话全量 71 单测(7 文件)通过。
仍待完成
- Phase 3 第三片余项:跨端断线恢复真机/真 relay E2E(需 relay + host + 浏览器三方联调,Win 单机不可跑)。
- Phase 4 余项:FCM + APNs + Web push 三 sender 服务端、Android 取 token、
FirebaseMessagingServiceonNewToken、web SW + 订阅、配对后 relaypush.register刷新路径均已完成;仍缺:真实google-services.json/APNs.p8/VAPID 密钥接入 + 真机·真浏览器端到端验证(RemoteSessionFirebaseService条件源集需 google-services.json 才编译,Win 不可跑);国内 provider(小米、华为 HMS、OPPO、vivo)sender 均已完成;SQLite 审计后端(当前 JSONL 已足够,按需再上);策略的运行时热更新与来源(config.json vs env)合并。 - 进程冷启动后的重新配对(当前自动重连仅覆盖同进程内瞬断;进程被杀后内存态密钥丢失需重新扫码)。
