device-center 0.2.0-beta.7 → 0.2.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## 快速使用
10
10
 
11
- ### 简化接入(0.2.0-beta.7)
11
+ ### 简化接入(0.2.0-beta.8)
12
12
 
13
13
  一页指南提供 **交给 Agent/下载安装包/复制脚本**,分别支持 macOS、Windows、Linux,目标设备不需要先安装 AI Agent。
14
14
 
@@ -23,7 +23,7 @@
23
23
 
24
24
  有浏览器的设备无需搬运配对码,也不需要回 Agent 回复“已批准”。无浏览器服务器可选手动安装:目标终端运行脚本,在另一台电脑登录控制面,填入终端码、核对指纹并批准。全新安装脚本会配置用户级后台服务;已有安装时停止并保留设置。AI Agent/MCP 仅是管理电脑的可选能力。身份私钥留在设备;安装绑定只允许资源回报,远程日志仍需另外授权。[手动步骤与排查](docs/agent-install.md#手动安装目标设备没有-ai-agent)
25
25
 
26
- 本指南固定 **0.2.0-beta.7**,提供统一 CLI 管理与 Windows 11 预览接入。无需 AI Agent 或 AD 域,使用普通用户 PowerShell、Node.js 24+,按 [Windows 11 验收](docs/windows-acceptance.md)操作;安装前以公开 npm registry 的版本记录为准。Windows 11 后台与真实绑定仍需实机验收。官方控制面已包含登录恢复修复,旧客户端仍可终端填码。
26
+ 本指南固定 **0.2.0-beta.8**,提供统一 CLI 管理与 Windows 11 预览接入。无需 AI Agent 或 AD 域,使用普通用户 PowerShell、Node.js 24+,按 [Windows 11 验收](docs/windows-acceptance.md)操作;安装前以公开 npm registry 的版本记录为准。Windows 11 后台与真实绑定仍需实机验收。官方控制面已包含登录恢复修复,旧客户端仍可终端填码。
27
27
 
28
28
  希望交给本机 Agent 安装时,按[Agent 安装指南](docs/agent-install.md)选择本机、官方托管或已有自建服务。“交给 Agent”复制一行,由 Agent 读取公开 Skill 后执行;登录和设备绑定仍由用户核对。管理当前账号内设备则从 [CLI 与 Agent 接入](https://devices.owenshen.top/cli)开始,首次需另行批准 60 分钟 CLI 权限;详见 [Agent Skill](docs/SKILL.md)。朋友试用从[一页指南](https://devices.owenshen.top/start)开始,无需先登录。
29
29
 
@@ -38,7 +38,7 @@
38
38
  只用本机,不注册账号:
39
39
 
40
40
  ```sh
41
- npx --yes device-center@0.2.0-beta.7 start
41
+ npx --yes device-center@0.2.0-beta.8 start
42
42
  ```
43
43
 
44
44
  打开 `http://127.0.0.1:5174`,将 `/mcp` 添加到本机智能体。侧栏“开始使用”按管理电脑 → 智能体 → 设备引导;导入 SSH 后检查一次连通性。没有可用 Agent/SSH 的设备保持未知;资源快照过期显示“待刷新”,不代表密钥过期。列表刷新不主动连接 SSH。
@@ -46,8 +46,8 @@ npx --yes device-center@0.2.0-beta.7 start
46
46
  需要在外查看设备、使用日志中转:
47
47
 
48
48
  ```sh
49
- npx --yes device-center@0.2.0-beta.7 plan
50
- npx --yes device-center@0.2.0-beta.7 connect --browser --confirm
49
+ npx --yes device-center@0.2.0-beta.8 plan
50
+ npx --yes device-center@0.2.0-beta.8 connect --browser --confirm
51
51
  # 自建控制面可指定 --cloud https://devices.example.com
52
52
  ```
53
53
 
package/docs/SKILL.md CHANGED
@@ -7,7 +7,7 @@ description: Install Device Center on a computer, connect a resource device or c
7
7
 
8
8
  Turn the user's own computers into resources their local AI Agent can inspect. The hosted control plane manages account-bound registration and encrypted relay; the Driver and identity private key stay on each computer.
9
9
 
10
- This skill describes published CLI **0.2.0-beta.7**, Node.js **24+**, macOS/Linux and Windows 11 (preview). Reading this document is not permission to install software, bind a computer, approve access or contact other devices. Follow the user's selected role, mode, OS, port and foreground/background choice.
10
+ This skill describes published CLI **0.2.0-beta.8**, Node.js **24+**, macOS/Linux and Windows 11 (preview). Reading this document is not permission to install software, bind a computer, approve access or contact other devices. Follow the user's selected role, mode, OS, port and foreground/background choice.
11
11
 
12
12
  ## Select the role
13
13
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  目标设备只需要 Device Center Driver,不要求已经安装 AI Agent。一页指南提供 **交给 Agent**、**下载安装包**、**复制脚本** 三个入口。安装包在目标机无需 Node.js/npm 下载,详见 [离线安装包](offline-install.md)。网页提供说明和下载,安装发生在你运行安装入口的目标设备上。
4
4
 
5
- **本指南固定 `device-center@0.2.0-beta.7`(npm `beta`)**。macOS/Linux 使用下方终端命令;Windows 11 预览版按 [PowerShell 安装图示](windows-acceptance.md)操作。实际发布状态以 npm registry 为准;旧 beta.4 不支持原生 Windows。
5
+ **本指南固定 `device-center@0.2.0-beta.8`(npm `beta`)**。macOS/Linux 使用下方终端命令;Windows 11 预览版按 [PowerShell 安装图示](windows-acceptance.md)操作。实际发布状态以 npm registry 为准;旧 beta.4 不支持原生 Windows。
6
6
 
7
7
  ## 用户只需要选择一种方式
8
8
 
@@ -61,7 +61,7 @@ AI Agent 和 MCP 是管理端的可选能力:只有以后想让某台电脑上
61
61
  全新安装可以使用:
62
62
 
63
63
  ```sh
64
- npm install --global --prefix "$HOME/.local" --ignore-scripts device-center@0.2.0-beta.7
64
+ npm install --global --prefix "$HOME/.local" --ignore-scripts device-center@0.2.0-beta.8
65
65
  "$HOME/.local/bin/device-center" --version
66
66
  ```
67
67
 
@@ -1,6 +1,6 @@
1
1
  # Device Center · 朋友试用指南
2
2
 
3
- 让本机 Agent 看见自己的电脑与闲置设备:真实内存、磁盘、设备用途,以及单独授权的指定日志。本指南版本 `0.2.0-beta.7`,发布状态以 npm registry 为准。
3
+ 让本机 Agent 看见自己的电脑与闲置设备:真实内存、磁盘、设备用途,以及单独授权的指定日志。本指南版本 `0.2.0-beta.8`,发布状态以 npm registry 为准。
4
4
 
5
5
  **入口:https://devices.owenshen.top/start**。支持 macOS / Linux,需要 Node.js 24+;Windows 11 提供 PowerShell 预览版,可选择“手动安装”,不需要 AI Agent 或 AD。
6
6
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  Agent 按说明检查已有配置、固定版本安装、设置用户级后台服务、配置 MCP 并核验结果。已有配置冲突时解释并让你选择,不自动覆盖。系统授权或当前 Agent 无法配置 MCP 时,会请你完成对应一步。
16
16
 
17
- 不使用 Agent 时,可在全新环境运行 `npx --yes device-center@0.2.0-beta.7 start` 体验本机面板;这是前台方式,终端关闭后停止。长期使用按[完整安装指南](https://devices.owenshen.top/install/agent.md)固定安装。
17
+ 不使用 Agent 时,可在全新环境运行 `npx --yes device-center@0.2.0-beta.8 start` 体验本机面板;这是前台方式,终端关闭后停止。长期使用按[完整安装指南](https://devices.owenshen.top/install/agent.md)固定安装。
18
18
 
19
19
  ## 2. 托管模式才需要登录绑定
20
20
 
@@ -1,14 +1,18 @@
1
1
  # 本机、官方与自建接入
2
2
 
3
- 本版本 0.2.0-beta.4 使用同一 CLI 支持官方与自建 HTTPS 控制面;每个账号独立管理自己的设备。安装不自动接入,connect --confirm 默认申请官方服务,用户登录并核对批准后才绑定。
3
+ 同一 CLI 支持官方与自建 HTTPS 控制面;每个账号独立管理自己的设备。安装不自动接入,connect --confirm 默认申请官方服务,用户登录并核对批准后才绑定。
4
4
 
5
5
  ## 用户流程
6
6
 
7
+ 官方控制面先复用浏览器中有效的共享登录;未登录时直接在同一标签页打开带 Device Center 标识的登录与注册表单,完成后自动继续配对或 CLI 授权。无需先去个人站登录再手动返回。账号中心须先支持 `/auth/connect?product=device-center`,再更新控制面入口;回退时顺序相反。
8
+
9
+ 临时配对凭据保留在控制面的标签页会话存储中,账号页 URL 仅包含固定回跳路径与短时关联 ID。回跳须匹配该 ID,之后仍由服务端核验账号与控制面会话;回跳本身不批准设备或授予 CLI 权限。存储被禁用、会话未保存或账号服务不可用时显示可重试说明,避免来回跳转。
10
+
7
11
  1. **本机使用**:运行 Driver → 添加本机智能体的 MCP → 导入已有 SSH。本机没有平台注册要求,只监听回环地址。
8
12
  2. **需要云端面板**:本机侧栏点“云端面板” → 官方控制面或自建 HTTPS 域名 → 复制命令,在自己的电脑运行。
9
- 3. **确认归属**:登录对应面板的账号 → 输入终端上的五分钟配对码 → 核对完整公钥指纹和分享范围 → 批准 → 等待首次真实回报。
13
+ 3. **确认归属**:有浏览器时自动进入登录/注册和绑定页;无浏览器时在本人面板输入终端上的五分钟配对码 → 核对完整公钥指纹和分享范围 → 批准 → 等待首次真实回报。
10
14
 
11
- 官方多用户模式复用 App Services 的已验证账号;每账号最多16台有效 Driver、100MiB/UTC日密文中转。执行命令前需 Node.js 24,支持 macOS/Linux。安装不会自动配对,复制不会执行,配对不会安装常驻服务。
15
+ 官方多用户模式复用 App Services 的已验证账号;每账号最多16台有效 Driver、100MiB/UTC日密文中转。支持 macOS/Linux/Windows 11;npm 方式需系统 Node.js 24,离线包内置运行环境。安装不会自动配对,复制不会执行,配对不会安装常驻服务。
12
16
 
13
17
  同一 CLI 的 `--cloud` 指向所选控制面;默认只回报本机名称、系统、内存、磁盘。SSH 名称与缓存回报需要命令申请并在面板再次批准。私钥不托管到云端、不包含在复制命令、prompt 或下载包中。
14
18
 
@@ -47,6 +51,6 @@
47
51
 
48
52
  每天每账号100MiB、全实例1GiB持久计数;单会话60秒、每账号2/全实例8并发、每方向16KiB/s。签名 nonce 全实例4096/180秒,公共登记8/分钟/32待批准;查找/批准每账号8/分钟/全局64/分钟。内存限流会随重启重置,日流量不会。当前固定默认额度,尚无计费、付费服务或 SLA。
49
53
 
50
- 任意命令、通用文件传输、云端 MCP、Windows 原生 Agent、CF 专用适配器和自动配置迁移尚未提供。自建/官方使用相同中转实现;CF 仅可作为用户自行配置的 HTTPS 前置。
54
+ 任意命令、通用文件传输、云端 MCP、CF 专用适配器和自动配置迁移尚未提供。自建/官方使用相同中转实现;CF 仅可作为用户自行配置的 HTTPS 前置。
51
55
 
52
56
  隔离测试使用临时身份与注入上游,验证真实 Node TLS、中转密文、权限、账号和配额。它们不是对真实用户设备的授权验收,也不构成第三方安全审计。
package/docs/npm.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # npm / npx 分发
2
2
 
3
- MIT,Node.js 24。本指南固定 `0.2.0-beta.7`,支持 macOS/Linux,并提供 Windows 11 预览接入。使用 npm `beta` 渠道;`latest` 仍可能是旧版本,安装请写明版本,实际发布状态以公开 registry 为准。
3
+ MIT,Node.js 24。本指南固定 `0.2.0-beta.8`,支持 macOS/Linux,并提供 Windows 11 预览接入。使用 npm `beta` 渠道;`latest` 仍可能是旧版本,安装请写明版本,实际发布状态以公开 registry 为准。
4
4
 
5
5
  beta.6 增加统一 CLI 与独立云端管理授权,见 [CLI 手册](cli.md)。沿用 beta.5 的 PowerShell 安装、DPAPI CurrentUser 身份保护与当前用户登录后台任务,详见 [Windows 验收](windows-acceptance.md)。发布先后顺序是新 CLI → 匿名下载验包 → 对应指南与控制面上线。旧客户端的终端填码仍兼容;回退网页或旧 CLI 不删除既有身份。
6
6
 
7
7
  ```sh
8
- npx --yes device-center@0.2.0-beta.7 --help
9
- npx --yes device-center@0.2.0-beta.7 plan
8
+ npx --yes device-center@0.2.0-beta.8 --help
9
+ npx --yes device-center@0.2.0-beta.8 plan
10
10
  # 主动接入默认官方服务;有浏览器时加 --browser 自动打开本次绑定页
11
- npx --yes device-center@0.2.0-beta.7 connect --browser --confirm
11
+ npx --yes device-center@0.2.0-beta.8 connect --browser --confirm
12
12
  # 无浏览器:在终端与另一台电脑的面板之间核对配对码
13
- npx --yes device-center@0.2.0-beta.7 connect --confirm
13
+ npx --yes device-center@0.2.0-beta.8 connect --confirm
14
14
  # 自建服务
15
- npx --yes device-center@0.2.0-beta.7 connect --cloud https://你的控制面域名 --confirm
15
+ npx --yes device-center@0.2.0-beta.8 connect --cloud https://你的控制面域名 --confirm
16
16
  # 仅本机,无账号要求
17
- npx --yes device-center@0.2.0-beta.7 start
17
+ npx --yes device-center@0.2.0-beta.8 start
18
18
  ```
19
19
 
20
20
  安装与无参/help/version/plan 不生成身份,不配对,不自动启动后台进程。复制命令不含凭据。只有 connect --confirm 才生成本机身份并申请绑定;默认官方地址不会自动批准,用户需登录自己的账号。SSH 缓存分享须 --share-ssh 与面板再次批准。
@@ -22,7 +22,7 @@ npx --yes device-center@0.2.0-beta.7 start
22
22
  面板和本机 MCP:`http://127.0.0.1:5174/`、`/mcp`。常驻先固定安装:
23
23
 
24
24
  ```sh
25
- npm install -g device-center@0.2.0-beta.7
25
+ npm install -g device-center@0.2.0-beta.8
26
26
  device-center service plan --port 5174
27
27
  # 自行停止同端口前台进程,再主动安装
28
28
  device-center service install --port 5174 --confirm
@@ -13,13 +13,17 @@
13
13
  ## 逐步确认
14
14
 
15
15
  1. 选择官方托管(默认)、仅本机或已有自建 HTTPS 地址。
16
- 2. 确认端口、用户级后台/前台、有浏览器/无浏览器,以及安装位置。
16
+ 2. 默认端口 5174、用户登录后后台运行、桌面自动打开浏览器;需要时展开推荐设置并修改。确认安装位置。
17
17
  3. 安装或核验复用同一版本程序;后台需要再次确认才登记。不支持的用户服务管理器不自动提权、改策略或启用 linger,可重新运行向导选择前台。
18
18
  4. 本机模式显示回环面板;托管模式由本人登录、核对完整指纹并批准一次。无浏览器时配对码只留在终端与本人面板,不发给 Agent。
19
19
 
20
20
  仅本机模式不是独立局域网多机管理,仍只监听回环。自建选项连接已有服务,不会自动部署公网服务器。安装和配对不授予 SSH、日志或任意命令权限。
21
21
 
22
- 取消、失败后保留已完成的安装和身份。重开同一包可核验并继续;已有其他版本、无法确认的服务或不同控制面的身份会停止并解释,不自动覆盖或解绑。空的安装目标可安全恢复。程序与应用数据分离,安装包不包含数据库、身份、认证配置或用户 SSH 文件。
22
+ 取消、失败后保留已完成的安装和身份。重开同一包可核验并继续。识别到旧版离线包时,须明确确认升级;仅停止并重启配置完全匹配的用户后台,完整旧程序另存备份,应用数据与绑定不迁移。无法识别的安装、无法确认的服务或不同控制面的身份会停止并解释,不自动覆盖或解绑。空的安装目标可安全恢复。
23
+
24
+ 后台安装失败时,向导列出原因与日志位置,可明确选择核验已有 Driver 或前台继续;关闭前台终端会停止回报。暂时断网会重试当前配对申请;重开同一连接命令可续接。申请五分钟内须完成批准,过期后登录/邮箱验证已完成的用户可明确确认新申请,无需重装。已批准但回执丢失时,原设备签名可在 24 小时内核对结果;这不延长批准有效期,也不重复添加设备。页面批准结果不确定时先检查结果,再决定是否继续。
25
+
26
+ 程序与应用数据分离,安装包不包含数据库、身份、认证配置或用户 SSH 文件。日志授权与 CLI 授权的后续命令会优先寻找离线包,再寻找已有 npm 安装,不要求安装包修改 PATH。
23
27
 
24
28
  ## 安装路径与诊断
25
29
 
@@ -50,7 +50,15 @@
50
50
  - macOS:用户 LaunchAgents 配置 `local.device-center.control-plane.plist`;查看 `~/Library/Application Support/Device Center/service-error.log`。
51
51
  - Linux:用户 systemd 配置 `~/.config/systemd/user/device-center.service`;通过 `journalctl --user -u device-center` 查看日志。需要支持 systemd user 的用户登录环境;不自动启用系统 linger。
52
52
  - `status --port 端口` 查看配置是否存在和服务实际响应。安装失败不自动清理;按错误检查已登记服务和日志。
53
- - Windows 原生后台安装未实现,先使用前台或可选 Docker。
53
+ - Windows 11:当前用户登录触发的任务计划程序,使用普通用户权限;日志在 `%USERPROFILE%\AppData\Local\Device Center\service.log` 与 `service-error.log`。`service status` 同时核对实际任务身份、启动参数和运行状态,任务账户名与 SID 会归一化比较。失败信息包含受限错误码与 HRESULT;不修改组策略或覆盖既有任务。安装器可在本人确认后前台继续。
54
+
55
+ ## 配对中断
56
+
57
+ 官方登录复用已有会话,未登录直接进入正式登录/注册并自动返回。网络暂时失败时,绑定页保留当前申请并提供重试;批准回执不确定时点击「检查批准结果」,不要反复批准。
58
+
59
+ 配对批准有效期仍为五分钟。注册或邮箱验证耗时较长时,先完成登录,再在目标电脑的安装向导中确认重新打开绑定页;程序、身份与已完成的绑定保留。已批准但终端没收到结果时,重新执行同一 `connect` 命令核对原申请;已绑定的设备以最新资源回报为完成依据,勿重装或解绑来排查网络。
60
+
61
+ `ECONNREFUSED 127.0.0.1:7890` 表示某个请求指向了不可用的本机代理,并不证明官方域名故障。先分别检查程序运行时的 HTTPS 与残留代理设置;离线包安装本身不访问 npm,不自动修改系统/npm/环境代理。
54
62
 
55
63
  ## Docker 与宿主机
56
64
 
@@ -1,6 +1,6 @@
1
1
  # Windows 11 手动接入验收
2
2
 
3
- **0.2.0-beta.7 · Windows 11 预览版。** 安装前可用 `npm.cmd view device-center@0.2.0-beta.7 version` 确认发布状态。Windows CI 的脚本、安全存储及安装检查不能替代你的 Windows 11 实机验收。
3
+ **0.2.0-beta.8 · Windows 11 预览版。** 安装前可用 `npm.cmd view device-center@0.2.0-beta.8 version` 确认发布状态。Windows CI 的脚本、安全存储及安装检查不能替代你的 Windows 11 实机验收。
4
4
 
5
5
  ## 准备与安装
6
6
 
@@ -23,7 +23,7 @@
23
23
 
24
24
  ## 验收清单
25
25
 
26
- 1. `--version` 是 `0.2.0-beta.7`;后台状态显示 `windows-task`,本机面板 `http://127.0.0.1:5174` 正常加载,页面脚本无 404。
26
+ 1. `--version` 是 `0.2.0-beta.8`;后台状态显示 `windows-task`,本机面板 `http://127.0.0.1:5174` 正常加载,页面脚本无 404。
27
27
  2. 浏览器只批准刚由本机发起的申请;完整指纹与终端一致。终端有“云端已接收首次资源回报”,`firstReport.verified=true`。仅 `configured=true` 不算连通。
28
28
  3. 云端出现这台 Windows 电脑,内存和用户目录所在卷磁盘分别显示真实数值;没有测试设备或伪造在线。可填写设备名、用途与标签。
29
29
  4. 点击云端“采集一次”,采集时间在约 30–75 秒内更新。关闭安装终端后仍更新;注销/休眠会停止回报,登录/唤醒且网络恢复后继续。
package/login.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
2
  <html lang="zh-CN">
3
3
  <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.svg" type="image/svg+xml"><title data-i18n="login.title">登录 · Device Center</title><link rel="stylesheet" href="/src/login.css"><link rel="stylesheet" href="/src/i18n.css"></head>
4
- <body class="login-page"><main><div class="language-picker" data-language-picker></div><div class="mark">DC</div><h1>Device Center</h1><p data-i18n="login.subtitle">登录你的设备控制面</p><p id="loading" role="status" data-i18n="login.loading">正在读取登录方式…</p><form id="login" hidden><label><span data-i18n="login.username">用户名</span><input name="username" autocomplete="username" required maxlength="64"></label><label><span data-i18n="login.password">密码</span><input name="password" type="password" autocomplete="current-password" required maxlength="256"></label><button type="submit" data-i18n="login.submit">登录</button><p id="message" role="alert"></p></form><section id="unified" hidden><p data-i18n="login.unified">使用 App Services/个人站的统一账号。</p><button id="continue-login" type="button" data-i18n="login.continue">进入控制面</button><p id="unified-message" role="status" aria-live="polite"></p><p class="help" data-i18n="login.help">已有登录会自动进入。需要登录时打开下方账号页,完成后回到这里继续。</p><a id="unified-login" class="login-link" target="_blank" rel="noopener noreferrer" data-i18n="login.link">使用统一账号登录 ↗</a></section><p class="install-help"><span data-i18n="login.notInstalled">还未安装?</span><a href="/start" target="_blank" rel="noopener noreferrer" data-language-link data-i18n="login.guide">查看一页试用指南 ↗</a><br><span data-i18n="login.installHint">本机使用无需账号;官方托管需登录并核对绑定。</span></p><small id="access-notes" data-i18n="login.multi">账号设备隔离 · HTTPS 保护</small></main><script type="module" src="/src/login.js"></script></body>
4
+ <body class="login-page"><main><div class="language-picker" data-language-picker></div><div class="mark">DC</div><h1>Device Center</h1><p data-i18n="login.subtitle">登录你的设备控制面</p><p id="loading" role="status" data-i18n="login.loading">正在读取登录方式…</p><form id="login" hidden><label><span data-i18n="login.username">用户名</span><input name="username" autocomplete="username" required maxlength="64"></label><label><span data-i18n="login.password">密码</span><input name="password" type="password" autocomplete="current-password" required maxlength="256"></label><button type="submit" data-i18n="login.submit">登录</button><p id="message" role="alert"></p></form><section id="unified" hidden><p class="help" data-i18n="login.help">正在检查登录状态。未登录时将打开登录与注册,完成后自动继续。</p><button id="continue-login" type="button" data-i18n="login.continue">登录或注册</button><p id="unified-message" role="status" aria-live="polite"></p></section><p class="install-help"><span data-i18n="login.notInstalled">还未安装?</span><a href="/start" target="_blank" rel="noopener noreferrer" data-language-link data-i18n="login.guide">查看一页试用指南 ↗</a><br><span data-i18n="login.installHint">本机使用无需账号;官方托管需登录并核对绑定。</span></p><small id="access-notes" data-i18n="login.multi">账号设备隔离 · HTTPS 保护</small></main><script type="module" src="/src/login.js"></script></body>
5
5
  </html>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "device-center",
3
- "version": "0.2.0-beta.7",
3
+ "version": "0.2.0-beta.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "device-center",
9
- "version": "0.2.0-beta.7",
9
+ "version": "0.2.0-beta.8",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@peculiar/x509": "2.1.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "device-center",
3
- "version": "0.2.0-beta.7",
3
+ "version": "0.2.0-beta.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Local device connector with account-bound pairing, TLS encrypted relay and explicitly authorized log access",
@@ -9,6 +9,8 @@ import { cloudOrigin, publicIdentity, signedRequest } from '../server/drivers/dr
9
9
  import { cloudPost, defaultLinkFile, readLink, writeLink, linkSummary, ensurePrivateDirectory } from '../server/drivers/cloudLink.mjs';
10
10
  import { issueCertificate } from '../server/drivers/certificates.mjs';
11
11
  import { pairingBrowserUrl, openPairingBrowser, waitForFirstReport } from '../server/drivers/browserPairing.mjs';
12
+ import { transientConnectionError, validPendingPairing, connectionFailure } from '../server/drivers/connectionRecovery.mjs';
13
+ import { createInterface } from 'node:readline/promises';
12
14
 
13
15
  const usage = 'npm run driver -- plan|connect|run|status|disconnect --cloud https://你的域名 [--browser] [--share-ssh] [--port 5174] [--confirm]';
14
16
  export function driverPlan(argv = []) {
@@ -45,7 +47,12 @@ export async function runLocal(plan) {
45
47
  const shutdown = () => instance.server.close(() => { instance.db.close(); process.exit(0); });
46
48
  process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
47
49
  }
48
- export async function main(argv = process.argv.slice(2), { file = defaultLinkFile(), post = cloudPost, openBrowser = openPairingBrowser, runDriver = runLocal, waitReport = waitForFirstReport } = {}) {
50
+ export async function main(argv = process.argv.slice(2), { file = defaultLinkFile(), post = cloudPost, openBrowser = openPairingBrowser, runDriver = runLocal, waitReport = waitForFirstReport, now = Date.now, sleep = ms => new Promise(done => setTimeout(done, ms)), renew = async () => {
51
+ if (!process.stdin.isTTY) return false;
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
53
+ try { return /^(y|yes|是)$/i.test((await rl.question('配对申请已过期,安装与身份已保留。登录或邮箱验证完成后,重新打开绑定页面继续?[y/N]:')).trim()); }
54
+ finally { rl.close(); }
55
+ } } = {}) {
49
56
  const plan = driverPlan(argv);
50
57
  if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('需要 Node.js 24 或更高版本。');
51
58
  if (plan.command === 'plan') return plan; // No files, key generation, network or installation.
@@ -67,36 +74,57 @@ export async function main(argv = process.argv.slice(2), { file = defaultLinkFil
67
74
  writeLink(file, state, { create: true });
68
75
  }
69
76
  if (!state.certificate) { state.certificate = await issueCertificate(state); writeLink(file, state); }
70
- let pending;
71
- try { pending = await post(plan.origin, '/api/driver/pair/start', { publicKey: state.publicKey, certificate: state.certificate, name: hostname().slice(0,80), platform: ({ darwin: 'macOS', linux: 'Linux', win32: 'Windows' })[platform()] ?? platform(), shareSsh: plan.shareSsh, ...(plan.browser ? { browser: true } : {}) }); }
72
- catch (error) {
73
- if (plan.browser && error.statusCode === 400 && error.message === 'invalid_driver_input') throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。未自动输出配对凭据。');
74
- throw error;
75
- }
76
- if (!/^[a-f0-9-]{36}$/.test(pending.id) || (!plan.browser && !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(pending.code)) || pending.fingerprint !== state.fingerprint || !Number.isSafeInteger(pending.expiresAt)) throw new Error('云端配对响应无效。');
77
- if (plan.browser) {
78
- const url = pairingBrowserUrl(plan.origin, pending);
79
- console.log(`正在打开本机浏览器。请本人登录账号,核对设备后批准一次。\n本机公钥指纹:${state.fingerprint}\n无需填写配对码,也无需回复 Agent;程序会自动等待并核验。`);
80
- await openBrowser(url);
81
- } else console.log(`请打开 ${plan.origin},点“连接我的电脑”,在面板单独输入以下配对码(5分钟内有效):\n${pending.code}\n核对本机公钥指纹:${state.fingerprint}\n仅批准你刚在这台电脑发起的请求;配对码不放入智能体prompt或安装脚本。`);
82
- const deadline = Math.min(pending.expiresAt, Date.now() + 300_000);
83
- while (Date.now() < deadline) {
84
- const result = await post(plan.origin, '/api/driver/pair/poll', signedRequest(state.privateKey, plan.origin, '/api/driver/pair/poll', pending.id, {}));
85
- if (result.state === 'approved') {
86
- if (!/^[a-f0-9-]{36}$/.test(result.driverId) || typeof result.shareSsh !== 'boolean' || result.shareSsh && !plan.shareSsh) throw new Error('云端批准范围无效。');
87
- state = { ...state, driverId: result.driverId, shareSsh: result.shareSsh, disabled: false }; writeLink(file, state);
88
- console.log('已绑定。私钥留在本机,只回报获准字段;远程任务未授权。');
89
- const since = Date.now();
90
- await runDriver(plan);
91
- if (!plan.browser) return linkSummary(state);
92
- const report = await waitReport({ port: plan.port, driverId: state.driverId, origin: state.origin, since });
93
- console.log(report.verified ? '云端已接收首次资源回报,接入完成。' : '账号绑定已完成,但尚未确认首次资源回报。请检查本机服务与网络,不要重复绑定。');
94
- return { ...linkSummary(state), firstReport: report };
77
+ let pending = validPendingPairing(state.pendingPairing, { now: now(), browser: plan.browser, shareSsh: plan.shareSsh, recoverApproved: true }) ? state.pendingPairing : null;
78
+ const sessionDeadline = now() + 30 * 60_000;
79
+ while (now() < sessionDeadline) {
80
+ if (!pending) try { pending = await post(plan.origin, '/api/driver/pair/start', { publicKey: state.publicKey, certificate: state.certificate, name: hostname().slice(0,80), platform: ({ darwin: 'macOS', linux: 'Linux', win32: 'Windows' })[platform()] ?? platform(), shareSsh: plan.shareSsh, ...(plan.browser ? { browser: true } : {}) }); }
81
+ catch (error) {
82
+ if (plan.browser && error.statusCode === 400 && error.message === 'invalid_driver_input') throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。未自动输出配对凭据。');
83
+ throw connectionFailure(error, '创建配对申请');
84
+ }
85
+ pending = { id: pending.id, fingerprint: pending.fingerprint, expiresAt: pending.expiresAt, browser: plan.browser, shareSsh: plan.shareSsh, ...(plan.browser ? { browserTicket: pending.browserTicket } : { code: pending.code }) };
86
+ if (!validPendingPairing(pending, { now: now(), browser: plan.browser, shareSsh: plan.shareSsh, recoverApproved: true }) || pending.fingerprint !== state.fingerprint) throw new Error('云端配对响应无效。');
87
+ state.pendingPairing = pending;
88
+ writeLink(file, state);
89
+ const recoveringExpired = pending.expiresAt <= now();
90
+ if (recoveringExpired) console.log('正在核对上次申请的批准结果;不会重复批准或新增设备。');
91
+ else if (plan.browser) {
92
+ const url = pairingBrowserUrl(plan.origin, pending);
93
+ console.log(`正在打开本机浏览器。请本人登录账号,核对设备后批准一次。\n本机公钥指纹:${state.fingerprint}\n无需填写配对码,也无需回复 Agent;程序会自动等待并核验。`);
94
+ await openBrowser(url);
95
+ } else console.log(`请打开 ${plan.origin},点“连接我的电脑”,在面板单独输入以下配对码(5分钟内有效):\n${pending.code}\n核对本机公钥指纹:${state.fingerprint}\n仅批准你刚在这台电脑发起的请求;配对码不放入智能体prompt或安装脚本。`);
96
+ const deadline = Math.min(pending.expiresAt, now() + 300_000);
97
+ let networkWaiting = false;
98
+ while (true) {
99
+ let result;
100
+ try { result = await post(plan.origin, '/api/driver/pair/poll', signedRequest(state.privateKey, plan.origin, '/api/driver/pair/poll', pending.id, {}, now())); }
101
+ catch (error) {
102
+ if (error.statusCode === 410) break;
103
+ if (error.statusCode === 403 && error.message === 'driver_revoked') { delete state.pendingPairing; writeLink(file, state); throw connectionFailure(error, '上次批准已撤销'); }
104
+ if (!transientConnectionError(error) || recoveringExpired || now() >= deadline) throw connectionFailure(error, '等待设备批准');
105
+ if (!networkWaiting) console.log('等待批准时网络暂不可用,正在保留当前申请并重试;无需重新安装。');
106
+ networkWaiting = true; await sleep(5000); continue;
107
+ }
108
+ networkWaiting = false;
109
+ if (result.state === 'approved') {
110
+ if (!/^[a-f0-9-]{36}$/.test(result.driverId) || typeof result.shareSsh !== 'boolean' || result.shareSsh && !plan.shareSsh) throw new Error('云端批准范围无效。');
111
+ state = { ...state, driverId: result.driverId, shareSsh: result.shareSsh, disabled: false }; delete state.pendingPairing; writeLink(file, state);
112
+ console.log('已绑定。私钥留在本机,只回报获准字段;远程任务未授权。');
113
+ const since = now();
114
+ await runDriver(plan);
115
+ const report = await waitReport({ port: plan.port, driverId: state.driverId, origin: state.origin, since });
116
+ console.log(report.verified ? '云端已接收首次资源回报,接入完成。' : '账号绑定已完成,但尚未确认首次资源回报。请检查本机服务与网络,不要重复绑定。');
117
+ return { ...linkSummary(state), firstReport: report };
118
+ }
119
+ if (result.state !== 'pending') throw new Error('配对响应无效。');
120
+ if (now() >= deadline) throw connectionFailure(new Error('pairing_result_unconfirmed'), '等待设备批准');
121
+ await sleep(5000);
95
122
  }
96
- if (result.state !== 'pending') throw new Error('配对响应无效。');
97
- await new Promise(done => setTimeout(done, 5000));
123
+ pending = null;
124
+ delete state.pendingPairing; writeLink(file, state);
125
+ if (!await renew()) break;
98
126
  }
99
- throw new Error('配对已过期;未绑定,可重新 connect。身份文件保留在本机。');
127
+ throw new Error('配对已过期;安装与身份已保留。完成登录后重新运行同一 connect 命令或打开安装器继续,无需重装。');
100
128
  }
101
129
  if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
102
130
  try { const result = await main(); if (result) console.log(JSON.stringify(result, null, 2)); }
@@ -50,6 +50,24 @@ function run(command, args) {
50
50
  return child.stdout;
51
51
  }
52
52
  async function exists(path) { try { await lstat(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
53
+ // Used only after the installer explicitly confirms a program upgrade.
54
+ export async function controlExistingService(plan, operation) {
55
+ if (!['start', 'stop'].includes(operation) || !plan.supported) throw new Error('unsupported_service_control');
56
+ const { readFile } = await import('node:fs/promises');
57
+ const configuration = plan.task ? JSON.stringify(readPrivateJson(plan.configPath, null)) : await readFile(plan.configPath, 'utf8');
58
+ if (configuration !== plan.configuration) throw new Error('已有后台配置与程序位置或端口不同,已保留,未停止或替换。');
59
+ if (plan.task) windowsTask(operation, plan.task);
60
+ else if (plan.manager === 'launchd') run('launchctl', [operation === 'stop' ? 'bootout' : 'bootstrap', `gui/${process.getuid()}`, plan.configPath]);
61
+ else run('systemctl', ['--user', operation, 'device-center.service']);
62
+ for (let attempt = 0; attempt < 40; attempt++) {
63
+ let running = false;
64
+ try { const status = await (await fetch(`${plan.url}/api/connector/status`, { signal: AbortSignal.timeout(500) })).json(); running = status.service?.persistence === plan.manager; }
65
+ catch (error) { if (operation === 'stop' && error.cause?.code === 'ECONNREFUSED') return; }
66
+ if (operation === 'start' && running) return;
67
+ await new Promise(resolve => setTimeout(resolve, 250));
68
+ }
69
+ throw new Error('后台启动或停止尚未确认,已保留配置。请检查服务日志后继续。');
70
+ }
53
71
  async function install(plan) {
54
72
  if (!plan.supported) throw new Error('Unsupported user service platform.');
55
73
  if (await exists(plan.configPath)) throw new Error('The service config already exists. Inspect it first; this installer will not overwrite it.');
@@ -41,9 +41,9 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
41
41
  if (typeof code !== 'string' || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(code)) fail('invalid_pairing_code');
42
42
  return publicCodeHash(code);
43
43
  };
44
- function pairing(id) {
44
+ function pairing(id, recoverApproved = false) {
45
45
  const row = db.prepare('SELECT * FROM driver_pairings WHERE id=?').get(id);
46
- if (!row || row.expires_at <= now()) fail('driver_pairing_expired', 410);
46
+ if (!row || (row.expires_at <= now() && !(recoverApproved && row.driver_id && row.expires_at + 86_400_000 > now()))) fail('driver_pairing_expired', 410);
47
47
  return row;
48
48
  }
49
49
  function credential(input) {
@@ -93,9 +93,9 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
93
93
  const name = text(input.name), platform = text(input.platform, 64);
94
94
  if (typeof input.shareSsh !== 'boolean') fail('invalid_driver_scope');
95
95
  // Only protocol-expired pending requests are removed; registered devices and reports are retained.
96
- db.prepare('DELETE FROM driver_pairings WHERE expires_at<=?').run(now());
96
+ db.prepare('DELETE FROM driver_pairings WHERE (driver_id IS NULL AND expires_at<=?) OR expires_at<=?').run(now(), now() - 86_400_000);
97
97
  for (const [id, at] of polls) if (at <= now() - PAIR_AGE) polls.delete(id);
98
- if (db.prepare('SELECT count(*) AS n FROM driver_pairings').get().n >= 32) fail('driver_pairing_busy', 429);
98
+ if (db.prepare('SELECT count(*) AS n FROM driver_pairings WHERE driver_id IS NULL').get().n >= 32) fail('driver_pairing_busy', 429);
99
99
  const id = randomUUID(), raw = randomBytes(4).toString('hex').toUpperCase(), code = `${raw.slice(0,4)}-${raw.slice(4)}`;
100
100
  db.prepare('INSERT INTO driver_pairings (id,code_hash,public_key,fingerprint,name,platform,requested_ssh,created_at,expires_at,certificate) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE, certificate);
101
101
  if (input.browser) {
@@ -108,7 +108,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
108
108
  poll(input) {
109
109
  object(input, ['id', 'at', 'nonce', 'payload', 'signature']);
110
110
  if (typeof input.id !== 'string') fail('driver_proof_required', 401);
111
- const row = pairing(input.id); proof(row, '/api/driver/pair/poll', input); object(input.payload, []);
111
+ const row = pairing(input.id, true); proof(row, '/api/driver/pair/poll', input); object(input.payload, []);
112
112
  if (polls.has(row.id) && now() - polls.get(row.id) < 4500) fail('driver_poll_too_frequent', 429);
113
113
  polls.set(row.id, now());
114
114
  if (!row.driver_id) return { state: 'pending' };
@@ -124,6 +124,19 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
124
124
  if (!row || row.expires_at <= now() || row.driver_id) fail('driver_pairing_unavailable', 404);
125
125
  return { id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, requestedSsh: Boolean(row.requested_ssh), expiresAt: row.expires_at };
126
126
  },
127
+ pairingResult(input, tenantId) {
128
+ globalLookups = limited(globalLookups, 64); limitAccount(lookups, tenantId); object(input, ['browserTicket']);
129
+ const token = credential(input);
130
+ const row = db.prepare('SELECT * FROM driver_pairings WHERE browser_hash=?').get(token.hash);
131
+ if (!row || row.expires_at + 86_400_000 <= now()) fail('driver_pairing_unavailable', 404);
132
+ if (row.driver_id) {
133
+ const driver = db.prepare('SELECT * FROM cloud_drivers WHERE id=? AND tenant_id=? AND revoked_at IS NULL').get(row.driver_id, tenantId);
134
+ if (!driver) fail('driver_pairing_unavailable', 404);
135
+ return { state: 'approved', driverId: driver.id };
136
+ }
137
+ if (row.expires_at <= now()) fail('driver_pairing_expired', 410);
138
+ return { state: 'pending' };
139
+ },
127
140
  approve(input, tenantId) {
128
141
  globalApprovals = limited(globalApprovals, 64); limitAccount(approvals, tenantId);
129
142
  object(input, ['id', 'code', 'browserTicket', 'fingerprint', 'shareSsh']);
@@ -222,7 +235,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
222
235
  forTenant(tenantId) {
223
236
  if (typeof tenantId !== 'string' || !/^(cloud-owner|account-[a-f0-9]{64})$/.test(tenantId)) fail('driver_account_required', 403);
224
237
  return {
225
- lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
238
+ lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId), pairingResult: input => registry.pairingResult(input, tenantId),
226
239
  revoke: input => registry.revoke(input, tenantId), bindings: () => registry.bindings(tenantId), devices: () => registry.devices(tenantId), relayUsage: () => relay.usage(tenantId),
227
240
  saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
228
241
  cli: management.owner(tenantId),
@@ -0,0 +1,10 @@
1
+ export function transientConnectionError(error) {
2
+ return [429, 502, 503, 504].includes(error.statusCode) || ['TimeoutError', 'AbortError'].includes(error.name) || ['ECONNRESET','ECONNREFUSED','ETIMEDOUT','EHOSTUNREACH','ENETUNREACH','EAI_AGAIN','UND_ERR_CONNECT_TIMEOUT','UND_ERR_SOCKET'].includes(error.code ?? error.cause?.code) || (error.name === 'TypeError' && error.message === 'fetch failed');
3
+ }
4
+ export function validPendingPairing(value, { now = Date.now(), browser, shareSsh, recoverApproved = false } = {}) {
5
+ return Boolean(value && /^[a-f0-9-]{36}$/.test(value.id ?? '') && Number.isSafeInteger(value.expiresAt) && value.expiresAt + (recoverApproved ? 86_400_000 : 0) > now && value.expiresAt <= now + 330_000 && value.browser === browser && value.shareSsh === shareSsh && (browser ? /^[a-f0-9]{64}$/.test(value.browserTicket ?? '') : /^[A-F0-9]{4}-[A-F0-9]{4}$/.test(value.code ?? '')));
6
+ }
7
+ export function connectionFailure(error, stage) {
8
+ const code = transientConnectionError(error) ? 'network_timeout_or_unavailable' : /^[a-z_]+$/.test(error.message ?? '') ? error.message : 'connection_failed';
9
+ return new Error(`接入停在“${stage}”(${code})。已保留程序、身份与当前申请;网络恢复后重新运行同一 connect 命令或打开安装器继续,无需重新安装或解绑。`);
10
+ }
@@ -11,8 +11,18 @@ export function windowsServiceDefinition({ home, root, node, script, port, dbPat
11
11
  workingDirectory: root, logonType: 'InteractiveToken', runLevel: 'Limited', trigger: 'CurrentUserLogon',
12
12
  };
13
13
  }
14
- // COM Task Scheduler is built into Windows. Only this user's exact task is touched.
15
- export const windowsTaskSource = powershellPreamble + `
14
+ export const windowsTaskIdentitySource = `
15
+ function Resolve-TaskSid([string] $userId) {
16
+ try {
17
+ if ($userId -match '^S-1-') { return ([System.Security.Principal.SecurityIdentifier]::new($userId)).Value }
18
+ return ([System.Security.Principal.NTAccount]::new($userId)).Translate([System.Security.Principal.SecurityIdentifier]).Value
19
+ } catch { throw 'task_user_unresolved' }
20
+ }
21
+ `;
22
+ // COM may return account names even when the task was registered with a SID.
23
+ // Normalize both identities while retaining every action, scope and trigger check.
24
+ export const windowsTaskSource = powershellPreamble + windowsTaskIdentitySource + `
25
+ try {
16
26
  $scheduler = New-Object -ComObject 'Schedule.Service'
17
27
  $scheduler.Connect()
18
28
  $folder = $scheduler.GetFolder('\\')
@@ -26,15 +36,19 @@ function Assert-OurTask {
26
36
  if (-not $task) { throw 'task_missing' }
27
37
  $definition = $task.Definition
28
38
  $principal = $definition.Principal
29
- $principalSid = $principal.UserId
30
- if ($principal.UserId -ne $sid.Value) { $principalSid = ([System.Security.Principal.NTAccount]::new($principal.UserId)).Translate([System.Security.Principal.SecurityIdentifier]).Value }
39
+ $principalSid = Resolve-TaskSid $principal.UserId
31
40
  if ($principalSid -ne $sid.Value -or $principal.LogonType -ne 3 -or $principal.RunLevel -ne 0) { throw 'task_principal_changed' }
32
41
  if ($definition.Actions.Count -ne 1 -or $definition.Triggers.Count -ne 1) { throw 'task_changed' }
33
42
  $action = $definition.Actions.Item(1)
34
43
  $trigger = $definition.Triggers.Item(1)
35
- if ($action.Type -ne 0 -or $action.Path -ne $request.task.executable -or $action.Arguments -cne $request.task.arguments -or $action.WorkingDirectory -cne $request.task.workingDirectory -or $trigger.Type -ne 9 -or $trigger.UserId -ne $sid.Value) { throw 'task_action_changed' }
44
+ if ($action.Type -ne 0 -or $action.Path -ne $request.task.executable -or $action.Arguments -cne $request.task.arguments -or $action.WorkingDirectory -cne $request.task.workingDirectory -or $trigger.Type -ne 9 -or (Resolve-TaskSid $trigger.UserId) -ne $sid.Value) { throw 'task_action_changed' }
36
45
  }
37
46
  switch ($request.operation) {
47
+ 'start' {
48
+ Assert-OurTask
49
+ $null = $task.Run($null)
50
+ @{ started = $true } | ConvertTo-Json -Compress
51
+ }
38
52
  'install' {
39
53
  if ($task) { throw 'task_already_exists' }
40
54
  $definition = $scheduler.NewTask(0)
@@ -69,9 +83,24 @@ switch ($request.operation) {
69
83
  'remove' { Assert-OurTask; $folder.DeleteTask($request.task.taskName, 0); @{ removed = $true } | ConvertTo-Json -Compress }
70
84
  default { throw 'unknown_task_operation' }
71
85
  }
86
+ } catch {
87
+ $failure = $_.Exception
88
+ while ($failure.InnerException) { $failure = $failure.InnerException }
89
+ $known = @('task_missing','task_user_unresolved','task_principal_changed','task_changed','task_action_changed','task_already_exists','unknown_task_operation')
90
+ $code = 'windows_task_failed'
91
+ if ($_.Exception.Message -in $known) { $code = $_.Exception.Message }
92
+ @{ error = $code; hresult = $failure.HResult } | ConvertTo-Json -Compress
93
+ }
72
94
  `;
73
- export function windowsTask(operation, task) {
74
- if (process.platform !== 'win32') throw new Error('windows_only');
75
- try { return runPowerShell(windowsTaskSource, { operation, task }); }
76
- catch { throw new Error('Windows 当前用户任务操作失败。请检查任务计划程序、组策略或既有任务;未提权、改策略或覆盖任务。可改用 start 前台模式。'); }
95
+ export function windowsTask(operation, task, { platform = process.platform, run = runPowerShell } = {}) {
96
+ if (platform !== 'win32') throw new Error('windows_only');
97
+ let result;
98
+ try { result = run(windowsTaskSource, { operation, task }); }
99
+ catch { result = { error: 'windows_task_failed' }; }
100
+ if (result.error) {
101
+ const known = ['task_missing','task_user_unresolved','task_principal_changed','task_changed','task_action_changed','task_already_exists'];
102
+ const code = known.includes(result.error) ? result.error : 'windows_task_failed';
103
+ throw new Error(`Windows 后台任务检查失败(${code}${Number.isInteger(result.hresult) ? `,HRESULT ${result.hresult}` : ''})。已保留任务和数据;可在安装向导中选择前台继续。请查看本用户 Device Center 数据目录中的 service.log / service-error.log。`);
104
+ }
105
+ return result;
77
106
  }
package/server/index.mjs CHANGED
@@ -96,7 +96,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
96
96
  const path = url.pathname;
97
97
  // Public liveness and deployed version only; no account, device or configuration data.
98
98
  if (path === '/api/health' && request.method === 'GET') return sendJson(response, 200, health);
99
- if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
99
+ if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/pair-recovery.js', '/src/installed-cli.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
100
100
  if (['/cli', '/src/cli-approval.js', '/src/cli-handoff.js', '/src/agent-setup.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
101
101
  // Public static installation reference only. No identity, configuration or owner API.
102
102
  if (['/start', '/start.html', '/favicon.svg', '/src/start.js', '/src/install-packages.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js', '/src/install-walkthrough.js', '/src/install-walkthrough.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
@@ -158,7 +158,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
158
158
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
159
159
  return sendJson(response, 200, accountRegistry.cli[cliOwnerActions[path]](await readJson(request)));
160
160
  }
161
- const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
161
+ const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/pairing/result': 'pairingResult', '/api/drivers/revoke': 'revoke' };
162
162
  if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
163
163
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
164
164
  return sendJson(response, 200, accountRegistry[ownerActions[path]](await readJson(request)));
@@ -120,7 +120,7 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
120
120
  legacyOwnerMapped: accountMode === 'personal' || config.legacyOwnerId !== undefined,
121
121
  resolvePrincipal,
122
122
  options(request) {
123
- return { provider: 'app-services', accountMode, accountLoginUrl: `${account.origin}/?login=1&loginNext=%2Fdashboard`, automaticContinue: readCookie(request, SIGNED_OUT) !== '1' };
123
+ return { provider: 'app-services', accountMode, accountLoginUrl: `${account.origin}/auth/connect?${new URLSearchParams({ product: 'device-center', parentOrigin: publicOrigin })}`, automaticContinue: readCookie(request, SIGNED_OUT) !== '1' };
124
124
  },
125
125
  async authenticated(request) {
126
126
  return Boolean(await resolvePrincipal(request));