@inventec/dsh-copilot-auth 1.2.7 → 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -25
- package/lib/client.js +806 -275
- package/package.json +9 -2
- package/src/auth-flow.mjs +190 -0
- package/src/auth-host.mjs +182 -0
- package/src/client-http.mjs +80 -0
- package/src/host.mjs +352 -635
- package/src/model-update.mjs +118 -0
- package/src/refresh-flow.mjs +200 -86
- package/src/refresh-service.mjs +276 -0
- package/src/refresh-transaction.mjs +573 -0
- package/src/runtime-scope.mjs +25 -0
- package/src/shared.mjs +3 -0
- package/src/state.mjs +61 -60
package/src/host.mjs
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
// host 半区:cordis 插件。注册
|
|
2
|
-
// OAuth 设备码流(ctx.authorization)暴露给 Web client
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
1
|
+
// host 半区:cordis 插件。注册 8 条 exact 路由,把内置 github-copilot 的
|
|
2
|
+
// OAuth 设备码流(ctx.authorization)暴露给 Web client(T3 授权控制器),并提供
|
|
3
|
+
// 「补充模型/重建模型列表」双入口(T8 预览服务 + T9 事务内核,协议 v2)。
|
|
4
|
+
// 数据级目录补丁(只增)与供应链守卫复用 catalog/catalog-fetch/catalog-registry;
|
|
5
|
+
// pi-ai 代码版本不动;只读 GET /models 适配显式耦合 pi-ai 0.84.4(见 GLOSSARY 与 ADR 0001)。
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { CREDENTIAL_KEY,
|
|
10
|
+
import { CREDENTIAL_KEY, PROTOCOL_VERSION, routes } from "./shared.mjs";
|
|
10
11
|
import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
|
|
11
|
-
import {
|
|
12
|
+
import { createAuthorizationController, sanitizeAuthError } from "./auth-host.mjs";
|
|
13
|
+
import { resolveRuntimeScope } from "./runtime-scope.mjs";
|
|
14
|
+
import { mergeCatalog, digest, findPiAiInstallation, classifyCatalogTarget, normalizeRemoteCatalog, catalogShapeKeys } from "./catalog.mjs";
|
|
12
15
|
import { fetchLatestCatalog } from "./catalog-fetch.mjs";
|
|
13
|
-
import { injectCatalogEntries, registryModelIds, registryVia, registryInstanceMismatch } from "./catalog-registry.mjs";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
+
import { injectCatalogEntries, registryModelIds, registryVia, registryInstanceMismatch, loadRegistryModules } from "./catalog-registry.mjs";
|
|
17
|
+
import { loadRefreshState, saveRefreshState } from "./state.mjs";
|
|
18
|
+
import { createRefreshService } from "./refresh-service.mjs";
|
|
19
|
+
import { createTransaction } from "./refresh-transaction.mjs";
|
|
16
20
|
|
|
17
21
|
export const name = "copilot-auth";
|
|
18
22
|
export const inject = ["webServer", "authorization", "credentials", "settings", "llm"];
|
|
19
23
|
|
|
20
|
-
// 内置覆盖层的收割来源版本(
|
|
24
|
+
// 内置覆盖层的收割来源版本(integrity 记录于 README「模型目录刷新」章节)
|
|
21
25
|
const OVERLAY_SOURCE_VERSION = "0.85.1";
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
const defaultStateFile = () => join(homedir(), ".dsh", "copilot-auth-state.json");
|
|
28
|
+
const defaultOverlayFile = () => fileURLToPath(new URL("./catalog-overlay.json", import.meta.url));
|
|
29
|
+
const realClock = { now: () => Date.now() };
|
|
30
|
+
|
|
31
|
+
// pi-ai 目录数据文件定位层:从进程入口逐级向上找 node_modules/@earendil-works/pi-ai。
|
|
25
32
|
function findCatalogFile() {
|
|
26
33
|
try {
|
|
27
34
|
let dir = dirname(realpathSync(process.argv?.[1] ?? ""));
|
|
@@ -36,10 +43,7 @@ function findCatalogFile() {
|
|
|
36
43
|
return null;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
// 读取 github-copilot 的「可解析模型 id →
|
|
40
|
-
// 两个来源的并集:① 安装树内的目录数据文件;② 进程内目录注册表(ADR 0003——desktop
|
|
41
|
-
// 只读安装树下,刷新出来的增量条目只存在于注册表,目录文件里永远看不到)。两者都
|
|
42
|
-
// 拿不到才返回 null(调用方回退 listModels 交集)。
|
|
46
|
+
// 读取 github-copilot 的「可解析模型 id → 协议」映射(目录文件 ∪ 进程内注册表)。
|
|
43
47
|
async function readCatalogModelIds(install, log) {
|
|
44
48
|
const byId = {};
|
|
45
49
|
try {
|
|
@@ -52,31 +56,14 @@ async function readCatalogModelIds(install, log) {
|
|
|
52
56
|
}
|
|
53
57
|
} catch { /* 安装树结构变化时回退 */ }
|
|
54
58
|
try {
|
|
55
|
-
// 注册表面只在**只读安装树**(desktop asar)上才有意义:可写安装树下目录文件就是
|
|
56
|
-
// 运行时加载的副本,多读一次模块只是白白把 pi-ai 的模块图拉进本进程。
|
|
57
59
|
const ids = install && install.writable !== true ? await registryModelIds(install, log) : null;
|
|
58
60
|
if (ids) for (const id of ids) byId[id] ??= "registry";
|
|
59
61
|
} catch { /* 注册表不可用不阻断同步 */ }
|
|
60
62
|
return Object.keys(byId).length > 0 ? byId : null;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// 可写集合取交集:凭据 payload.availableModelIds(账号可用)∩ 内置目录已描述。
|
|
66
|
-
// 目录快照外的 id 必须排除——catalog 路由校验要求模型的 wire 协议可解析
|
|
67
|
-
// (api = 路由设置 ?? 目录条目 ?? 路由共享协议),目录外 id 三者皆空会被整体
|
|
68
|
-
// 拒绝;pi-ai 目录更新后它们会自然进入可写集合。
|
|
69
|
-
// 目录保护(2026-09-05):该路由已配置模型目录(models 键存在,含空列表)或
|
|
70
|
-
// modelOverrides 时视作用户所有,同步直接让路——用户在 Models 页精简过的目录
|
|
71
|
-
// 不会被重置。只有目录尚不存在时才填充一次;想重置全量,删掉 settings.yaml
|
|
72
|
-
// 里该路由的 models 列表后重新登录即可。
|
|
73
|
-
|
|
74
|
-
// 读取解析后的 github-copilot 路由配置(base 层 + 用户层的最终值)。
|
|
75
|
-
// 0.1.7 起 settings.get(ns) 被移除(settings API 重塑,见 know-how 013 §3),
|
|
76
|
-
// 改经 describe():value 层(base+user 合成)语义等价旧 get;旧版 DSH 的
|
|
77
|
-
// describe 行无 value 时回退 user 层(用户定制目录恰在 user 层)。
|
|
78
|
-
// settings 未注入 / 未注册 / 读取失败一律按空处理——回退到可写入分支,
|
|
79
|
-
// 保持既有 turnkey 行为不变。
|
|
65
|
+
// 登录成功(handoff 完好)后首次填充:可写集合=账号可用 ∩ 内置目录已描述;
|
|
66
|
+
// 目录保护:models 键存在(含空列表)或 modelOverrides 视作用户所有,同步让路。
|
|
80
67
|
function readConfiguredRoute(ctx) {
|
|
81
68
|
try {
|
|
82
69
|
const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
@@ -102,15 +89,24 @@ async function syncAvailableModels(ctx, opts = {}) {
|
|
|
102
89
|
if (catalog) {
|
|
103
90
|
ids = available.filter((id) => catalog[id] !== undefined);
|
|
104
91
|
} else {
|
|
105
|
-
// 回退:listModels 反映当前已配置列表,存在「已配置→只见已配置」自锁,
|
|
106
|
-
// 仅作目录定位失败时的降级。
|
|
107
92
|
const served = new Set((await ctx.llm.listModels("github-copilot")).map((m) => m?.id).filter(Boolean));
|
|
108
93
|
ids = available.filter((id) => served.has(id));
|
|
109
94
|
}
|
|
110
95
|
if (ids.length === 0) return;
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
96
|
+
// T4 首次填充保护:提交前重核 handoff 仍完好(同 attempt、originIntentVersion 仍当前、
|
|
97
|
+
// 无撤回/超时标记)——取数 await 期间意图已变则只记录事实,不写配置。
|
|
98
|
+
if (opts.handoff) {
|
|
99
|
+
const intact = typeof opts.handoffIntact === "function" ? opts.handoffIntact(opts.handoff) : false;
|
|
100
|
+
if (!intact) {
|
|
101
|
+
// 计划 T4「记录跳过原因」:留可 grep 痕迹(attemptId/意图版本),不误报为失败
|
|
102
|
+
ctx.logger?.info?.(
|
|
103
|
+
"copilot-auth: first-fill skipped — handoff stale (attemptId=%s, originIntentVersion=%s)",
|
|
104
|
+
opts.handoff.attemptId ?? "?", opts.handoff.originIntentVersion ?? "?",
|
|
105
|
+
);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 真 CAS:携带 expectedRevision 提交,与用户并发编辑互斥(冲突抛 SETTINGS_CONFLICT)
|
|
114
110
|
const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
115
111
|
await ctx.settings.mutate("llm-pi-ai", [
|
|
116
112
|
{ op: "set", path: ["providers", "github-copilot", "models"], value: ids.map((id) => ({ id })) },
|
|
@@ -144,14 +140,6 @@ function guard(req, res, method) {
|
|
|
144
140
|
return true;
|
|
145
141
|
}
|
|
146
142
|
|
|
147
|
-
// ==================== 模型目录手动刷新(数据级目录补丁,ADR 0001) ====================
|
|
148
|
-
|
|
149
|
-
class InvalidModeError extends Error {}
|
|
150
|
-
|
|
151
|
-
const defaultStateFile = () => join(homedir(), ".dsh", "copilot-auth-state.json");
|
|
152
|
-
const defaultOverlayFile = () => fileURLToPath(new URL("./catalog-overlay.json", import.meta.url));
|
|
153
|
-
|
|
154
|
-
// 字面量 body 直接用(测试);否则按流读并 JSON.parse
|
|
155
143
|
function readBody(req) {
|
|
156
144
|
if (req.body !== undefined) return Promise.resolve(req.body);
|
|
157
145
|
return new Promise((resolve, reject) => {
|
|
@@ -169,8 +157,8 @@ function readBody(req) {
|
|
|
169
157
|
});
|
|
170
158
|
}
|
|
171
159
|
|
|
172
|
-
// 版本与 catalog
|
|
173
|
-
//
|
|
160
|
+
// 版本与 catalog 同根解析:注入 catalogFile 时 version 取其所在根的 package.json。
|
|
161
|
+
// 可写性分类(ADR 0002):desktop asar 形态只读——读正常、写必拒,结构化失败。
|
|
174
162
|
function resolveInstall(opts) {
|
|
175
163
|
let install;
|
|
176
164
|
if (opts.catalogFile) {
|
|
@@ -187,13 +175,9 @@ function resolveInstall(opts) {
|
|
|
187
175
|
if (!found) throw new Error("pi-ai installation not found");
|
|
188
176
|
install = found;
|
|
189
177
|
}
|
|
190
|
-
// 可写性门禁(ADR 0002):desktop asar 打包形态下 catalogFile 在只读归档内——读一切
|
|
191
|
-
// 正常、写必被拒。这里统一分类,apply/boot 据此结构化快速失败而非抛天书 ENOENT。
|
|
192
|
-
// opts.writableProbe 为测试注入钩子(生产恒用默认探测实现)。
|
|
193
178
|
return { ...install, ...classifyCatalogTarget(install.catalogFile, opts.writableProbe) };
|
|
194
179
|
}
|
|
195
180
|
|
|
196
|
-
// 定位失败不抛(boot/同步/settings 都必须能在安装树异常时降级)。
|
|
197
181
|
function safeInstall(opts = {}) {
|
|
198
182
|
try {
|
|
199
183
|
return resolveInstall(opts);
|
|
@@ -214,17 +198,7 @@ function readLocalCatalog(opts) {
|
|
|
214
198
|
};
|
|
215
199
|
}
|
|
216
200
|
|
|
217
|
-
|
|
218
|
-
async function resolveAvailable(ctx, fetchImpl) {
|
|
219
|
-
const record = await ctx.credentials.readRecord(CREDENTIAL_KEY);
|
|
220
|
-
const payload = record?.payload ?? {};
|
|
221
|
-
if (payload.access) {
|
|
222
|
-
try {
|
|
223
|
-
return { ids: await fetchLiveAvailableModelIds({ credential: payload, fetchImpl }), source: "live" };
|
|
224
|
-
} catch { /* 回退 cache */ }
|
|
225
|
-
}
|
|
226
|
-
return { ids: Array.isArray(payload.availableModelIds) ? payload.availableModelIds : [], source: "cache" };
|
|
227
|
-
}
|
|
201
|
+
class InvalidModeError extends Error {}
|
|
228
202
|
|
|
229
203
|
// R3-3 严格分支:仅 mode==="overlay" 读内置覆盖层;normal npm 失败只回
|
|
230
204
|
// catalogSource:"local"(绝不隐式读 overlay);未知 mode → 400。
|
|
@@ -232,8 +206,6 @@ async function resolveCatalogSource(opts, mode, fetchImpl, allowedKeys) {
|
|
|
232
206
|
if (mode === "overlay") {
|
|
233
207
|
const raw = readJson(opts.overlayFile ?? defaultOverlayFile());
|
|
234
208
|
if (raw === undefined) throw new Error("bundled overlay file missing");
|
|
235
|
-
// 形状规范化(ADR 0003):pi-ai ≥0.99.0 的目录用 `chat:<id>` 键,必须规范化回
|
|
236
|
-
// 裸 id + 本机字段集合,合并/校验/digest 才与 0.87.x 世代同语义。
|
|
237
209
|
const normalized = normalizeRemoteCatalog(raw, { allowedKeys });
|
|
238
210
|
return {
|
|
239
211
|
catalog: normalized.catalog,
|
|
@@ -265,9 +237,8 @@ async function resolveCatalogSource(opts, mode, fetchImpl, allowedKeys) {
|
|
|
265
237
|
}
|
|
266
238
|
}
|
|
267
239
|
|
|
268
|
-
// settings raw user
|
|
269
|
-
// 缺席一律 null 占位(非 undefined
|
|
270
|
-
// 与现算视图可逐字节比较;「未配置 vs 显式空」由 *Present 标志区分。
|
|
240
|
+
// settings 双视图(T8/T9 取数面):raw user 层 + base+user 合成层 + 同次 revision。
|
|
241
|
+
// 缺席一律 null 占位(非 undefined),保证持久化无损与逐字节比较可行。
|
|
271
242
|
function viewFromUserLayer(user) {
|
|
272
243
|
let route = {};
|
|
273
244
|
if (user && typeof user === "object" && !Array.isArray(user)) {
|
|
@@ -284,39 +255,25 @@ function viewFromUserLayer(user) {
|
|
|
284
255
|
};
|
|
285
256
|
}
|
|
286
257
|
|
|
287
|
-
function
|
|
258
|
+
function describeRouteView(ctx) {
|
|
259
|
+
const empty = { view: viewFromUserLayer(undefined), effectiveView: viewFromUserLayer(undefined), revision: null };
|
|
288
260
|
try {
|
|
289
261
|
const d = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (!view.modelsPresent || !Array.isArray(view.models)) return false;
|
|
299
|
-
if (view.models.some((m) => !m || typeof m !== "object" || Array.isArray(m) || Object.keys(m).some((k) => k !== "id"))) {
|
|
300
|
-
return false;
|
|
262
|
+
if (!d) return empty;
|
|
263
|
+
return {
|
|
264
|
+
view: viewFromUserLayer(d.user),
|
|
265
|
+
effectiveView: viewFromUserLayer(d.value ?? d.user),
|
|
266
|
+
revision: d.revision,
|
|
267
|
+
};
|
|
268
|
+
} catch {
|
|
269
|
+
return empty;
|
|
301
270
|
}
|
|
302
|
-
const ids = view.models.map((m) => m.id);
|
|
303
|
-
if ([...ids].sort().join("\0") !== [...targetIds].sort().join("\0")) return false;
|
|
304
|
-
if (view.modelOverridesPresent && view.modelOverrides && Object.keys(view.modelOverrides).length > 0) return false;
|
|
305
|
-
return true;
|
|
306
271
|
}
|
|
307
272
|
|
|
308
273
|
function overlayEntryIds(overlay) {
|
|
309
274
|
return Object.values(overlay ?? {}).flatMap((s) => Object.keys(s ?? {}));
|
|
310
275
|
}
|
|
311
276
|
|
|
312
|
-
// ==================== 注入后的"路由是否真的端出来"复核(ADR 0003 增补) ====================
|
|
313
|
-
// 为什么需要这一层:llm-pi-ai 在**挂载时**就构建快照,并把目录解析结果按 settings 配置
|
|
314
|
-
// 对象的身份 memoize(其 profiles() 只在 config.providers.get() 变身份时重建)。插件挂载
|
|
315
|
-
// 晚于它,动态 import pi-ai 模块图又要几秒——boot 期注入因此常常落在快照之后:注册表里
|
|
316
|
-
// 有条目,路由仍按旧快照把它判为"目录不描述"并丢弃。注入成功后必须用**宿主自己的**
|
|
317
|
-
// listModels 复核(不能用我们自己的模块实例自证,那是自指的),缺失就做一次
|
|
318
|
-
// **净零切换**触发快照重建——两次真内容变化的写,最终内容与用户原配置完全一致。
|
|
319
|
-
|
|
320
277
|
// 路由当前端出的模型 id 集合;宿主 API 不可用时返回 null(无从判断,不误报)。
|
|
321
278
|
async function servedModelIds(ctx) {
|
|
322
279
|
try {
|
|
@@ -326,14 +283,12 @@ async function servedModelIds(ctx) {
|
|
|
326
283
|
}
|
|
327
284
|
}
|
|
328
285
|
|
|
329
|
-
// 用户 settings 里当前引用的模型 id(只有它们才需要"端得出来")。
|
|
330
286
|
function configuredModelIds(ctx) {
|
|
331
287
|
const models = readConfiguredRoute(ctx).models;
|
|
332
288
|
return new Set((Array.isArray(models) ? models : []).map((m) => m?.id).filter(Boolean));
|
|
333
289
|
}
|
|
334
290
|
|
|
335
|
-
// 诊断口径:已注入条目中,被 settings 引用却没能从路由端出来的 id
|
|
336
|
-
// 返回 { referenced, missing };宿主 API 不可用时 missing 为 null。
|
|
291
|
+
// 诊断口径:已注入条目中,被 settings 引用却没能从路由端出来的 id(ADR 0003)。
|
|
337
292
|
async function registryServedDiagnostics(ctx, overlayIds) {
|
|
338
293
|
const referenced = [...new Set(overlayIds)].filter((id) => configuredModelIds(ctx).has(id));
|
|
339
294
|
if (referenced.length === 0) return { referenced, missing: [] };
|
|
@@ -342,24 +297,10 @@ async function registryServedDiagnostics(ctx, overlayIds) {
|
|
|
342
297
|
return { referenced, missing: referenced.filter((id) => !served.has(id)) };
|
|
343
298
|
}
|
|
344
299
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
let missing = ids;
|
|
348
|
-
for (let i = 0; i < attempts; i++) {
|
|
349
|
-
const diag = await registryServedDiagnostics(ctx, ids);
|
|
350
|
-
missing = diag.missing ?? missing;
|
|
351
|
-
if (missing.length === 0) return [];
|
|
352
|
-
if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
353
|
-
}
|
|
354
|
-
return missing;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// 净零切换(ADR 0003 增补 v1.2.7):**同值写不会改变配置对象身份**——宿主快照因此不重建
|
|
358
|
-
// (2026-10-03 实测:boot 期同值触碰写了文件、宿主仍按旧快照丢弃注入条目;apply 写的也是
|
|
359
|
-
// 同值,同样 500 registry-not-effective)。所以这里做两次**真内容变化**的写把身份推两次:
|
|
360
|
-
// 先把该路由 `displayName` 改成当前值 + 一个尾随空格,再改回原状(该键非用户所有时 unset)。
|
|
361
|
-
// 净效果 = 用户配置一字不改,但宿主必然重建快照;displayName 不参与模型列表语义。
|
|
300
|
+
// 净零切换(ADR 0003 增补 v1.2.7):同值写不改变配置对象身份、宿主快照不重建;
|
|
301
|
+
// 两次真内容变化(displayName 加空格再复原)推动快照重建,净效果=用户配置一字不改。
|
|
362
302
|
async function touchRouteIdentity(ctx, log) {
|
|
303
|
+
void log;
|
|
363
304
|
const describe = () => ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
364
305
|
const first = describe();
|
|
365
306
|
const route = first?.user?.providers?.["github-copilot"];
|
|
@@ -374,367 +315,230 @@ async function touchRouteIdentity(ctx, log) {
|
|
|
374
315
|
return true;
|
|
375
316
|
}
|
|
376
317
|
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
return { ok, touched, referenced: before.referenced, missing };
|
|
318
|
+
// intentVersion 持久层(T3 契约):`<dataDir>/auth-intent.json` 原子写;
|
|
319
|
+
// scope 未知时读写一律抛错 → 写路由 503,不伪成功。
|
|
320
|
+
function createIntentIO(scope) {
|
|
321
|
+
const file = scope?.known ? join(scope.dataDir, "auth-intent.json") : null;
|
|
322
|
+
return {
|
|
323
|
+
read() {
|
|
324
|
+
if (!file) throw new Error("scope-unavailable");
|
|
325
|
+
const raw = readJson(file);
|
|
326
|
+
return typeof raw?.intentVersion === "number" ? raw.intentVersion : 0;
|
|
327
|
+
},
|
|
328
|
+
bump() {
|
|
329
|
+
if (!file) throw new Error("scope-unavailable");
|
|
330
|
+
mkdirSync(scope.dataDir, { recursive: true });
|
|
331
|
+
const cur = readJson(file);
|
|
332
|
+
const next = (typeof cur?.intentVersion === "number" ? cur.intentVersion : 0) + 1;
|
|
333
|
+
writeJsonAtomic(file, { version: 1, intentVersion: next, updatedAt: new Date().toISOString() });
|
|
334
|
+
return next;
|
|
335
|
+
},
|
|
336
|
+
};
|
|
397
337
|
}
|
|
398
338
|
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// 走进程内目录注册表注入(ADR 0003)。返回 { ok, wrote, mode, reason },绝不抛。
|
|
425
|
-
const landIncrement = async (overlay, merged) => {
|
|
426
|
-
if (install.writable) {
|
|
427
|
-
writeJsonAtomic(install.catalogFile, merged.merged);
|
|
428
|
-
return { ok: true, wrote: true, mode: "file", reason: null };
|
|
429
|
-
}
|
|
430
|
-
const r = await injectRegistry(install, overlay, {
|
|
431
|
-
log,
|
|
432
|
-
allowedKeys: catalogShapeKeys(readDiskCatalog()),
|
|
433
|
-
});
|
|
434
|
-
return { ok: r.ok, wrote: false, mode: "registry", reason: r.reason, detail: r };
|
|
435
|
-
};
|
|
436
|
-
// 运行时实际可解析的模型 id:目录文件 ∪ 进程内注册表(ADR 0003——注入的条目
|
|
437
|
-
// 不在文件里,任何"目录可解析"判定都必须走这一面)。
|
|
438
|
-
const servedIds = async () => {
|
|
439
|
-
const ids = diskIds(readDiskCatalog());
|
|
440
|
-
// 同 readCatalogModelIds:只有只读安装树才需要并注册表面(可写树下两者同源)。
|
|
441
|
-
const reg = install.writable === true ? null : await readRegistryIds(install, log);
|
|
442
|
-
if (reg) for (const id of reg) ids.add(id);
|
|
443
|
-
return ids;
|
|
444
|
-
};
|
|
339
|
+
// 服务错误 → HTTP 投影(白名单 code,脱敏)
|
|
340
|
+
function serviceError(res, err) {
|
|
341
|
+
const map = {
|
|
342
|
+
SCOPE_UNAVAILABLE: [503, "scope-unavailable"],
|
|
343
|
+
INVALID_OPERATION: [400, "invalid-operation"],
|
|
344
|
+
INVALID_REQUEST: [400, "bad-request"],
|
|
345
|
+
INVALID_CATALOG_SOURCE: [400, "invalid-catalog-source"],
|
|
346
|
+
INSTALL_UNRESOLVED: [409, "install-unresolved"],
|
|
347
|
+
PREVIEW_INVALID: [409, "preview-invalid"],
|
|
348
|
+
PREVIEW_STALE: [409, "preview-stale"],
|
|
349
|
+
AUTH_CHANGED: [409, "auth-changed"],
|
|
350
|
+
EVIDENCE_UNAVAILABLE: [409, "evidence-unavailable"],
|
|
351
|
+
EVIDENCE_STALE: [409, "evidence-stale"],
|
|
352
|
+
RESOURCE_BUSY: [423, "resource-busy"],
|
|
353
|
+
STATE_UNKNOWN: [409, "state-unknown"],
|
|
354
|
+
DEPS_MISSING: [500, "service-not-wired"],
|
|
355
|
+
};
|
|
356
|
+
const m = map[err?.code];
|
|
357
|
+
if (m) {
|
|
358
|
+
json(res, m[0], { ok: false, error: m[1], reason: err.reason ?? null });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// 策略拒绝(empty-intersection-unconfirmed/inherited-overrides-unclearable 等)
|
|
362
|
+
json(res, 409, { ok: false, error: sanitizeAuthError(err) });
|
|
363
|
+
}
|
|
445
364
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
bootPatched = landed.wrote;
|
|
465
|
-
// 注册表通道:注入只是把条目放进进程内注册表,宿主快照可能已在挂载时按旧目录
|
|
466
|
-
// 定稿——用宿主 API 复核,缺失即净零切换触发重建(ADR 0003 增补)。
|
|
467
|
-
if (landed.mode === "registry") {
|
|
468
|
-
await ensureRegistryServed(ctx, overlayEntryIds(state.journal.pendingOverlay), log);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
state.appliedOverlay = mergeCatalog(state.appliedOverlay, state.journal.pendingOverlay).merged;
|
|
472
|
-
state.appliedProvenance = {
|
|
473
|
-
sourcePiAiVersion: state.journal.source?.piAiVersion ?? null,
|
|
474
|
-
integrity: state.journal.source?.integrity ?? null,
|
|
475
|
-
appliedAgainstPiAiVersion: state.journal.appliedAgainstPiAiVersion,
|
|
476
|
-
catalogSchemaVersion: 1,
|
|
477
|
-
};
|
|
478
|
-
state.activated = true;
|
|
479
|
-
// file 通道才需要重启标记(下个 boot 由 2a 把 settings 镜像到 target);
|
|
480
|
-
// 注册表注入当次生效,boot2a 在本 boot 就会同步 settings(ADR 0003)。
|
|
481
|
-
if (install.writable) state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
|
|
482
|
-
state.journal = advanceJournal(state.journal);
|
|
483
|
-
state = clearLastError(state);
|
|
484
|
-
saveState(stateFile, state);
|
|
485
|
-
}
|
|
365
|
+
export function apply(ctx, opts = {}) {
|
|
366
|
+
const r = routes();
|
|
367
|
+
const scope = resolveRuntimeScope(ctx);
|
|
368
|
+
const intentIO = createIntentIO(scope);
|
|
369
|
+
const legacyPath = opts.stateFile ?? defaultStateFile();
|
|
370
|
+
const log = (m) => ctx.logger?.info?.(m);
|
|
371
|
+
let lastSyncError;
|
|
372
|
+
const refreshMutex = createMutex(); // 预览串行(apply/retire 在服务队列内串行)
|
|
373
|
+
|
|
374
|
+
// v2 状态读写层(profile 隔离;unknownVersion 以标记对象上抛给服务层拒绝)
|
|
375
|
+
const stateIO = {
|
|
376
|
+
load: () => {
|
|
377
|
+
const loaded = loadRefreshState(scope, { legacyPath });
|
|
378
|
+
if (loaded.flags.unknownVersion) return { unknownVersion: true };
|
|
379
|
+
return loaded.state;
|
|
380
|
+
},
|
|
381
|
+
save: (state) => saveRefreshState(scope, state),
|
|
382
|
+
};
|
|
486
383
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
saveState(stateFile, state);
|
|
495
|
-
} else if (install.version === baseline) {
|
|
496
|
-
const replay = mergeCatalog(readDiskCatalog(), state.appliedOverlay);
|
|
497
|
-
if (replay.added.length > 0) {
|
|
498
|
-
const landed = await landIncrement(state.appliedOverlay, replay);
|
|
499
|
-
if (!landed.ok) {
|
|
500
|
-
// 自愈失败不抛错、不阻断启动序列:appliedOverlay 保留,下次 boot 重放。
|
|
501
|
-
const why = landed.mode === "file" ? `catalog-not-writable (${install.unwritableReason})` : `registry-inject-failed (${landed.reason})`;
|
|
502
|
-
state = setLastError(state, `${why}: self-heal deferred, overlay entries stay pending`);
|
|
503
|
-
saveState(stateFile, state);
|
|
504
|
-
} else {
|
|
505
|
-
bootPatched = landed.wrote;
|
|
506
|
-
// 注册表注入是进程内的、当次 boot 即生效,不需要重启标记(ADR 0003)。
|
|
507
|
-
if (landed.wrote) state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
|
|
508
|
-
state = clearLastError(state);
|
|
509
|
-
saveState(stateFile, state);
|
|
510
|
-
// 注入通常落在宿主快照之后(宿主在挂载时即定稿目录解析结果)——用宿主
|
|
511
|
-
// 自己的 listModels 复核,缺失即净零切换触发重建;仍缺失则记可 grep 的
|
|
512
|
-
// lastError(不写盘、不阻断挂载)。
|
|
513
|
-
if (landed.mode === "registry") {
|
|
514
|
-
const served = await ensureRegistryServed(ctx, overlayEntryIds(state.appliedOverlay), log);
|
|
515
|
-
if (!served.ok) {
|
|
516
|
-
state = setLastError(state, `registry-not-served: ${(served.missing ?? served.referenced).join(",")} — injected entries are in the registry but the route still does not serve them`);
|
|
517
|
-
saveState(stateFile, state);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
} else {
|
|
523
|
-
const served = await servedIds();
|
|
524
|
-
const missing = overlayIds.filter((id) => !served.has(id));
|
|
525
|
-
if (missing.length > 0) {
|
|
526
|
-
// 跨版本有缺失:上报 self-heal-incompatible,不改安装树
|
|
527
|
-
state = setLastError(state, `self-heal-incompatible: pi-ai ${install.version} != baseline ${baseline}, missing ${missing.join(",")}`);
|
|
528
|
-
saveState(stateFile, state);
|
|
529
|
-
}
|
|
530
|
-
// 条目已原生存在 → 空操作
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
384
|
+
// 模型目录同步只在登录成功(且 handoff 完好)后执行;失败经 /status 暴露 syncError。
|
|
385
|
+
const runSync = (handoff) => syncAvailableModels(ctx, { ...opts, handoff, handoffIntact: controller.handoffIntact }).then(() => {
|
|
386
|
+
lastSyncError = undefined;
|
|
387
|
+
}).catch((err) => {
|
|
388
|
+
lastSyncError = String(err?.message ?? err);
|
|
389
|
+
ctx.logger?.warn?.("copilot-auth: model sync failed: %s", lastSyncError);
|
|
390
|
+
});
|
|
534
391
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
let failed = null;
|
|
542
|
-
try {
|
|
543
|
-
if (!install) throw new Error("pi-ai installation not found");
|
|
544
|
-
// 语义前置(Y2-3):targetIds 全部可由当前目录解析;whole-file digest 仅诊断
|
|
545
|
-
const ids = await servedIds();
|
|
546
|
-
const unresolvable = j.targetIds.filter((id) => !ids.has(id));
|
|
547
|
-
if (unresolvable.length > 0) throw new Error(`target-unresolvable: ${unresolvable.join(",")}`);
|
|
548
|
-
const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
549
|
-
if (!desc) throw new Error("settings namespace llm-pi-ai unavailable");
|
|
550
|
-
const view = viewFromUserLayer(desc.user);
|
|
551
|
-
if (digest(view) === digest(j.settingsBaseline)) {
|
|
552
|
-
await ctx.settings.mutate("llm-pi-ai", [
|
|
553
|
-
{ op: "set", path: ["providers", "github-copilot", "models"], value: j.targetIds.map((id) => ({ id })) },
|
|
554
|
-
{ op: "unset", path: ["providers", "github-copilot", "modelOverrides"] },
|
|
555
|
-
], desc.revision); // 真 CAS:revision 不符由 settings 抛 SETTINGS_CONFLICT
|
|
556
|
-
consumed = true;
|
|
557
|
-
} else if (viewEqualsTarget(view, j.targetIds)) {
|
|
558
|
-
consumed = true; // 已等于 target → 幂等消费,不再 mutate
|
|
559
|
-
} else {
|
|
560
|
-
failed = "settings-conflict: configuration changed since preview; re-run refresh";
|
|
561
|
-
}
|
|
562
|
-
} catch (err) {
|
|
563
|
-
failed = err?.code === "SETTINGS_CONFLICT" || /conflict/i.test(String(err?.message ?? ""))
|
|
564
|
-
? `settings-conflict: ${err.message}`
|
|
565
|
-
: String(err?.message ?? err);
|
|
566
|
-
}
|
|
567
|
-
if (consumed) {
|
|
568
|
-
state.journal = null;
|
|
569
|
-
state = clearLastError(state);
|
|
570
|
-
saveState(stateFile, state);
|
|
571
|
-
} else if (failed) {
|
|
572
|
-
state = setLastError(state, failed); // 不覆盖、记 conflict、/status 上报
|
|
573
|
-
saveState(stateFile, state);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
// 2b. restartState 非空且 expected entries 已在目录 → 仅清 restartState
|
|
577
|
-
// (不携带 settings 意图:单独存在时不得触碰 describe/mutate)
|
|
578
|
-
if (state.restartState && install) {
|
|
579
|
-
const ids = await servedIds();
|
|
580
|
-
if (overlayEntryIds(state.appliedOverlay).every((id) => ids.has(id))) {
|
|
581
|
-
state.restartState = null;
|
|
582
|
-
state = clearLastError(state);
|
|
583
|
-
saveState(stateFile, state);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
} catch (err) {
|
|
588
|
-
try {
|
|
589
|
-
saveState(stateFile, setLastError(state, err));
|
|
590
|
-
} catch { /* 记录失败不掩盖 */ }
|
|
591
|
-
ctx.logger?.warn?.("copilot-auth: refresh boot failed: %s", String(err?.message ?? err));
|
|
592
|
-
}
|
|
593
|
-
}
|
|
392
|
+
// 授权控制器(T3):软撤回+风险锁存;退出/重新授权在当前宿主下统一禁用。
|
|
393
|
+
const controller = createAuthorizationController(ctx, {
|
|
394
|
+
scope,
|
|
395
|
+
intentIO,
|
|
396
|
+
onAuthorized: (handoff) => { void runSync(handoff); },
|
|
397
|
+
});
|
|
594
398
|
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
const corrupt = () => ({ ...freshState(), lastError: "state-corrupt" });
|
|
598
|
-
let raw;
|
|
399
|
+
// ---- 初始化期能力探测(Q23:GET status 不执行写探针,通道分类来自此处缓存) ----
|
|
400
|
+
let installInfo = null;
|
|
599
401
|
try {
|
|
600
|
-
|
|
402
|
+
installInfo = resolveInstall(opts);
|
|
601
403
|
} catch {
|
|
602
|
-
|
|
404
|
+
installInfo = null;
|
|
603
405
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const diff = diffModels(currentIds, ids, catalogIds);
|
|
622
|
-
const customizationReset = {
|
|
623
|
-
modelEntryIds: (Array.isArray(view.models) ? view.models : [])
|
|
624
|
-
.filter((m) => m && typeof m === "object" && Object.keys(m).some((k) => k !== "id"))
|
|
625
|
-
.map((m) => m.id),
|
|
626
|
-
modelOverrideIds:
|
|
627
|
-
view.modelOverrides && typeof view.modelOverrides === "object" && !Array.isArray(view.modelOverrides)
|
|
628
|
-
? Object.keys(view.modelOverrides)
|
|
629
|
-
: [],
|
|
630
|
-
};
|
|
631
|
-
const digests = {
|
|
632
|
-
settings: digest(view), // raw 完整视图:字段级变化必漂移(R3-2)
|
|
633
|
-
available: digest([...ids].sort()),
|
|
634
|
-
catalog: digest(local.bytes), // 本地目录文件字节
|
|
635
|
-
remote: digest(resolved.catalog), // 实际使用的目录来源对象(与 mode 严格对应)
|
|
636
|
-
};
|
|
637
|
-
return { view, ids, source, local, resolved, merged, diff, customizationReset, digests };
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// 端到端自证(ADR 0003):注入 + settings 写入后,路由必须真的把 target 端出来。
|
|
641
|
-
// settings→快照重建是异步的,故带短重试。返回 { ok, missing, served }。
|
|
642
|
-
async function waitForServedModels(ctx, targetIds, { attempts = 6, delayMs = 150 } = {}) {
|
|
643
|
-
const want = [...new Set(targetIds)];
|
|
644
|
-
let served = new Set();
|
|
645
|
-
for (let i = 0; i < attempts; i++) {
|
|
646
|
-
try {
|
|
647
|
-
served = new Set((await ctx.llm.listModels("github-copilot")).map((m) => m?.id).filter(Boolean));
|
|
648
|
-
} catch (err) {
|
|
649
|
-
if (i === attempts - 1) return { ok: false, missing: want, served: [...served], error: String(err?.message ?? err) };
|
|
406
|
+
let catalogMode = "unknown";
|
|
407
|
+
let blockedReason = installInfo ? null : "install-unresolved";
|
|
408
|
+
if (installInfo) {
|
|
409
|
+
if (installInfo.writable === true) {
|
|
410
|
+
catalogMode = "file";
|
|
411
|
+
} else {
|
|
412
|
+
// 只读安装树:注册表面可用性探测(boot 期一次,异步;不写任何文件)
|
|
413
|
+
void loadRegistryModules(installInfo, log).then((m) => {
|
|
414
|
+
if (m?.via) catalogMode = "registry";
|
|
415
|
+
else {
|
|
416
|
+
catalogMode = "blocked";
|
|
417
|
+
blockedReason = "inject-unavailable";
|
|
418
|
+
}
|
|
419
|
+
}).catch(() => {
|
|
420
|
+
catalogMode = "blocked";
|
|
421
|
+
blockedReason = "inject-unavailable";
|
|
422
|
+
});
|
|
650
423
|
}
|
|
651
|
-
const missing = want.filter((id) => !served.has(id));
|
|
652
|
-
if (missing.length === 0) return { ok: true, missing: [], served: [...served] };
|
|
653
|
-
if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
654
424
|
}
|
|
655
|
-
return { ok: false, missing: want.filter((id) => !served.has(id)), served: [...served] };
|
|
656
|
-
}
|
|
657
425
|
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
}
|
|
426
|
+
// ---- 事务内核(T9)与服务(T8)接线 ----
|
|
427
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
428
|
+
const transaction = installInfo ? createTransaction(ctx, {
|
|
429
|
+
scope,
|
|
430
|
+
stateIO,
|
|
431
|
+
readIntentVersion: () => intentIO.read(),
|
|
432
|
+
clock: realClock,
|
|
433
|
+
install: installInfo,
|
|
434
|
+
deps: {
|
|
435
|
+
describeConfigView: () => describeRouteView(ctx),
|
|
436
|
+
mutateSettings: (ops, expectedRevision) => ctx.settings.mutate("llm-pi-ai", ops, expectedRevision),
|
|
437
|
+
listServedIds: () => servedModelIds(ctx),
|
|
438
|
+
touchRouteIdentity: () => touchRouteIdentity(ctx, log),
|
|
439
|
+
readDiskCatalog: () => JSON.parse(readFileSync(installInfo.catalogFile, "utf8")),
|
|
440
|
+
injectRegistry: (overlay, { allowedKeys }) => (opts.injectRegistry ?? injectCatalogEntries)(installInfo, overlay, { log, allowedKeys }),
|
|
441
|
+
registryIds: () => (opts.registryIds ?? registryModelIds)(installInfo, log),
|
|
442
|
+
},
|
|
443
|
+
log,
|
|
444
|
+
servedRetry: { attempts: 5, delayMs: 150 },
|
|
445
|
+
}) : null;
|
|
446
|
+
|
|
447
|
+
const resolveCatalogForService = async (catalogSource) => {
|
|
448
|
+
if (!installInfo) {
|
|
449
|
+
const err = new Error("install-unresolved");
|
|
450
|
+
err.code = "INSTALL_UNRESOLVED";
|
|
451
|
+
throw err;
|
|
452
|
+
}
|
|
453
|
+
if (catalogSource !== undefined && !["latest", "local", "overlay"].includes(catalogSource)) {
|
|
454
|
+
const err = new Error("invalid-catalog-source");
|
|
455
|
+
err.code = "INVALID_CATALOG_SOURCE";
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
458
|
+
const local = readLocalCatalog(opts);
|
|
459
|
+
let resolved;
|
|
460
|
+
if (catalogSource === "local") {
|
|
461
|
+
resolved = { catalog: local.catalog, normalized: null, catalogSource: "local", catalogError: null, provenance: { kind: "local" } };
|
|
462
|
+
} else {
|
|
463
|
+
resolved = await resolveCatalogSource(opts, catalogSource === "overlay" ? "overlay" : undefined, fetchImpl, catalogShapeKeys(local.catalog));
|
|
464
|
+
}
|
|
465
|
+
const merged = mergeCatalog(local.catalog, resolved.catalog);
|
|
466
|
+
const resolvableIds = Object.values(merged.merged).flatMap((s) => Object.keys(s ?? {}));
|
|
467
|
+
return {
|
|
468
|
+
catalogSource: resolved.catalogSource,
|
|
469
|
+
catalogError: resolved.catalogError,
|
|
470
|
+
skipped: merged.skipped,
|
|
471
|
+
normalized: resolved.normalized,
|
|
472
|
+
addedOverlay: merged.addedOverlay,
|
|
473
|
+
resolvableIds,
|
|
474
|
+
newEntryCount: overlayEntryIds(merged.addedOverlay).length,
|
|
475
|
+
provenance: resolved.provenance,
|
|
476
|
+
};
|
|
477
|
+
};
|
|
673
478
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
479
|
+
const refreshService = createRefreshService(ctx, {
|
|
480
|
+
scope,
|
|
481
|
+
stateIO,
|
|
482
|
+
readIntentVersion: () => intentIO.read(),
|
|
483
|
+
clock: realClock,
|
|
484
|
+
deps: {
|
|
485
|
+
resolveCatalog: resolveCatalogForService,
|
|
486
|
+
describeConfigView: () => describeRouteView(ctx),
|
|
487
|
+
},
|
|
488
|
+
transaction,
|
|
489
|
+
fetchImpl,
|
|
490
|
+
});
|
|
679
491
|
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
void bootRefresh(ctx, opts)
|
|
492
|
+
// 启动序列(boot 恢复+保守激活/自愈,T9 bootRecover);绝不阻断挂载。
|
|
493
|
+
void refreshService.bootReady()
|
|
683
494
|
.catch((err) => {
|
|
684
495
|
ctx.logger?.warn?.("copilot-auth: refresh boot failed: %s", String(err?.message ?? err));
|
|
685
496
|
})
|
|
686
497
|
.finally(() => opts.onBootDone?.());
|
|
687
498
|
|
|
688
|
-
//
|
|
689
|
-
// 插件启动/重启绝不触碰用户 settings,用户精简过的目录不会被动重置。
|
|
690
|
-
// 失败不静默:错误经 /status 的 syncError 字段暴露,便于诊断。
|
|
691
|
-
const sync = () => syncAvailableModels(ctx, opts).then(() => { lastSyncError = undefined; }).catch((err) => {
|
|
692
|
-
lastSyncError = String(err?.message ?? err);
|
|
693
|
-
ctx.logger?.warn?.("copilot-auth: model sync failed: %s", lastSyncError);
|
|
694
|
-
});
|
|
499
|
+
// ==================== 授权路由(T3/T4) ====================
|
|
695
500
|
|
|
696
501
|
ctx.webServer.register({
|
|
697
502
|
kind: "exact",
|
|
698
503
|
path: r.start,
|
|
699
504
|
handler: (req, res) => {
|
|
700
505
|
if (!guard(req, res, "POST")) return;
|
|
701
|
-
if (
|
|
702
|
-
json(res,
|
|
506
|
+
if (!scope.known) {
|
|
507
|
+
json(res, 503, { ok: false, error: "scope-unavailable" });
|
|
703
508
|
return;
|
|
704
509
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
})
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
json(res, 202, { ok: true });
|
|
510
|
+
try {
|
|
511
|
+
const { attemptId } = controller.start();
|
|
512
|
+
json(res, 202, { ok: true, attemptId });
|
|
513
|
+
} catch (err) {
|
|
514
|
+
if (err?.code === "ATTEMPT_RUNNING") {
|
|
515
|
+
json(res, 409, { ok: false, error: "already running", attemptId: err.attemptId });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (err?.code === "AUTH_UNSAFE") {
|
|
519
|
+
json(res, 409, { ok: false, error: "auth-unsafe" });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
json(res, 500, { ok: false, error: sanitizeAuthError(err) });
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
ctx.webServer.register({
|
|
528
|
+
kind: "exact",
|
|
529
|
+
path: r.cancel,
|
|
530
|
+
handler: async (req, res) => {
|
|
531
|
+
if (!guard(req, res, "POST")) return;
|
|
532
|
+
if (!scope.known) {
|
|
533
|
+
json(res, 503, { ok: false, error: "scope-unavailable" });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
const { withdrawalDelivery } = await controller.cancel();
|
|
538
|
+
json(res, 200, { ok: true, withdrawalDelivery });
|
|
539
|
+
} catch (err) {
|
|
540
|
+
json(res, 500, { ok: false, error: sanitizeAuthError(err) });
|
|
541
|
+
}
|
|
738
542
|
},
|
|
739
543
|
});
|
|
740
544
|
|
|
@@ -743,7 +547,7 @@ export function apply(ctx, opts = {}) {
|
|
|
743
547
|
path: r.state,
|
|
744
548
|
handler: (req, res) => {
|
|
745
549
|
if (!guard(req, res, "GET")) return;
|
|
746
|
-
json(res, 200,
|
|
550
|
+
json(res, 200, controller.snapshot());
|
|
747
551
|
},
|
|
748
552
|
});
|
|
749
553
|
|
|
@@ -753,77 +557,96 @@ export function apply(ctx, opts = {}) {
|
|
|
753
557
|
handler: async (req, res) => {
|
|
754
558
|
if (!guard(req, res, "GET")) return;
|
|
755
559
|
// describeRecord 只回传 presence,不把含 token 的 GrantRecord 拉进内存。
|
|
756
|
-
let
|
|
560
|
+
let credential = "absent";
|
|
757
561
|
try {
|
|
758
562
|
const info = await ctx.credentials.describeRecord(CREDENTIAL_KEY);
|
|
759
|
-
|
|
563
|
+
credential = info?.configured === true ? "present" : "absent";
|
|
760
564
|
} catch {
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
// refresh 状态块:lastError 直读状态文件顶层(R3-6);pendingRestart 派生自
|
|
764
|
-
// 状态文件(journal 相位或 restartState),不用模块布尔;piAiVersion /
|
|
765
|
-
// catalogDigest / catalogFile 来自同根解析,digest 为当前文件字节 sha256
|
|
766
|
-
// (E2E 据此把被检查文件绑定到实际加载副本,R3-8/R4-5)。
|
|
767
|
-
let install = null;
|
|
768
|
-
try {
|
|
769
|
-
install = resolveInstall(opts);
|
|
770
|
-
} catch {
|
|
771
|
-
install = null;
|
|
565
|
+
credential = "read-error";
|
|
772
566
|
}
|
|
567
|
+
const authSnapshot = controller.snapshot();
|
|
568
|
+
// refresh 块:只读聚合(通道分类来自启动期缓存;GET 不执行写探针,Q23)
|
|
569
|
+
const loaded = loadRefreshState(scope, { legacyPath });
|
|
570
|
+
const st = loaded.flags.unknownVersion ? null : loaded.state;
|
|
773
571
|
let catalogDigest = null;
|
|
774
|
-
if (
|
|
572
|
+
if (installInfo) {
|
|
775
573
|
try {
|
|
776
|
-
catalogDigest = digest(readFileSync(
|
|
574
|
+
catalogDigest = digest(readFileSync(installInfo.catalogFile));
|
|
777
575
|
} catch {
|
|
778
576
|
catalogDigest = null;
|
|
779
577
|
}
|
|
780
578
|
}
|
|
781
|
-
|
|
579
|
+
let registryViaNow = null;
|
|
580
|
+
let registryMismatch = null;
|
|
581
|
+
try {
|
|
582
|
+
registryViaNow = registryVia();
|
|
583
|
+
registryMismatch = registryInstanceMismatch();
|
|
584
|
+
} catch { /* 诊断字段缺失不阻断 */ }
|
|
782
585
|
const refresh = {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
586
|
+
scopeAvailable: scope.known,
|
|
587
|
+
catalogWritable: installInfo ? installInfo.writable === true : null,
|
|
588
|
+
catalogMode,
|
|
589
|
+
blockedReason,
|
|
590
|
+
registryInjected: st ? overlayEntryIds(st.appliedOverlay).length : 0,
|
|
591
|
+
registryVia: registryViaNow,
|
|
592
|
+
registryInstanceMismatch: registryMismatch,
|
|
593
|
+
catalogFile: installInfo?.catalogFile ?? null,
|
|
594
|
+
settingsNotServed: (await registryServedDiagnostics(ctx, st ? overlayEntryIds(st.appliedOverlay) : [])).missing,
|
|
595
|
+
activated: st?.activated === true,
|
|
596
|
+
activeOperation: st?.activeOperation ?? null,
|
|
597
|
+
lastResult: st?.lastResult ?? null,
|
|
598
|
+
pendingRestart: !!st?.restartState || st?.activeOperation?.phaseResult?.status === "pending-restart",
|
|
599
|
+
legacyStateDetected: loaded.flags.legacyStateDetected === true,
|
|
600
|
+
stateUnknownVersion: loaded.flags.unknownVersion === true,
|
|
601
|
+
piAiVersion: installInfo?.version ?? null,
|
|
788
602
|
catalogDigest,
|
|
789
|
-
|
|
790
|
-
catalogWritable: install ? install.writable === true : null,
|
|
791
|
-
// 落地通道(ADR 0003):file = 可写安装树走目录补丁;registry = 只读安装树
|
|
792
|
-
// (desktop asar)走进程内目录注册表注入。registryInjected = 已注入条目数。
|
|
793
|
-
catalogMode: install ? (install.writable === true ? "file" : "registry") : null,
|
|
794
|
-
registryInjected: overlayEntryIds(state.appliedOverlay).length,
|
|
603
|
+
lastError: st?.lastError ?? null,
|
|
795
604
|
};
|
|
796
|
-
//
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
605
|
+
// GET status 的 operationId 查询三态(active / last / unknown;unknown≠未执行)
|
|
606
|
+
let operation = null;
|
|
607
|
+
const opId = typeof req.url === "string" ? new URL(req.url, "http://local").searchParams.get("operationId") : null;
|
|
608
|
+
if (opId) {
|
|
609
|
+
try {
|
|
610
|
+
operation = await refreshService.status({ operationId: opId });
|
|
611
|
+
} catch {
|
|
612
|
+
operation = { query: "unknown" };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
json(res, 200, {
|
|
616
|
+
configured: credential === "present",
|
|
617
|
+
syncError: lastSyncError,
|
|
618
|
+
authorization: {
|
|
619
|
+
credential,
|
|
620
|
+
attempt: authSnapshot,
|
|
621
|
+
riskLatch: authSnapshot.riskLatch,
|
|
622
|
+
capabilities: { logout: false, reauthorize: false },
|
|
623
|
+
},
|
|
624
|
+
refresh,
|
|
625
|
+
...(operation ? { operation } : {}),
|
|
626
|
+
});
|
|
804
627
|
},
|
|
805
628
|
});
|
|
806
629
|
|
|
630
|
+
// 退出登录:当前宿主桥接无法证明安全退出(D-01),统一拒绝且绝不 deleteRecord。
|
|
807
631
|
ctx.webServer.register({
|
|
808
632
|
kind: "exact",
|
|
809
633
|
path: r.logout,
|
|
810
|
-
handler:
|
|
634
|
+
handler: (req, res) => {
|
|
811
635
|
if (!guard(req, res, "POST")) return;
|
|
812
|
-
|
|
813
|
-
// 真实删除与否由随后的 /status 反映。异常兜底与其余路由对齐
|
|
814
|
-
// (2026-09-28 审计:无 try/catch 的 reject 会造成连接悬挂/未处理拒绝)。
|
|
815
|
-
try {
|
|
816
|
-
await ctx.credentials.deleteRecord(CREDENTIAL_KEY);
|
|
817
|
-
} catch (err) {
|
|
818
|
-
json(res, 500, { ok: false, error: String(err?.message ?? err) });
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
json(res, 200, { ok: true });
|
|
636
|
+
json(res, 403, { ok: false, error: "logout-safety-unavailable" });
|
|
822
637
|
},
|
|
823
638
|
});
|
|
824
639
|
|
|
825
|
-
//
|
|
826
|
-
|
|
640
|
+
// ==================== 模型管理路由(协议 v2,T8/T9/T10) ====================
|
|
641
|
+
|
|
642
|
+
const requireProtocol = (res, body) => {
|
|
643
|
+
if (body?.protocolVersion !== PROTOCOL_VERSION) {
|
|
644
|
+
json(res, 400, { ok: false, error: "upgrade-required" });
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
return true;
|
|
648
|
+
};
|
|
649
|
+
|
|
827
650
|
ctx.webServer.register({
|
|
828
651
|
kind: "exact",
|
|
829
652
|
path: r.refreshPreview,
|
|
@@ -836,43 +659,20 @@ export function apply(ctx, opts = {}) {
|
|
|
836
659
|
json(res, 400, { ok: false, error: "bad-body" });
|
|
837
660
|
return;
|
|
838
661
|
}
|
|
839
|
-
|
|
840
|
-
if (mode !== undefined && mode !== "latest" && mode !== "overlay") {
|
|
841
|
-
json(res, 400, { ok: false, error: "invalid-mode" });
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
662
|
+
if (!requireProtocol(res, body)) return;
|
|
844
663
|
try {
|
|
845
|
-
const
|
|
846
|
-
json(res, 200,
|
|
847
|
-
ok: true,
|
|
848
|
-
source: inputs.source,
|
|
849
|
-
catalogSource: inputs.resolved.catalogSource,
|
|
850
|
-
catalogError: inputs.resolved.catalogError,
|
|
851
|
-
catalogWritable: inputs.local.writable,
|
|
852
|
-
catalogMode: inputs.local.writable ? "file" : "registry",
|
|
853
|
-
catalogNormalized: inputs.resolved.normalized,
|
|
854
|
-
skipped: inputs.merged.skipped,
|
|
855
|
-
added: inputs.diff.added,
|
|
856
|
-
removed: inputs.diff.removed,
|
|
857
|
-
kept: inputs.diff.kept,
|
|
858
|
-
target: inputs.diff.target,
|
|
859
|
-
customizationReset: inputs.customizationReset,
|
|
860
|
-
digests: inputs.digests,
|
|
861
|
-
});
|
|
664
|
+
const out = await refreshMutex(() => refreshService.preview(body ?? {}));
|
|
665
|
+
json(res, 200, out);
|
|
862
666
|
} catch (err) {
|
|
863
667
|
if (err instanceof InvalidModeError) {
|
|
864
668
|
json(res, 400, { ok: false, error: "invalid-mode" });
|
|
865
669
|
return;
|
|
866
670
|
}
|
|
867
|
-
|
|
671
|
+
serviceError(res, err);
|
|
868
672
|
}
|
|
869
673
|
},
|
|
870
674
|
});
|
|
871
675
|
|
|
872
|
-
// 手动刷新 · apply:mutex 内重算全部输入 digest 与回传比对(漂移 → 409
|
|
873
|
-
// preview-stale,client 重新 preview);一致则 write-ahead:journal(prepared)
|
|
874
|
-
// → 原子提交目录 → 合入 appliedOverlay + activated → journal 推进 committed。
|
|
875
|
-
// 重启 dsh web 后由启动序列把 settings 目录镜像重建为 target(见 boot 序列)。
|
|
876
676
|
ctx.webServer.register({
|
|
877
677
|
kind: "exact",
|
|
878
678
|
path: r.refreshApply,
|
|
@@ -885,121 +685,38 @@ export function apply(ctx, opts = {}) {
|
|
|
885
685
|
json(res, 400, { ok: false, error: "bad-body" });
|
|
886
686
|
return;
|
|
887
687
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
688
|
+
if (!requireProtocol(res, body)) return;
|
|
689
|
+
try {
|
|
690
|
+
const out = await refreshService.apply(body ?? {});
|
|
691
|
+
json(res, 200, out);
|
|
692
|
+
} catch (err) {
|
|
693
|
+
serviceError(res, err);
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
ctx.webServer.register({
|
|
699
|
+
kind: "exact",
|
|
700
|
+
path: r.refreshRetire,
|
|
701
|
+
handler: async (req, res) => {
|
|
702
|
+
if (!guard(req, res, "POST")) return;
|
|
703
|
+
let body;
|
|
704
|
+
try {
|
|
705
|
+
body = await readBody(req);
|
|
706
|
+
} catch {
|
|
707
|
+
json(res, 400, { ok: false, error: "bad-body" });
|
|
891
708
|
return;
|
|
892
709
|
}
|
|
710
|
+
if (!requireProtocol(res, body)) return;
|
|
893
711
|
try {
|
|
894
|
-
const out = await
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
for (const k of ["settings", "available", "catalog", "remote"]) {
|
|
898
|
-
if (got[k] !== inputs.digests[k]) {
|
|
899
|
-
return { code: 409, payload: { ok: false, error: "preview-stale" } };
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
// digest 全部一致后才解析版本(写入失败点上移,500 不留 journal)
|
|
903
|
-
const install = resolveInstall(opts);
|
|
904
|
-
const stateFile = opts.stateFile ?? defaultStateFile();
|
|
905
|
-
if (!install.writable) {
|
|
906
|
-
// 只读安装树(desktop asar):ADR 0002 的写盘门禁之外再开一条**不写盘**的
|
|
907
|
-
// 落地通道——进程内目录注册表注入(ADR 0003)。注入成功才写 settings,
|
|
908
|
-
// 再以 listModels 端到端自证;自证失败回滚 settings,绝不产出
|
|
909
|
-
// "能选中但发不出去"的假模型。
|
|
910
|
-
const injectRegistry = opts.injectRegistry ?? injectCatalogEntries;
|
|
911
|
-
const injected = await injectRegistry(install, inputs.merged.addedOverlay, {
|
|
912
|
-
log: (m) => ctx.logger?.info?.(m),
|
|
913
|
-
allowedKeys: catalogShapeKeys(inputs.local.catalog),
|
|
914
|
-
});
|
|
915
|
-
if (!injected.ok) {
|
|
916
|
-
return {
|
|
917
|
-
code: 400,
|
|
918
|
-
payload: {
|
|
919
|
-
ok: false,
|
|
920
|
-
error: "registry-inject-failed",
|
|
921
|
-
reason: injected.reason,
|
|
922
|
-
missing: injected.missing,
|
|
923
|
-
rejected: injected.rejected,
|
|
924
|
-
catalogFile: install.catalogFile,
|
|
925
|
-
},
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
929
|
-
if (!desc) {
|
|
930
|
-
return { code: 500, payload: { ok: false, error: "settings namespace llm-pi-ai unavailable" } };
|
|
931
|
-
}
|
|
932
|
-
await ctx.settings.mutate("llm-pi-ai", [
|
|
933
|
-
{ op: "set", path: ["providers", "github-copilot", "models"], value: inputs.diff.target.map((id) => ({ id })) },
|
|
934
|
-
], desc.revision);
|
|
935
|
-
const served = await waitForServedModels(ctx, inputs.diff.target);
|
|
936
|
-
if (!served.ok) {
|
|
937
|
-
const rolledBack = await rollbackSettings(ctx, inputs.view);
|
|
938
|
-
return {
|
|
939
|
-
code: 500,
|
|
940
|
-
payload: { ok: false, error: "registry-not-effective", missing: served.missing, rolledBack },
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
let state = loadState(stateFile);
|
|
944
|
-
state.appliedOverlay = mergeCatalog(state.appliedOverlay, inputs.merged.addedOverlay).merged;
|
|
945
|
-
state.appliedProvenance = {
|
|
946
|
-
sourcePiAiVersion: inputs.resolved.provenance.piAiVersion ?? null,
|
|
947
|
-
integrity: inputs.resolved.provenance.integrity ?? null,
|
|
948
|
-
appliedAgainstPiAiVersion: install.version,
|
|
949
|
-
catalogSchemaVersion: 1,
|
|
950
|
-
};
|
|
951
|
-
state.activated = true;
|
|
952
|
-
state.journal = null; // 注册表注入当次生效,无写盘、无重启相位
|
|
953
|
-
state = clearLastError(state);
|
|
954
|
-
saveState(stateFile, state);
|
|
955
|
-
return {
|
|
956
|
-
code: 200,
|
|
957
|
-
payload: {
|
|
958
|
-
ok: true,
|
|
959
|
-
restartRequired: false,
|
|
960
|
-
mode: "registry",
|
|
961
|
-
registryVia: injected.via,
|
|
962
|
-
injected: [...injected.injected, ...injected.present],
|
|
963
|
-
target: inputs.diff.target.length,
|
|
964
|
-
},
|
|
965
|
-
};
|
|
966
|
-
}
|
|
967
|
-
const patchedBytes = Buffer.from(JSON.stringify(inputs.merged.merged, null, 2) + "\n", "utf8");
|
|
968
|
-
let state = loadState(stateFile);
|
|
969
|
-
state.journal = createJournal({
|
|
970
|
-
settingsBaseline: inputs.view,
|
|
971
|
-
targetIds: inputs.diff.target,
|
|
972
|
-
pendingOverlay: inputs.merged.addedOverlay, // catalog-shaped 增量(R3-4)
|
|
973
|
-
appliedAgainstPiAiVersion: install.version,
|
|
974
|
-
catalogBaselineDigest: inputs.digests.catalog,
|
|
975
|
-
patchedCatalogDigest: digest(patchedBytes),
|
|
976
|
-
source: inputs.resolved.provenance,
|
|
977
|
-
});
|
|
978
|
-
saveState(stateFile, state); // ① write-ahead:prepared
|
|
979
|
-
writeJsonAtomic(install.catalogFile, inputs.merged.merged); // ② 目录原子提交
|
|
980
|
-
state = loadState(stateFile);
|
|
981
|
-
state.appliedOverlay = mergeCatalog(state.appliedOverlay, inputs.merged.addedOverlay).merged;
|
|
982
|
-
state.appliedProvenance = {
|
|
983
|
-
sourcePiAiVersion: inputs.resolved.provenance.piAiVersion ?? null,
|
|
984
|
-
integrity: inputs.resolved.provenance.integrity ?? null,
|
|
985
|
-
appliedAgainstPiAiVersion: install.version,
|
|
986
|
-
catalogSchemaVersion: 1,
|
|
987
|
-
};
|
|
988
|
-
state.activated = true;
|
|
989
|
-
state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
|
|
990
|
-
saveState(stateFile, state); // ③ appliedOverlay/activated 落盘
|
|
991
|
-
state = loadState(stateFile);
|
|
992
|
-
state.journal = advanceJournal(state.journal);
|
|
993
|
-
saveState(stateFile, state); // ④ 相位推进 catalog-committed-needs-restart
|
|
994
|
-
return { code: 200, payload: { ok: true, restartRequired: true } };
|
|
995
|
-
});
|
|
996
|
-
json(res, out.code, out.payload);
|
|
997
|
-
} catch (err) {
|
|
998
|
-
if (err instanceof InvalidModeError) {
|
|
999
|
-
json(res, 400, { ok: false, error: "invalid-mode" });
|
|
712
|
+
const out = await refreshService.retire(body ?? {});
|
|
713
|
+
if (out.ok === false) {
|
|
714
|
+
json(res, 409, { ok: false, error: out.error ?? "not-active" });
|
|
1000
715
|
return;
|
|
1001
716
|
}
|
|
1002
|
-
json(res,
|
|
717
|
+
json(res, 200, out);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
serviceError(res, err);
|
|
1003
720
|
}
|
|
1004
721
|
},
|
|
1005
722
|
});
|