# DSH 多设备加入与设备管理设计 状态:JOIN-001 自动管理源码候选已实现;正式发布与完整真机验收未完成。 当前计划:[JOIN-001 自动管理与验收](../../awiki-plan/20260920-auto-admin-join/plan.md)。下文注明日期的验证结果与第 7~10 节为历史记录,不代表当前发布通过。 跨仓导航:[Harness Feature](../../awiki-harness/features/dsh-device-join.md) · [Node SDK 增量合同](../../awiki-cli-rs2/docs/node-sdk/dsh-device-join-extension.md) · [AWiki Me 参考实现](../../awiki-me/docs/multi-device-join-ui.md) · [当前开发计划](../../awiki-plan/20260831-dsh-awiki-multi-device-productization/plan.md) 2026-08-31 源码基线中,DSH 已实现 `join-required` 分流、Host-only continuation、Core local-session 恢复、Join/SAS UI、ready-admin 设备页、approve/reject/revoke、Recovery V4 和身份级 Realtime。 `@awiki/im-core-node` native API v12 已提供 Join/admin、Root Transfer 和 Darwin user-presence facade。当前真实远端证据只覆盖 DSH ready-admin → CLI member;DSH joiner、普通 sibling 数据同步、Schema 3 诊断接线、Human Recovery 三场景和 Root Transfer 的本地单元合同已完成;远端证据由后续独立 System Test 任务补齐。 本文第 3~6 节继续作为稳定产品/安全语义。第 7~10 节保留 2026-08-23 的历史实施与验收设计, 不得替代当前计划、当前源码或后续独立 System Test 交接。 ## 1. 目标与范围 本文覆盖两个方向:`dsh-awiki` 可以作为独立设备加入已有 Handle;当 DSH 自己创建 或 Recovery Handle、成为 bootstrap ready-admin 时,也能批准手机等后续设备加入。 本阶段必须形成双向闭环: - 已有 AWiki Me 或 CLI ready-admin 设备负责发现、核对 SAS 和批准; - DSH Join 先安装 `active + member + management_ready=false`,新管理端随后自动发送根密钥;接收端本机激活后才具备管理能力; - DSH 重启后恢复同一设备,不重新注册、不复制其他设备的私钥; - Handle Recovery 保留为显式、破坏性替代操作,不能再作为已有 Handle 的默认路径。 - DSH-created/Recovery identity 精确为 `active + admin + management_ready=true` 时,提供 Registry、 Join 请求、验证/SAS、批准并自动配置管理权、拒绝和撤销其他设备; - 安装管理 UI 本身不授予权限;旧管理端批准仍为 member,新管理端的一次 Join 批准包含自动管理授权。 DSH 的 Skill Agent DID 不是该 Handle 的 sibling device。若多 Agent 身份能力同时存在,必须 先完成部署级默认身份的 Device Join,再加载本机 Agent binding;不能把子 Agent DID 加入 Human Controller 的设备 Registry。 ## 2. 当前实现基线与剩余缺口 当前身份入口在真实 registration/account-verification OTP 消费后,由 Core 返回 `registered / existing_handle`。已有 Handle 进入 Join/Recovery/Cancel 选择,不再默认执行 Recovery。Host 只在进程内持有一次性 continuation;远端 Join 创建后,Core local session 是 重启恢复的唯一真相源。 开始 Join 成功后,Controller 使此前的身份发现请求失效。Host 可能先读到空 local session,再等待其他状态读取;这类迟到快照不能覆盖已开始的 Join,也不能让关闭再打开的面板回到验证码。已持久化会话仍由 Core 续接。 DSH ready-admin 管理面已经通过 Node facade 读取 Registry/local request、推进 SAS 验证并执行 approve/reject/revoke。Browser 只看到 Host opaque refs 和短期 SAS,不获得 raw session/device ID、 approval handle、proof、token 或私钥。 当前剩余工作严格限于: - 为已实现的 DSH joiner/admin/revoke 生成后续远端交接;G1 本地单元负例已闭合; - 普通 sibling Direct/Group/read/attachment 已复用现有 Core facade;G2 Schema 3 脱敏诊断接线已闭合; - Human Handle Recovery 的 Fresh Root、Local Data、crash/resume 单元测试已闭合;old-peer re-Join 因 `handle_recovery_rebind` user-presence 要求移到 G4; - 为已实现的 Node/DSH Root Transfer 和 Darwin 本机可信 user-presence 生成后续远端交接; - 生成 RWiki.cn System Test/三端 UI 交接,不在本开发任务中实现或执行系统测试。 G1 于 2026-08-31 新增 begin/start outcome-unknown 恢复、Recovery rebind user-presence fail-closed、 member/self-revoke/reject 和 Browser reject/revoke 单元覆盖。DSH 完整门禁为 35 files / 359 tests passed;该结果只证明本地产品/Host/UI 合同,不升级为远端或三端 UI 通过。 G2 于 2026-08-31 复用现有 Service/Core Schema 3,只补 Node TypeScript `olderHistoryExcluded` 与 DSH Host-only sync diagnostics。诊断仅包含 pages/messages 计数、boolean 和 sync cause,不包含 cursor、page ref、token、manifest 或正文。Rust DTO focused 1 项、Node package 28 项、DSH focused 46 项和完整 DSH 35 files / 359 tests passed;未修改服务端或同步状态机。 G3 于 2026-08-31 增加 Fresh Root/Local Data impact 映射和 Controller 单元 oracle:Fresh Root 不合成旧会话,Local Data 不调用 clear/reset 并保留已有投影,既有 Provider test 继续证明同一 operation crash/resume 与远端 Commit exact-once。完整 DSH 门禁为 35 files / 362 tests passed, 生产代码无需修改。G4 已让 old-peer re-Join 在 Darwin 原生认证后继续,并固定为 member;Root Transfer 使用另一次独立认证,Host-only 保留 authorization handle,Browser 只持有 opaque ref。 ## 3. 目标用户流程 ### 界面生命周期(2026-09-08) 普通刷新只更新投影,保留当前步骤、草稿和会话选择。冷启动及租户/身份变更才重新初始化。 启动先查询 Host 的入口状态,查询中或失败不能显示成没有操作。进程内 continuation 只投影 公开选择信息,Host 重启后失效须重新验证;Core 已存在的 Join/Recovery 续接原操作。 Core 提供只读未完成恢复发现,浏览器操作编号只是提示;多个操作必须明确选择。 未知恢复状态只允许查询,只有明确等待 factor 才输入/重发 OTP;已提交操作只能向前续接。 草稿按租户、身份、会话隔离,普通刷新和关闭面板不清空;未保存内容在整页退出前提示。 OTP、SAS、grant 和授权句柄不持久化。旧租户/旧流程的异步响应不得推进当前界面。 自动续接有界,失败保留重试入口;管理设备重新进入时查询真实阶段并重新填写敏感确认。 ```text DSH:输入 Handle + 手机号 -> 请求真实 registration/account-verification OTP -> 输入 OTP,调用 completeRegistrationWithOutcome -> registered:新 Handle,直接进入消息页 -> existing_handle:显示“加入新设备 / 恢复 Handle / 取消” 选择“加入新设备” -> Host 单次消费进程内 continuation -> Core 创建独立设备 key 和持久化 Join session -> DSH 显示等待已有设备验证 -> AWiki Me/CLI ready-admin 经 system.notification 发现请求 -> 两端独立计算并显示相同的 6 位 SAS -> 用户在已有设备确认 SAS,一次批准 Join 与自动管理 -> DSH 轮询到 authorized + consumed -> Core 安装本地 identity/account/device binding -> Host 启动 listener/sync,进入消息页 ``` DSH 管理手机 Join: ```text DSH 创建/Recovery Handle -> current device = ready-admin 手机提交同一 Handle -> pending Join 用户打开 DSH“设备”页 -> reliable sync -> Core local Join request 明确点击开始验证 -> DSH/手机各自显示 SAS 用户在 DSH 输入相同 SAS + APPROVE Host 执行 Core prepare + confirmDeviceJoinWithManagement -> 手机先成为 active member Core 自动交付根密钥 -> Registry 管理登记与接收端本机激活分别确认 手机进入消息页并在本机管理就绪后开放管理动作 ``` 首次选择“恢复 Handle”只进入独立风险说明页,不丢弃尚未消费的 Join continuation,不创建 Recovery operation,也不发送 OTP。只有用户再次点击“发送恢复验证码并替换 DID”后,DSH 才 丢弃 Join continuation并进入现有 Recovery V4流程;连续点击和页面重渲染不得重复发送。风险 说明必须明确恢复会替换 DID、使旧设备凭证失效,并继续突出普通 Device Join是推荐路径。 Recovery 必须为同一只读 Handle/phone重新请求一枚 purpose隔离的 Recovery OTP;刚消费的 registration OTP/grant不能复用,fresh Recovery也不得携带 local identity selector。OTP和 SAS 任意一个都不能单独授权设备。只有 controller 已通过凭证错误权威判定为 `recovery-required` 时, 入口状态或 Recovery状态查询失败页才同时保留恢复入口和重新检查动作,即使此时 Recovery capability 查询暂时失败;正常 `active` 会话的瞬时查询失败只允许重新检查,不得暴露破坏性 Recovery 入口。查询和已有 operation续跑不得隐式创建另一个 operation。 ## 4. DSH Host 合同 ### 4.1 注册结果改为闭集 `AwikiSdkClient.registerIdentity()` 和 Browser Remote 的 `registerIdentity()` 改为返回闭集, 不再把 `join_required` 压成 `handle-unavailable`: ```ts type AwikiIdentityAccessResult = | { status: 'registered'; identity: AwikiIdentity } | { status: 'join-required' fullHandle: AwikiHandle mode: 'ordinary' | 'handle-recovery-rebind' requiresUserPresence: boolean } ``` Native `continuationId` 只保存在 Host 内存中的单一 pending slot。Browser、Agent tool、日志和 controller snapshot 均不得看到它。新的注册提交、清空本地数据、provider replacement 和 service disposal 都必须使旧 slot 失效;并发或过期调用失败关闭。 DSH 对 `ordinary` 固定传 `userPresenceConfirmed=false`。Core 返回 `handle_recovery_rebind + requiresUserPresence=true` 时,只有本机 Darwin x64 原生系统认证成功才 继续消费相同 continuation;取消、不可交互和不支持的平台保留 continuation 并失败关闭。re-Join Join 授权回执仍为 member 中间态;管理端只有显式调用新自动管理入口,才会持久化后续根交付授权。旧入口不静默升级。 ### 4.2 Browser Remote 新增三个仅供本地 AWiki 面板使用的方法,不加入 Agent tools: - `beginDeviceJoin()`:消费当前 Host continuation,生成 Host operation ID,开始 Join; - `getDeviceJoinStatus()`:从 Core exact-one local session 恢复并推进同一 Join; - `cancelDeviceJoin()`:开始前丢弃 continuation,开始后调用 Core 取消并验证 typed terminal summary。 Host `getConfig()` 同步增加 `handleRecoveryPhoneEnabled`:它只能来自同源 canonical `/user-service/v1/server-info` 的 schema-v1 phone + `sms_otp` Recovery 声明;响应不可用、畸形或 关闭时为 false。Browser 不按 realm、Handle lookup 或本地 recovery flag 猜测该能力。 Browser 不提交 DID、device ID、Join session ID、operation ID、role、SAS 或授权结果。Host 只 返回适合渲染的状态: ```ts interface AwikiDeviceJoinProgress { readonly phase: | 'pending' | 'verifying' | 'sas-ready' | 'authorized' | 'cancelled' | 'rejected' | 'expired' readonly expiresAt: string readonly sas?: string readonly completed: boolean } ``` `sas` 只在 Core 已验证 challenge response 后短暂出现,必须是 6 位十进制字符串。它可以穿过 本地 Browser Remote 供人眼比较,但不能进入 `localStorage`、DSH settings、性能埋点、错误、 测试报告或任何 JSON 快照。UI 离开 SAS 阶段或流程终止时立即清空它。`expiresAt` 只显示 倒计时,不能由 Browser 本地时钟直接决定 expired。 Core → Browser 映射固定为: | Core 条件 | Browser phase | |---|---| | `remote=rejected` | `rejected` | | `remote=cancelled` 或 `local=cancelled` | `cancelled` | | `remote=expired` 或 `local=expired` | `expired` | | `local=authorized + remote=consumed + identity` | `authorized`,且仅此时 `completed=true` | | `sas` 存在且 `remote=response_verified` | `sas-ready` | | challenge/response/activation 中间态 | `verifying` | | `local=pending + remote=pending` | `pending` | 远端 terminal 优先;未知组合失败关闭。重启后如果 Core local session 只保留 `cancelled` 而不再 保留远端 rejected 原因,UI 显示通用 cancelled,不得猜测拒绝原因。 ### 4.3 Ready-admin 管理 Remote 管理能力只对 Core current device 精确返回 `active + admin + management_ready=true` 的默认身份 开放。Browser 只获得 Host 生成、绑定当前 session generation 的 `requestRef/deviceRef`,不获得 Join session ID、protocol device ID、approval handle、proof、token 或 Registry hash。 ```ts refreshDeviceManagement(): Promise> startDeviceJoinVerification({ requestRef }): Promise> approveDeviceJoin({ requestRef, enteredSas, confirmation: 'APPROVE' }): Promise> rejectDeviceJoin({ requestRef, reason }): Promise> revokeDevice({ deviceRef, confirmation: 'REVOKE' }): Promise> prepareRootTransfer({ deviceRef }): Promise> confirmRootTransfer({ transferRef }): Promise> ``` Snapshot 只包含 `canManage`、`rootTransferSupported`、当前 role/readiness,以及每台设备的 Host `deviceRef`、 status(当前快照仅包含 active 设备)、role、management-ready、is-current;Join request 只含 Host `requestRef`、时间、 Core-verified candidate fingerprint、`canStartVerification`、claimed-by-current/other 和状态。 名称、DID、raw device/session ID、SAS 和 proof 均不进入 snapshot。 刷新只执行 existing `syncNow()` 加 Core local projection reads,不调用 User Service admin pending/ status list。设备页打开时立即刷新,页面可见期间使用现有有界 single-flight 节奏作为可靠 HTTP fallback。身份激活后由默认开启、与 Workspace 无关的 Realtime Supervisor 独占唯一 Node WSS; Direct、Group 和 System Notification hint 只调度同一可靠同步,页面关闭不停止身份级 WSS。 可选私聊 Agent consumer 不再启动、停止或重连 WSS,只在合格 message cause 已提交后读取白名单 Direct 文本。该变更有意替代旧的“关页即停、无常驻 control listener”决定,仍禁止第二条 WSS。 local request list 不发网络请求、也不 claim,但对本机已 claim 的 ResponseVerified notification 会验证 response 并幂等推进 local phase。刷新顺序必须是 `syncNow -> listLocalDeviceJoinRequests -> localVerificationProgress`;start 后只轮询 progress 会 一直得到 invalid state,不能显示 SAS。 批准步骤固定为:显式开始验证(同一 session 使用确定性 operation ID,TTL=240 秒)→ sync/list 推进 local response verification → local SAS → 用户输入手机 SAS 和 `APPROVE` → Host 常量时间 比较 → Core prepare → approval handle 只留 Host → 同一次前台交互 Core confirm。 只有来自已认证 DSH 用户界面的这次明确提交才映射为 `userPresenceConfirmed=true`;Agent tools、 模型、页面 mount、后台 timer 和 Remote replay 均不能批准。该模型等价于 CLI foreground TTY, 不宣称具备系统生物认证;不能证明交互式用户会话的部署必须关闭管理 mutation。 start 结果未知时先 sync/list:当前设备已 claim 则进入 local progress 等待,其他 admin 已 claim 则 只读,仍 `canStartVerification=true` 才允许用同一 operation ID 重试。不得为重试生成新 ID 或对 Claimed payload 再次 start。 撤销要求 ready-admin、非当前设备和显式 `REVOKE`。Core 继续执行 self/last-admin 拒绝、CAS、 outcome-unknown resume 和 live fencing。新 Join 不提供 role selector,一次 SAS/APPROVE 授权自动管理,Core 独占发送与重试账本。历史 member 的显式 Root Transfer 仍是独立操作:fresh eligible member → Core prepare → 原生认证 → fresh context recheck → Core confirm/send。Browser 不接收 authorization handle、raw device ID 或 Root material。 ### 4.4 Host 重启恢复 prepared-registration continuation 是进程内秘密:若 DSH 在远端 Join 创建前退出,用户必须 重新请求 OTP,不尝试持久化或重建 continuation。 远端 Join 创建成功时,Core 已经先持久化 candidate keys、Vault refs 和 local Join session。 Host 不再创建 `stateRoot/.host/device-join-v1.json` 或任何第二份 Join journal。Node v10 必须 暴露只读 `listLocalDeviceJoinSessions()`,其投影不含 SAS、token、challenge 或私钥。 重启时: 1. `getSession()` 仍以 Core 已提交 identity 为真相; 2. `active` 进入已登录产品、`signed-out` 进入本机解锁;只有 `unregistered` 才读取 Core local sessions; 3. `resumable` 固定为 `side=new_device` 且 phase 属于 `pending/challenge_prepared/response_prepared/response_verified/approval_prepared/authorized`; cancelled/expired history 不计入 conflict,也不阻止重新 OTP/begin; 4. 0 条 resumable 回 onboarding;精确 1 条直接进入 Join 页,只允许 status/resume/cancel; 多于 1 条 resumable 返回稳定 conflict,不静默选择 newest/first; 5. `authorized + consumed` 且 Node 返回 exact identity 后,Host 才激活 session 和 listener; 6. cancelled/rejected/expired 以 Core terminal state 收敛;网络失败保留 Core session 并重试。 已完成 local session 可继续由 Core 持有并在既有 identity retirement/clear 路径清理;identity 已经 active 时 Host 不再把它路由为 pending onboarding,也不为 UI 整洁提前删除 Core 记录。 该设计关闭“Core begin 已提交、Host 文件尚未写入就崩溃”的孤儿窗口,并且不在既有 `stateRoot/.host/` 共享写入面上再增加 Join 文件。DSH 当前的 signed-out 和 conversation-preference 文件仍在该 Host 目录中;已删除的 sent-mail authority 仅保留精确的 Clear Local Data 旧目录清理。本文不把整个 state root 误写为 Node 独占。 `clearLocalData()` 只清理当前租户。Core 根据本地 Registry 和经过验证的注册、Join、Recovery、 Legacy-upgrade journal 收集精确 provider 引用,先完成对应身份清理,再擦除 Node-owned Core session/Vault。共享 ANP Provider 的全量列表不代表当前租户所有权;无本地归属证据的孤儿和 其他租户身份必须保留。归属记录损坏或 provider 清理失败时不继续擦除 Core state,可重试同一 显式动作。Browser 使用返回的 `clearedIdentityDids` 限定缓存清除范围。既有 `.host/signed-out` marker 继续由 `AwikiSessionStore` 管理,但不承载 Join truth。 status/resume 和 cancel 在打开 remote token 前先读 exact local phase。cancelled/expired 直接 投影通用 terminal,不再调用 remote advance;因此不会把 token 已清理后的 `invalid_state` 暴露给 Browser。当前 poll 已观察到 `remote=rejected` 时仍显示 rejected,重启后只剩 local cancelled 则显示通用 cancelled。 ### 4.5 Web 身份方法与非设备服务更新 DSH 的新方法入口需要 Core Node native API v18。Host 只转发 Core 的 `identityCreationMethods`、`identityMethodCapabilities` 和 `pendingIdentityRegistrations`, 不按 DID 前缀推断能力。新建方法是服务声明与 SDK 支持的交集;保留 WBA 默认,能力读取失败 不开放新建。既有 Handle 的 `join-required` 使用 Core `expectedDid` 的方法能力,不能用 用户本次选中的新建方法决定恢复入口。Web 不提供 Recovery、Root Import、Root Transfer 或原位换钥;界面明确首个管理员丢失后无法恢复管理能力。WBA 原有恢复及原生管理权转移保留。 `getIdentityAccessState` 增加当前身份的方法能力、可创建方法和未完成注册公开摘要。注册摘要 只含 DID、full Handle、method、display name、verification kind、phase,不含手机号、OTP、 operation ID、continuation、密钥引用或私钥。Core 按当前租户从 Vault 读取,Host 不写第二份 注册日志。选择摘要只填写公开 Handle 和原方法;用户输入新验证码后交回原注册入口,Core 匹配原业务内容并续接同一 DID。重挂载、浏览器重载、Host 重启和能力关闭均不触发新注册或 删除候选记录。DSH 当前仅显示其已支持的 phone verification 注册。 设备快照增加 Core 方法能力;`rootTransferSupported` 同时要求方法支持与本机原生用户认证。 管理动作仍要求 Core 当前设备为 `active/admin/management_ready`;方法能力自身不授予权限。 新增三个只面向 Browser 的 Remote,不加入模型工具: - `getIdentityServices()`:从 Core 文档和未决标记读取白名单公开服务,返回当前 DID 和 `canManage/pending`; - `updateIdentityServices({ did, services })`:Host 检查当前身份精确匹配、方法支持、当前 管理资格及没有原未决更新,然后调用 Core 原服务更新入口; - `resumeIdentityServicesUpdate({ did })`:确认当前身份精确匹配后,只显式继续 Core 保存的原更新,不接受新操作号或新内容。 服务编辑不接受任意 JSON patch。公开服务仅含 id、type、serviceEndpoint、可选 serviceDid、 profiles、securityProfiles;`AgentDescription`、`ANPHandleService`、`ANPMessageService` 在界面只读,编辑其他服务保留这些条目和原 profiles。Core 继续执行受保护字段、绑定、签名、 当前 Registry 锁内资格和幂等校验。member 不显示服务编辑或其他管理按钮。 更新失败后重新读取 Core pending;状态读取失败时关闭写入,pending 时只提供显式续接。 关闭页面不取消发布、不重新提交,也不丢弃候选记录。 本节仓内单元/契约测试由 `did-method-web.spec.ts`、`did-method-web.client.spec.tsx` 及原 SDK adapter、identity bridge、devices、controller 测试拥有。真实后端 UI 验收由本仓 `tests/e2e/` 的 `DID-WEB` 场景负责;本地 mock 结果不替代该证据。 ## 5. UI 状态与文案 - ANP Identity 插件依赖固定为 `0.1.3-rc.1`。提交注册验证码前显示“确认创建 AWiki 身份” 弹窗,展示本次 Handle;取消保留输入且不调用注册,确认才提交,并阻止重复点击。 - 创建调用链仍为 `registerIdentity` → Core `completeRegistrationWithOutcome` → ANP Identity Host Provider `create()`,使用其持久化身份存储;获取 OTP 不创建身份。这里保留受信 Host 集成,未改成普通插件的 `requestCreateIdentity` 授权模式,避免改变 Core 的密钥能力及所有权。 - 注册成功和读取本机身份时,将 Core 返回的完整 Handle 同步到同一 DID 的 ANP Identity 目录。只更新 AWiki 已拥有的身份;值相同时不重复写入。目录同步失败不回滚已完成注册, 在下一次身份读取时重试,因此升级后既有身份也会补齐 Handle。 - 弹窗明确说明已有 Handle 会进入设备加入,不以公开 Handle 查询提前判断身份是否存在。 - 统一入口不再在发码前用 public Handle 查询决定安全协议;真实 OTP 消费后的 Core 结果才是 `registered / join-required` 分流事实源。 - `join-required` 对话框把“加入新设备”作为推荐操作;只有 canonical server-info 明确声明 phone + SMS Recovery 时才显示危险 Recovery 选项,并说明会替换 DID 和影响其他设备。 - Join 页显示等待、SAS、完成、拒绝、过期、取消和网络重试;不允许选择 admin role。 - 启动发现 exact-one pending Core session 时直接进入 Join 页;不得先显示 onboarding 并允许重复 registration OTP。 - SAS 未出现前不得提示用户确认;两端不一致时只能在管理设备走 `sas_mismatch` 拒绝。 - DSH 只有在 Node 返回 `completed=true` 和 identity 后才进入消息页。中间阶段不得用 `expectedDid`、Handle 或本地草稿合成 active session。 - ready-admin 显示“设备”页:设备列表、待处理请求、开始验证、SAS、批准/拒绝和撤销;member 只显示“由其他管理设备管理”,不显示 mutation。 - 打开请求本身不 claim;只有用户点击“开始验证”才调用 Core。批准必须输入手机上的 SAS 和 `APPROVE`,撤销必须输入 `REVOKE`;错误、离页或会话切换清空短期输入/opaque refs。 现有 `inspectIdentityAccess()` 为兼容保留在 public Remote/Typert baseline,但新 UI 不再调用 它决定 OTP purpose;后续删除必须作为独立 breaking change。`registerIdentity()` 返回类型变化 要求 Host/Typert/Browser 同版本原子切换,旧 Browser 不能把 `join-required` 当注册成功。 新短 Handle 缺少邀请码只由 `registerIdentity()` 提交后的精确 Node `short_handle_invite_required` 触发固定提示;Host/Browser 不按 Handle 长度或服务端 message 预判。提示沿用 identity-flow draft,关闭再打开仍保留,修改 Handle 时清除。 ## 6. 安全与数据边界 - 每个设备独立生成并持有 signing/E2EE private key、PreKey、Ratchet、MLS Leaf 和 replay 状态;禁止从 AWiki Me、CLI 或其他 DSH state root 复制。 - Node/Core 独占 OTP grant、continuation、Join crypto 和 identity activation;DSH TypeScript 不拼 User/Message RPC,不解析 raw challenge,不计算 SAS。 - Browser Remote 只暴露短期 SAS 和闭集阶段;不暴露 continuation、Join token、proof、root key、auth generation、Registry hash 或原始服务错误。 - Device management 不加入 Agent tools;Browser request/device refs、typed SAS/确认词和 Core approval handle 都不持久化、不记录,跨 Host/session generation 立即失效。 - 新设备是 tail-only:不承诺自动获得 Join 前的 Direct 明文、MLS epoch secret 或附件 key。 - `stateRoot` 必须部署级独占。清空本地数据只删除本机设备材料,不撤销远端其他设备;撤销 只能由当前仍有效的 management-ready admin 完成。 - 注册 bootstrap 遇到“外部 provider 已有多设备 active 身份、Core Registry 却为空”时,Node 返回 `local_identity_recovery_required`,DSH 映射为 `identity-recovery-required` 并提示恢复或 完整清除本机身份;不得再显示注册白名单 403。 - DSH identity realtime、conversation poll 和 Agent routing 只在身份最终激活后启动;授权前任何消息 API 都返回 `not-registered`。 ## 7. 历史实施切片(2026-08-23) ### A. `awiki-cli-rs2` 1. 完成 [Node SDK 增量合同](../../awiki-cli-rs2/docs/node-sdk/dsh-device-join-extension.md): SAS/expiry 投影、准确 user-presence、Core local-session list/restore、ordinary resume gate 修复 和 typed cancel;同时映射 current summary、Registry、Join requests、admin verify/approve/reject 与 revoke。 2. 更新 Rust DTO、N-API wrapper、TypeScript 类型、native API version、五个平台包版本、 changelog、制品校验和 parity tests。 3. 先发布新的 `@awiki/im-core-node` patch,再更新 DSH 固定版本;禁止依赖 workspace link 冒充 registry 制品已发布。 ordinary Join 必须在 Handle Recovery gate 关闭时也能 begin/resume。DSH 保留现有 Recovery, 所以 provider 的 recovery flag/audience 仍需与 awiki.info 部署匹配,但不能成为 ordinary Join 的隐藏前置。Node 当前未暴露 Direct/Group E2EE 开关;focused test 必须证明这些 gate 默认关闭 时,Join 后 PreKey publication 与 DSH `default-plain` Direct 仍可用,不能为通过 Join 偷开 E2EE。 Node v10 另增加默认 false 的 device-revoke open option;DSH provider 显式启用,Core 仍验证当前 ready-admin、self/last-admin 和远端 CAS,不能因为本地开关开启就放宽权限。 ### B. `dsh-awiki` 1. Provider 改用 `completeRegistrationWithOutcome()`,建立 Host-only continuation slot。 2. 增加 Core exact-one restore、Remote 闭集、session 激活/清理和错误映射;不新增 Host Join journal。 3. 增加 Host opaque request/device refs、ready-admin gate、可靠同步/local management projection、 SAS 比较、split approve/reject 和 revoke 编排。 4. 把现有身份页改为注册结果后的 Join/Recovery 选择,增加 Join 和设备管理页面,并为 Recovery 重新发送 purpose 隔离的专用 OTP。 5. 更新 Typert 生成物、公开 Remote baseline、源码构建产物、README 和固定 Node package。 ### C. `awiki-system-test` 实现时在同一任务新增 DSH-joiner 与 DSH-ready-admin 两个方向的远端用例和 catalog/suite 文档; 另在 `awiki-me/tests/e2e/` 增加 DSH admin 批准真实手机 App joiner 的产品 case。测试必须通过 reviewed `AWIKI_SYSTEM_TEST_TARGET=awiki-info-testing`,不得靠隐式默认域名或本地服务替代。 `production-awiki-ai` 只允许发布后只读 smoke,不开启 DEV OTP 或创建测试 Handle。 ## 8. 历史验收矩阵(2026-08-23) ### 单元/合同 - Node:`registered / existing_handle`、ordinary user-presence=false、SAS/expiry 映射、Debug 脱敏、local-session list、ordinary gate-off resume、typed cancel、restart restore、terminal idempotency、admin Join/Registry/revoke facade 和 native version mismatch。 - DSH Host:continuation 一次性、Browser 不见 native ID、Core exact-one restore/multiple conflict、 terminal local preflight、ready-admin capability、opaque refs、SAS compare、approval/revoke gate、 完成后才后台启动 identity realtime、provider replacement 和 clear-local-data 清理;身份成功不 await startup sync 或 first connected,Agent consumer 不拥有 WSS。 - DSH Browser:Join/Recovery/Cancel 选择、pending 无 SAS、SAS 只在正确阶段显示、拒绝/过期/ 取消/网络重试、设备列表/请求/批准/拒绝/撤销、离页清空 SAS、429 Retry-After、Recovery capability、专用 Recovery OTP。 - 公开面扫描:Agent tools、Remote 生成物、日志、snapshot 和构建产物不含 OTP、continuation、 Join token 或持久化 SAS。 ### `awiki-info-testing` 产品 E2E 方向 A 保留原 DSH joiner 流程: 1. 用受保护的固定测试手机号先建立一个 fresh Handle ready-admin 设备; 2. 在隔离 DSH `stateRoot` 走真实发码和 OTP 消费,必须得到 `join-required` 而非 Recovery; 3. AWiki Me/CLI 管理端必须由真实 system notification 唤醒,不能直接 hydrate inbox; 4. 两端 SAS 在内存中精确相等,管理端前台批准一次且只能得到 member; 5. Registry 精确包含原 admin 与 DSH current member,DID/Handle 不变; 6. DSH 与独立账号各发送一条 Join 后 Direct,双方 exact-one 收敛; 7. 重启 DSH 后复用同一设备并继续收发; 8. 管理端撤销 DSH 后,旧 HTTP/WS/PreKey 和未来消息均失败关闭; 9. 覆盖 wrong/replayed OTP、无批准、SAS mismatch、cancel、expiry、begin 返回边界 crash、清本地 后 ordinary re-Join、429 和 multiple-session conflict; 10. fresh Handle 创建后立即登记脱敏 residual,不调用 Handle revoke; 11. 输出脱敏 residual ledger,并恢复服务端测试 OTP 配置。 方向 B 增加 DSH admin 流程: 1. DSH 创建 fresh Handle 并精确成为 current ready-admin; 2. CLI joiner 提交同一 Handle,DSH 设备页经 reliable sync/local inbox 发现请求; 3. 打开不 claim,明确开始验证,DSH/CLI SAS 相同; 4. DSH 输入 exact SAS + `APPROVE`,手机/CLI 只成为 member; 5. 双向 Direct/restart 通过;DSH 用 `REVOKE` 撤销 member 并完成 live fence; 6. member/blocked、wrong SAS、页面卸载、重复 approve、self/last-admin revoke 均失败关闭。 AWiki Me 另执行真实手机加入产品 E2E:DSH-created ready-admin 通过实际 Web UI 批准 App joiner; 不能用 CLI joiner 或 fake Host 替代该结论。 测试手机号和固定验证码只允许位于 ignored、`0600` 的本地配置和受保护服务配置;不得写入 Git、命令参数、报告或回复。服务端如为测试临时启用 `DEV_OTP_PHONE` / `DEV_OTP_CODE`,必须在 测试后重新注释、重启 User Service,并以不回显值的方式确认 preset 已关闭。 Handle revoke 尚未进入本功能的产品或测试验收范围。`remote-dsh-device-join` 只撤销 joining device;fresh Handle 在创建成功后立即进入脱敏 residual tracker,并允许保留在专用 awiki.info 测试服务器。driver、Browser Remote 和 Agent tools 均不得调用 `request_revoke/confirm_revoke`。 手机号 active Handle 配额由测试环境运维单独治理,不能为了 cleanup 把未充分验证的 Handle 注销 能力混入 Device Join gate。 远端 DSH 进程必须显式覆盖默认生产配置:隔离 `stateRoot`,所有 User/Message/Mail/public URL、 Handle domain 和 Message Service DID 均来自 reviewed awiki.info target;当前服务 DID 预期为 `did:wba:awiki.info`。hardcoded `multiDeviceAudience=awiki-user-service` 必须与 Ali 配置闭合。 任一 resolved URL/domain/DID 仍指向默认 `awiki.ai` 时,在发送 OTP 前 fail closed。 expiry 负例不使用固定 sleep,直接按 begin/status 返回的 `expiresAt` 计算有界等待;超出 suite budget 时在创建 session 前失败关闭。candidate status 本身会让 User Service 同步提交到期状态, 不依赖后台 worker interval;无请求自动过期通知不扩入本用例。 ## 9. 历史 `awiki.ai` 只读预检(2026-08-22) 已完成无业务副作用的公开连通性预检: - `/user-service/v1/server-info`:HTTP 200,`schema_version=1`; - `/user-service/server-info`:HTTP 200; - `/.well-known/did.json`:HTTP 200; - `/user-service/health`、`/message-service/health`:HTTP 200; - `/im/ws` 普通 HTTP GET:HTTP 400,符合缺少 WebSocket upgrade/auth 的预期。 `server-info` 当前公开 phone + SMS OTP 的 Handle registration 和 Recovery;多设备 discovery 仍按既有 hidden-rollout 边界处理,不能仅凭公开响应推断 Join 已发布。设计阶段未发送 OTP、未 创建 Handle、未启动 Join,因此没有产生远端测试身份或修改服务端 preset。完整业务 E2E 必须 等待 Node/DSH 实现完成后在现有 `awiki-info-testing` 执行。`production-awiki-ai` 已登记但只声明 `core-messaging`,且生产配置拒绝非空 DEV OTP;它不能作为本计划的写操作 Join target。 ## 10. 历史非目标与发布门禁(2026-08-23) 历史第一阶段不实现、但当前 G4 已补齐 Root Transfer;仍不实现: - 批量管理和设备自定义命名; - 跨设备复制历史密钥、自动恢复 Join 前历史或让 DSH 继承 MLS 状态; - 新 User/Message API、数据库 migration、ANP wire 或公开 discovery; - 自动 Recovery、无 ready-admin 的 OTP-only 登录、自动批准或跳过 SAS; - 把 Skill Agent DID 建模为 Human Handle 的设备。 发布顺序固定为:Node focused tests → Node 全量/制品 gate → Node patch 发布 → DSH 固定依赖与 单元/构建 gate → reviewed `awiki-info-testing` DSH Join E2E → DSH package 发布 → `production-awiki-ai` 只读 smoke。任一阶段只有 mock、 collect-only、skipped、未清理 preset 或未记录 residual,都不能声明功能完成。 设备撤销后的 Host 快照只保留 active 设备。验证撤销应断言精确目标 deviceRef 消失、原 current ready-admin 唯一保留,并在随后刷新中再次确认;不得在已过滤的快照中要求出现 revoked 条目。Core 原始 Registry 的 revoked 状态仍由 Core/SDK 的对应测试验证。 ## 面板刷新与身份操作连续性(2026-09-08) 右上角刷新只重新读取权威状态。同一租户、同一身份时,Controller 保留已显示的会话、 选中项与普通错误恢复入口;关闭面板只结束读取轮询,不撤销已经提交的操作。 进行中的操作由 Controller 跟踪到响应结束,重复提交返回忙碌提示,旧读取结果受生命周期隔离。 身份入口先调用 `getIdentityAccessState`。Host 返回已有账号选择的公开摘要、是否存在 Join, 以及当前租户未完成 Recovery 的 operation ID / Handle。Join journal 和 Recovery journal 继续由 Core 拥有;Host 不建立第二份流程日志。Core Node v14 提供 `listPendingHandleRecoveryOperations`,可以发现尚未成为公共身份的 fresh owner。 Recovery 按 Handle 续接:入口不自动打开唯一的未完成恢复,也不受其他 Handle 的恢复阻塞。 用户输入 Handle 后,先读取当前租户的 Core 操作目录;命中时查询并进入同一个 operation, 不重新发验证码。目录读取失败时禁止发起新的身份操作;已选恢复的状态查询失败仍可返回。 每个恢复页面(包括失败、结果未知、凭证不可用及已被新状态取代)都提供返回入口。 返回仅释放当前页面选择和敏感草稿,不撤销请求、不删除 Core journal 或本机身份数据。 刷新/关闭重开保留当前页面选择;显式返回后重开仍停留入口。完整浏览器重载不读取旧 localStorage operation 提示,不自动把用户拉回恢复页;再次输入原 Handle 即可发现未完成操作。 只有 Core `allowedActions` 明确允许时才提供激活、续跑或丢弃;取消恢复是单独按钮, 只检查 `discard_pre_attempt`,不与 `activate` 绑定。授权过期但仍可 prepare 时显示验证码表单。 发现或状态查询失败不退回新注册或验证码表单。正常 `active` 会话 只提供重新检查;仅 controller 的权威 session 已是 `recovery-required` 时,失败页仍保留恢复 入口,即使 phone Recovery capability 暂时不可用。既有 Core operation 优先继续展示和查询, 同样不受 capability discovery 失败影响;这些例外都不得把 `active` 伪装为 `recovery-required`,也不得因打开入口而发送 OTP。 缺少 action 权威信息时只读查询,不按 phase / retryable 猜测权限;`local_transition_superseded` 显示已关闭状态,不再自动续跑。获准的自动续跑每个操作至多一次,后续由用户重试;不得自动重复激活。 Host 的 `getRecoveryStatus` 只查询;`activateRecovery` / `resumeRecovery` 只推进 Core 恢复, 不隐式 sign-in。新的 `enterRecoveredSession({ operationId })` 才执行本机会话接续:先重新 核验 applied 和 `activate_identity`,再沿用原有精确 DID 校验、session/provider generation 隔离、Mail 接续和 session 事件去重。当前页面内恢复成功可接着进入;只读查询到 applied 时显示“进入 AWiki”。Controller 对恢复请求使用页面 revision 隔离,用户返回后不再消费 该页面的迟到成功/错误,也不触发后续会话接续;旧请求的 finally 不得释放新账号的 pending。 已明确发起的 Core 提交或本机会话进入不因页面导航被撤销;返回不是回滚或删除。 原身份凭证失效的入口继续展示原 Handle,提供恢复原身份的入口及其他账号表单。 `AwikiDraftStore` 只保存浏览器内存中的表单草稿,按租户、身份和会话隔离。OTP、手机号、 Handle 和附件 File 不进入公开快照、localStorage 或 Host DTO。Controller 只额外按 `tenant + recovery operation` 保存非敏感的 Recovery OTP 重发截止时间,用于延迟成功后切回 原租户时恢复同一服务端 cooldown;它不能恢复或推断任何 factor。切换租户清除身份流程输入; 注销、清除本机数据及成功结束身份流程清除对应草稿;重置前捕获的异步回调不能重新填回数据。 消息、邮件、资料及 Integration 编辑在面板重挂载时保留;Integration 草稿同时保留原始 revision,防止覆盖远端的新修改。资料的取消、邮件的放弃仍明确丢弃草稿。 邮件发送不自动重试,关闭重开保留发送中的状态与失败提示。SAS 输入、批准词、撤销词和 其他确认授权不作为草稿恢复;管理端从 Core 重新读取已认领请求,再显式继续验证。 完整浏览器重载/进程退出会清空内存草稿;存在未提交输入时注册标准 `beforeunload` 提示, 是否显示由浏览器/宿主控制。重启后的 Join/Recovery 由 Host/Core 重新发现,不依赖浏览器 存储。这里不引入临时中间版本数据兼容或迁移,也不修改 Recovery、支付和 Mail 服务协议。 安全边界复核:浏览器无法获取 continuation、vault key、恢复 grant 或私钥;操作编号仅用于 选择,非敏感重发截止时间仅用于禁用重复发码,Host/Core 仍负责当前租户、状态和所有权校验。 未知提交结果不提供取消/重新激活。 本次本地验证覆盖 UI 重挂载、状态读取失败、响应丢失、重复操作、跨作用域回调以及真实 Node binding 重开查询;产品 E2E 的 Join 和 Recovery 用例同步加入刷新/新浏览器步骤。 真实服务、短信和人工交互验收不属于本次本地测试结果。 ### 恢复导航修复的验证边界 本次只改 DSH Browser / Host 会话接续以及共享入口的 Model Proxy 消费方;不改 App、Core 恢复协议、密钥所有权及本地数据迁移规则。参照 App 的按 Handle 页面选择和迟到回调隔离, 沿用 Core journal 和已有浏览器草稿 epoch,不新增持久化流程日志或恢复任务管理器。 新增 `recovery-navigation.client.spec.tsx` 覆盖各阶段返回、重开、同 Handle 续接、另一账号 验证码、迟到成功/失败和新 pending 隔离。Host Mail 接续用例转到显式进入接口,保留 精确 DID、注销/删除/provider 换代隔离及 Mail 失败降级断言。 `live-recovery.spec.ts` 增加提交前返回、原 operation 不变以及新浏览器按 Handle 续接; 产品 E2E 使用真实 UI,不靠注入浏览器 operation 提示。真实服务/短信 E2E 本次不执行。 System 层复核 `tests_v2/multi_device/test_handle_recovery_v1.py` 与 DSH Device Join 合同: 本次没有新增跨服务状态或 wire 行为,导航隔离的新增覆盖归 DSH;不在 System 重复实现 UI。 本地验证(2026-09-08,本次恢复导航修复;不替代下方历史记录): - `pnpm exec vitest run tests/controller.client.spec.ts tests/awiki-overlay.client.spec.tsx tests/recovery-navigation.client.spec.tsx tests/recovery-authority.client.spec.tsx tests/refresh-continuity.client.spec.tsx tests/recovery-mail-continuity.spec.ts tests/index-coverage.spec.ts tests/awiki.spec.ts tests/contract-baseline.spec.ts`:294 项通过。 - `pnpm test` 全量检查:573 项通过、1 项失败。失败是未修改的 `recovery-external-provider.spec.ts` 在 `prepareRecovery` 的真实本地原生调用中返回 `remote`;直接调用 Node `prepareHandleRecovery` 也失败,不经过新 Host 会话接续。 保留原用例,没有跳过或放宽断言;当前不能宣称原生 Recovery 集成全通过。 - `pnpm --filter @awiki/dsh-model-proxy run verify`:构建、检查和 113 项测试通过。 - `uv run pytest tests/non_did/test_dsh_device_join_contract.py -q`:4 项通过; 这是服务无关合同,不是远端 System 测试。 - `pnpm run build:raw`、`typecheck`、`typecheck:e2e`、`check:public`、 `check:generated` 通过;61 个 Remote 的 Typert 产物由仓库生成脚本生成。 - 默认 `pnpm build` 在依赖来源检查入口因本机缺少 `yaml` 模块而失败; 使用现有显式本地链接完成源码构建,不修改正式依赖来源来规避门禁,也不把 `build:raw` 当作 registry/发布构建通过。工作区并行的升级策略改动保留原样。 - 真实短信、远端服务、DSH Web E2E 和人工验收未执行;无提交、推送或发布。 安全复核:返回只清理浏览器选中项/草稿,不触及 custody、数据库或服务端请求; 会话接续仍由 Host/Core 验证 exact DID 和当前 generation,浏览器不能传入新 DID 或密钥。 准入 action 来自 Core,未知结果不开放丢弃或重新激活。以上本地证据支持导航边界; 原生集成失败和未跑的真实产品 E2E 仍是后续发布前需要补齐的验证,不能以界面测试替代。 ### 本地验证记录 2026-09-08,`release/0815` 工作区: - `pnpm run build:raw`、`check:public`、`check:generated`、`typecheck:e2e` 通过; Typert 生成合同为 60 个 Remote(含共享工作区既有/并行的公开资料查询改动)。 - 主插件 `pnpm test -- --reporter=dot`:57 个文件,547 项通过、失败 0。 - Model Proxy `pnpm --filter @awiki/dsh-model-proxy run verify`:构建和各项检查通过,106 项测试通过。 - Core `CARGO_INCREMENTAL=0 cargo test -p awiki-im-core identity_handle_recovery_operation --lib`:6 项通过; Node `node --test test/client.test.mjs`:14 项通过,使用本地真实原生绑定和临时测试目录。 - Node TypeScript 构建、类型用例及 `check-node-identity-boundary.mjs` 通过。 - System `uv run pytest tests/non_did/test_dsh_device_join_contract.py -q`:4 项通过,失败 0、跳过 0; 此命令是无服务契约检查,不使用真实租户/短信,也不代表 System 或产品 E2E 全量通过。 - 产品 E2E 的实现及类型检查已更新,真实 Join/Recovery E2E 与人工验收未执行。 测试中曾因本机磁盘不足中断,清理可重新生成的 Rust 增量缓存后复验通过。 共享工作区另有租户隔离、本机清理及资料展示开发,本次保留这些改动;接口基线按当前 完整工作区校验。以上为本地开发检查,没有提交、推送、发布或操作生产账号。 Handle 投影在 Recovery 保留旧 DID 时,将当前客户端可访问的旧记录上的同名 Handle 清除后写入 Core 当前 DID。两次写入之间失败时,下次身份读取继续补齐;不删除旧身份,也不修改其他 Handle。同步失败日志包含稳定错误码与公开 DID/Handle,不输出原始异常或密钥材料。 ## Automatic administrator provisioning New approvals call `confirmDeviceJoinWithManagement` through the native Node bridge. SAS and APPROVE remain the Join authorization; no additional root-transfer confirmation is required. Core owns the durable limit of four attempts per round and the five-second delay after retryable failures. Browser timers only refresh the secret-free progress projection and never send keys or reset counters. The device view shows pending, waiting for recipient, failed, and completed states. An explicit failed-task retry resolves an opaque device reference inside Host, verifies current admin authority, and delegates reconciliation/new-round selection to Core. Tasks with accepted delivery cannot be restarted through this UI. Historical devices without an automatic task keep the existing manual transfer entry. The external identity bridge requires strict sibling-document adoption for stale administrator documents. Missing provider support fails closed without falling back to general adoption. Core Node API 16 and the matching identity provider are required for the new source combination. Default automatic delivery uses the original V1 envelope and completion contract; User Service V2/extensions and Message Service continuity fixes are independent milestones, not mandatory prerequisites. Registry dependencies must be published and pinned before a release build. Local/mock validation does not establish DSH Web or native Desktop live acceptance. ## Device Join 测试驱动的目标边界 `scripts/device-join-e2e.mjs` 通过 `device-join-target.mjs` 校验 stdin 配置。允许精确的 `awiki.info`、`rwiki.cn` 和本任务隔离域 `test-fix.anpclaw.com`;三个服务入口必须是该域的 HTTPS 根路径、默认端口,并使用对应 `did:wba:<域名>`。生产域、混合目标、用户信息、查询参数及片段均拒绝;原有独立绝对状态目录、realtime/listener 约束保留。配置与秘密仍只经 stdin 传递。 App 互操作用例由 AWiki Me 拥有;这里新增目标支持不代表 DSH Web 或 Desktop 全量验收。无服务验证入口:`pnpm exec vitest run tests/device-join-target.spec.ts`。