@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 +20 -6
- package/lib/client.js +332 -4
- package/package.json +10 -3
- package/src/atomic-json.mjs +89 -0
- package/src/catalog-fetch.mjs +98 -0
- package/src/catalog-overlay.json +115 -0
- package/src/catalog.mjs +156 -0
- package/src/copilot-models.mjs +113 -0
- package/src/host.mjs +505 -16
- package/src/refresh-flow.mjs +127 -0
- package/src/shared.mjs +2 -0
- package/src/state.mjs +98 -0
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**:
|
|
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 上开
|
|
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
|
|
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 半区:登录状态机、
|
|
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
|
-
-
|
|
106
|
-
- 模型目录只写入 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.
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
+
}
|