@inventec/dsh-copilot-auth 1.0.2 → 1.1.1

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
@@ -19,9 +19,10 @@
19
19
  - 🔐 **设备码登录/注销**:网页 + user code,免 API Token;SSO 组织授权友好
20
20
  - 🧩 **预置提供方路由**:安装即出现在 Models 页,无需手动配置
21
21
  - 📦 **模型目录兜底填充**:登录成功时,若 GitHub Copilot 路由还没有模型目录,自动填入账号全部可用模型;你已精简/定制过的目录**永不覆盖**(重启、重新登录均不重置)
22
+ - 🔄 **手动补充新模型目录条目**:GHC 设置页一键刷新——diff 预览 + 二次确认 + 数据级目录补丁(**pi-ai 版本不动**),新模型(如 gemini-3.8-flash)即可加入、失效模型镜像移除
22
23
  - 🌐 **中/英双语界面**:跟随 DSH 语言设置自动切换
23
24
  - 🔑 **凭据安全托管**:存入 DSH 内置凭据库(文件强制 0600 权限),Copilot 临时 token 到期自动刷新
24
- - 🧪 **测试与 CI**:15 条单元测试;GitHub Actions 构建测试 + tag 触发自动发布(provenance)
25
+ - 🧪 **测试与 CI**:105 条单元测试;GitHub Actions 构建测试 + tag 触发自动发布(provenance)
25
26
 
26
27
  ## 前置要求
27
28
 
@@ -62,11 +63,22 @@ dsh plugin --profile web add @inventec/dsh-copilot-auth
62
63
  - 注销:设置页「注销」按钮(清除本机凭据记录)
63
64
  - 卸载:`dsh plugin --profile web remove @inventec/dsh-copilot-auth` 后重启 dsh web
64
65
 
66
+ ## 模型目录刷新
67
+
68
+ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 gemini-3.8-flash)在本机无法解析、失效模型残留。「刷新可用模型目录」按钮(GHC 设置页,登录后可见)在不升级 pi-ai 的前提下解决这一问题。
69
+
70
+ - **两阶段生效**:确认后先对本机 `github-copilot.json` 做原子数据级补丁(pi-ai 代码版本不变);**重启 dsh web 后**插件再把 settings 模型目录镜像重建为「账号可用 ∩ 目录可解析」集合(目录在模块加载时读取,同 boot 内写完目录不会同步 settings)。
71
+ - **diff 预览 + 二次确认**:弹窗展示新增(绿)/移除(红)/保留计数、被校验跳过的上游条目、以及**将被重置的定制名单**(字段微调与 modelOverrides)。确认前请核对三条风险:失效模型将消失、你的定制将被重置、需重启后才生效。
72
+ - **激活边界**:插件首次安装/升级后处于未激活态,启动时不触碰 pi-ai 安装树;只有成功执行过一次手动刷新后才激活。DSH 升级冲掉补丁后,已激活实例启动时自愈重放**历史上实际应用过**的补丁条目(内置覆盖层仅作离线数据源,绝不自动应用)。
73
+ - **数据源与回退**:账号可用模型现场拉取(失败回退凭证缓存);目录数据从 npm 拉取最新 pi-ai tarball(强制 `dist.integrity` 校验、超时与大小上限、tar 严格解析),失败时可改用内置覆盖层(0.85.1 收割,integrity `sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==`)出 diff。
74
+ - **只增不更新**:合并只补充本机从未见过的模型条目;上游对已有 id 的元数据修正(api 归属、contextWindow 等)不会同步——这是「永不覆盖」语义的代价(ADR 0001)。
75
+ - **digest 绑定**:preview 与 apply 之间以输入 digest(settings 完整配置 / 可用模型 / 本地目录 / 目录来源)绑定,任何漂移即 409 并要求重新预览确认。
76
+
65
77
  ## 工作原理
66
78
 
67
79
  - 通过 `cordis.patch.yml` 三段生效:
68
80
  1. 挂载 `@deepseek-ai/dsh-authorization` 服务(DSH 内置 bundle 未挂载,不挂则内置登录流不存在)
69
- 2. 注册本插件(host 侧在 webserver 上开 4 条本地路由:`/copilot-auth/start|state|status|logout`,跨站 Origin 拒绝)
81
+ 2. 注册本插件(host 侧在 webserver 上开 6 条本地路由:`/copilot-auth/start|state|status|logout|refresh/preview|refresh/apply`,跨站 Origin 拒绝)
70
82
  3. 以 settings base 层预置 `github-copilot` 路由(用户 `settings.yaml` 可逐字段覆盖)
71
83
  - 登录走 GitHub 设备码流;凭据存 `~/.dsh/.credentials.yaml`(强制 600 权限)的 `llm-pi-ai/github-copilot` 记录,Copilot 临时 token 到期自动刷新
72
84
  - **模型目录兜底填充**:登录成功时,取「账号可用模型 ∩ pi-ai 内置目录」写入该路由的模型目录——仅当该路由尚未配置 `models` 且无 `modelOverrides` 时写入;目录已存在(含空列表)一律让路,插件启动/重启也绝不触碰用户 settings。同步失败经 `/copilot-auth/status` 的 `syncError` 字段暴露
@@ -75,7 +87,7 @@ dsh plugin --profile web add @inventec/dsh-copilot-auth
75
87
 
76
88
  ```bash
77
89
  npm install
78
- npm test # node:test:patch 结构 + host 行为共 15 条
90
+ npm test # node:test:patch 结构 + host/目录/状态机/启动序列共 105 条
79
91
  npm run build # esbuild 打包 client 到 lib/client.js(__ModuleLoader__ 信封)
80
92
  npm pack --dry-run
81
93
  ```
@@ -83,7 +95,7 @@ npm pack --dry-run
83
95
  | 路径 | 职责 |
84
96
  |---|---|
85
97
  | `cordis.patch.yml` | 三段 patch(authorization 挂载 / 本插件 entry / 预置路由) |
86
- | `src/host.mjs` · `src/shared.mjs` | host 半区:登录状态机、4 条本地路由、模型目录同步 |
98
+ | `src/host.mjs` · `src/shared.mjs` | host 半区:登录状态机、6 条本地路由、模型目录同步、刷新 preview/apply 与启动序列 |
87
99
  | `src/client.jsx` | client 半区:settings.section 插槽 + 设备码交互 + 双语文案 |
88
100
  | `scripts/build-client.mjs` | client 打包(`__ModuleLoader__` CJS 工厂信封) |
89
101
  | `lib/client.js` | 构建产物(入库,使 git 安装免构建) |
@@ -102,8 +114,10 @@ npm pack --dry-run
102
114
  - `settings.yaml` 的 `llm-pi-ai:` 节只能稀疏覆盖字段,无法删除预置路由本身(移除须卸载本插件)
103
115
  - 路由前缀 `/copilot-auth` 两侧硬编码,不可配置
104
116
  - DSH rc 版本耦合:实测 `0.1.2-rc.1`,peer 仅 `@deepseek-ai/cordis@^4.0.2`
105
- - 模型目录只在「尚不存在」时由登录成功兜底填充一次,填充后归用户所有:账号新增的模型不会自动出现,需在 Models 页手动添加,或删掉 `settings.yaml` 里该路由的 `models` 列表后**重新登录**重新填充全量(插件启动不触发填充)
106
- - 模型目录只写入 pi-ai 内置目录已描述的模型:目录快照外的新模型暂不写入(catalog 路由的校验要求模型协议可解析),pi-ai 升级目录后重新登录即可纳入
117
+ - 模型目录只在「尚不存在」时由登录成功兜底填充一次,填充后归用户所有:账号新增的模型不会自动出现——用 GHC 设置页的「**刷新可用模型目录**」同步(见上节),或在 Models 页手动添加
118
+ - 模型目录只写入 pi-ai 内置目录已描述的模型:目录快照外的新模型需先经「刷新可用模型目录」补入目录条目(数据级补丁,pi-ai 版本不变)才会出现在可选列表
119
+ - DSH 升级后自愈仅在 pi-ai 基线版本不变(0.84.4)时重放;跨版本且条目未原生存在时上报 `self-heal-incompatible`,不修改安装树(重新执行一次手动刷新即可在新基线上激活)
120
+ - 并发保护为宿主单进程内 mutex,**不支持多实例/多进程并发刷新**
107
121
 
108
122
  ## 维护者发布
109
123
 
package/lib/client.js CHANGED
@@ -31,6 +31,122 @@ __export(client_exports, {
31
31
  });
32
32
  module.exports = __toCommonJS(client_exports);
33
33
  var import_react = require("react");
34
+
35
+ // src/shared.mjs
36
+ var ROUTE_PREFIX = "/copilot-auth";
37
+ var routes = () => ({
38
+ start: `${ROUTE_PREFIX}/start`,
39
+ state: `${ROUTE_PREFIX}/state`,
40
+ status: `${ROUTE_PREFIX}/status`,
41
+ logout: `${ROUTE_PREFIX}/logout`,
42
+ refreshPreview: `${ROUTE_PREFIX}/refresh/preview`,
43
+ refreshApply: `${ROUTE_PREFIX}/refresh/apply`
44
+ });
45
+
46
+ // src/refresh-flow.mjs
47
+ var initial = Object.freeze({ name: "idle" });
48
+ function reduce(state, event) {
49
+ switch (state.name) {
50
+ case "idle":
51
+ case "failed":
52
+ case "restartNeeded": {
53
+ if (event.type === "start") {
54
+ return [{ name: "previewing", mode: event.mode, stale: false }, { type: "preview", mode: event.mode }];
55
+ }
56
+ if (state.name === "failed" && event.type === "dismiss") return [initial, null];
57
+ if (state.name === "idle" && event.type === "init") return [state, { type: "status" }];
58
+ if (event.type === "hydrate") {
59
+ const refresh = event.status?.refresh;
60
+ if (!event.status) return [state, null];
61
+ if (refresh?.lastError === "state-corrupt") return [{ name: "failed", error: "state-corrupt" }, null];
62
+ if (refresh?.pendingRestart === true) return [{ name: "restartNeeded" }, null];
63
+ return state.name === "idle" ? [state, null] : [state, null];
64
+ }
65
+ return [state, null];
66
+ }
67
+ case "previewing": {
68
+ if (event.type === "preview-ok") {
69
+ return [{ name: "confirming", preview: event.preview, mode: state.mode, staleNotice: state.stale === true }, null];
70
+ }
71
+ if (event.type === "preview-fail") return [{ name: "failed", error: event.error }, null];
72
+ return [state, null];
73
+ }
74
+ case "confirming": {
75
+ if (event.type === "start") {
76
+ return [{ name: "previewing", mode: event.mode, stale: false }, { type: "preview", mode: event.mode }];
77
+ }
78
+ if (event.type === "confirm") {
79
+ return [
80
+ { name: "applying", mode: state.mode, digests: state.preview.digests },
81
+ { type: "apply", mode: state.mode, digests: state.preview.digests }
82
+ ];
83
+ }
84
+ if (event.type === "cancel") return [initial, null];
85
+ return [state, null];
86
+ }
87
+ case "applying": {
88
+ if (event.type === "apply-ok") return [{ name: "restartNeeded" }, null];
89
+ if (event.type === "apply-stale") {
90
+ return [{ name: "previewing", mode: state.mode, stale: true }, { type: "preview", mode: state.mode }];
91
+ }
92
+ if (event.type === "apply-fail") return [{ name: "failed", error: event.error }, null];
93
+ return [state, null];
94
+ }
95
+ default:
96
+ return [state, null];
97
+ }
98
+ }
99
+ async function runEffect(effect, fetchImpl) {
100
+ const r = routes();
101
+ const post = (url, body) => fetchImpl(url, {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json" },
104
+ body: JSON.stringify(body)
105
+ });
106
+ if (effect.type === "preview") {
107
+ try {
108
+ const res = await post(r.refreshPreview, effect.mode ? { mode: effect.mode } : {});
109
+ const body = await res.json();
110
+ return res.status === 200 && body?.ok ? { type: "preview-ok", preview: body } : { type: "preview-fail", error: body?.error ?? `HTTP ${res.status}` };
111
+ } catch (err) {
112
+ return { type: "preview-fail", error: String(err?.message ?? err) };
113
+ }
114
+ }
115
+ if (effect.type === "apply") {
116
+ try {
117
+ const res = await post(r.refreshApply, {
118
+ digests: effect.digests,
119
+ ...effect.mode ? { mode: effect.mode } : {}
120
+ });
121
+ const body = await res.json().catch(() => null);
122
+ if (res.status === 409) return { type: "apply-stale" };
123
+ return res.status === 200 && body?.ok ? { type: "apply-ok" } : { type: "apply-fail", error: body?.error ?? `HTTP ${res.status}` };
124
+ } catch (err) {
125
+ return { type: "apply-fail", error: String(err?.message ?? err) };
126
+ }
127
+ }
128
+ if (effect.type === "status") {
129
+ try {
130
+ const res = await fetchImpl(r.status);
131
+ return { type: "hydrate", status: await res.json() };
132
+ } catch {
133
+ return { type: "hydrate", status: null };
134
+ }
135
+ }
136
+ throw new Error(`unknown effect: ${effect.type}`);
137
+ }
138
+ async function advance(state, event, fetchImpl, onState) {
139
+ let [s, fx] = reduce(state, event);
140
+ onState?.(s);
141
+ while (fx) {
142
+ const resultEvent = await runEffect(fx, fetchImpl);
143
+ [s, fx] = reduce(s, resultEvent);
144
+ onState?.(s);
145
+ }
146
+ return s;
147
+ }
148
+
149
+ // src/client.jsx
34
150
  var import_jsx_runtime = require("react/jsx-runtime");
35
151
  var name = "copilot-auth-ui";
36
152
  var inject = ["slots", "locale"];
@@ -49,7 +165,34 @@ var DICTS = {
49
165
  codeHint: "Open the link below and enter this code to finish signing in:",
50
166
  copy: "Copy",
51
167
  copied: "Copied \u2713",
52
- unknown: "Unknown error"
168
+ unknown: "Unknown error",
169
+ refreshNow: "Refresh model catalog",
170
+ refreshTitle: "Refresh available model catalog",
171
+ refreshDesc: "This pulls your account's available models and the latest pi-ai catalog data, then applies a data-level, add-only patch to the local catalog (pi-ai version stays unchanged). Review the diff before confirming.",
172
+ riskRemoved: "Models no longer available to your account will DISAPPEAR from the model list after restart.",
173
+ riskReset: "Your customizations (trimmed model list, per-model field tweaks, modelOverrides) will be RESET to the mirrored set.",
174
+ riskRestart: "Takes effect only after a dsh web restart (two-phase: catalog patch now, settings sync on next boot).",
175
+ addedModels: "New models",
176
+ removedModels: "Removed (no longer available)",
177
+ keptModels: "Kept",
178
+ skippedModels: "Upstream entries skipped by validation",
179
+ customReset: "These customizations will be reset:",
180
+ customEntries: "field tweaks",
181
+ customOverrides: "modelOverrides",
182
+ confirmRefresh: "Confirm refresh",
183
+ cancel: "Cancel",
184
+ refreshing: "Fetching preview\u2026",
185
+ applying: "Applying\u2026",
186
+ restartNeeded: "Catalog patched. Restart dsh web to finish syncing the model list.",
187
+ stalePreview: "Inputs changed \u2014 review the new diff",
188
+ overlayBtn: "Preview with bundled overlay",
189
+ stateCorrupt: "State file was corrupted and quarantined; auto self-heal is disabled \u2014 run a refresh to re-activate.",
190
+ srcLive: "Account models: live fetch",
191
+ srcCache: "Account models: credential cache (live fetch failed)",
192
+ srcLatest: "Catalog source: latest pi-ai from npm",
193
+ srcLocal: "Catalog source: local only (npm fetch failed)",
194
+ srcOverlay: "Catalog source: bundled overlay (offline bootstrap)",
195
+ none: "(none)"
53
196
  },
54
197
  zh: {
55
198
  nav: "GHC\u8BBE\u7F6E",
@@ -65,7 +208,34 @@ var DICTS = {
65
208
  codeHint: "\u5728\u6D4F\u89C8\u5668\u6253\u5F00\u4E0B\u9762\u7684\u94FE\u63A5\uFF0C\u8F93\u5165\u8FD9\u4E32\u4EE3\u7801\u5B8C\u6210\u6388\u6743\uFF1A",
66
209
  copy: "\u590D\u5236",
67
210
  copied: "\u5DF2\u590D\u5236 \u2713",
68
- unknown: "\u672A\u77E5\u9519\u8BEF"
211
+ unknown: "\u672A\u77E5\u9519\u8BEF",
212
+ refreshNow: "\u5237\u65B0\u53EF\u7528\u6A21\u578B\u76EE\u5F55",
213
+ refreshTitle: "\u5237\u65B0\u53EF\u7528\u6A21\u578B\u76EE\u5F55",
214
+ refreshDesc: "\u5C06\u73B0\u573A\u62C9\u53D6\u8D26\u53F7\u53EF\u7528\u6A21\u578B\u4E0E\u6700\u65B0 pi-ai \u76EE\u5F55\u6570\u636E\uFF0C\u5BF9\u672C\u673A\u76EE\u5F55\u505A\u6570\u636E\u7EA7\u53EA\u589E\u8865\u4E01\uFF08pi-ai \u7248\u672C\u4E0D\u53D8\uFF09\u3002\u786E\u8BA4\u524D\u8BF7\u6838\u5BF9\u4E0B\u65B9\u5DEE\u5F02\u3002",
215
+ riskRemoved: "\u5931\u6548\u6A21\u578B\uFF08\u8D26\u53F7\u5DF2\u4E0D\u518D\u53EF\u7528\u7684\u6A21\u578B\uFF09\u91CD\u542F\u540E\u5C06\u4ECE\u6A21\u578B\u5217\u8868\u6D88\u5931\u3002",
216
+ riskReset: "\u4F60\u7684\u5B9A\u5236\uFF08\u7CBE\u7B80\u88C1\u526A\u3001\u6761\u76EE\u5B57\u6BB5\u5FAE\u8C03\u3001modelOverrides\uFF09\u5C06\u88AB\u91CD\u7F6E\u4E3A\u955C\u50CF\u96C6\u5408\u3002",
217
+ riskRestart: "\u9700\u91CD\u542F dsh web \u540E\u751F\u6548\uFF08\u4E24\u9636\u6BB5\uFF1A\u73B0\u5728\u5199\u76EE\u5F55\u8865\u4E01\uFF0C\u4E0B\u4E2A boot \u540C\u6B65 settings\uFF09\u3002",
218
+ addedModels: "\u65B0\u589E\u6A21\u578B",
219
+ removedModels: "\u79FB\u9664\uFF08\u5DF2\u5931\u6548\uFF09",
220
+ keptModels: "\u4FDD\u7559",
221
+ skippedModels: "\u88AB\u6821\u9A8C\u8DF3\u8FC7\u7684\u4E0A\u6E38\u6761\u76EE",
222
+ customReset: "\u4EE5\u4E0B\u5B9A\u5236\u5C06\u88AB\u91CD\u7F6E\uFF1A",
223
+ customEntries: "\u5B57\u6BB5\u5FAE\u8C03",
224
+ customOverrides: "modelOverrides",
225
+ confirmRefresh: "\u786E\u8BA4\u5237\u65B0",
226
+ cancel: "\u53D6\u6D88",
227
+ refreshing: "\u62C9\u53D6\u9884\u89C8\u4E2D\u2026",
228
+ applying: "\u5E94\u7528\u4E2D\u2026",
229
+ restartNeeded: "\u76EE\u5F55\u8865\u4E01\u5DF2\u5199\u5165\u3002\u91CD\u542F dsh web \u540E\u5B8C\u6210\u6A21\u578B\u5217\u8868\u540C\u6B65\u3002",
230
+ stalePreview: "\u6570\u636E\u5DF2\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u786E\u8BA4\u5DEE\u5F02",
231
+ overlayBtn: "\u6539\u7528\u5185\u7F6E\u8986\u76D6\u5C42\u9884\u89C8",
232
+ stateCorrupt: "\u72B6\u6001\u6587\u4EF6\u5DF2\u635F\u574F\u5E76\u88AB\u9694\u79BB\uFF1B\u81EA\u52A8\u81EA\u6108\u5DF2\u505C\u7528\uFF0C\u8BF7\u91CD\u65B0\u6267\u884C\u4E00\u6B21\u5237\u65B0\u4EE5\u6FC0\u6D3B\u3002",
233
+ srcLive: "\u8D26\u53F7\u6A21\u578B\uFF1A\u73B0\u573A\u62C9\u53D6",
234
+ srcCache: "\u8D26\u53F7\u6A21\u578B\uFF1A\u51ED\u8BC1\u7F13\u5B58\uFF08\u73B0\u573A\u62C9\u53D6\u5931\u8D25\uFF09",
235
+ srcLatest: "\u76EE\u5F55\u6765\u6E90\uFF1Anpm \u6700\u65B0 pi-ai",
236
+ srcLocal: "\u76EE\u5F55\u6765\u6E90\uFF1A\u4EC5\u672C\u5730\u76EE\u5F55\uFF08npm \u62C9\u53D6\u5931\u8D25\uFF09",
237
+ srcOverlay: "\u76EE\u5F55\u6765\u6E90\uFF1A\u5185\u7F6E\u8986\u76D6\u5C42\uFF08\u79BB\u7EBF bootstrap\uFF09",
238
+ none: "\uFF08\u65E0\uFF09"
69
239
  }
70
240
  };
71
241
  var NAV_TEXTS = Object.keys(DICTS).map((locale) => DICTS[locale].nav);
@@ -137,15 +307,146 @@ var styles = {
137
307
  codeRow: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" },
138
308
  code: { fontSize: 24, fontWeight: 700, letterSpacing: 2, fontVariantNumeric: "tabular-nums" },
139
309
  link: { fontSize: 13, color: "#4c6ef5" },
140
- error: { margin: 0, fontSize: 13, lineHeight: "20px", color: "#e03131" }
310
+ error: { margin: 0, fontSize: 13, lineHeight: "20px", color: "#e03131" },
311
+ banner: { margin: 0, fontSize: 13, lineHeight: "20px", color: "#f59f00" },
312
+ modalMask: {
313
+ position: "fixed",
314
+ inset: 0,
315
+ background: "rgba(0,0,0,0.45)",
316
+ zIndex: 1e3,
317
+ display: "flex",
318
+ alignItems: "center",
319
+ justifyContent: "center",
320
+ padding: 24
321
+ },
322
+ // 弹窗背景/文字由 sampleThemeSurface() 在渲染时采样覆盖(皮肤可能把 bg-base
323
+ // 做成半透明磨砂,弹窗必须不透明);此处仅为兜底
324
+ modal: {
325
+ background: "var(--dsw-alias-bg-base, #fff)",
326
+ color: "var(--dsw-alias-label-primary, inherit)",
327
+ border: "1px solid rgba(128,128,128,0.35)",
328
+ borderRadius: 12,
329
+ maxWidth: 560,
330
+ width: "100%",
331
+ maxHeight: "80vh",
332
+ overflowY: "auto",
333
+ padding: "18px 20px",
334
+ display: "flex",
335
+ flexDirection: "column",
336
+ gap: 10,
337
+ boxShadow: "0 8px 32px rgba(0,0,0,0.35)"
338
+ },
339
+ modalTitle: { margin: 0, fontSize: 15, fontWeight: 600 },
340
+ modalText: { margin: 0, fontSize: 13, lineHeight: "20px", opacity: 0.85 },
341
+ diffList: { margin: 0, paddingLeft: 18, fontSize: 13, lineHeight: "20px" },
342
+ added: { color: "#37b24d" },
343
+ removed: { color: "#e03131" },
344
+ skipped: { color: "#f59f00" },
345
+ risk: { margin: 0, fontSize: 12, lineHeight: "18px", color: "#e8590c" },
346
+ modalActions: { display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 4 },
347
+ mono: { fontFamily: "ui-monospace, monospace" }
141
348
  };
142
349
  var badgeColor = { idle: "#adb5bd", running: "#f59f00", authorized: "#37b24d", failed: "#e03131", loading: "#adb5bd" };
350
+ function sampleThemeSurface() {
351
+ if (typeof document === "undefined") return { bg: "#fff", fg: "inherit" };
352
+ const cs = getComputedStyle(document.body);
353
+ const m = cs.backgroundColor.match(/rgba?\(([^)]+)\)/);
354
+ const bg = m ? `rgb(${m[1].split(",").slice(0, 3).join(",")})` : "#fff";
355
+ return { bg, fg: cs.color || "inherit" };
356
+ }
357
+ function RefreshModal({ t, flow, onConfirm, onCancel, onOverlay }) {
358
+ const p = flow.preview;
359
+ const surface = (0, import_react.useRef)(null);
360
+ if (!surface.current) surface.current = sampleThemeSurface();
361
+ const sourceKeys = [
362
+ p.source === "live" ? "srcLive" : "srcCache",
363
+ p.catalogSource === "latest" ? "srcLatest" : p.catalogSource === "overlay" ? "srcOverlay" : "srcLocal"
364
+ ];
365
+ const list = (ids, style) => ids.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { opacity: 0.6 }, children: t("none") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { style: styles.diffList, children: ids.map((id) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { style: { ...style, ...styles.mono }, children: id }, id)) });
366
+ const resets = [
367
+ ...p.customizationReset.modelEntryIds.map((id) => `${id} (${t("customEntries")})`),
368
+ ...p.customizationReset.modelOverrideIds.map((id) => `${id} (${t("customOverrides")})`)
369
+ ];
370
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.modalMask, role: "dialog", "aria-modal": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { ...styles.modal, background: surface.current.bg, color: surface.current.fg }, children: [
371
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h4", { style: styles.modalTitle, children: t("refreshTitle") }),
372
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.modalText, children: t("refreshDesc") }),
373
+ flow.staleNotice && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
374
+ "\u26A0 ",
375
+ t("stalePreview")
376
+ ] }),
377
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.modalText, children: sourceKeys.map((k) => t(k)).join(" \xB7 ") }),
378
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.modalText, children: [
379
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("addedModels") }),
380
+ "\uFF08",
381
+ p.added.length,
382
+ "\uFF09"
383
+ ] }),
384
+ list(p.added, styles.added),
385
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.modalText, children: [
386
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("removedModels") }),
387
+ "\uFF08",
388
+ p.removed.length,
389
+ "\uFF09"
390
+ ] }),
391
+ list(p.removed, styles.removed),
392
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.modalText, children: [
393
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("keptModels") }),
394
+ "\uFF08",
395
+ p.kept.length,
396
+ "\uFF09"
397
+ ] }),
398
+ p.skipped.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
399
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.modalText, children: [
400
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("skippedModels") }),
401
+ "\uFF08",
402
+ p.skipped.length,
403
+ "\uFF09"
404
+ ] }),
405
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { style: styles.diffList, children: p.skipped.map((s) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: styles.skipped, children: [
406
+ s.id,
407
+ " \u2014 ",
408
+ s.reason
409
+ ] }, s.id)) })
410
+ ] }),
411
+ resets.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
412
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.modalText, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("customReset") }) }),
413
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { style: styles.diffList, children: resets.map((x) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { style: styles.skipped, children: x }, x)) })
414
+ ] }),
415
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.risk, children: [
416
+ "\u26A0 ",
417
+ t("riskRemoved")
418
+ ] }),
419
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.risk, children: [
420
+ "\u26A0 ",
421
+ t("riskReset")
422
+ ] }),
423
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.risk, children: [
424
+ "\u26A0 ",
425
+ t("riskRestart")
426
+ ] }),
427
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.modalActions, children: [
428
+ p.catalogSource === "local" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary, marginRight: "auto" }, onClick: onOverlay, children: t("overlayBtn") }),
429
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary }, onClick: onCancel, children: t("cancel") }),
430
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.primary }, onClick: onConfirm, children: t("confirmRefresh") })
431
+ ] })
432
+ ] }) });
433
+ }
143
434
  function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
144
435
  const [page, setPage] = (0, import_react.useState)("loading");
145
436
  const [notices, setNotices] = (0, import_react.useState)([]);
146
437
  const [error, setError] = (0, import_react.useState)(void 0);
147
438
  const [copied, setCopied] = (0, import_react.useState)(false);
148
439
  const timer = (0, import_react.useRef)(null);
440
+ const [flow, setFlow] = (0, import_react.useState)(initial);
441
+ const flowRef = (0, import_react.useRef)(flow);
442
+ const drive = (event) => advance(flowRef.current, event, fetch, (next) => {
443
+ flowRef.current = next;
444
+ setFlow(next);
445
+ }).catch(() => {
446
+ });
447
+ (0, import_react.useEffect)(() => {
448
+ drive({ type: "init" });
449
+ }, []);
149
450
  (0, import_react.useEffect)(() => {
150
451
  let alive = true;
151
452
  fetch("/copilot-auth/status").then((r) => r.json()).then((d) => {
@@ -244,7 +545,34 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
244
545
  codeNotice.url && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", { style: styles.link, href: codeNotice.url, target: "_blank", rel: "noreferrer", children: codeNotice.url })
245
546
  ] }),
246
547
  page === "failed" && error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, children: error }),
247
- page === "authorized" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary }, onClick: logout, children: t("logout") }) }),
548
+ page === "authorized" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }, children: [
549
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary }, onClick: logout, children: t("logout") }),
550
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
551
+ "button",
552
+ {
553
+ type: "button",
554
+ style: { ...styles.button, ...styles.primary },
555
+ disabled: flow.name === "previewing" || flow.name === "applying",
556
+ onClick: () => drive({ type: "start" }),
557
+ children: flow.name === "previewing" ? t("refreshing") : flow.name === "applying" ? t("applying") : t("refreshNow")
558
+ }
559
+ )
560
+ ] }),
561
+ page === "authorized" && flow.name === "restartNeeded" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
562
+ "\u26A0 ",
563
+ t("restartNeeded")
564
+ ] }),
565
+ page === "authorized" && flow.name === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, children: flow.error === "state-corrupt" ? t("stateCorrupt") : flow.error }),
566
+ flow.name === "confirming" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
567
+ RefreshModal,
568
+ {
569
+ t,
570
+ flow,
571
+ onConfirm: () => drive({ type: "confirm" }),
572
+ onCancel: () => drive({ type: "cancel" }),
573
+ onOverlay: () => drive({ type: "start", mode: "overlay" })
574
+ }
575
+ ),
248
576
  (page === "idle" || page === "failed") && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.primary }, onClick: login, children: t("login") }) })
249
577
  ] });
250
578
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.0.2",
4
- "description": "Device-code sign-in UI and preconfigured route for the built-in GitHub Copilot provider of DeepSeek Harness (DSH)",
3
+ "version": "1.1.1",
4
+ "description": "为内置 Copilot 提供方补设备码登录/注销设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "iasi",
@@ -21,7 +21,14 @@
21
21
  "lib/client.js",
22
22
  "cordis.patch.yml",
23
23
  "README.md",
24
- "LICENSE"
24
+ "LICENSE",
25
+ "src/catalog.mjs",
26
+ "src/catalog-fetch.mjs",
27
+ "src/copilot-models.mjs",
28
+ "src/atomic-json.mjs",
29
+ "src/state.mjs",
30
+ "src/refresh-flow.mjs",
31
+ "src/catalog-overlay.json"
25
32
  ],
26
33
  "scripts": {
27
34
  "build": "node scripts/build-client.mjs",
@@ -0,0 +1,89 @@
1
+ // atomic-json.mjs — 所有 JSON 写盘的唯一通道。
2
+ // writeJsonAtomic: 同目录 temp(wx 排他创建)→ writeAll → fsyncFile → 重读 parse 校验
3
+ // → rename → fsyncDirectory(仅此处失败降级 warn)。rename 前任何失败清理 temp 并抛错,
4
+ // 原文件不动。第三参为语义 adapter 的部分覆盖(未覆盖键回落默认实现),供故障注入测试。
5
+ import { randomUUID } from "node:crypto";
6
+ import {
7
+ openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync, readFileSync,
8
+ } from "node:fs";
9
+ import { basename, dirname, join } from "node:path";
10
+
11
+ export function readJson(path) {
12
+ let raw;
13
+ try {
14
+ raw = readFileSync(path, "utf8");
15
+ } catch (err) {
16
+ if (err && err.code === "ENOENT") return undefined;
17
+ throw err;
18
+ }
19
+ return JSON.parse(raw);
20
+ }
21
+
22
+ // 短写循环补写;writeChunk 返回 <= 0 或非整数抛错防死循环(R4-2)。
23
+ export function writeAll(fd, buffer, writeChunk = writeSync) {
24
+ let offset = 0;
25
+ while (offset < buffer.length) {
26
+ const written = writeChunk(fd, buffer, offset, buffer.length - offset);
27
+ if (!Number.isInteger(written) || written <= 0) {
28
+ throw new Error("write made no progress");
29
+ }
30
+ offset += written;
31
+ }
32
+ }
33
+
34
+ const defaultFs = {
35
+ openTemp: (path) => openSync(path, "wx"),
36
+ writeAll: (fd, buf) => writeAll(fd, buf),
37
+ fsyncFile: (fd) => fsyncSync(fd),
38
+ closeFd: (fd) => closeSync(fd),
39
+ rename: (from, to) => renameSync(from, to),
40
+ fsyncDirectory: (dir) => {
41
+ const fd = openSync(dir, "r");
42
+ try {
43
+ fsyncSync(fd);
44
+ } finally {
45
+ closeSync(fd);
46
+ }
47
+ },
48
+ remove: (path) => unlinkSync(path),
49
+ };
50
+
51
+ export function writeJsonAtomic(path, obj, fs = {}) {
52
+ const a = { ...defaultFs, ...fs };
53
+ const dir = dirname(path);
54
+ const tempPath = join(dir, `.${basename(path)}.tmp-${randomUUID()}`);
55
+ const payload = Buffer.from(JSON.stringify(obj, null, 2) + "\n", "utf8");
56
+ let fd;
57
+ try {
58
+ fd = a.openTemp(tempPath);
59
+ a.writeAll(fd, payload);
60
+ a.fsyncFile(fd);
61
+ a.closeFd(fd);
62
+ fd = undefined;
63
+ JSON.parse(readFileSync(tempPath, "utf8")); // 提交前重读校验,坏内容不过 rename
64
+ a.rename(tempPath, path);
65
+ } catch (err) {
66
+ if (fd !== undefined) {
67
+ try { a.closeFd(fd); } catch { /* 清理失败不掩盖原始错误 */ }
68
+ }
69
+ try { a.remove(tempPath); } catch { /* temp 可能尚未创建 */ }
70
+ throw err;
71
+ }
72
+ try {
73
+ a.fsyncDirectory(dir);
74
+ } catch (err) {
75
+ // 目录项落盘失败仅降级告警:rename 已生效,语义如实成文(R3-7 ⑤)
76
+ console.warn(`[copilot-auth] fsyncDirectory failed for ${dir}: ${err}`);
77
+ }
78
+ }
79
+
80
+ // settled-tail mutex:回调拒绝后不中毒,后续任务仍串行执行(Y2-4)。
81
+ // 仅提供宿主单进程内互斥,不支持多实例/多进程并发(见 README 已知边界)。
82
+ export function createMutex() {
83
+ let tail = Promise.resolve();
84
+ return (fn) => {
85
+ const run = tail.then(fn, fn);
86
+ tail = run.catch(() => {});
87
+ return run;
88
+ };
89
+ }
@@ -0,0 +1,98 @@
1
+ // catalog-fetch.mjs — 最新 pi-ai 目录的受信获取通道。
2
+ // 分层校验契约(Y2-7):本层只管传输可信——integrity / 超时 / 流式大小上限 /
3
+ // 包膜结构;逐条目合法性唯一归 mergeCatalog 的 validateCatalog,本层不拦。
4
+ import { createHash } from "node:crypto";
5
+ import { extractTgzEntry } from "./catalog.mjs";
6
+
7
+ const PKG = "@earendil-works/pi-ai";
8
+ const DEFAULT_CAPS = { metadata: 20_000_000, tarball: 30_000_000, decompressed: 100_000_000 };
9
+ const MAX_REDIRECTS = 3;
10
+
11
+ // 流式限额读取(Y3-1):逐 chunk 累计,超限立即 cancel 并抛错——不得先无限缓冲再检查。
12
+ async function readCapped(res, cap) {
13
+ const chunks = [];
14
+ let total = 0;
15
+ for await (const chunk of res.body) {
16
+ total += chunk.length;
17
+ if (total > cap) {
18
+ try {
19
+ await res.body.cancel();
20
+ } catch { /* cancel 失败不掩盖超限错误 */ }
21
+ throw new Error(`response too large (cap ${cap} bytes)`);
22
+ }
23
+ chunks.push(chunk);
24
+ }
25
+ return Buffer.concat(chunks);
26
+ }
27
+
28
+ // URL 信任:仅 HTTPS,或与所配置 registry 同主机的 http(企业镜像例外)。
29
+ function assertUrlAllowed(rawUrl, registryHost) {
30
+ const u = new URL(rawUrl);
31
+ if (u.protocol === "https:") return u;
32
+ if (u.protocol === "http:" && u.host === registryHost) return u;
33
+ throw new Error(`disallowed url: ${rawUrl}`);
34
+ }
35
+
36
+ // 一律 redirect:"manual"(Y3-2):30x 逐跳校验 Location 的协议/host(同 tarball
37
+ // 规则),相对 Location 以当前 URL 解析,最多 3 跳,回环(重复 URL)即抛错。
38
+ async function fetchValidated(fetchImpl, url, registryHost) {
39
+ let current = url;
40
+ const seen = new Set([current]);
41
+ for (let redirects = 0; ; ) {
42
+ assertUrlAllowed(current, registryHost);
43
+ const res = await fetchImpl(current, { signal: AbortSignal.timeout(10_000), redirect: "manual" });
44
+ const location = res.headers?.get?.("location");
45
+ if (res.status >= 300 && res.status < 400 && location) {
46
+ redirects += 1;
47
+ if (redirects > MAX_REDIRECTS) throw new Error(`too many redirects (>${MAX_REDIRECTS}) from ${url}`);
48
+ const next = new URL(location, current).href;
49
+ if (seen.has(next)) throw new Error(`redirect loop detected: ${next}`);
50
+ seen.add(next);
51
+ try {
52
+ await res.body?.cancel?.();
53
+ } catch { /* redirect 响应体丢弃失败可忽略 */ }
54
+ current = next;
55
+ continue;
56
+ }
57
+ return res;
58
+ }
59
+ }
60
+
61
+ // fetchLatestCatalog({ registry, fetchImpl, caps? }) → { catalog, piAiVersion, integrity }
62
+ // caps 为测试注入钩子(默认 20MB metadata / 30MB tgz 压缩 / 100MB 解压)。
63
+ export async function fetchLatestCatalog({ registry, fetchImpl, caps = {} } = {}) {
64
+ if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl required");
65
+ const limit = { ...DEFAULT_CAPS, ...caps };
66
+ const reg = (registry ?? process.env.npm_config_registry ?? "https://registry.npmjs.org").replace(/\/+$/, "");
67
+ const registryHost = new URL(reg).host;
68
+
69
+ const metaRes = await fetchValidated(fetchImpl, `${reg}/${PKG}`, registryHost);
70
+ if (!metaRes.ok) throw new Error(`npm metadata -> ${metaRes.status}`);
71
+ const metadata = JSON.parse((await readCapped(metaRes, limit.metadata)).toString("utf8"));
72
+ const version = metadata?.["dist-tags"]?.latest;
73
+ const dist = version ? metadata?.versions?.[version]?.dist : undefined;
74
+ if (!version || !dist?.tarball || !dist?.integrity) {
75
+ throw new Error("npm metadata missing dist-tags.latest/versions[latest].dist");
76
+ }
77
+
78
+ const tgzRes = await fetchValidated(fetchImpl, dist.tarball, registryHost);
79
+ if (!tgzRes.ok) throw new Error(`npm tarball -> ${tgzRes.status}`);
80
+ const tgz = await readCapped(tgzRes, limit.tarball);
81
+
82
+ const actual = "sha512-" + createHash("sha512").update(tgz).digest("base64");
83
+ if (actual !== dist.integrity) {
84
+ throw new Error(`integrity mismatch for ${PKG}@${version}`);
85
+ }
86
+
87
+ const catalog = JSON.parse(extractTgzEntry(tgz, { maxBytes: limit.decompressed }).toString("utf8"));
88
+ // 包膜结构校验(本层职责):catalog 为对象且每个 section 为对象。
89
+ if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) {
90
+ throw new Error("catalog envelope invalid: root is not an object");
91
+ }
92
+ for (const [api, section] of Object.entries(catalog)) {
93
+ if (!section || typeof section !== "object" || Array.isArray(section)) {
94
+ throw new Error(`catalog envelope invalid: section ${api} is not an object`);
95
+ }
96
+ }
97
+ return { catalog, piAiVersion: version, integrity: dist.integrity };
98
+ }