@inventec/dsh-copilot-auth 1.2.6 → 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 +40 -26
- 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/catalog-registry.mjs +31 -4
- package/src/client-http.mjs +80 -0
- package/src/host.mjs +365 -631
- 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 } 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
|
-
// **同值 settings 触碰**触发快照重建——只改配置对象身份,绝不改用户内容。
|
|
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,386 +297,248 @@ async function registryServedDiagnostics(ctx, overlayIds) {
|
|
|
342
297
|
return { referenced, missing: referenced.filter((id) => !served.has(id)) };
|
|
343
298
|
}
|
|
344
299
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
300
|
+
// 净零切换(ADR 0003 增补 v1.2.7):同值写不改变配置对象身份、宿主快照不重建;
|
|
301
|
+
// 两次真内容变化(displayName 加空格再复原)推动快照重建,净效果=用户配置一字不改。
|
|
302
|
+
async function touchRouteIdentity(ctx, log) {
|
|
303
|
+
void log;
|
|
304
|
+
const describe = () => ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
305
|
+
const first = describe();
|
|
306
|
+
const route = first?.user?.providers?.["github-copilot"];
|
|
307
|
+
if (!first || !route || typeof route !== "object" || Array.isArray(route)) return false;
|
|
308
|
+
const hadOwn = Object.prototype.hasOwnProperty.call(route, "displayName");
|
|
309
|
+
const current = hadOwn ? route.displayName : undefined;
|
|
310
|
+
const probeBase = typeof current === "string" && current.length > 0 ? current : "GitHub Copilot";
|
|
311
|
+
const path = ["providers", "github-copilot", "displayName"];
|
|
312
|
+
await ctx.settings.mutate("llm-pi-ai", [{ op: "set", path, value: `${probeBase} ` }], first.revision);
|
|
313
|
+
const second = describe();
|
|
314
|
+
await ctx.settings.mutate("llm-pi-ai", [hadOwn ? { op: "set", path, value: current } : { op: "unset", path }], second?.revision);
|
|
315
|
+
return true;
|
|
355
316
|
}
|
|
356
317
|
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const ok = missing !== null && missing.length === 0;
|
|
377
|
-
if (!ok) {
|
|
378
|
-
log?.(
|
|
379
|
-
`copilot-auth: registry-not-served — ${(missing ?? before.referenced).join(",")}`
|
|
380
|
-
+ (touched ? "" : " (同值触碰未执行:settings 未注入或该路由无 models)"),
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
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
|
+
};
|
|
384
337
|
}
|
|
385
338
|
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
// 走进程内目录注册表注入(ADR 0003)。返回 { ok, wrote, mode, reason },绝不抛。
|
|
412
|
-
const landIncrement = async (overlay, merged) => {
|
|
413
|
-
if (install.writable) {
|
|
414
|
-
writeJsonAtomic(install.catalogFile, merged.merged);
|
|
415
|
-
return { ok: true, wrote: true, mode: "file", reason: null };
|
|
416
|
-
}
|
|
417
|
-
const r = await injectRegistry(install, overlay, {
|
|
418
|
-
log,
|
|
419
|
-
allowedKeys: catalogShapeKeys(readDiskCatalog()),
|
|
420
|
-
});
|
|
421
|
-
return { ok: r.ok, wrote: false, mode: "registry", reason: r.reason, detail: r };
|
|
422
|
-
};
|
|
423
|
-
// 运行时实际可解析的模型 id:目录文件 ∪ 进程内注册表(ADR 0003——注入的条目
|
|
424
|
-
// 不在文件里,任何"目录可解析"判定都必须走这一面)。
|
|
425
|
-
const servedIds = async () => {
|
|
426
|
-
const ids = diskIds(readDiskCatalog());
|
|
427
|
-
// 同 readCatalogModelIds:只有只读安装树才需要并注册表面(可写树下两者同源)。
|
|
428
|
-
const reg = install.writable === true ? null : await readRegistryIds(install, log);
|
|
429
|
-
if (reg) for (const id of reg) ids.add(id);
|
|
430
|
-
return ids;
|
|
431
|
-
};
|
|
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
|
+
}
|
|
432
364
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
bootPatched = landed.wrote;
|
|
452
|
-
// 注册表通道:注入只是把条目放进进程内注册表,宿主快照可能已在挂载时按旧目录
|
|
453
|
-
// 定稿——用宿主 API 复核,缺失即同值触碰触发重建(ADR 0003 增补)。
|
|
454
|
-
if (landed.mode === "registry") {
|
|
455
|
-
await ensureRegistryServed(ctx, overlayEntryIds(state.journal.pendingOverlay), log);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
state.appliedOverlay = mergeCatalog(state.appliedOverlay, state.journal.pendingOverlay).merged;
|
|
459
|
-
state.appliedProvenance = {
|
|
460
|
-
sourcePiAiVersion: state.journal.source?.piAiVersion ?? null,
|
|
461
|
-
integrity: state.journal.source?.integrity ?? null,
|
|
462
|
-
appliedAgainstPiAiVersion: state.journal.appliedAgainstPiAiVersion,
|
|
463
|
-
catalogSchemaVersion: 1,
|
|
464
|
-
};
|
|
465
|
-
state.activated = true;
|
|
466
|
-
// file 通道才需要重启标记(下个 boot 由 2a 把 settings 镜像到 target);
|
|
467
|
-
// 注册表注入当次生效,boot2a 在本 boot 就会同步 settings(ADR 0003)。
|
|
468
|
-
if (install.writable) state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
|
|
469
|
-
state.journal = advanceJournal(state.journal);
|
|
470
|
-
state = clearLastError(state);
|
|
471
|
-
saveState(stateFile, state);
|
|
472
|
-
}
|
|
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
|
+
};
|
|
473
383
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
saveState(stateFile, state);
|
|
482
|
-
} else if (install.version === baseline) {
|
|
483
|
-
const replay = mergeCatalog(readDiskCatalog(), state.appliedOverlay);
|
|
484
|
-
if (replay.added.length > 0) {
|
|
485
|
-
const landed = await landIncrement(state.appliedOverlay, replay);
|
|
486
|
-
if (!landed.ok) {
|
|
487
|
-
// 自愈失败不抛错、不阻断启动序列:appliedOverlay 保留,下次 boot 重放。
|
|
488
|
-
const why = landed.mode === "file" ? `catalog-not-writable (${install.unwritableReason})` : `registry-inject-failed (${landed.reason})`;
|
|
489
|
-
state = setLastError(state, `${why}: self-heal deferred, overlay entries stay pending`);
|
|
490
|
-
saveState(stateFile, state);
|
|
491
|
-
} else {
|
|
492
|
-
bootPatched = landed.wrote;
|
|
493
|
-
// 注册表注入是进程内的、当次 boot 即生效,不需要重启标记(ADR 0003)。
|
|
494
|
-
if (landed.wrote) state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
|
|
495
|
-
state = clearLastError(state);
|
|
496
|
-
saveState(stateFile, state);
|
|
497
|
-
// 注入通常落在宿主快照之后(宿主在挂载时即定稿目录解析结果)——用宿主
|
|
498
|
-
// 自己的 listModels 复核,缺失即同值触碰触发重建;仍缺失则记可 grep 的
|
|
499
|
-
// lastError(不写盘、不阻断挂载)。
|
|
500
|
-
if (landed.mode === "registry") {
|
|
501
|
-
const served = await ensureRegistryServed(ctx, overlayEntryIds(state.appliedOverlay), log);
|
|
502
|
-
if (!served.ok) {
|
|
503
|
-
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`);
|
|
504
|
-
saveState(stateFile, state);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
} else {
|
|
510
|
-
const served = await servedIds();
|
|
511
|
-
const missing = overlayIds.filter((id) => !served.has(id));
|
|
512
|
-
if (missing.length > 0) {
|
|
513
|
-
// 跨版本有缺失:上报 self-heal-incompatible,不改安装树
|
|
514
|
-
state = setLastError(state, `self-heal-incompatible: pi-ai ${install.version} != baseline ${baseline}, missing ${missing.join(",")}`);
|
|
515
|
-
saveState(stateFile, state);
|
|
516
|
-
}
|
|
517
|
-
// 条目已原生存在 → 空操作
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
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
|
+
});
|
|
521
391
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
let failed = null;
|
|
529
|
-
try {
|
|
530
|
-
if (!install) throw new Error("pi-ai installation not found");
|
|
531
|
-
// 语义前置(Y2-3):targetIds 全部可由当前目录解析;whole-file digest 仅诊断
|
|
532
|
-
const ids = await servedIds();
|
|
533
|
-
const unresolvable = j.targetIds.filter((id) => !ids.has(id));
|
|
534
|
-
if (unresolvable.length > 0) throw new Error(`target-unresolvable: ${unresolvable.join(",")}`);
|
|
535
|
-
const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
536
|
-
if (!desc) throw new Error("settings namespace llm-pi-ai unavailable");
|
|
537
|
-
const view = viewFromUserLayer(desc.user);
|
|
538
|
-
if (digest(view) === digest(j.settingsBaseline)) {
|
|
539
|
-
await ctx.settings.mutate("llm-pi-ai", [
|
|
540
|
-
{ op: "set", path: ["providers", "github-copilot", "models"], value: j.targetIds.map((id) => ({ id })) },
|
|
541
|
-
{ op: "unset", path: ["providers", "github-copilot", "modelOverrides"] },
|
|
542
|
-
], desc.revision); // 真 CAS:revision 不符由 settings 抛 SETTINGS_CONFLICT
|
|
543
|
-
consumed = true;
|
|
544
|
-
} else if (viewEqualsTarget(view, j.targetIds)) {
|
|
545
|
-
consumed = true; // 已等于 target → 幂等消费,不再 mutate
|
|
546
|
-
} else {
|
|
547
|
-
failed = "settings-conflict: configuration changed since preview; re-run refresh";
|
|
548
|
-
}
|
|
549
|
-
} catch (err) {
|
|
550
|
-
failed = err?.code === "SETTINGS_CONFLICT" || /conflict/i.test(String(err?.message ?? ""))
|
|
551
|
-
? `settings-conflict: ${err.message}`
|
|
552
|
-
: String(err?.message ?? err);
|
|
553
|
-
}
|
|
554
|
-
if (consumed) {
|
|
555
|
-
state.journal = null;
|
|
556
|
-
state = clearLastError(state);
|
|
557
|
-
saveState(stateFile, state);
|
|
558
|
-
} else if (failed) {
|
|
559
|
-
state = setLastError(state, failed); // 不覆盖、记 conflict、/status 上报
|
|
560
|
-
saveState(stateFile, state);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
// 2b. restartState 非空且 expected entries 已在目录 → 仅清 restartState
|
|
564
|
-
// (不携带 settings 意图:单独存在时不得触碰 describe/mutate)
|
|
565
|
-
if (state.restartState && install) {
|
|
566
|
-
const ids = await servedIds();
|
|
567
|
-
if (overlayEntryIds(state.appliedOverlay).every((id) => ids.has(id))) {
|
|
568
|
-
state.restartState = null;
|
|
569
|
-
state = clearLastError(state);
|
|
570
|
-
saveState(stateFile, state);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
} catch (err) {
|
|
575
|
-
try {
|
|
576
|
-
saveState(stateFile, setLastError(state, err));
|
|
577
|
-
} catch { /* 记录失败不掩盖 */ }
|
|
578
|
-
ctx.logger?.warn?.("copilot-auth: refresh boot failed: %s", String(err?.message ?? err));
|
|
579
|
-
}
|
|
580
|
-
}
|
|
392
|
+
// 授权控制器(T3):软撤回+风险锁存;退出/重新授权在当前宿主下统一禁用。
|
|
393
|
+
const controller = createAuthorizationController(ctx, {
|
|
394
|
+
scope,
|
|
395
|
+
intentIO,
|
|
396
|
+
onAuthorized: (handoff) => { void runSync(handoff); },
|
|
397
|
+
});
|
|
581
398
|
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
const corrupt = () => ({ ...freshState(), lastError: "state-corrupt" });
|
|
585
|
-
let raw;
|
|
399
|
+
// ---- 初始化期能力探测(Q23:GET status 不执行写探针,通道分类来自此处缓存) ----
|
|
400
|
+
let installInfo = null;
|
|
586
401
|
try {
|
|
587
|
-
|
|
402
|
+
installInfo = resolveInstall(opts);
|
|
588
403
|
} catch {
|
|
589
|
-
|
|
404
|
+
installInfo = null;
|
|
590
405
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const diff = diffModels(currentIds, ids, catalogIds);
|
|
609
|
-
const customizationReset = {
|
|
610
|
-
modelEntryIds: (Array.isArray(view.models) ? view.models : [])
|
|
611
|
-
.filter((m) => m && typeof m === "object" && Object.keys(m).some((k) => k !== "id"))
|
|
612
|
-
.map((m) => m.id),
|
|
613
|
-
modelOverrideIds:
|
|
614
|
-
view.modelOverrides && typeof view.modelOverrides === "object" && !Array.isArray(view.modelOverrides)
|
|
615
|
-
? Object.keys(view.modelOverrides)
|
|
616
|
-
: [],
|
|
617
|
-
};
|
|
618
|
-
const digests = {
|
|
619
|
-
settings: digest(view), // raw 完整视图:字段级变化必漂移(R3-2)
|
|
620
|
-
available: digest([...ids].sort()),
|
|
621
|
-
catalog: digest(local.bytes), // 本地目录文件字节
|
|
622
|
-
remote: digest(resolved.catalog), // 实际使用的目录来源对象(与 mode 严格对应)
|
|
623
|
-
};
|
|
624
|
-
return { view, ids, source, local, resolved, merged, diff, customizationReset, digests };
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// 端到端自证(ADR 0003):注入 + settings 写入后,路由必须真的把 target 端出来。
|
|
628
|
-
// settings→快照重建是异步的,故带短重试。返回 { ok, missing, served }。
|
|
629
|
-
async function waitForServedModels(ctx, targetIds, { attempts = 6, delayMs = 150 } = {}) {
|
|
630
|
-
const want = [...new Set(targetIds)];
|
|
631
|
-
let served = new Set();
|
|
632
|
-
for (let i = 0; i < attempts; i++) {
|
|
633
|
-
try {
|
|
634
|
-
served = new Set((await ctx.llm.listModels("github-copilot")).map((m) => m?.id).filter(Boolean));
|
|
635
|
-
} catch (err) {
|
|
636
|
-
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
|
+
});
|
|
637
423
|
}
|
|
638
|
-
const missing = want.filter((id) => !served.has(id));
|
|
639
|
-
if (missing.length === 0) return { ok: true, missing: [], served: [...served] };
|
|
640
|
-
if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
641
424
|
}
|
|
642
|
-
return { ok: false, missing: want.filter((id) => !served.has(id)), served: [...served] };
|
|
643
|
-
}
|
|
644
425
|
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
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
|
+
};
|
|
660
478
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
+
});
|
|
666
491
|
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
void bootRefresh(ctx, opts)
|
|
492
|
+
// 启动序列(boot 恢复+保守激活/自愈,T9 bootRecover);绝不阻断挂载。
|
|
493
|
+
void refreshService.bootReady()
|
|
670
494
|
.catch((err) => {
|
|
671
495
|
ctx.logger?.warn?.("copilot-auth: refresh boot failed: %s", String(err?.message ?? err));
|
|
672
496
|
})
|
|
673
497
|
.finally(() => opts.onBootDone?.());
|
|
674
498
|
|
|
675
|
-
//
|
|
676
|
-
// 插件启动/重启绝不触碰用户 settings,用户精简过的目录不会被动重置。
|
|
677
|
-
// 失败不静默:错误经 /status 的 syncError 字段暴露,便于诊断。
|
|
678
|
-
const sync = () => syncAvailableModels(ctx, opts).then(() => { lastSyncError = undefined; }).catch((err) => {
|
|
679
|
-
lastSyncError = String(err?.message ?? err);
|
|
680
|
-
ctx.logger?.warn?.("copilot-auth: model sync failed: %s", lastSyncError);
|
|
681
|
-
});
|
|
499
|
+
// ==================== 授权路由(T3/T4) ====================
|
|
682
500
|
|
|
683
501
|
ctx.webServer.register({
|
|
684
502
|
kind: "exact",
|
|
685
503
|
path: r.start,
|
|
686
504
|
handler: (req, res) => {
|
|
687
505
|
if (!guard(req, res, "POST")) return;
|
|
688
|
-
if (
|
|
689
|
-
json(res,
|
|
506
|
+
if (!scope.known) {
|
|
507
|
+
json(res, 503, { ok: false, error: "scope-unavailable" });
|
|
690
508
|
return;
|
|
691
509
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
})
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
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
|
+
}
|
|
725
542
|
},
|
|
726
543
|
});
|
|
727
544
|
|
|
@@ -730,7 +547,7 @@ export function apply(ctx, opts = {}) {
|
|
|
730
547
|
path: r.state,
|
|
731
548
|
handler: (req, res) => {
|
|
732
549
|
if (!guard(req, res, "GET")) return;
|
|
733
|
-
json(res, 200,
|
|
550
|
+
json(res, 200, controller.snapshot());
|
|
734
551
|
},
|
|
735
552
|
});
|
|
736
553
|
|
|
@@ -740,73 +557,96 @@ export function apply(ctx, opts = {}) {
|
|
|
740
557
|
handler: async (req, res) => {
|
|
741
558
|
if (!guard(req, res, "GET")) return;
|
|
742
559
|
// describeRecord 只回传 presence,不把含 token 的 GrantRecord 拉进内存。
|
|
743
|
-
let
|
|
560
|
+
let credential = "absent";
|
|
744
561
|
try {
|
|
745
562
|
const info = await ctx.credentials.describeRecord(CREDENTIAL_KEY);
|
|
746
|
-
|
|
747
|
-
} catch {
|
|
748
|
-
configured = false;
|
|
749
|
-
}
|
|
750
|
-
// refresh 状态块:lastError 直读状态文件顶层(R3-6);pendingRestart 派生自
|
|
751
|
-
// 状态文件(journal 相位或 restartState),不用模块布尔;piAiVersion /
|
|
752
|
-
// catalogDigest / catalogFile 来自同根解析,digest 为当前文件字节 sha256
|
|
753
|
-
// (E2E 据此把被检查文件绑定到实际加载副本,R3-8/R4-5)。
|
|
754
|
-
let install = null;
|
|
755
|
-
try {
|
|
756
|
-
install = resolveInstall(opts);
|
|
563
|
+
credential = info?.configured === true ? "present" : "absent";
|
|
757
564
|
} catch {
|
|
758
|
-
|
|
565
|
+
credential = "read-error";
|
|
759
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;
|
|
760
571
|
let catalogDigest = null;
|
|
761
|
-
if (
|
|
572
|
+
if (installInfo) {
|
|
762
573
|
try {
|
|
763
|
-
catalogDigest = digest(readFileSync(
|
|
574
|
+
catalogDigest = digest(readFileSync(installInfo.catalogFile));
|
|
764
575
|
} catch {
|
|
765
576
|
catalogDigest = null;
|
|
766
577
|
}
|
|
767
578
|
}
|
|
768
|
-
|
|
579
|
+
let registryViaNow = null;
|
|
580
|
+
let registryMismatch = null;
|
|
581
|
+
try {
|
|
582
|
+
registryViaNow = registryVia();
|
|
583
|
+
registryMismatch = registryInstanceMismatch();
|
|
584
|
+
} catch { /* 诊断字段缺失不阻断 */ }
|
|
769
585
|
const refresh = {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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,
|
|
775
602
|
catalogDigest,
|
|
776
|
-
|
|
777
|
-
catalogWritable: install ? install.writable === true : null,
|
|
778
|
-
// 落地通道(ADR 0003):file = 可写安装树走目录补丁;registry = 只读安装树
|
|
779
|
-
// (desktop asar)走进程内目录注册表注入。registryInjected = 已注入条目数。
|
|
780
|
-
catalogMode: install ? (install.writable === true ? "file" : "registry") : null,
|
|
781
|
-
registryInjected: overlayEntryIds(state.appliedOverlay).length,
|
|
603
|
+
lastError: st?.lastError ?? null,
|
|
782
604
|
};
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
+
});
|
|
787
627
|
},
|
|
788
628
|
});
|
|
789
629
|
|
|
630
|
+
// 退出登录:当前宿主桥接无法证明安全退出(D-01),统一拒绝且绝不 deleteRecord。
|
|
790
631
|
ctx.webServer.register({
|
|
791
632
|
kind: "exact",
|
|
792
633
|
path: r.logout,
|
|
793
|
-
handler:
|
|
634
|
+
handler: (req, res) => {
|
|
794
635
|
if (!guard(req, res, "POST")) return;
|
|
795
|
-
|
|
796
|
-
// 真实删除与否由随后的 /status 反映。异常兜底与其余路由对齐
|
|
797
|
-
// (2026-09-28 审计:无 try/catch 的 reject 会造成连接悬挂/未处理拒绝)。
|
|
798
|
-
try {
|
|
799
|
-
await ctx.credentials.deleteRecord(CREDENTIAL_KEY);
|
|
800
|
-
} catch (err) {
|
|
801
|
-
json(res, 500, { ok: false, error: String(err?.message ?? err) });
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
json(res, 200, { ok: true });
|
|
636
|
+
json(res, 403, { ok: false, error: "logout-safety-unavailable" });
|
|
805
637
|
},
|
|
806
638
|
});
|
|
807
639
|
|
|
808
|
-
//
|
|
809
|
-
|
|
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
|
+
|
|
810
650
|
ctx.webServer.register({
|
|
811
651
|
kind: "exact",
|
|
812
652
|
path: r.refreshPreview,
|
|
@@ -819,43 +659,20 @@ export function apply(ctx, opts = {}) {
|
|
|
819
659
|
json(res, 400, { ok: false, error: "bad-body" });
|
|
820
660
|
return;
|
|
821
661
|
}
|
|
822
|
-
|
|
823
|
-
if (mode !== undefined && mode !== "latest" && mode !== "overlay") {
|
|
824
|
-
json(res, 400, { ok: false, error: "invalid-mode" });
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
662
|
+
if (!requireProtocol(res, body)) return;
|
|
827
663
|
try {
|
|
828
|
-
const
|
|
829
|
-
json(res, 200,
|
|
830
|
-
ok: true,
|
|
831
|
-
source: inputs.source,
|
|
832
|
-
catalogSource: inputs.resolved.catalogSource,
|
|
833
|
-
catalogError: inputs.resolved.catalogError,
|
|
834
|
-
catalogWritable: inputs.local.writable,
|
|
835
|
-
catalogMode: inputs.local.writable ? "file" : "registry",
|
|
836
|
-
catalogNormalized: inputs.resolved.normalized,
|
|
837
|
-
skipped: inputs.merged.skipped,
|
|
838
|
-
added: inputs.diff.added,
|
|
839
|
-
removed: inputs.diff.removed,
|
|
840
|
-
kept: inputs.diff.kept,
|
|
841
|
-
target: inputs.diff.target,
|
|
842
|
-
customizationReset: inputs.customizationReset,
|
|
843
|
-
digests: inputs.digests,
|
|
844
|
-
});
|
|
664
|
+
const out = await refreshMutex(() => refreshService.preview(body ?? {}));
|
|
665
|
+
json(res, 200, out);
|
|
845
666
|
} catch (err) {
|
|
846
667
|
if (err instanceof InvalidModeError) {
|
|
847
668
|
json(res, 400, { ok: false, error: "invalid-mode" });
|
|
848
669
|
return;
|
|
849
670
|
}
|
|
850
|
-
|
|
671
|
+
serviceError(res, err);
|
|
851
672
|
}
|
|
852
673
|
},
|
|
853
674
|
});
|
|
854
675
|
|
|
855
|
-
// 手动刷新 · apply:mutex 内重算全部输入 digest 与回传比对(漂移 → 409
|
|
856
|
-
// preview-stale,client 重新 preview);一致则 write-ahead:journal(prepared)
|
|
857
|
-
// → 原子提交目录 → 合入 appliedOverlay + activated → journal 推进 committed。
|
|
858
|
-
// 重启 dsh web 后由启动序列把 settings 目录镜像重建为 target(见 boot 序列)。
|
|
859
676
|
ctx.webServer.register({
|
|
860
677
|
kind: "exact",
|
|
861
678
|
path: r.refreshApply,
|
|
@@ -868,121 +685,38 @@ export function apply(ctx, opts = {}) {
|
|
|
868
685
|
json(res, 400, { ok: false, error: "bad-body" });
|
|
869
686
|
return;
|
|
870
687
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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" });
|
|
874
708
|
return;
|
|
875
709
|
}
|
|
710
|
+
if (!requireProtocol(res, body)) return;
|
|
876
711
|
try {
|
|
877
|
-
const out = await
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
for (const k of ["settings", "available", "catalog", "remote"]) {
|
|
881
|
-
if (got[k] !== inputs.digests[k]) {
|
|
882
|
-
return { code: 409, payload: { ok: false, error: "preview-stale" } };
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
// digest 全部一致后才解析版本(写入失败点上移,500 不留 journal)
|
|
886
|
-
const install = resolveInstall(opts);
|
|
887
|
-
const stateFile = opts.stateFile ?? defaultStateFile();
|
|
888
|
-
if (!install.writable) {
|
|
889
|
-
// 只读安装树(desktop asar):ADR 0002 的写盘门禁之外再开一条**不写盘**的
|
|
890
|
-
// 落地通道——进程内目录注册表注入(ADR 0003)。注入成功才写 settings,
|
|
891
|
-
// 再以 listModels 端到端自证;自证失败回滚 settings,绝不产出
|
|
892
|
-
// "能选中但发不出去"的假模型。
|
|
893
|
-
const injectRegistry = opts.injectRegistry ?? injectCatalogEntries;
|
|
894
|
-
const injected = await injectRegistry(install, inputs.merged.addedOverlay, {
|
|
895
|
-
log: (m) => ctx.logger?.info?.(m),
|
|
896
|
-
allowedKeys: catalogShapeKeys(inputs.local.catalog),
|
|
897
|
-
});
|
|
898
|
-
if (!injected.ok) {
|
|
899
|
-
return {
|
|
900
|
-
code: 400,
|
|
901
|
-
payload: {
|
|
902
|
-
ok: false,
|
|
903
|
-
error: "registry-inject-failed",
|
|
904
|
-
reason: injected.reason,
|
|
905
|
-
missing: injected.missing,
|
|
906
|
-
rejected: injected.rejected,
|
|
907
|
-
catalogFile: install.catalogFile,
|
|
908
|
-
},
|
|
909
|
-
};
|
|
910
|
-
}
|
|
911
|
-
const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
|
|
912
|
-
if (!desc) {
|
|
913
|
-
return { code: 500, payload: { ok: false, error: "settings namespace llm-pi-ai unavailable" } };
|
|
914
|
-
}
|
|
915
|
-
await ctx.settings.mutate("llm-pi-ai", [
|
|
916
|
-
{ op: "set", path: ["providers", "github-copilot", "models"], value: inputs.diff.target.map((id) => ({ id })) },
|
|
917
|
-
], desc.revision);
|
|
918
|
-
const served = await waitForServedModels(ctx, inputs.diff.target);
|
|
919
|
-
if (!served.ok) {
|
|
920
|
-
const rolledBack = await rollbackSettings(ctx, inputs.view);
|
|
921
|
-
return {
|
|
922
|
-
code: 500,
|
|
923
|
-
payload: { ok: false, error: "registry-not-effective", missing: served.missing, rolledBack },
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
let state = loadState(stateFile);
|
|
927
|
-
state.appliedOverlay = mergeCatalog(state.appliedOverlay, inputs.merged.addedOverlay).merged;
|
|
928
|
-
state.appliedProvenance = {
|
|
929
|
-
sourcePiAiVersion: inputs.resolved.provenance.piAiVersion ?? null,
|
|
930
|
-
integrity: inputs.resolved.provenance.integrity ?? null,
|
|
931
|
-
appliedAgainstPiAiVersion: install.version,
|
|
932
|
-
catalogSchemaVersion: 1,
|
|
933
|
-
};
|
|
934
|
-
state.activated = true;
|
|
935
|
-
state.journal = null; // 注册表注入当次生效,无写盘、无重启相位
|
|
936
|
-
state = clearLastError(state);
|
|
937
|
-
saveState(stateFile, state);
|
|
938
|
-
return {
|
|
939
|
-
code: 200,
|
|
940
|
-
payload: {
|
|
941
|
-
ok: true,
|
|
942
|
-
restartRequired: false,
|
|
943
|
-
mode: "registry",
|
|
944
|
-
registryVia: injected.via,
|
|
945
|
-
injected: [...injected.injected, ...injected.present],
|
|
946
|
-
target: inputs.diff.target.length,
|
|
947
|
-
},
|
|
948
|
-
};
|
|
949
|
-
}
|
|
950
|
-
const patchedBytes = Buffer.from(JSON.stringify(inputs.merged.merged, null, 2) + "\n", "utf8");
|
|
951
|
-
let state = loadState(stateFile);
|
|
952
|
-
state.journal = createJournal({
|
|
953
|
-
settingsBaseline: inputs.view,
|
|
954
|
-
targetIds: inputs.diff.target,
|
|
955
|
-
pendingOverlay: inputs.merged.addedOverlay, // catalog-shaped 增量(R3-4)
|
|
956
|
-
appliedAgainstPiAiVersion: install.version,
|
|
957
|
-
catalogBaselineDigest: inputs.digests.catalog,
|
|
958
|
-
patchedCatalogDigest: digest(patchedBytes),
|
|
959
|
-
source: inputs.resolved.provenance,
|
|
960
|
-
});
|
|
961
|
-
saveState(stateFile, state); // ① write-ahead:prepared
|
|
962
|
-
writeJsonAtomic(install.catalogFile, inputs.merged.merged); // ② 目录原子提交
|
|
963
|
-
state = loadState(stateFile);
|
|
964
|
-
state.appliedOverlay = mergeCatalog(state.appliedOverlay, inputs.merged.addedOverlay).merged;
|
|
965
|
-
state.appliedProvenance = {
|
|
966
|
-
sourcePiAiVersion: inputs.resolved.provenance.piAiVersion ?? null,
|
|
967
|
-
integrity: inputs.resolved.provenance.integrity ?? null,
|
|
968
|
-
appliedAgainstPiAiVersion: install.version,
|
|
969
|
-
catalogSchemaVersion: 1,
|
|
970
|
-
};
|
|
971
|
-
state.activated = true;
|
|
972
|
-
state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
|
|
973
|
-
saveState(stateFile, state); // ③ appliedOverlay/activated 落盘
|
|
974
|
-
state = loadState(stateFile);
|
|
975
|
-
state.journal = advanceJournal(state.journal);
|
|
976
|
-
saveState(stateFile, state); // ④ 相位推进 catalog-committed-needs-restart
|
|
977
|
-
return { code: 200, payload: { ok: true, restartRequired: true } };
|
|
978
|
-
});
|
|
979
|
-
json(res, out.code, out.payload);
|
|
980
|
-
} catch (err) {
|
|
981
|
-
if (err instanceof InvalidModeError) {
|
|
982
|
-
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" });
|
|
983
715
|
return;
|
|
984
716
|
}
|
|
985
|
-
json(res,
|
|
717
|
+
json(res, 200, out);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
serviceError(res, err);
|
|
986
720
|
}
|
|
987
721
|
},
|
|
988
722
|
});
|