@inventec/dsh-copilot-auth 1.2.20 → 1.2.22

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
@@ -1,10 +1,9 @@
1
1
  # @inventec/dsh-copilot-auth
2
2
 
3
- [![Release](https://img.shields.io/github/v/release/iasiv5/dsh-copilot-auth?label=Release&sort=semver)](https://github.com/iasiv5/dsh-copilot-auth/releases)
4
3
  [![npm](https://img.shields.io/npm/v/%40inventec%2Fdsh-copilot-auth?label=npm)](https://www.npmjs.com/package/@inventec/dsh-copilot-auth)
5
4
  [![CI](https://img.shields.io/github/actions/workflow/status/iasiv5/dsh-copilot-auth/ci.yml?branch=main&label=CI)](https://github.com/iasiv5/dsh-copilot-auth/actions/workflows/ci.yml)
6
5
  [![License](https://img.shields.io/github/license/iasiv5/dsh-copilot-auth?label=License)](https://github.com/iasiv5/dsh-copilot-auth/blob/main/LICENSE)
7
- [![DSH](https://img.shields.io/badge/DSH-0.1.7--rc.2%20%7C%200.2.0--rc.1%2Frc.2%20verified-2563eb)](#版本兼容)
6
+ [![DSH](https://img.shields.io/badge/DSH-0.2.1--rc.3%20verified-2563eb)](#版本兼容)
8
7
 
9
8
  为 DSH(DeepSeek Harness)内置的 GitHub Copilot 通道补上**网页设备码登录 / 注销**入口,预置开箱即用的 Copilot 提供方路由,并提供**模型列表管理**。用公司 GitHub 账号(带 Copilot 订阅)登录 DSH 的方式与其他 Copilot 客户端一致:**网页 + 设备码**,全程不填 API Token。
10
9
 
@@ -54,7 +53,7 @@ dsh plugin --profile web remove @inventec/dsh-copilot-auth # 卸载
54
53
  | 📦 模型目录兜底 | 首次登录自动写入账号全部可用模型;你已精简 / 定制过的目录**永不覆盖**(重启、重新登录均不重置) |
55
54
  | 🎛️ 管理模型列表 | 目标状态编辑:勾选即终态,支持新增、移除、挽留与「对齐账号与目录」(见下节) |
56
55
  | 🌐 中 / 英双语 | 跟随 DSH 语言设置自动切换 |
57
- | 🧪 测试与 CI | 210 条单元测试(ubuntu / windows / macos 三平台矩阵);tag 触发 npm Trusted Publishing 发布(provenance) |
56
+ | 🧪 测试与 CI | 224 条单元测试(ubuntu / windows / macos 三平台矩阵);tag 触发 npm Trusted Publishing 发布(provenance) |
58
57
 
59
58
  ## 管理模型列表
60
59
 
@@ -64,9 +63,9 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型在本机
64
63
 
65
64
  - **勾选即终态**:一张分组全表——**可新增**(账号可用 ∩ 目录可解析,默认不勾)、**在列**(默认勾选)、**待删除候选**(在列但账号未报告 / 目录无法解析,默认不勾=删除提案,重新勾选即**挽留**)。应用时被保留模型的对象、顺序与定制逐字保留,被移除模型的定制随模型消失。
66
65
  - **快捷动作**:「全选 / 取消全选」作用于全表;「**对齐账号与目录**」仅在漂移时出现,一键把勾选态设为账号 ∩ 可解析;「**同时清除全部模型定制**」复选框仅在存在可清除定制时出现;摘要行常驻显示「应用后共 N 个:保留 · 新增 · 移除」。
67
- - **证据门控按实际 diff**:纯新增可用 24 小时内同授权的缓存;**移除类变更强制实时账号证据 + 最新目录并二次确认**(缓存下勾选删除会自动升级为实时预览);空目标需单独确认,落显式空列表且不会被登录自动回填。
66
+ - **证据门控按实际 diff**:纯新增可用 24 小时内同授权的缓存;**移除类变更强制实时账号证据 + 最新目录并二次确认**(缓存下勾选删除会自动升级为实时预览);空目标需单独确认,落显式空列表且不会被登录自动回填。移除所需的实时证据会在本地会话令牌过期时**自动续期**(仅内存、不落库);续期仍失败时,来源行会给出原因与出路(先用一次模型 / 重新授权 / 稍后重试)。
68
67
  - **生效时机**:web / 服务形态先做原子只增补丁、**重启后**生效;desktop(只读 `app.asar`)走进程内注册表注入,**当次生效、无需重启**、不碰官方产物(ADR 0003)。
69
- - **并发与恢复**:每个操作带服务端签发的 operationId(幂等可查、绝不重复执行);配置并发编辑永远优先(漂移返回 409 要求重新预览);崩溃 / 重启后可恢复,出现冲突时提供「结束旧配置意图」显式闭环。
68
+ - **并发与恢复**:每个操作带服务端签发的 operationId(幂等可查、绝不重复执行);配置并发编辑永远优先(漂移返回 409 要求重新预览);崩溃 / 重启后可恢复,出现冲突时提供「结束旧配置意图」显式闭环。apply 撞上卡死的旧操作(423)且该操作已 `recovery-needed` 时,弹窗与刷新后的页面都会提供同一「结束旧配置意图」出口(know-how 032 缺口闭环)。
70
69
 
71
70
  ## 工作原理
72
71
 
@@ -80,10 +79,10 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型在本机
80
79
 
81
80
  | 插件线 | DSH 实测版本 | 说明 |
82
81
  |---|---|---|
83
- | **v1.2.x** | `0.1.7-rc.2` ~ `0.2.0-rc.2` | 当前线;`0.2.0` 起注意插件 peer 精确版本守卫 |
82
+ | **v1.2.x** | `0.1.7-rc.2`、`0.2.0-rc.1/rc.2`、`0.2.1-rc.2/rc.3` | 当前线;`0.2.0` 起注意插件 peer 精确版本守卫;rc.3 Web 冒烟:`/copilot-auth/status` 200、`activated=true`、`settingsNotServed=[]`、`lastError=null` |
84
83
  | v1.1.x | `0.1.2-rc.1` ~ `0.1.5-rc.2` | 旧版线;依赖 v1.2.0 已移除的 authorization 挂载补丁,新版 DSH 上无法激活 |
85
84
 
86
- 逐版本沿革(v1.2.0 ~ v1.2.20 完整说明与已移除能力的语义记录)见 [docs/CHANGELOG.md](./docs/CHANGELOG.md) 与 [GitHub Releases](https://github.com/iasiv5/dsh-copilot-auth/releases)。
85
+ 逐版本沿革(v1.2.0 ~ v1.2.20 完整说明与已移除能力的语义记录)见 [docs/CHANGELOG.md](./docs/CHANGELOG.md)。
87
86
 
88
87
  ## FAQ
89
88
 
@@ -121,7 +120,7 @@ base 模型(GPT-4o / 4.1 一类)不耗 premium requests;premium 模型(C
121
120
  - 自行 patch `llm-pi-ai` 整段 config 会覆盖预置路由;`settings.yaml` 的 `llm-pi-ai:` 节只能稀疏覆盖字段,无法删除预置路由本身(移除须卸载本插件)
122
121
  - 路由前缀 `/copilot-auth` 两侧硬编码,不可配置
123
122
  - 模型目录「**只增不更新**」:合并只补充本机从未见过的条目,上游对已有 id 的元数据修正不会同步(ADR 0001)
124
- - DSH rc 版本耦合:实测 `0.1.7-rc.2` 与 `0.2.0-rc.1 / rc.2`,peer 仅 `@deepseek-ai/cordis@^4.0.2`
123
+ - DSH rc 版本耦合:实测 `0.1.7-rc.2`、`0.2.0-rc.1/rc.2` 与 `0.2.1-rc.2/rc.3`;rc.3 Web 冒烟见「版本兼容」,peer 仅 `@deepseek-ai/cordis@^4.0.2`
125
124
  - DSH 升级后的目录自愈仅在 pi-ai 基线版本不变时按已确认条目只增重放;跨基线且条目缺失时上报 `self-heal-incompatible`,不修改安装树(重新执行一次手动刷新即可在新基线上激活)
126
125
  - 并发保护为宿主单进程内 mutex,不支持多实例 / 多进程并发操作同一安装目录
127
126
  - 目录 fsync 仅在 POSIX 执行:Windows 无目录 fsync 语义(必然 `EPERM`),直接跳过,一致性由 NTFS 元数据日志保证(v1.2.3 起)
@@ -131,7 +130,7 @@ base 模型(GPT-4o / 4.1 一类)不耗 premium requests;premium 模型(C
131
130
 
132
131
  ```bash
133
132
  npm install
134
- npm test # node:test:210 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
133
+ npm test # node:test:224 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
135
134
  npm run build # esbuild 打包 client 到 lib/client.js(__ModuleLoader__ 信封)
136
135
  npm pack --dry-run
137
136
  npm run capture # 截取 README 预览图(需本机 DSH 实例;详见脚本头注)
package/icon.svg ADDED
@@ -0,0 +1,18 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="dsh-copilot-auth">
2
+ <!-- dsh-copilot-auth 认证:斜向钥匙(环尾含青色轴点)= 设备码登录与凭据入库 -->
3
+ <circle cx="12.5" cy="12.5" r="6" stroke="url(#b)" stroke-width="4" fill="none"/>
4
+ <circle cx="12.5" cy="12.5" r="2.2" fill="url(#t)"/>
5
+ <path d="M17 17L27.5 27.5" stroke="url(#b)" stroke-width="4" stroke-linecap="round"/>
6
+ <path d="M24 24L27.5 20.5" stroke="url(#b)" stroke-width="3.4" stroke-linecap="round"/>
7
+ <path d="M27.5 27.5L31 24" stroke="url(#b)" stroke-width="3.4" stroke-linecap="round"/>
8
+ <defs>
9
+ <linearGradient id="b" x1="6.5" y1="6.5" x2="31" y2="27.5" gradientUnits="userSpaceOnUse">
10
+ <stop stop-color="#69B9FF"/>
11
+ <stop offset="1" stop-color="#324DE2"/>
12
+ </linearGradient>
13
+ <linearGradient id="t" x1="10.3" y1="10.3" x2="14.7" y2="14.7" gradientUnits="userSpaceOnUse">
14
+ <stop stop-color="#45E7A4"/>
15
+ <stop offset="1" stop-color="#05909D"/>
16
+ </linearGradient>
17
+ </defs>
18
+ </svg>
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") return [{ name: "result", result: state.result, retireError: event.error }, null];
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,8 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.2.20",
4
- "description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.19:管理模型列表 v3 目标状态编辑(分组全表/挽留/对齐/清空语义告知)+单次确认+移除请求撤回+退出残影修复。v1.2.18:授权失败附网络失败码+人性化提示。适配 DSH 0.1.7+(≤0.1.5 请用 1.1.x)。",
3
+ "version": "1.2.22",
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
+ "icon": "./icon.svg",
5
6
  "type": "module",
6
7
  "license": "MIT",
7
8
  "author": "iasi",
@@ -38,7 +39,8 @@
38
39
  "src/model-update.mjs",
39
40
  "src/refresh-service.mjs",
40
41
  "src/refresh-transaction.mjs",
41
- "src/catalog-overlay.json"
42
+ "src/catalog-overlay.json",
43
+ "icon.svg"
42
44
  ],
43
45
  "scripts": {
44
46
  "build": "node scripts/build-client.mjs",
@@ -59,6 +61,9 @@
59
61
  "yaml": "^2.5.0"
60
62
  },
61
63
  "dsh": {
64
+ "engines": {
65
+ "dsh": ">=0.1.7-rc.2"
66
+ },
62
67
  "bundle": {
63
68
  "patch": "./cordis.patch.yml"
64
69
  },
@@ -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 获取/刷新/policy 修改;语义漂移或任何失败一律由调用方回退凭证缓存
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
- throw new Error(`${response.status} ${response.statusText}: GET ${baseUrl}/models`);
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
+ }
@@ -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
- return [{ name: "retiring", operationId: state.result?.operationId, result: state.result }, { type: "retire", operationId: state.result?.operationId }];
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") return [{ name: "result", result: state.result, retireError: event.error }, null];
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:
@@ -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(ctx, { scope, readIntentVersion, clock, fetchImpl: getFetch() });
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 入口清扫)