@inventec/dsh-copilot-auth 1.2.19 → 1.2.21
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 +117 -132
- package/docs/CHANGELOG.md +35 -0
- package/docs/assets/preview-device-code.webp +0 -0
- package/docs/assets/preview-github-authorize.webp +0 -0
- package/docs/assets/preview-login-idle.webp +0 -0
- package/docs/assets/preview-logout-confirm.webp +0 -0
- package/docs/assets/preview-manage-models.webp +0 -0
- package/docs/assets/preview-settings-dark.webp +0 -0
- package/docs/assets/preview-settings-light.webp +0 -0
- package/lib/client.js +31 -5
- package/package.json +7 -3
- package/src/copilot-models.mjs +46 -2
- package/src/refresh-flow.mjs +20 -4
- package/src/refresh-service.mjs +83 -38
package/README.md
CHANGED
|
@@ -1,178 +1,163 @@
|
|
|
1
1
|
# @inventec/dsh-copilot-auth
|
|
2
2
|
|
|
3
|
-
[](https://github.com/iasiv5/dsh-copilot-auth/releases)
|
|
4
3
|
[](https://www.npmjs.com/package/@inventec/dsh-copilot-auth)
|
|
5
4
|
[](https://github.com/iasiv5/dsh-copilot-auth/actions/workflows/ci.yml)
|
|
6
5
|
[](https://github.com/iasiv5/dsh-copilot-auth/blob/main/LICENSE)
|
|
7
|
-
[](
|
|
8
|
-
|
|
9
|
-
为 DSH(DeepSeek Harness)内置的 GitHub Copilot LLM provider 补上 Web 端设备码(device flow)登录/注销入口,并预置一条开箱即用的 GitHub Copilot 提供方路由。
|
|
10
|
-
|
|
11
|
-
## 这是什么
|
|
12
|
-
|
|
13
|
-
用公司分配的 GitHub 账号(带 Copilot 订阅)登录 DSH 的方式,和其他 Copilot 客户端一致:**网页 + 设备码**,全程不填 API Token。
|
|
14
|
-
|
|
15
|
-
本插件**复用 DSH 内置通道**(pi-ai 的 github-copilot provider),不实现任何 GitHub 协议代码——token 自动轮换、模型发现、协议适配全部由内置实现负责。插件本身只做三件事:提供「登录/注销」设置页、预置 `GitHub Copilot` 路由、模型目录兜底/刷新(授权服务 0.1.7+ 由 runtime 内置;≤0.1.5 时代由本插件补丁挂载,该版本线见 v1.1.x)。
|
|
16
|
-
|
|
17
|
-
## 特性
|
|
18
|
-
|
|
19
|
-
- 🔐 **设备码登录**:网页 + user code,免 API Token;SSO 组织授权友好
|
|
20
|
-
- 🛑 **诚实的授权语义(v3)**:**「请求撤回」已移除(Q38)**——它无法真正撤销 GitHub 侧设备流,其进程级风险锁存反而造成「登录成功却不能退出」死局;误启动的等待会由 15 分钟超时自然收敛;**「退出登录」在静止状态可用**(确认弹窗 → 清除凭据 → 删后核实;尝试进行中/风险锁存期拒绝);同一实例只有一个授权尝试,409 时恢复展示共同进度
|
|
21
|
-
- 🧩 **预置提供方路由**:安装即出现在 Models 页,无需手动配置
|
|
22
|
-
- 📦 **模型目录兜底填充**:登录成功时,若 GitHub Copilot 路由还没有模型目录,自动填入账号全部可用模型;你已精简/定制过的目录**永不覆盖**(重启、重新登录均不重置);授权等待超时/意图漂移后晚到的授权成功**不会**再写你的配置
|
|
23
|
-
- ➕➖➖ **管理模型列表(v3 目标状态编辑)**:一张分组全表(可新增/在列/待删除候选),**勾选集合即期望终态**——勾上候选即新增、取消勾选在列模型即移除(可挽留),被保留模型的对象/顺序/定制逐字保留,被移除模型的定制随模型消失;含「对齐账号与目录」「全选/取消全选」快捷动作与「同时清除全部定制」复选框。门控按实际 diff:纯新增可用 24h 缓存,**移除类变更强制实时账号证据+latest 目录并二次确认**(缓存下勾选删除会自动升级为实时预览);空目标需单独确认。预览快照 10 分钟有效、绑定选择;同一操作幂等可查;配置并发编辑优先受保护
|
|
24
|
-
- 🌐 **中/英双语界面**:跟随 DSH 语言设置自动切换
|
|
25
|
-
- 🔑 **凭据安全托管**:存入 DSH 内置凭据库(文件强制 0600 权限),Copilot 临时 token 到期自动刷新
|
|
26
|
-
- 🧪 **测试与 CI**:211 条单元测试(ubuntu/windows/macos 三平台矩阵);GitHub Actions 构建测试 + tag 触发自动发布(provenance)
|
|
27
|
-
|
|
28
|
-
## 前置要求
|
|
29
|
-
|
|
30
|
-
- DSH `0.1.7-rc.2` / `0.2.0-rc.1` / `0.2.0-rc.2`(实测版本,v1.2.x 的适配目标;0.2.0 起注意插件 peer 精确版本守卫,见下文版本矩阵)
|
|
31
|
-
- 版本矩阵:`0.1.2-rc.1` ~ `0.1.5-rc.2` 请用 **v1.1.x**(v1.2.0 移除了旧版必需的 authorization 挂载补丁,在旧版上无法激活)
|
|
32
|
-
- v1.2.0 适配了 0.1.7 的 settings API 重塑(`settings.get` 移除),目录保护逻辑改走 `describe()`:**`describe()` 读取在 value 层缺失时回退 user 层,兼容旧版 describe 行形状**;DSH 版本支持矩阵见下表
|
|
33
|
-
|
|
34
|
-
| 本插件 | DSH 实测版本 | authorization 服务 | 说明 |
|
|
35
|
-
|---|---|---|---|
|
|
36
|
-
| v1.2.19 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **管理模型列表 v3:目标状态编辑**——「补充/重建」双入口合并为单一「管理模型列表」分组全表(可新增/在列/待删除候选,勾选即终态,支持挽留与「对齐账号与目录」);移除成为一等能力且定制随模型保留/消失(`removals-need-live` 门控:移除类强制实时证据+latest 目录,缓存下自动升级 live);单次确认(Q37,风险线常驻底栏前置告知);「请求撤回」移除(Q38)+退出后 attempt 残影清空(修复「退出没作用」循环)。协议 v3。其余同 v1.2.18 |
|
|
37
|
-
| v1.2.18+ | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **授权失败附网络失败码+人性化提示**:`fetch failed` 这类 Node undici 网络层异常的原话不含定位信息,现按 `cause.code` 映射为可操作的提示——超时/拒连→「检查网络或代理设置」、域名解析失败→「检查 DNS」、证书校验失败→「可能存在企业 TLS 检查拦截」;原始失败码弱化展示供诊断。其余同 v1.2.17 |
|
|
38
|
-
| v1.2.17 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **保留模型逐项列出**(弱化色,与增/删同构)——「保留 (N)」自 v1.2.8 起只有计数没有清单,预览终态不可审计;数据服务端一直有,本版补上渲染。其余同 v1.2.16 |
|
|
39
|
-
| v1.2.16 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **移除物化中指示浮层**:勾选为乐观即时反馈、物化通常亚秒完成,任何显隐指示都构成闪烁(主人裁决完全移除)。物化中「应用更改」点击由状态机安全忽略(防旧 previewId 提交),指示移除后卡片视觉零变化。其余同 v1.2.15 |
|
|
40
|
-
| v1.2.15 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **物化指示改右上角浮层**(绝对定位、透明度渐显渐隐、不占布局流)——取代 v1.2.14 的常驻占位行(占位行零位移但留空行,显空旷);弹跳与空行双消除。其余同 v1.2.14 |
|
|
41
|
-
| v1.2.14 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **消除弹窗上下弹跳**:「拉取预览中…」物化指示改为**常驻占位行**(visibility 显隐,不插入/移除节点)——v1.2.13 固定高度解决了整卡伸缩,本版消除指示条插入/移除引起的垂直位移。其余同 v1.2.13 |
|
|
42
|
-
| v1.2.13 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **修复模型弹窗勾选闪烁**:勾选/全选触发的物化期间弹窗保持挂载(历史实现切回 `previewing` 导致弹窗整体卸载重挂——卡片闪现「拉取预览中」、滚动位置丢失,实测探针元素直接从 DOM 消失);弹窗改固定高度三段式(头部/内部滚动/常驻操作栏),尺寸不再随增删列表变化;勾选走本地乐观更新(服务端回显到达后对齐)。其余同 v1.2.12 |
|
|
43
|
-
| v1.2.12 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **交互打磨**:「补充模型」的全选按钮在全选中切换为「取消全选」(任一取消勾选即恢复「全选」);「重建」风险提示精简为一行;成功路径的「技术详情」计数移除(与弹窗增删列表重复),技术详情仅在真实失败时展示。其余同 v1.2.11 |
|
|
44
|
-
| v1.2.11 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **界面收敛:移除「重新授权」按钮与 `capabilities.reauthorize` 能力位**(逻辑冗余——已登录只需退出登录,未登录入口本就是「授权登录」;换号/重授权=先退出再登录两步)。其余同 v1.2.10 |
|
|
45
|
-
| v1.2.10 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **退出登录正式可用**:无进行中授权尝试且无未核实风险(风险锁存)时,经确认弹窗清除凭据并**删除后立即复核**(凭据未消失则如实报「退出未完成」);授权尝试进行中(begin 晚写窗口)与撤回/超时未核实期一律拒绝退出。`/status` 的 `authorization.capabilities.logout` 按可用性动态返回。经主人 2026-10-05 裁决替代 v1.2.8 D-01「退出全禁」策略 |
|
|
46
|
-
| v1.2.9 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **修复 v1.2.8 授权页显示回归**:授权状态机漏接 `fetch`,页面恒显「未登录」、点「授权登录」恒「失败」且请求根本不发出(2026-10-04 实机实证,nginx 全量零 `POST /copilot-auth/start`)。另加:/status 加载失败退避自愈重试(60 秒转手动+「查询状态」)、失败原因分层展示(网络/网关/响应,不再裸「失败」)、已登录时服务端拒绝替换性授权(409 `already-configured`)且客户端自动恢复已登录视图 |
|
|
47
|
-
| v1.2.8 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **协议 v2:诚实的授权语义+补充/重建双入口**。授权侧:等待中可「请求撤回」(结果如实显示待核实)、退出/重新授权在宿主可证明安全前禁用并说明、单实例 attempt、15 分钟等待上限、晚到授权成功不再写配置;模型侧:旧「刷新」拆为「补充模型」(候选默认不勾选、非破坏)与「重建模型列表」(二次确认+清 modelOverrides),预览 10 分钟绑定选择、operationId 幂等、配置并发编辑优先、崩溃可恢复(「结束旧配置意图」显式闭环),状态按 profile 隔离(旧全局状态只读提示);模型路由要求 `protocolVersion:2`,GET `/status` 零写探针;v1 函数族已从 state.mjs 移除。**已知缺陷**:授权状态机漏接 fetchImpl → 授权页恒「未登录/失败」(v1.2.9 修复) |
|
|
48
|
-
| v1.2.7+ | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **重启后自动恢复**:注入后以宿主 `listModels` 复核,未端出即**净零切换**(`displayName` 改后复原;两次真内容变化 ⇒ 宿主必然重建快照,用户配置逐字节不变)触发重建;另加双路取实例 + `registryInstanceMismatch`/`registryVia` 诊断。修复 v1.2.6 的"同值写不改变配置身份 ⇒ 快照不重建" |
|
|
49
|
-
| v1.2.6 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | 首次引入宿主侧复核与 `settingsNotServed` 诊断;重启自愈用的"同值触碰"**实测不足**(同值写不改变配置对象身份),重启后模型可能仍不出现——请用 v1.2.7 |
|
|
50
|
-
| v1.2.5 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **desktop 只读形态可刷新**:进程内目录注册表注入(ADR 0003),无需重启、不碰官方产物;`0.2.0-rc.2` desktop **实机端到端已验**(注入条目完成真实推理请求);同批修复 pi-ai ≥0.99.0 的 `chat:` 目录键格式。**已知缺陷**:重启后若宿主快照先于注入定稿,注入条目会被丢弃(v1.2.6 修复) |
|
|
51
|
-
| v1.2.4 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | desktop asar 只读形态:目录刷新入口显式置灰(ADR 0002),登录/路由/settings 不受影响 |
|
|
52
|
-
| v1.2.0 ~ v1.2.3 | `0.1.7-rc.2` ~ `0.2.0-rc.1`(web/服务形态) | runtime 内置,插件不挂载 | 适配 settings API 重塑后的 describe() 行形状;desktop asar 形态下目录刷新不可用且报错不友好 |
|
|
53
|
-
| v1.1.x | `0.1.2-rc.1` ~ `0.1.5-rc.2` | 由 cordis.patch.yml insert 挂载 | v1.2.0 起移除该补丁,旧版 DSH 上无法激活 |
|
|
54
|
-
|
|
55
|
-
- 有效的 GitHub Copilot 订阅(公司分配的 github.com 组织账号,SSO 登录)
|
|
56
|
-
- `pnpm` 可用(`dsh plugin` 是 pnpm 转发器)
|
|
57
|
-
|
|
58
|
-
## 安装
|
|
6
|
+
[](#版本兼容)
|
|
59
7
|
|
|
60
|
-
|
|
8
|
+
为 DSH(DeepSeek Harness)内置的 GitHub Copilot 通道补上**网页设备码登录 / 注销**入口,预置开箱即用的 Copilot 提供方路由,并提供**模型列表管理**。用公司 GitHub 账号(带 Copilot 订阅)登录 DSH 的方式与其他 Copilot 客户端一致:**网页 + 设备码**,全程不填 API Token。
|
|
9
|
+
|
|
10
|
+
插件**复用 DSH 内置通道**(pi-ai 的 github-copilot provider),不实现任何 GitHub 协议代码——token 自动轮换、模型发现、协议适配全部由内置实现负责,插件只做设置页、预置路由与目录管理。
|
|
11
|
+
|
|
12
|
+
| 深色 | 浅色 |
|
|
13
|
+
|---|---|
|
|
14
|
+
|  |  |
|
|
15
|
+
|
|
16
|
+
## 30 秒上手
|
|
17
|
+
|
|
18
|
+
本节回答:怎么装上、怎么登录。
|
|
19
|
+
|
|
20
|
+
```sh
|
|
61
21
|
dsh plugin --profile web add @inventec/dsh-copilot-auth
|
|
62
22
|
```
|
|
63
23
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
24
|
+
1. 重启 DSH Web 并刷新页面。
|
|
25
|
+
2. 打开 设置 → **GHC设置**(英文界面为 *GHC Settings*,位于 Models 之后),点「**授权登录**」。
|
|
26
|
+
3. 在 `https://github.com/login/device` 输入页面显示的设备码(可一键复制)完成授权;**SSO 组织账号必须对组织点 Authorize**,跳过则登录不生效。
|
|
27
|
+
4. 回到设置页看到「已登录」:首次登录自动填充模型目录,到 Models 页选择 `GitHub Copilot` 路由即可使用。
|
|
67
28
|
|
|
68
|
-
|
|
69
|
-
- registry 的 `latest` dist-tag 可能落后于 `next`:显式按版本号安装/排查时勿被 `latest` 误导。
|
|
29
|
+
固定版本 / 更新 / 卸载:
|
|
70
30
|
|
|
71
|
-
|
|
31
|
+
```sh
|
|
32
|
+
dsh plugin --profile web add @inventec/dsh-copilot-auth@1.2.20 # 固定版本(可复现安装)
|
|
33
|
+
dsh plugin --profile web update @inventec/dsh-copilot-auth # 更新
|
|
34
|
+
dsh plugin --profile web remove @inventec/dsh-copilot-auth # 卸载
|
|
35
|
+
```
|
|
72
36
|
|
|
73
|
-
|
|
74
|
-
2. 点「登录」,页面显示一串**代码**(user code,可一键复制)与 `https://github.com/login/device` 链接
|
|
75
|
-
3. 新标签打开链接,用公司 GitHub 账号输入代码
|
|
76
|
-
4. **SSO 用户必须对组织点 Authorize**(授权页会出现该步骤,跳过则登录不生效)
|
|
77
|
-
5. 回到设置页看到「已登录」即成功;首次登录会自动填充模型目录(已定制过的目录不会被覆盖)
|
|
37
|
+
> 企业镜像 / 私有源下 pnpm 解析 peer 失败:`export npm_config_auto_install_peers=false` 后重试(profile 自带的 pnpm workspace 默认已关闭 peer 自动安装)。registry 的 `latest` dist-tag 可能落后于 `next`,按版本号排查时勿被 `latest` 误导。
|
|
78
38
|
|
|
79
|
-
|
|
39
|
+
登录流程实拍(未登录入口 → 设备码 → GitHub 授权页):
|
|
80
40
|
|
|
81
|
-
|
|
41
|
+
| | |
|
|
42
|
+
|---|---|
|
|
43
|
+
|  |  |
|
|
82
44
|
|
|
83
|
-
|
|
84
|
-
- **配额说明**:base 模型(GPT-4o/4.1 一类)不耗 premium requests;premium 模型(Claude、Gemini、o 系列等)每次调用消耗月度配额。日常建议 base 档,premium 模型按需手动选
|
|
45
|
+

|
|
85
46
|
|
|
86
|
-
##
|
|
47
|
+
## 功能一览
|
|
87
48
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
49
|
+
| 能力 | 说明 |
|
|
50
|
+
|---|---|
|
|
51
|
+
| 🔐 设备码登录 / 注销 | 网页 + user code,免 API Token;SSO 组织授权友好。凭据存入 DSH 内置凭据库(文件强制 0600),Copilot 临时 token 到期自动刷新 |
|
|
52
|
+
| 🧩 预置提供方路由 | 安装即在 Models 页出现 `GitHub Copilot` 路由,无需手动「添加模型」 |
|
|
53
|
+
| 📦 模型目录兜底 | 首次登录自动写入账号全部可用模型;你已精简 / 定制过的目录**永不覆盖**(重启、重新登录均不重置) |
|
|
54
|
+
| 🎛️ 管理模型列表 | 目标状态编辑:勾选即终态,支持新增、移除、挽留与「对齐账号与目录」(见下节) |
|
|
55
|
+
| 🌐 中 / 英双语 | 跟随 DSH 语言设置自动切换 |
|
|
56
|
+
| 🧪 测试与 CI | 224 条单元测试(ubuntu / windows / macos 三平台矩阵);tag 触发 npm Trusted Publishing 发布(provenance) |
|
|
91
57
|
|
|
92
58
|
## 管理模型列表
|
|
93
59
|
|
|
94
|
-
pi-ai 的模型目录是打包时硬编码的 JSON
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
-
|
|
99
|
-
-
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
- **profile 隔离与旧状态**:刷新状态存于 `<profile>/copilot-auth/refresh-state.json`,配置意图绝不跨 profile 消费;旧版全局状态文件只读检测并提示重新预览确认,不自动继承。
|
|
104
|
-
- **激活与自愈(保留)**:首次成功操作后激活;pi-ai 升级/重装冲掉目录后,同基线启动时按已确认条目**只增重放**(file 通道写盘后提示需再重启加载;registry 通道重放后复核端出);跨 pi-ai 基线且条目缺失时上报 `self-heal-incompatible`,不自动应用。
|
|
105
|
-
- **数据源与供应链(不变)**:目录数据从 npm 拉取最新 pi-ai tarball(强制 `dist.integrity` 校验、超时与大小上限、tar 严格解析);npm 失败回退本地目录(`catalogSource: local`),可**显式**改用内置覆盖层(0.85.1 收割,integrity `sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==`)生成新预览——绝不隐式降级。
|
|
106
|
-
- **只增不更新**:合并只补充本机从未见过的模型条目;上游对已有 id 的元数据修正不会同步——这是「永不覆盖」语义的代价(ADR 0001)。
|
|
107
|
-
- **远端目录形状规范化**:pi-ai ≥0.99.0 的 `chat:` 键统一规范化回裸 id;非法/冲突条目进 skipped 列表展示,不静默。
|
|
108
|
-
- **净零切换(v1.2.7 机制保留)**:registry 通道注入落在宿主快照之后时,以 `displayName`「加空格再复原」的两次真内容变化触发快照重建(用户配置逐字节不变);仍端不出来记 `registry-not-served` 并暴露 `settingsNotServed` / `registryVia` / `registryInstanceMismatch` 诊断。GET `/status` 不执行任何写探针(通道分类来自启动期缓存;`catalogMode` 为 `blocked`/`unknown` 时入口禁用并给出具体原因)。
|
|
109
|
-
- **单宿主进程假设**:互斥为宿主单进程内串行队列,不支持多实例/多进程并发操作同一安装目录。
|
|
60
|
+
pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型在本机无法解析。GHC 设置页的「**管理模型列表**」(登录后可见)在不升级 pi-ai 的前提下解决目录过期,并补上局部删除能力:
|
|
61
|
+
|
|
62
|
+

|
|
63
|
+
|
|
64
|
+
- **勾选即终态**:一张分组全表——**可新增**(账号可用 ∩ 目录可解析,默认不勾)、**在列**(默认勾选)、**待删除候选**(在列但账号未报告 / 目录无法解析,默认不勾=删除提案,重新勾选即**挽留**)。应用时被保留模型的对象、顺序与定制逐字保留,被移除模型的定制随模型消失。
|
|
65
|
+
- **快捷动作**:「全选 / 取消全选」作用于全表;「**对齐账号与目录**」仅在漂移时出现,一键把勾选态设为账号 ∩ 可解析;「**同时清除全部模型定制**」复选框仅在存在可清除定制时出现;摘要行常驻显示「应用后共 N 个:保留 · 新增 · 移除」。
|
|
66
|
+
- **证据门控按实际 diff**:纯新增可用 24 小时内同授权的缓存;**移除类变更强制实时账号证据 + 最新目录并二次确认**(缓存下勾选删除会自动升级为实时预览);空目标需单独确认,落显式空列表且不会被登录自动回填。移除所需的实时证据会在本地会话令牌过期时**自动续期**(仅内存、不落库);续期仍失败时,来源行会给出原因与出路(先用一次模型 / 重新授权 / 稍后重试)。
|
|
67
|
+
- **生效时机**:web / 服务形态先做原子只增补丁、**重启后**生效;desktop(只读 `app.asar`)走进程内注册表注入,**当次生效、无需重启**、不碰官方产物(ADR 0003)。
|
|
68
|
+
- **并发与恢复**:每个操作带服务端签发的 operationId(幂等可查、绝不重复执行);配置并发编辑永远优先(漂移返回 409 要求重新预览);崩溃 / 重启后可恢复,出现冲突时提供「结束旧配置意图」显式闭环。apply 撞上卡死的旧操作(423)且该操作已 `recovery-needed` 时,弹窗与刷新后的页面都会提供同一「结束旧配置意图」出口(know-how 032 缺口闭环)。
|
|
110
69
|
|
|
111
70
|
## 工作原理
|
|
112
71
|
|
|
113
|
-
- 通过 `cordis.patch.yml`
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
72
|
+
- 通过 `cordis.patch.yml` 两段生效:挂载本插件 entry(host 侧在 webserver 上开 7 条 `/copilot-auth/*` 本地路由,跨站 Origin 拒绝;模型路由要求 `protocolVersion: 3`),并以 settings base 层预置 `github-copilot` 路由(用户 `settings.yaml` 可逐字段覆盖)
|
|
73
|
+
- authorization 服务 **0.1.7+ 由 runtime 内置**,插件不再挂载(≤0.1.5 请用 v1.1.x,见「版本兼容」)
|
|
74
|
+
- 登录走 GitHub 设备码流;授权生命周期有单实例 attempt、15 分钟等待上限与超时风险锁存,超时后晚到的授权成功只更新事实、不再写配置
|
|
75
|
+
- 模型目录兜底只在「尚不存在」时由登录成功写入一次,填充后归用户所有;同步失败经 `/copilot-auth/status` 的 `syncError` 暴露
|
|
76
|
+
- 刷新状态按 profile 隔离存于 `<profile>/copilot-auth/`;目录数据从 npm 拉取最新 pi-ai tarball(强制 integrity 校验),失败回退本地目录,可**显式**改用内置覆盖层,绝不隐式降级
|
|
77
|
+
|
|
78
|
+
## 版本兼容
|
|
79
|
+
|
|
80
|
+
| 插件线 | DSH 实测版本 | 说明 |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| **v1.2.x** | `0.1.7-rc.2` ~ `0.2.0-rc.2` | 当前线;`0.2.0` 起注意插件 peer 精确版本守卫 |
|
|
83
|
+
| v1.1.x | `0.1.2-rc.1` ~ `0.1.5-rc.2` | 旧版线;依赖 v1.2.0 已移除的 authorization 挂载补丁,新版 DSH 上无法激活 |
|
|
84
|
+
|
|
85
|
+
逐版本沿革(v1.2.0 ~ v1.2.20 完整说明与已移除能力的语义记录)见 [docs/CHANGELOG.md](./docs/CHANGELOG.md)。
|
|
86
|
+
|
|
87
|
+
## FAQ
|
|
88
|
+
|
|
89
|
+
**需要填 GitHub API Token 吗?**
|
|
90
|
+
不需要。登录走标准设备码流,凭据由 DSH 内置凭据库托管(0600),插件不接触、不存储任何 Token。
|
|
91
|
+
|
|
92
|
+
**点「授权登录」后一直等待,或显示超时?**
|
|
93
|
+
等待有 15 分钟本地时限。超时后显示「结果待核实」:此时不能再发起新的授权尝试,请点「查询状态」或人工核实;若确认未授权,重启 DSH Web 后即可重试(风险锁存随进程结束清空)。
|
|
94
|
+
|
|
95
|
+
**SSO 账号登录后没生效?**
|
|
96
|
+
GitHub 授权页上必须对组织点 **Authorize**,跳过该步登录不生效。
|
|
97
|
+
|
|
98
|
+
**怎么退出登录 / 换账号?**
|
|
99
|
+
GHC 设置页「退出登录」:确认弹窗 → 清除凭据 → 删后核实。它会清除同一凭据库中所有实例共享的 Copilot 授权,但**不注销 GitHub 账号**。换号 = 先退出再登录;授权进行中或超时未核实期间退出会被拒绝(如实提示)。
|
|
100
|
+
|
|
101
|
+

|
|
102
|
+
|
|
103
|
+
**「请求撤回」按钮去哪了?**
|
|
104
|
+
已移除(Q38):它无法真正撤销 GitHub 侧设备流,其风险锁存反而造成「登录成功却不能退出」死局。误启动的等待由 15 分钟超时自然收敛。详见 [docs/CHANGELOG.md](./docs/CHANGELOG.md)。
|
|
105
|
+
|
|
106
|
+
**账号新出的模型没出现?**
|
|
107
|
+
目录只在首次登录兜底填充一次,之后归你所有。用 GHC 设置页「管理模型列表」勾选加入,或在 Models 页手动添加。
|
|
108
|
+
|
|
109
|
+
**premium 配额怎么算?**
|
|
110
|
+
base 模型(GPT-4o / 4.1 一类)不耗 premium requests;premium 模型(Claude、Gemini、o 系列等)每次调用消耗月度配额。日常建议 base 档,premium 按需手动选。
|
|
111
|
+
|
|
112
|
+
**dsh-desktop(Electron)支持吗?**
|
|
113
|
+
支持(v1.2.5 起):只读 `app.asar` 形态下模型刷新走进程内注册表注入,当次生效、无需重启;上游改动注册表形状时守卫会拦截并报 `registry-*` 结构化错误,模型列表保持原样(不写 settings)。
|
|
114
|
+
|
|
115
|
+
**Models 页的状态圆点显示未配置?**
|
|
116
|
+
圆点只反映 API Key 引用,OAuth 登录成功后仍可能显示未配置——外观问题,功能不受影响。
|
|
117
|
+
|
|
118
|
+
## 已知边界
|
|
119
|
+
|
|
120
|
+
- 自行 patch `llm-pi-ai` 整段 config 会覆盖预置路由;`settings.yaml` 的 `llm-pi-ai:` 节只能稀疏覆盖字段,无法删除预置路由本身(移除须卸载本插件)
|
|
121
|
+
- 路由前缀 `/copilot-auth` 两侧硬编码,不可配置
|
|
122
|
+
- 模型目录「**只增不更新**」:合并只补充本机从未见过的条目,上游对已有 id 的元数据修正不会同步(ADR 0001)
|
|
123
|
+
- DSH rc 版本耦合:实测 `0.1.7-rc.2` 与 `0.2.0-rc.1 / rc.2`,peer 仅 `@deepseek-ai/cordis@^4.0.2`
|
|
124
|
+
- DSH 升级后的目录自愈仅在 pi-ai 基线版本不变时按已确认条目只增重放;跨基线且条目缺失时上报 `self-heal-incompatible`,不修改安装树(重新执行一次手动刷新即可在新基线上激活)
|
|
125
|
+
- 并发保护为宿主单进程内 mutex,不支持多实例 / 多进程并发操作同一安装目录
|
|
126
|
+
- 目录 fsync 仅在 POSIX 执行:Windows 无目录 fsync 语义(必然 `EPERM`),直接跳过,一致性由 NTFS 元数据日志保证(v1.2.3 起)
|
|
127
|
+
- 等待超时后,该 profile 的旧预览与待生效配置意图按代次失效,须重新预览或显式「结束旧配置意图」
|
|
121
128
|
|
|
122
129
|
## 开发
|
|
123
130
|
|
|
124
131
|
```bash
|
|
125
132
|
npm install
|
|
126
|
-
npm test # node:test:
|
|
133
|
+
npm test # node:test:224 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
|
|
127
134
|
npm run build # esbuild 打包 client 到 lib/client.js(__ModuleLoader__ 信封)
|
|
128
135
|
npm pack --dry-run
|
|
136
|
+
npm run capture # 截取 README 预览图(需本机 DSH 实例;详见脚本头注)
|
|
129
137
|
```
|
|
130
138
|
|
|
131
139
|
| 路径 | 职责 |
|
|
132
140
|
|---|---|
|
|
133
|
-
| `cordis.patch.yml` | 两段 patch(本插件 entry /
|
|
141
|
+
| `cordis.patch.yml` | 两段 patch(本插件 entry / 预置路由) |
|
|
134
142
|
| `src/host.mjs` · `src/shared.mjs` | host 半区:路由接线、status 聚合、启动期能力探测 |
|
|
135
|
-
| `src/auth-host.mjs` |
|
|
143
|
+
| `src/auth-host.mjs` · `src/auth-flow.mjs` | 授权控制器(单实例尝试、超时风险锁存、首次填充 handoff)与客户端状态机 |
|
|
136
144
|
| `src/runtime-scope.mjs` | 稳定 profile 身份与私有 dataDir(`<profile>/copilot-auth/`) |
|
|
137
|
-
| `src/refresh-service.mjs` · `src/refresh-transaction.mjs` · `src/model-update.mjs` |
|
|
138
|
-
| `src/
|
|
139
|
-
| `src/
|
|
140
|
-
| `src/client.jsx` | client 半区:settings.section 插槽 + 授权/模型双区交互 + 双语文案 |
|
|
141
|
-
| `scripts/build-client.mjs` | client 打包(`__ModuleLoader__` CJS 工厂信封) |
|
|
142
|
-
| `lib/client.js` | 构建产物(入库,使 git 安装免构建) |
|
|
145
|
+
| `src/refresh-service.mjs` · `src/refresh-transaction.mjs` · `src/model-update.mjs` | 预览快照 / 证据门禁 / 幂等 · 事务内核与恢复 · 纯变更策略 |
|
|
146
|
+
| `src/catalog.mjs` · `src/catalog-fetch.mjs` · `src/catalog-registry.mjs` | 目录解析 / npm 拉取 · 只读安装树的进程内注入通道(ADR 0003) |
|
|
147
|
+
| `src/client.jsx` · `lib/client.js` | client 半区:设置页 UI(双语)与构建产物(入库,git 安装免构建) |
|
|
143
148
|
|
|
144
149
|
从源码安装:`dsh plugin --profile web add /path/to/dsh-copilot-auth`
|
|
145
150
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
- 通道为社区通用路线(基于 pi-ai 内部实现),**非 GitHub 官方支持 API**,可能随服务端变更失效
|
|
149
|
-
- GitHub AUP 禁止批量自动化滥用;**公司账号请正常强度使用**
|
|
150
|
-
|
|
151
|
-
## 已知边界
|
|
152
|
-
|
|
153
|
-
- Models 页的凭据状态圆点只反映 API Key 引用,OAuth 登录成功后圆点仍可能显示未配置(外观问题,功能不受影响)
|
|
154
|
-
- 自行在 `cordis.patch.yml` patch `llm-pi-ai` 整段 config 会覆盖预置路由
|
|
155
|
-
- `settings.yaml` 的 `llm-pi-ai:` 节只能稀疏覆盖字段,无法删除预置路由本身(移除须卸载本插件)
|
|
156
|
-
- 路由前缀 `/copilot-auth` 两侧硬编码,不可配置
|
|
157
|
-
- DSH rc 版本耦合:实测 `0.1.7-rc.2` 与 `0.2.0-rc.1`(v1.2.x;`0.1.2-rc.1` 由 v1.1.x 实测),peer 仅 `@deepseek-ai/cordis@^4.0.2`
|
|
158
|
-
- 模型目录只在「尚不存在」时由登录成功兜底填充一次,填充后归用户所有:账号新增的模型不会自动出现——用 GHC 设置页的「**管理模型列表**」勾选加入(见上节),或在 Models 页手动添加
|
|
159
|
-
- 模型目录只写入「可解析」的模型:目录快照外的新模型须先经「管理模型列表」补入——可写安装树补进目录文件、只读安装树(desktop)注入进程内注册表(ADR 0003),两条通道都保持 pi-ai 版本不变
|
|
160
|
-
- 等待超时后,该 profile 的旧预览与待生效配置意图按 intentVersion 失效(auth-changed / recovery-needed),须重新预览或显式「结束旧配置意图」
|
|
161
|
-
- DSH 升级后自愈仅在 pi-ai 基线版本不变(0.84.4)时重放;跨版本且条目未原生存在时上报 `self-heal-incompatible`,不修改安装树(重新执行一次手动刷新即可在新基线上激活)
|
|
162
|
-
- **dsh-desktop(Electron 桌面版)可刷新模型目录(v1.2.5 起)**:运行时树打包在只读的 `app.asar` 内,写盘通道物理不可用,但刷新改走进程内目录注册表注入(ADR 0003)——当次生效、无需重启、不碰官方产物;重启后由启动序列重放,并按 v1.2.6 的复核/同值触碰机制保证路由真的端出这些模型。前提是该进程的 pi-ai 目录注册表形态与实测一致(`MODELS[provider]` 未冻结、扁平、键=模型 id);上游改形状时注入守卫会拦下并报 `registry-*` 结构化错误,模型列表保持原样(不写 settings)。v1.2.4 及之前该形态只能置灰刷新入口(`catalog-not-writable`,另见 ADR 0002)
|
|
163
|
-
- 并发保护为宿主单进程内 mutex,**不支持多实例/多进程并发刷新**
|
|
164
|
-
- 状态落盘后的目录 fsync 仅在 POSIX 执行:Windows 无目录 fsync 语义(对目录句柄 fsync 必然 `EPERM`),win32 直接跳过、不再刷告警,目录项一致性由 NTFS 元数据日志保证(v1.2.3 起,issue #1);POSIX 行为不变
|
|
151
|
+
设计决策与评审记录见 [docs/adr/](./docs/adr/) 与 [docs/plans/](./docs/plans/)。
|
|
165
152
|
|
|
166
153
|
## 维护者发布
|
|
167
154
|
|
|
168
|
-
本项目使用 npm **Trusted Publishing(GitHub Actions OIDC)** 发布,不在仓库或 GitHub Secrets 中保存 `NPM_TOKEN
|
|
155
|
+
本项目使用 npm **Trusted Publishing(GitHub Actions OIDC)** 发布,不在仓库或 GitHub Secrets 中保存 `NPM_TOKEN`:推送 `v*` tag 或手动触发 [`.github/workflows/release.yml`](./.github/workflows/release.yml),workflow 以 `id-token: write` 运行,npm 自动使用 OIDC 并生成 provenance。Trusted Publisher 按 package 配置(owner `iasiv5`,repository `dsh-copilot-auth`,workflow `release.yml`);首次发布前需在 npm 侧完成一次性 bootstrap,详见 [npm Trusted Publishing 文档](https://docs.npmjs.com/trusted-publishers)。
|
|
156
|
+
|
|
157
|
+
## 风险与合规
|
|
169
158
|
|
|
170
|
-
-
|
|
171
|
-
- 触发方式:推送 `v*` tag,或在 GitHub Actions 手动触发
|
|
172
|
-
- npm Trusted Publisher 配置:GitHub Actions;owner `iasiv5`;repository `dsh-copilot-auth`;workflow filename `release.yml`;environment 留空;允许 `npm publish`
|
|
173
|
-
- workflow 使用 GitHub-hosted runner、`id-token: write`,npm CLI 自动使用 OIDC 并生成 provenance
|
|
174
|
-
- npm Trusted Publisher 是按 package 配置的。首次发布前如果 package 尚不存在,需要先完成 npm 要求的一次性 bootstrap,再在 package settings 中配置 Trusted Publisher;bootstrap 不应通过长期 GitHub secret 实现。详见 [npm Trusted Publishing 文档](https://docs.npmjs.com/trusted-publishers)
|
|
159
|
+
通道为社区通用路线(基于 pi-ai 内部实现),**非 GitHub 官方支持 API**,可能随服务端变更失效;GitHub AUP 禁止批量自动化滥用,**公司账号请正常强度使用**。
|
|
175
160
|
|
|
176
161
|
## License
|
|
177
162
|
|
|
178
|
-
MIT
|
|
163
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# 版本沿革(详细)
|
|
2
|
+
|
|
3
|
+
> 本文件保留 README 精简后移出的逐版本说明与语义沿革,内容以 v1.2.20 发布时的 README 记载为准。
|
|
4
|
+
> 当前版本线与兼容范围见 [README「版本兼容」](../README.md#版本兼容);发布产物见
|
|
5
|
+
> [GitHub Releases](https://github.com/iasiv5/dsh-copilot-auth/releases)。
|
|
6
|
+
|
|
7
|
+
## 版本矩阵(v1.2.0 ~ v1.2.20)
|
|
8
|
+
|
|
9
|
+
| 本插件 | DSH 实测版本 | authorization 服务 | 说明 |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| v1.2.20 | `0.1.7-rc.2`+(同 v1.2.x 运行时兼容范围) | runtime 内置,插件不挂载 | **文档与截图更新**:README 改为精简的热门插件结构,新增未登录、设备码、GitHub 授权与退出确认 WebP 实拍;移除非本插件模型页截图。无运行时代码行为变更。 |
|
|
12
|
+
| v1.2.19 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **管理模型列表 v3:目标状态编辑**——「补充/重建」双入口合并为单一「管理模型列表」分组全表(可新增/在列/待删除候选,勾选即终态,支持挽留与「对齐账号与目录」);移除成为一等能力且定制随模型保留/消失(`removals-need-live` 门控:移除类强制实时证据+latest 目录,缓存下自动升级 live);单次确认(Q37,风险线常驻底栏前置告知);「请求撤回」移除(Q38)+退出后 attempt 残影清空(修复「退出没作用」循环)。协议 v3。其余同 v1.2.18 |
|
|
13
|
+
| v1.2.18+ | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **授权失败附网络失败码+人性化提示**:`fetch failed` 这类 Node undici 网络层异常的原话不含定位信息,现按 `cause.code` 映射为可操作的提示——超时/拒连→「检查网络或代理设置」、域名解析失败→「检查 DNS」、证书校验失败→「可能存在企业 TLS 检查拦截」;原始失败码弱化展示供诊断。其余同 v1.2.17 |
|
|
14
|
+
| v1.2.17 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **保留模型逐项列出**(弱化色,与增/删同构)——「保留 (N)」自 v1.2.8 起只有计数没有清单,预览终态不可审计;数据服务端一直有,本版补上渲染。其余同 v1.2.16 |
|
|
15
|
+
| v1.2.16 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **移除物化中指示浮层**:勾选为乐观即时反馈、物化通常亚秒完成,任何显隐指示都构成闪烁(主人裁决完全移除)。物化中「应用更改」点击由状态机安全忽略(防旧 previewId 提交),指示移除后卡片视觉零变化。其余同 v1.2.15 |
|
|
16
|
+
| v1.2.15 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **物化指示改右上角浮层**(绝对定位、透明度渐显渐隐、不占布局流)——取代 v1.2.14 的常驻占位行(占位行零位移但留空行,显空旷);弹跳与空行双消除。其余同 v1.2.14 |
|
|
17
|
+
| v1.2.14 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **消除弹窗上下弹跳**:「拉取预览中…」物化指示改为**常驻占位行**(visibility 显隐,不插入/移除节点)——v1.2.13 固定高度解决了整卡伸缩,本版消除指示条插入/移除引起的垂直位移。其余同 v1.2.13 |
|
|
18
|
+
| v1.2.13 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **修复模型弹窗勾选闪烁**:勾选/全选触发的物化期间弹窗保持挂载(历史实现切回 `previewing` 导致弹窗整体卸载重挂——卡片闪现「拉取预览中」、滚动位置丢失,实测探针元素直接从 DOM 消失);弹窗改固定高度三段式(头部/内部滚动/常驻操作栏),尺寸不再随增删列表变化;勾选走本地乐观更新(服务端回显到达后对齐)。其余同 v1.2.12 |
|
|
19
|
+
| v1.2.12 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **交互打磨**:「补充模型」的全选按钮在全选中切换为「取消全选」(任一取消勾选即恢复「全选」);「重建」风险提示精简为一行;成功路径的「技术详情」计数移除(与弹窗增删列表重复),技术详情仅在真实失败时展示。其余同 v1.2.11 |
|
|
20
|
+
| v1.2.11 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **界面收敛:移除「重新授权」按钮与 `capabilities.reauthorize` 能力位**(逻辑冗余——已登录只需退出登录,未登录入口本就是「授权登录」;换号/重授权=先退出再登录两步)。其余同 v1.2.10 |
|
|
21
|
+
| v1.2.10 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | **退出登录正式可用**:无进行中授权尝试且无未核实风险(风险锁存)时,经确认弹窗清除凭据并**删除后立即复核**(凭据未消失则如实报「退出未完成」);授权尝试进行中(begin 晚写窗口)与撤回/超时未核实期一律拒绝退出。`/status` 的 `authorization.capabilities.logout` 按可用性动态返回。经主人 2026-10-05 裁决替代 v1.2.8 D-01「退出全禁」策略 |
|
|
22
|
+
| v1.2.9 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **修复 v1.2.8 授权页显示回归**:授权状态机漏接 `fetch`,页面恒显「未登录」、点「授权登录」恒「失败」且请求根本不发出(2026-10-04 实机实证,nginx 全量零 `POST /copilot-auth/start`)。另加:/status 加载失败退避自愈重试(60 秒转手动+「查询状态」)、失败原因分层展示(网络/网关/响应,不再裸「失败」)、已登录时服务端拒绝替换性授权(409 `already-configured`)且客户端自动恢复已登录视图 |
|
|
23
|
+
| v1.2.8 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **协议 v2:诚实的授权语义+补充/重建双入口**。授权侧:等待中可「请求撤回」(结果如实显示待核实)、退出/重新授权在宿主可证明安全前禁用并说明、单实例 attempt、15 分钟等待上限、晚到授权成功不再写配置;模型侧:旧「刷新」拆为「补充模型」(候选默认不勾选、非破坏)与「重建模型列表」(二次确认+清 modelOverrides),预览 10 分钟绑定选择、operationId 幂等、配置并发编辑优先、崩溃可恢复(「结束旧配置意图」显式闭环),状态按 profile 隔离(旧全局状态只读提示);模型路由要求 `protocolVersion:2`,GET `/status` 零写探针;v1 函数族已从 state.mjs 移除。**已知缺陷**:授权状态机漏接 fetchImpl → 授权页恒「未登录/失败」(v1.2.9 修复) |
|
|
24
|
+
| v1.2.7+ | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **重启后自动恢复**:注入后以宿主 `listModels` 复核,未端出即**净零切换**(`displayName` 改后复原;两次真内容变化 ⇒ 宿主必然重建快照,用户配置逐字节不变)触发重建;另加双路取实例 + `registryInstanceMismatch`/`registryVia` 诊断。修复 v1.2.6 的"同值写不改变配置身份 ⇒ 快照不重建" |
|
|
25
|
+
| v1.2.6 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | 首次引入宿主侧复核与 `settingsNotServed` 诊断;重启自愈用的"同值触碰"**实测不足**(同值写不改变配置对象身份),重启后模型可能仍不出现——请用 v1.2.7 |
|
|
26
|
+
| v1.2.5 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **desktop 只读形态可刷新**:进程内目录注册表注入(ADR 0003),无需重启、不碰官方产物;`0.2.0-rc.2` desktop **实机端到端已验**(注入条目完成真实推理请求);同批修复 pi-ai ≥0.99.0 的 `chat:` 目录键格式。**已知缺陷**:重启后若宿主快照先于注入定稿,注入条目会被丢弃(v1.2.6 修复) |
|
|
27
|
+
| v1.2.4 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | desktop asar 只读形态:目录刷新入口显式置灰(ADR 0002),登录/路由/settings 不受影响 |
|
|
28
|
+
| v1.2.0 ~ v1.2.3 | `0.1.7-rc.2` ~ `0.2.0-rc.1`(web/服务形态) | runtime 内置,插件不挂载 | 适配 settings API 重塑后的 describe() 行形状;desktop asar 形态下目录刷新不可用且报错不友好 |
|
|
29
|
+
| v1.1.x | `0.1.2-rc.1` ~ `0.1.5-rc.2` | 由 cordis.patch.yml insert 挂载 | v1.2.0 起移除该补丁,旧版 DSH 上无法激活 |
|
|
30
|
+
|
|
31
|
+
## 已移除能力的语义沿革
|
|
32
|
+
|
|
33
|
+
- **「请求撤回」(Q38,2026-10-05 移除)**:真机验证发现其进程级风险锁存会造成「登录成功却不能退出」死局,且它本就无法真正撤销 GitHub 侧设备流;误启动的等待由 15 分钟超时自然收敛(超时后仍属「结果待核实」,锁存至进程重启)。
|
|
34
|
+
- **「重新授权」独立按钮(v1.2.11 移除)**:逻辑冗余——已登录只需退出登录,未登录入口本就是「授权登录」;需要换号/重授权时,先「退出登录」再「授权登录」即可。
|
|
35
|
+
- **退出登录(v1.2.10 落地)**:无进行中授权尝试且无未核实风险时可退出——确认弹窗(将清除同一凭据库中所有实例共享的 Copilot 授权,不注销 GitHub 账号)→ 删除凭据 → **删除后立即复核**,凭据仍在则如实显示「退出未完成,授权凭据仍可能存在」;授权尝试进行中(设备码等待期,begin 晚写窗口)与超时未核实的风险锁存期一律拒绝退出。
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -192,6 +192,9 @@ function reduce(state, event) {
|
|
|
192
192
|
if (event.type === "retire-request" && state.name === "result") {
|
|
193
193
|
return [{ name: "retireConfirm", result: state.result }, null];
|
|
194
194
|
}
|
|
195
|
+
if (event.type === "retire-request" && state.name === "busy" && state.active?.phaseResult?.status === "recovery-needed") {
|
|
196
|
+
return [{ name: "retireConfirm", active: state.active }, null];
|
|
197
|
+
}
|
|
195
198
|
if (event.type === "dismiss" && state.name === "failed") {
|
|
196
199
|
return [{ ...initial, flags: state.flags }, null];
|
|
197
200
|
}
|
|
@@ -200,6 +203,9 @@ function reduce(state, event) {
|
|
|
200
203
|
if (!event.status) return [state, null];
|
|
201
204
|
if (refresh?.lastError === "state-corrupt") return [{ ...state, name: "failed", error: "state-corrupt" }, null];
|
|
202
205
|
const base = fromRefresh({ ...initial, name: "idle" }, refresh);
|
|
206
|
+
if (refresh?.activeOperation?.phaseResult?.status === "recovery-needed") {
|
|
207
|
+
return [{ ...base, name: "busy", active: refresh.activeOperation }, null];
|
|
208
|
+
}
|
|
203
209
|
if (restartStates.has(refresh?.lastResult?.status ?? "") || refresh?.pendingRestart === true) {
|
|
204
210
|
return [{ ...base, name: "pendingRestart", result: refresh.lastResult }, null];
|
|
205
211
|
}
|
|
@@ -341,14 +347,16 @@ function reduce(state, event) {
|
|
|
341
347
|
}
|
|
342
348
|
case "retireConfirm": {
|
|
343
349
|
if (event.type === "retire-confirm") {
|
|
344
|
-
return [{ name: "retiring", operationId: state.result?.operationId, result: state.result }, { type: "retire", operationId: state.result?.operationId }];
|
|
350
|
+
return [{ name: "retiring", operationId: state.result?.operationId ?? state.active?.operationId, result: state.result, active: state.active }, { type: "retire", operationId: state.result?.operationId ?? state.active?.operationId }];
|
|
345
351
|
}
|
|
346
|
-
if (event.type === "cancel") return [{ name: "result", result: state.result }, null];
|
|
352
|
+
if (event.type === "cancel") return state.active ? [{ name: "busy", active: state.active }, null] : [{ name: "result", result: state.result }, null];
|
|
347
353
|
return [state, null];
|
|
348
354
|
}
|
|
349
355
|
case "retiring": {
|
|
350
356
|
if (event.type === "retire-ok") return [{ ...state, name: "checking" }, { type: "status" }];
|
|
351
|
-
if (event.type === "retire-fail")
|
|
357
|
+
if (event.type === "retire-fail") {
|
|
358
|
+
return state.active ? [{ name: "busy", active: state.active, retireError: event.error }, null] : [{ name: "result", result: state.result, retireError: event.error }, null];
|
|
359
|
+
}
|
|
352
360
|
return [state, null];
|
|
353
361
|
}
|
|
354
362
|
default:
|
|
@@ -680,6 +688,10 @@ var DICTS = {
|
|
|
680
688
|
skippedModels: "Upstream entries skipped by validation",
|
|
681
689
|
srcLive: "Account models: live",
|
|
682
690
|
srcCache: "Account models: cached at {time}; removals require upgrading to live evidence",
|
|
691
|
+
srcCacheExpired: "Account models: cached at {time}. The session token is expired and could not be auto-renewed \u2014 chat with any Copilot model once; if it still fails, sign out and authorize again.",
|
|
692
|
+
srcCacheRejected: "Account models: cached at {time}. The local token was rejected by GitHub (possibly re-authorized on another device) \u2014 sign out and authorize again.",
|
|
693
|
+
srcCacheNetwork: "Account models: cached at {time}. Live evidence is temporarily unreachable \u2014 reopen this dialog later to retry.",
|
|
694
|
+
srcCacheNoSession: "Account models: cached at {time}. No local session token \u2014 authorize first.",
|
|
683
695
|
srcStale: "The cache has no trusted timestamp, is expired, or belongs to changed authorization; reference only.",
|
|
684
696
|
srcLatest: "Catalog source: latest pi-ai from npm",
|
|
685
697
|
srcLocal: "Catalog source: local (npm fetch failed)",
|
|
@@ -767,6 +779,10 @@ var DICTS = {
|
|
|
767
779
|
skippedModels: "\u88AB\u6821\u9A8C\u8DF3\u8FC7\u7684\u4E0A\u6E38\u6761\u76EE",
|
|
768
780
|
srcLive: "\u8D26\u53F7\u6A21\u578B\uFF1A\u5B9E\u65F6\u83B7\u53D6",
|
|
769
781
|
srcCache: "\u8D26\u53F7\u6A21\u578B\uFF1A\u7F13\u5B58\uFF0C\u83B7\u53D6\u4E8E {time}\uFF1B\u79FB\u9664\u7C7B\u66F4\u6539\u9700\u5347\u7EA7\u4E3A\u5B9E\u65F6\u8BC1\u636E",
|
|
782
|
+
srcCacheExpired: "\u8D26\u53F7\u6A21\u578B\uFF1A\u7F13\u5B58\uFF0C\u83B7\u53D6\u4E8E {time}\u3002\u4F1A\u8BDD\u4EE4\u724C\u5DF2\u8FC7\u671F\uFF0C\u672A\u80FD\u81EA\u52A8\u7EED\u671F\u2014\u2014\u5148\u7528\u4EFB\u4E00 Copilot \u6A21\u578B\u5BF9\u8BDD\u4E00\u6B21\uFF1B\u82E5\u4ECD\u5931\u8D25\uFF0C\u8BF7\u9000\u51FA\u767B\u5F55\u5E76\u91CD\u65B0\u6388\u6743\u3002",
|
|
783
|
+
srcCacheRejected: "\u8D26\u53F7\u6A21\u578B\uFF1A\u7F13\u5B58\uFF0C\u83B7\u53D6\u4E8E {time}\u3002\u672C\u5730\u4EE4\u724C\u88AB GitHub \u62D2\u7EDD\uFF08\u53EF\u80FD\u5DF2\u5728\u53E6\u4E00\u53F0\u8BBE\u5907\u91CD\u65B0\u6388\u6743\uFF09\u2014\u2014\u8BF7\u9000\u51FA\u767B\u5F55\u5E76\u91CD\u65B0\u6388\u6743\u3002",
|
|
784
|
+
srcCacheNetwork: "\u8D26\u53F7\u6A21\u578B\uFF1A\u7F13\u5B58\uFF0C\u83B7\u53D6\u4E8E {time}\u3002\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\u5B9E\u65F6\u8BC1\u636E\u2014\u2014\u7A0D\u540E\u91CD\u5F00\u6B64\u7A97\u53E3\u91CD\u8BD5\u3002",
|
|
785
|
+
srcCacheNoSession: "\u8D26\u53F7\u6A21\u578B\uFF1A\u7F13\u5B58\uFF0C\u83B7\u53D6\u4E8E {time}\u3002\u672C\u5730\u65E0\u4F1A\u8BDD\u4EE4\u724C\u2014\u2014\u8BF7\u5148\u5B8C\u6210\u6388\u6743\u3002",
|
|
770
786
|
srcStale: "\u7F13\u5B58\u7F3A\u5C11\u53EF\u4FE1\u65F6\u95F4\u3001\u5DF2\u8FC7\u671F\u6216\u6388\u6743\u5DF2\u53D8\u5316\uFF0C\u4EC5\u4F9B\u53C2\u8003\u3002",
|
|
771
787
|
srcLatest: "\u76EE\u5F55\u6765\u6E90\uFF1Anpm \u6700\u65B0 pi-ai",
|
|
772
788
|
srcLocal: "\u76EE\u5F55\u6765\u6E90\uFF1A\u672C\u5730\u76EE\u5F55\uFF08npm \u62C9\u53D6\u5931\u8D25\uFF09",
|
|
@@ -929,6 +945,14 @@ function sampleThemeSurface() {
|
|
|
929
945
|
const bg = m ? `rgb(${m[1].split(",").slice(0, 3).join(",")})` : "#fff";
|
|
930
946
|
return { bg, fg: cs.color || "inherit" };
|
|
931
947
|
}
|
|
948
|
+
function cacheCopyKey(reason) {
|
|
949
|
+
return {
|
|
950
|
+
"access-expired": "srcCacheExpired",
|
|
951
|
+
"token-rejected": "srcCacheRejected",
|
|
952
|
+
"network": "srcCacheNetwork",
|
|
953
|
+
"no-session": "srcCacheNoSession"
|
|
954
|
+
}[reason] ?? null;
|
|
955
|
+
}
|
|
932
956
|
function RefreshModal({ t, flow, onSelect, onConfirm, onCancel, onOverlay }) {
|
|
933
957
|
const p = flow.preview;
|
|
934
958
|
const isStale = p.evidence?.stale === true;
|
|
@@ -984,7 +1008,7 @@ function RefreshModal({ t, flow, onSelect, onConfirm, onCancel, onOverlay }) {
|
|
|
984
1008
|
onConfirm({});
|
|
985
1009
|
};
|
|
986
1010
|
const sourceLine = [
|
|
987
|
-
p.evidence?.source === "live" ? t("srcLive") : p.evidence?.source === "cache" ? t("srcCache").replace("{time}", String(p.evidence?.fetchedAt ?? "")) : null,
|
|
1011
|
+
p.evidence?.source === "live" ? t("srcLive") : p.evidence?.source === "cache" ? (cacheCopyKey(p.evidence?.reason) ? t(cacheCopyKey(p.evidence?.reason)) : t("srcCache")).replace("{time}", String(p.evidence?.fetchedAt ?? "")) : null,
|
|
988
1012
|
p.catalogSource === "latest" ? t("srcLatest") : p.catalogSource === "overlay" ? t("srcOverlay") : t("srcLocal")
|
|
989
1013
|
].filter(Boolean).join(" \xB7 ");
|
|
990
1014
|
const groupDefs = [
|
|
@@ -1319,8 +1343,10 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
|
|
|
1319
1343
|
] }),
|
|
1320
1344
|
flow.name === "busy" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
|
|
1321
1345
|
"\u24D8 ",
|
|
1322
|
-
t("busy")
|
|
1346
|
+
t("busy"),
|
|
1347
|
+
flow.retireError ? `\uFF08${flow.retireError}\uFF09` : ""
|
|
1323
1348
|
] }),
|
|
1349
|
+
flow.name === "busy" && flow.active?.phaseResult?.status === "recovery-needed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary }, onClick: () => drive({ type: "retire-request" }), children: t("retireBtn") }) }),
|
|
1324
1350
|
flow.name === "resultUnknown" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
1325
1351
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.error, children: [
|
|
1326
1352
|
"\u26A0 ",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inventec/dsh-copilot-auth",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.19:管理模型列表 v3 目标状态编辑(分组全表/挽留/对齐/清空语义告知)+单次确认+移除请求撤回+退出残影修复。v1.2.18:授权失败附网络失败码+人性化提示。适配 DSH 0.1.7+(≤0.1.5 请用 1.1.x)。",
|
|
3
|
+
"version": "1.2.21",
|
|
4
|
+
"description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.19:管理模型列表 v3 目标状态编辑(分组全表/挽留/对齐/清空语义告知)+单次确认+移除请求撤回+退出残影修复。v1.2.18:授权失败附网络失败码+人性化提示。适配 DSH 0.1.7+(≤0.1.5 请用 1.1.x)。v1.2.21:移除前实时证据自动续期(ephemeral 不落库+负缓存 5min)+缓存证据原因分型文案+busy 卡死 retire 出口与水合判序修正(032)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "iasi",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"lib/client.js",
|
|
22
22
|
"cordis.patch.yml",
|
|
23
23
|
"README.md",
|
|
24
|
+
"docs/CHANGELOG.md",
|
|
25
|
+
"docs/assets",
|
|
24
26
|
"LICENSE",
|
|
25
27
|
"src/catalog.mjs",
|
|
26
28
|
"src/catalog-registry.mjs",
|
|
@@ -41,7 +43,8 @@
|
|
|
41
43
|
"scripts": {
|
|
42
44
|
"build": "node scripts/build-client.mjs",
|
|
43
45
|
"test": "node --test",
|
|
44
|
-
"prepack": "npm run build"
|
|
46
|
+
"prepack": "npm run build",
|
|
47
|
+
"capture": "node scripts/capture-previews.mjs"
|
|
45
48
|
},
|
|
46
49
|
"engines": {
|
|
47
50
|
"node": ">=20"
|
|
@@ -51,6 +54,7 @@
|
|
|
51
54
|
},
|
|
52
55
|
"devDependencies": {
|
|
53
56
|
"esbuild": "^0.24.0",
|
|
57
|
+
"playwright-core": "1.62.1",
|
|
54
58
|
"react": "^18.2.0",
|
|
55
59
|
"yaml": "^2.5.0"
|
|
56
60
|
},
|
package/src/copilot-models.mjs
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// ⚠️ 显式耦合 pi-ai 0.84.4:COPILOT_HEADERS / COPILOT_API_VERSION / normalizeDomain /
|
|
3
3
|
// getGitHubCopilotBaseUrl / parseGitHubCopilotModelCatalog 的语义逐行抄录自
|
|
4
4
|
// @earendil-works/pi-ai@0.84.4 dist/auth/oauth/github-copilot.js。
|
|
5
|
-
// 不实现 token
|
|
5
|
+
// 不实现 token 获取/policy 修改;token 刷新仅限 v1.2.21 起的 ephemeral 续期(见文末,
|
|
6
|
+
// 只存内存、绝不落库);其余语义漂移或任何失败一律由调用方回退凭证缓存
|
|
6
7
|
// (payload.availableModelIds)。pi-ai 升级后本文件必须对照新版源码复核。
|
|
8
|
+
// v1.2.21 增抄:copilot_internal/v2/token 续期语义抄录自 pi-ai dist/auth/oauth/github-copilot.js
|
|
9
|
+
// refreshGitHubCopilotAccessToken(0.84.4 与 0.87.1 该函数 diff 为空);ephemeral 使用、绝不落库。
|
|
7
10
|
|
|
8
11
|
// ↓↓↓ 以下常量抄录自 pi-ai 0.84.4 dist/auth/oauth/github-copilot.js(勿改值)
|
|
9
12
|
const COPILOT_HEADERS = {
|
|
@@ -107,7 +110,48 @@ export async function fetchLiveAvailableModelIds({ credential, fetchImpl } = {})
|
|
|
107
110
|
signal: AbortSignal.timeout(5000),
|
|
108
111
|
});
|
|
109
112
|
if (!response.ok) {
|
|
110
|
-
|
|
113
|
+
// 携 status 供调用方证据分型(401/403 → token-rejected;其余 → network),与 refreshEphemeralAccess 同款
|
|
114
|
+
throw Object.assign(new Error(`${response.status} ${response.statusText}: GET ${baseUrl}/models`), { status: response.status });
|
|
111
115
|
}
|
|
112
116
|
return parseAvailableModelIds(await response.json(), allowPolicyFallback);
|
|
113
117
|
}
|
|
118
|
+
|
|
119
|
+
// 本地解析 access(tid 会话令牌)内的 exp=(秒 → 毫秒);缺失/不可解析 → null。
|
|
120
|
+
// 零网络:调用方据此在发请求前判断"是否需要续期"。
|
|
121
|
+
export function parseAccessExpiryMs(access) {
|
|
122
|
+
const match = String(access ?? "").match(/exp=(\d+)/);
|
|
123
|
+
return match ? Number(match[1]) * 1000 : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 铸一枚新的短命 tid:GET copilot_internal/v2/token(Bearer = 长期 ghu)。
|
|
127
|
+
// 语义抄录自 pi-ai refreshGitHubCopilotAccessToken(见文件头);单次尝试、5s 超时、
|
|
128
|
+
// 不重试、结果仅存内存(调用方绝不写回凭据库)。HTTP 失败抛错并携 status 供分型
|
|
129
|
+
// (401/403 → 长期令牌被拒;其余/网络 → network)。错误消息保真对齐 0.87.1 L261/L264。
|
|
130
|
+
export async function refreshEphemeralAccess({ refreshToken, enterpriseUrl, fetchImpl } = {}) {
|
|
131
|
+
if (typeof refreshToken !== "string" || refreshToken === "" || typeof fetchImpl !== "function") {
|
|
132
|
+
throw new Error("refreshToken and fetchImpl required");
|
|
133
|
+
}
|
|
134
|
+
const domain = normalizeDomain(enterpriseUrl) ?? "github.com";
|
|
135
|
+
const url = `https://api.${domain}/copilot_internal/v2/token`;
|
|
136
|
+
const response = await fetchImpl(url, {
|
|
137
|
+
headers: {
|
|
138
|
+
Accept: "application/json",
|
|
139
|
+
Authorization: `Bearer ${refreshToken}`,
|
|
140
|
+
...COPILOT_HEADERS,
|
|
141
|
+
},
|
|
142
|
+
signal: AbortSignal.timeout(5000),
|
|
143
|
+
});
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
throw Object.assign(new Error(`${response.status} ${response.statusText}: GET ${url}`), { status: response.status });
|
|
146
|
+
}
|
|
147
|
+
const raw = await response.json();
|
|
148
|
+
if (!raw || typeof raw !== "object") {
|
|
149
|
+
throw new Error("Invalid Copilot token response");
|
|
150
|
+
}
|
|
151
|
+
const token = raw.token;
|
|
152
|
+
const expiresAt = raw.expires_at;
|
|
153
|
+
if (typeof token !== "string" || typeof expiresAt !== "number") {
|
|
154
|
+
throw new Error("Invalid Copilot token response fields");
|
|
155
|
+
}
|
|
156
|
+
return { access: token, expiresAtMs: expiresAt * 1000 - 5 * 60 * 1000 };
|
|
157
|
+
}
|
package/src/refresh-flow.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// 状态:idle → previewing → confirming(勾选→materialize 循环;缓存证据+删除提案时
|
|
4
4
|
// 自动 live 升级:闩锁、硬失败回退 materialize、软降级置 staleNotice)→ applying →
|
|
5
5
|
// result / pendingRestart / checking / busy / resultUnknown;recovery-needed 经
|
|
6
|
-
// retireConfirm → retiring →
|
|
6
|
+
// retireConfirm → retiring → 重新预览闭环;busy 态在 active.phaseResult.status ===
|
|
7
|
+
// "recovery-needed" 时同样提供 retire 出口(032),水合时该判据优先于旧 lastResult
|
|
8
|
+
// (防旧 "applied" 遮蔽卡死操作),retire 失败回 busy 可重试。
|
|
7
9
|
// 关键规则:
|
|
8
10
|
// - 勾选集合即终态;选择变化经 basePreviewId 从原快照 materialize(不重新取数)
|
|
9
11
|
// - 确认门按实际 diff:removed>0 ∨ clearOverrides ∨ 空目标 → 二次确认;空目标另需 empty
|
|
@@ -108,6 +110,10 @@ export function reduce(state, event) {
|
|
|
108
110
|
if (event.type === "retire-request" && state.name === "result") {
|
|
109
111
|
return [{ name: "retireConfirm", result: state.result }, null];
|
|
110
112
|
}
|
|
113
|
+
// 032:busy 态的 retire 出口——仅当卡死操作已 recovery-needed(静止核实后清,绝不写配置)
|
|
114
|
+
if (event.type === "retire-request" && state.name === "busy" && state.active?.phaseResult?.status === "recovery-needed") {
|
|
115
|
+
return [{ name: "retireConfirm", active: state.active }, null];
|
|
116
|
+
}
|
|
111
117
|
if (event.type === "dismiss" && state.name === "failed") {
|
|
112
118
|
return [{ ...initial, flags: state.flags }, null];
|
|
113
119
|
}
|
|
@@ -116,6 +122,10 @@ export function reduce(state, event) {
|
|
|
116
122
|
if (!event.status) return [state, null];
|
|
117
123
|
if (refresh?.lastError === "state-corrupt") return [{ ...state, name: "failed", error: "state-corrupt" }, null];
|
|
118
124
|
const base = fromRefresh({ ...initial, name: "idle" }, refresh);
|
|
125
|
+
// 032 水合判序:卡死操作(recovery-needed)优先于旧 lastResult,防旧 "applied" 遮蔽 busy 死锁
|
|
126
|
+
if (refresh?.activeOperation?.phaseResult?.status === "recovery-needed") {
|
|
127
|
+
return [{ ...base, name: "busy", active: refresh.activeOperation }, null];
|
|
128
|
+
}
|
|
119
129
|
if (restartStates.has(refresh?.lastResult?.status ?? "") || refresh?.pendingRestart === true) {
|
|
120
130
|
return [{ ...base, name: "pendingRestart", result: refresh.lastResult }, null];
|
|
121
131
|
}
|
|
@@ -271,14 +281,20 @@ export function reduce(state, event) {
|
|
|
271
281
|
}
|
|
272
282
|
case "retireConfirm": {
|
|
273
283
|
if (event.type === "retire-confirm") {
|
|
274
|
-
|
|
284
|
+
// 双入口:result 入口携 result;busy 入口(032)携 active——operationId 二选一
|
|
285
|
+
return [{ name: "retiring", operationId: state.result?.operationId ?? state.active?.operationId, result: state.result, active: state.active }, { type: "retire", operationId: state.result?.operationId ?? state.active?.operationId }];
|
|
275
286
|
}
|
|
276
|
-
if (event.type === "cancel") return [{ name: "result", result: state.result }, null];
|
|
287
|
+
if (event.type === "cancel") return state.active ? [{ name: "busy", active: state.active }, null] : [{ name: "result", result: state.result }, null];
|
|
277
288
|
return [state, null];
|
|
278
289
|
}
|
|
279
290
|
case "retiring": {
|
|
280
291
|
if (event.type === "retire-ok") return [{ ...state, name: "checking" }, { type: "status" }];
|
|
281
|
-
if (event.type === "retire-fail")
|
|
292
|
+
if (event.type === "retire-fail") {
|
|
293
|
+
// 032:busy 入口失败回 busy 可重试;result 入口维持既有落点
|
|
294
|
+
return state.active
|
|
295
|
+
? [{ name: "busy", active: state.active, retireError: event.error }, null]
|
|
296
|
+
: [{ name: "result", result: state.result, retireError: event.error }, null];
|
|
297
|
+
}
|
|
282
298
|
return [state, null];
|
|
283
299
|
}
|
|
284
300
|
default:
|
package/src/refresh-service.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// refresh-service.mjs — 预览服务与账号证据(T8)。
|
|
2
2
|
// 职责:内存预览快照(10 分钟、选择从原快照重算、operationId 绑定选择)、
|
|
3
|
-
// 账号证据门禁(移除类变更需 live+latest;纯新增允许 ≤24h 同 intentVersion 缓存;stale
|
|
3
|
+
// 账号证据门禁(移除类变更需 live+latest;纯新增允许 ≤24h 同 intentVersion 缓存;stale 只参考不可应用)
|
|
4
|
+
// +tid 过期自愈(v2/token 铸 ephemeral tid:内存、不落库、单次尝试、负缓存 5min、按 exp 复用)、
|
|
4
5
|
// apply 幂等(同 operationId 返回已知结果;activeOperation 未终结时新 apply 423)、
|
|
5
6
|
// boot/apply/retire 同一队列串行。
|
|
6
7
|
// 事务内核(T9)经 transaction 注入:{runApply({snapshot}), bootRecover(), retire({operationId})}。
|
|
@@ -11,10 +12,11 @@ import { join } from "node:path";
|
|
|
11
12
|
import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
|
|
12
13
|
import { CREDENTIAL_KEY } from "./shared.mjs";
|
|
13
14
|
import { buildModelChange } from "./model-update.mjs";
|
|
14
|
-
import { fetchLiveAvailableModelIds } from "./copilot-models.mjs";
|
|
15
|
+
import { fetchLiveAvailableModelIds, parseAccessExpiryMs, refreshEphemeralAccess } from "./copilot-models.mjs";
|
|
15
16
|
|
|
16
17
|
const PREVIEW_TTL_MS = 10 * 60 * 1000;
|
|
17
18
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
const HEAL_RETRY_MS = 5 * 60 * 1000; // 自愈失败负缓存:期间不再打 v2/token
|
|
18
20
|
|
|
19
21
|
function flowError(code, message, extra = {}) {
|
|
20
22
|
const err = new Error(message ?? code);
|
|
@@ -25,40 +27,6 @@ function flowError(code, message, extra = {}) {
|
|
|
25
27
|
|
|
26
28
|
const iso = (ts) => new Date(ts).toISOString();
|
|
27
29
|
|
|
28
|
-
// 账号证据:live GET 成功才写缓存(fetchedAt+intentVersion);缓存仅 ≤24h 且同
|
|
29
|
-
// intentVersion 时可作纯新增的应用依据;否则 stale——预览可展示参考,apply 被拒(Q7/Q16)。
|
|
30
|
-
// 移除类变更(removed>0)在 apply 时强制 live 证据+latest 目录(removals-need-live,Q35/R2);
|
|
31
|
-
// live 拉取失败回退有效缓存属软降级,由 client 端升级/禁用逻辑兜底(R4-3),服务端门最终兜底。
|
|
32
|
-
async function resolveAccountEvidence(ctx, { scope, readIntentVersion, clock, fetchImpl }) {
|
|
33
|
-
const iv = readIntentVersion();
|
|
34
|
-
const cacheFile = join(scope.dataDir, "account-model-cache.json");
|
|
35
|
-
const readCache = () => {
|
|
36
|
-
try {
|
|
37
|
-
return readJson(cacheFile);
|
|
38
|
-
} catch {
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
const record = await ctx.credentials.readRecord(CREDENTIAL_KEY);
|
|
43
|
-
const payload = record?.payload ?? {};
|
|
44
|
-
if (payload.access) {
|
|
45
|
-
try {
|
|
46
|
-
const ids = await fetchLiveAvailableModelIds({ credential: payload, fetchImpl });
|
|
47
|
-
const fetchedAt = iso(clock.now()); // 单次取时:缓存值与返回值同源(避免毫秒漂移)
|
|
48
|
-
mkdirSync(scope.dataDir, { recursive: true });
|
|
49
|
-
writeJsonAtomic(cacheFile, { ids, fetchedAt, intentVersion: iv });
|
|
50
|
-
return { source: "live", ids, fetchedAt, stale: false };
|
|
51
|
-
} catch { /* 回退缓存判定 */ }
|
|
52
|
-
}
|
|
53
|
-
const cache = readCache();
|
|
54
|
-
if (cache && Array.isArray(cache.ids) && typeof cache.fetchedAt === "string"
|
|
55
|
-
&& Number.isFinite(Date.parse(cache.fetchedAt))
|
|
56
|
-
&& clock.now() - Date.parse(cache.fetchedAt) <= CACHE_TTL_MS
|
|
57
|
-
&& cache.intentVersion === iv) {
|
|
58
|
-
return { source: "cache", ids: cache.ids, fetchedAt: cache.fetchedAt, stale: false };
|
|
59
|
-
}
|
|
60
|
-
return { source: "stale", ids: Array.isArray(cache?.ids) ? cache.ids : [], fetchedAt: cache?.fetchedAt ?? null, stale: true };
|
|
61
|
-
}
|
|
62
30
|
|
|
63
31
|
export function createRefreshService(ctx, {
|
|
64
32
|
scope,
|
|
@@ -74,6 +42,80 @@ export function createRefreshService(ctx, {
|
|
|
74
42
|
};
|
|
75
43
|
const queue = createMutex();
|
|
76
44
|
const snapshots = new Map(); // previewId -> snapshot(内存态;重启即失效,幂等走持久状态)
|
|
45
|
+
// 自愈三态(N1,纯内存;dispose/重启即清):已铸的 ephemeral tid、失败负缓存时刻与原因
|
|
46
|
+
let ephemeral = null;
|
|
47
|
+
let healBlockedUntil = 0;
|
|
48
|
+
let healBlockedReason = null;
|
|
49
|
+
|
|
50
|
+
// 账号证据(v1.2.21 起闭包内化):live GET 成功才写缓存(fetchedAt+intentVersion);缓存仅 ≤24h
|
|
51
|
+
// 且同 intentVersion 时可作纯新增的应用依据;否则 stale——预览可展示参考,apply 被拒(Q7/Q16)。
|
|
52
|
+
// 移除类变更(removed>0)在 apply 时强制 live 证据+latest 目录(removals-need-live,Q35/R2)。
|
|
53
|
+
// 自愈(N1):tid 过期且记录带 refresh 时经 v2/token 铸 ephemeral tid——a 复用(未过 exp 不重铸)、
|
|
54
|
+
// b 负缓存期内沿用上次失败原因、c 单次续期;任何失败沿用软降级回退有效缓存,并给出 reason 分型。
|
|
55
|
+
async function resolveAccountEvidence({ clock, fetchImpl }) {
|
|
56
|
+
const iv = readIntentVersion();
|
|
57
|
+
const cacheFile = join(scope.dataDir, "account-model-cache.json");
|
|
58
|
+
const readCache = () => {
|
|
59
|
+
try {
|
|
60
|
+
return readJson(cacheFile);
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const record = await ctx.credentials.readRecord(CREDENTIAL_KEY);
|
|
66
|
+
const payload = record?.payload ?? {};
|
|
67
|
+
let reason = null;
|
|
68
|
+
let access = payload?.access;
|
|
69
|
+
if (!access) {
|
|
70
|
+
reason = "no-session";
|
|
71
|
+
} else {
|
|
72
|
+
const expMs = parseAccessExpiryMs(access);
|
|
73
|
+
const accessExpired = expMs !== null && expMs <= clock.now();
|
|
74
|
+
if (accessExpired && typeof payload.refresh === "string" && payload.refresh !== "") {
|
|
75
|
+
if (ephemeral && clock.now() < ephemeral.expiresAtMs) {
|
|
76
|
+
access = ephemeral.access; // a. 复用:不打续期
|
|
77
|
+
} else if (clock.now() < healBlockedUntil) {
|
|
78
|
+
reason = healBlockedReason ?? "access-expired"; // b. 负缓存:沿用上次失败原因
|
|
79
|
+
access = undefined;
|
|
80
|
+
} else {
|
|
81
|
+
try { // c. 续期:单次尝试,成功清负缓存
|
|
82
|
+
const minted = await refreshEphemeralAccess({ refreshToken: payload.refresh, enterpriseUrl: payload.enterpriseUrl, fetchImpl });
|
|
83
|
+
ephemeral = minted;
|
|
84
|
+
healBlockedUntil = 0;
|
|
85
|
+
healBlockedReason = null;
|
|
86
|
+
if (clock.now() < minted.expiresAtMs) access = minted.access;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
healBlockedUntil = clock.now() + HEAL_RETRY_MS;
|
|
89
|
+
healBlockedReason = error?.status === 401 || error?.status === 403 ? "token-rejected" : "network";
|
|
90
|
+
reason = healBlockedReason;
|
|
91
|
+
access = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else if (accessExpired) {
|
|
95
|
+
reason = "access-expired"; // 过期且无 refresh:无从续期,跳过 live GET
|
|
96
|
+
access = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (access) {
|
|
100
|
+
try {
|
|
101
|
+
const ids = await fetchLiveAvailableModelIds({ credential: { ...payload, access }, fetchImpl });
|
|
102
|
+
const fetchedAt = iso(clock.now()); // 单次取时:缓存值与返回值同源(避免毫秒漂移)
|
|
103
|
+
mkdirSync(scope.dataDir, { recursive: true });
|
|
104
|
+
writeJsonAtomic(cacheFile, { ids, fetchedAt, intentVersion: iv });
|
|
105
|
+
return { source: "live", ids, fetchedAt, stale: false, reason: null };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
reason = error?.status === 401 || error?.status === 403 ? "token-rejected" : "network";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const cache = readCache();
|
|
111
|
+
if (cache && Array.isArray(cache.ids) && typeof cache.fetchedAt === "string"
|
|
112
|
+
&& Number.isFinite(Date.parse(cache.fetchedAt))
|
|
113
|
+
&& clock.now() - Date.parse(cache.fetchedAt) <= CACHE_TTL_MS
|
|
114
|
+
&& cache.intentVersion === iv) {
|
|
115
|
+
return { source: "cache", ids: cache.ids, fetchedAt: cache.fetchedAt, stale: false, reason };
|
|
116
|
+
}
|
|
117
|
+
return { source: "stale", ids: Array.isArray(cache?.ids) ? cache.ids : [], fetchedAt: cache?.fetchedAt ?? null, stale: true, reason };
|
|
118
|
+
}
|
|
77
119
|
const getFetch = () => fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
78
120
|
const resolveCatalog = deps.resolveCatalog;
|
|
79
121
|
const describeConfigView = deps.describeConfigView;
|
|
@@ -140,7 +182,7 @@ export function createRefreshService(ctx, {
|
|
|
140
182
|
snapshots.set(snap.previewId, snap);
|
|
141
183
|
return projectPreview(snap);
|
|
142
184
|
}
|
|
143
|
-
const evidence = await resolveAccountEvidence(
|
|
185
|
+
const evidence = await resolveAccountEvidence({ clock, fetchImpl: getFetch() });
|
|
144
186
|
const catalog = await resolveCatalog(catalogSource);
|
|
145
187
|
const config = await describeConfigView();
|
|
146
188
|
const intentVersion = readIntentVersion();
|
|
@@ -174,7 +216,7 @@ export function createRefreshService(ctx, {
|
|
|
174
216
|
operationId: snap.operationId,
|
|
175
217
|
operation: snap.operation,
|
|
176
218
|
expiresAt: iso(snap.expiresAt),
|
|
177
|
-
evidence: { source: snap.evidence.source, fetchedAt: snap.evidence.fetchedAt, stale: snap.evidence.stale },
|
|
219
|
+
evidence: { source: snap.evidence.source, fetchedAt: snap.evidence.fetchedAt, stale: snap.evidence.stale, reason: snap.evidence.reason ?? null },
|
|
178
220
|
catalogSource: snap.catalog.catalogSource,
|
|
179
221
|
catalogError: snap.catalog.catalogError ?? null,
|
|
180
222
|
skipped: snap.catalog.skipped ?? [],
|
|
@@ -280,6 +322,9 @@ export function createRefreshService(ctx, {
|
|
|
280
322
|
|
|
281
323
|
function dispose() {
|
|
282
324
|
snapshots.clear();
|
|
325
|
+
ephemeral = null;
|
|
326
|
+
healBlockedUntil = 0;
|
|
327
|
+
healBlockedReason = null;
|
|
283
328
|
}
|
|
284
329
|
|
|
285
330
|
// 诊断/测试观察面:当前内存快照数(含未过期;过期项在下次 preview 入口清扫)
|