@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/src/host.mjs CHANGED
@@ -1,27 +1,34 @@
1
- // host 半区:cordis 插件。注册 6 条 exact 路由,把内置 github-copilot 的
2
- // OAuth 设备码流(ctx.authorization)暴露给 Web client,并提供「手动刷新可用
3
- // 模型目录」(数据级目录补丁,pi-ai 代码版本不动;只读 GET /models 适配显式
4
- // 耦合 pi-ai 0.84.4,详见 GLOSSARY.md 与 ADR 0001)。
5
- import { existsSync, readFileSync, realpathSync } from "node:fs";
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, emptyState, routes } from "./shared.mjs";
10
+ import { CREDENTIAL_KEY, PROTOCOL_VERSION, routes } from "./shared.mjs";
10
11
  import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
11
- import { mergeCatalog, diffModels, digest, findPiAiInstallation, classifyCatalogTarget, normalizeRemoteCatalog, catalogShapeKeys } from "./catalog.mjs";
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 { fetchLiveAvailableModelIds } from "./copilot-models.mjs";
15
- import { loadState, saveState, freshState, createJournal, advanceJournal, restartMarker, setLastError, clearLastError } from "./state.mjs";
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
- // 内置覆盖层的收割来源版本(Task 7,integrity 记录于 README「模型目录刷新」章节)
24
+ // 内置覆盖层的收割来源版本(integrity 记录于 README「模型目录刷新」章节)
21
25
  const OVERLAY_SOURCE_VERSION = "0.85.1";
22
26
 
23
- // pi-ai 目录数据文件定位层(从 readCatalogModelIds 拆出,行为不变):
24
- // 从进程入口(dsh 可执行文件)逐级向上找 node_modules/@earendil-works/pi-ai。
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
- // 登录成功后,把账号可用模型写入用户 settings 的模型目录
64
- // (turnkey,用户新增需求 2026-09-04;目录保护,用户反馈 2026-09-05)。
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
- // 真 CAS(与 boot 2a 同款,2026-09-28 审计补充):携带 expectedRevision 提交,
112
- // 与用户在 Models 页的并发编辑互斥——冲突时 settings 抛 SETTINGS_CONFLICT,
113
- // 由 sync() 的 catch 记入 syncError(失败不静默),不再有静默覆盖窗口。
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 同根解析(R3-8):注入 catalogFile 时 version 取其所在根的
173
- // package.json(opts.piAiVersion 为测试钩子);生产走 findPiAiInstallation()。
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
- // 账号可用模型:现场拉取(显式耦合 0.84.4)→ 任何失败回退凭证缓存 availableModelIds
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 层完整配置视图(R3-2):digest/baseline/CAS 的唯一取数面。
269
- // 缺席一律 null 占位(非 undefined),保证 journal JSON 持久化无损、boot 重载后
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 rawSettingsView(ctx) {
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
- return viewFromUserLayer(d?.user);
291
- } catch { /* settings 未注入/未注册/读取失败一律按空视图 */ }
292
- return viewFromUserLayer(undefined);
293
- }
294
-
295
- // 幂等判定:当前视图是否已等于 target 形状——models 恰好为 targetIds 的纯 {id}
296
- // 条目(无任何字段定制),且无有效 modelOverrides。
297
- function viewEqualsTarget(view, targetIds) {
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
- async function registryMissingWithRetry(ctx, ids, { attempts = 5, delayMs = 150 } = {}) {
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;
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
- // boot 期注入后的自愈复核:缺失即同值触碰,再复核。返回 { ok, touched, referenced, missing }。
358
- async function ensureRegistryServed(ctx, overlayIds, log) {
359
- const before = await registryServedDiagnostics(ctx, overlayIds);
360
- if (before.referenced.length === 0) return { ok: true, touched: false, ...before };
361
- if (before.missing !== null && before.missing.length === 0) return { ok: true, touched: false, ...before };
362
- let touched = false;
363
- try {
364
- const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
365
- const rawModels = desc?.user?.providers?.["github-copilot"]?.models;
366
- if (Array.isArray(rawModels)) {
367
- await ctx.settings.mutate("llm-pi-ai", [
368
- { op: "set", path: ["providers", "github-copilot", "models"], value: rawModels },
369
- ], desc.revision);
370
- touched = true;
371
- }
372
- } catch (err) {
373
- log?.(`copilot-auth: registry snapshot touch failed — ${String(err?.message ?? err)}`);
374
- }
375
- const missing = touched ? await registryMissingWithRetry(ctx, before.referenced) : before.missing;
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
- // 启动序列(严格按序 boot 0→1→2;整体 try/catch,失败只记 lastError +
387
- // logger.warn,绝不阻断挂载;所有写入幂等,崩溃现场下个 boot 安全重试)。
388
- async function bootRefresh(ctx, opts) {
389
- const stateFile = opts.stateFile ?? defaultStateFile();
390
- let state = loadState(stateFile);
391
- try {
392
- if (state.lastError === "state-corrupt") {
393
- // 损坏已隔离:持久化安全态(/status 稳定可见、激活丢失→自愈停用,🟡-4)
394
- saveState(stateFile, state);
395
- }
396
- if (!state.journal && !state.activated && !state.restartState) return; // R9:未激活不触碰
397
- let install = null;
398
- try {
399
- install = resolveInstall(opts);
400
- } catch {
401
- install = null;
402
- }
403
- let bootPatched = false;
404
- const readDiskCatalog = () => JSON.parse(readFileSync(install.catalogFile, "utf8"));
405
- const diskIds = (disk) => new Set(Object.values(disk).flatMap((s) => Object.keys(s ?? {})));
406
- const log = (m) => ctx.logger?.info?.(m);
407
- // 测试注入钩子:opts.injectRegistry / opts.registryIds(生产恒用真实实现)。
408
- const injectRegistry = opts.injectRegistry ?? injectCatalogEntries;
409
- const readRegistryIds = opts.registryIds ?? registryModelIds;
410
- // 落地一次目录增量:可写安装树走原子写盘(ADR 0001);只读安装树(desktop asar)
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
- // boot 0:journal=prepared 崩溃恢复(先于激活门,但先过兼容 gate,R3-1)
434
- if (state.journal?.phase === "prepared") {
435
- if (!install || install.version !== state.journal.appliedAgainstPiAiVersion) {
436
- state = setLastError(state, `prepared-incompatible: journal against ${state.journal.appliedAgainstPiAiVersion}, current ${install?.version ?? "none"} — re-run refresh preview`);
437
- saveState(stateFile, state); // 目录零写入、journal 保留
438
- return;
439
- }
440
- const replay = mergeCatalog(readDiskCatalog(), state.journal.pendingOverlay);
441
- if (replay.added.length > 0) {
442
- const landed = await landIncrement(state.journal.pendingOverlay, replay);
443
- if (!landed.ok) {
444
- // 落地失败(不可写且注册表注入失败):journal 原样保留、零写入、可 grep 的
445
- // lastError,下次 boot 重试;安装树将来变可写后本恢复自动完成。
446
- const why = landed.mode === "file" ? `catalog-not-writable (${install.unwritableReason})` : `registry-inject-failed (${landed.reason})`;
447
- state = setLastError(state, `${why}: pi-ai catalog is not landable; prepared journal kept`);
448
- saveState(stateFile, state);
449
- return;
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
- // boot 1:自愈(仅 activated,且当前 pi-ai 版本==基线才重放;跨版本见 gate)
475
- if (state.activated) {
476
- const baseline = state.appliedProvenance?.appliedAgainstPiAiVersion;
477
- const overlayIds = overlayEntryIds(state.appliedOverlay);
478
- if (overlayIds.length > 0 && baseline) {
479
- if (!install) {
480
- state = setLastError(state, "self-heal-incompatible: pi-ai installation not found");
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
- // boot 2:本 boot 未写目录时,两个独立出口(R3-5 职责分离)
523
- if (!bootPatched) {
524
- // 2a. journal=committed → settings 真 CAS(settings 同步的唯一触发源)
525
- if (state.journal?.phase === "catalog-committed-needs-restart") {
526
- const j = state.journal;
527
- let consumed = false;
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
- // /status 专用只读读取:不隔离、不写盘(隔离只在 boot 发生一次)
583
- function peekState(path) {
584
- const corrupt = () => ({ ...freshState(), lastError: "state-corrupt" });
585
- let raw;
399
+ // ---- 初始化期能力探测(Q23:GET status 不执行写探针,通道分类来自此处缓存) ----
400
+ let installInfo = null;
586
401
  try {
587
- raw = readJson(path);
402
+ installInfo = resolveInstall(opts);
588
403
  } catch {
589
- return corrupt();
404
+ installInfo = null;
590
405
  }
591
- if (raw === undefined) return freshState();
592
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return corrupt();
593
- return { ...freshState(), ...raw };
594
- }
595
-
596
- // preview 与 apply 共享的输入计算——digest 绑定的正确性依赖两侧走同一代码路径。
597
- async function computeRefreshInputs(ctx, opts, mode) {
598
- const fetchImpl = opts.fetchImpl ?? globalThis.fetch?.bind(globalThis);
599
- const { ids, source } = await resolveAvailable(ctx, fetchImpl);
600
- const local = readLocalCatalog(opts);
601
- const resolved = await resolveCatalogSource(opts, mode, fetchImpl, catalogShapeKeys(local.catalog));
602
- const merged = mergeCatalog(local.catalog, resolved.catalog);
603
- const catalogIds = new Set(Object.values(merged.merged).flatMap((s) => Object.keys(s ?? {})));
604
- const view = rawSettingsView(ctx);
605
- const currentIds = Array.isArray(view.models)
606
- ? view.models.map((m) => m?.id).filter((x) => typeof x === "string")
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
- // 自证失败时的 best-effort 回滚:把用户 settings 的 models 恢复成刷新前的视图。
646
- // 回滚本身失败不掩盖原错误——调用方以结构化错误(rolledBack:false)上报。
647
- async function rollbackSettings(ctx, previous) {
648
- try {
649
- const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
650
- if (!desc) return false;
651
- const ops = previous?.modelsPresent
652
- ? [{ op: "set", path: ["providers", "github-copilot", "models"], value: previous.models }]
653
- : [{ op: "unset", path: ["providers", "github-copilot", "models"] }];
654
- await ctx.settings.mutate("llm-pi-ai", ops, desc.revision);
655
- return true;
656
- } catch {
657
- return false;
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
- export function apply(ctx, opts = {}) {
662
- const r = routes();
663
- let attempt = emptyState();
664
- let lastSyncError;
665
- const refreshMutex = createMutex(); // 宿主单进程内互斥(不支持多实例,见 README)
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
- // 启动序列:prepared 恢复(先过兼容 gate)→ 激活门 → 自愈 → settings 真 CAS。
668
- // 绝不阻断挂载;失败仅 lastError + warn。opts.onBootDone 为测试钩子(boot settle 回调)。
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
- // 模型目录同步只在登录成功后执行(2026-09-05 对齐结论:挂载不再同步)。
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 (attempt.status === "running") {
689
- json(res, 409, { ok: false, error: "already running" });
506
+ if (!scope.known) {
507
+ json(res, 503, { ok: false, error: "scope-unavailable" });
690
508
  return;
691
509
  }
692
- attempt = emptyState();
693
- attempt.status = "running";
694
- const interaction = {
695
- notify: (notice) => {
696
- attempt.notices.push(notice);
697
- },
698
- prompt: (p) => {
699
- // 企业域名提问答空串(公司是普通 github.com 组织账号);
700
- // 其余任何 prompt 都是未预期的,拒绝并使 attempt 失败。
701
- if (p && typeof p.message === "string" && p.message.includes("Enterprise")) {
702
- return Promise.resolve("");
703
- }
704
- return Promise.reject(new Error("unexpected prompt: " + (p?.message ?? String(p))));
705
- },
706
- };
707
- // 响应先行:begin 以后台任务执行,handler 返回路径不得 await begin。
708
- void ctx.authorization
709
- .begin({ key: CREDENTIAL_KEY, method: "oauth", interaction })
710
- .then((outcome) => {
711
- // AuthorizationOutcome.status: 'authorized' | 'cancelled'(types.d.ts L68-71)
712
- if (outcome && outcome.status === "authorized") {
713
- attempt.status = "authorized";
714
- void sync();
715
- } else {
716
- attempt.status = "failed";
717
- attempt.error = "登录已取消";
718
- }
719
- })
720
- .catch((err) => {
721
- attempt.status = "failed";
722
- attempt.error = String(err?.message ?? err);
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, attempt);
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 configured = false;
560
+ let credential = "absent";
744
561
  try {
745
562
  const info = await ctx.credentials.describeRecord(CREDENTIAL_KEY);
746
- configured = info?.configured === true;
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
- install = null;
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 (install) {
572
+ if (installInfo) {
762
573
  try {
763
- catalogDigest = digest(readFileSync(install.catalogFile));
574
+ catalogDigest = digest(readFileSync(installInfo.catalogFile));
764
575
  } catch {
765
576
  catalogDigest = null;
766
577
  }
767
578
  }
768
- const state = peekState(opts.stateFile ?? defaultStateFile());
579
+ let registryViaNow = null;
580
+ let registryMismatch = null;
581
+ try {
582
+ registryViaNow = registryVia();
583
+ registryMismatch = registryInstanceMismatch();
584
+ } catch { /* 诊断字段缺失不阻断 */ }
769
585
  const refresh = {
770
- activated: state.activated === true,
771
- pendingRestart: state.restartState != null || state.journal?.phase === "catalog-committed-needs-restart",
772
- phase: state.journal?.phase ?? null,
773
- lastError: state.lastError ?? null,
774
- piAiVersion: install?.version ?? null,
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
- catalogFile: install?.catalogFile ?? null,
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
- // 诊断口径(ADR 0003 增补):已注入、被 settings 引用、却没能从路由端出来的 id。
784
- // "注册表里有了但 picker 里看不到"这类问题的唯一直接证据;宿主 API 不可用时为 null。
785
- refresh.settingsNotServed = (await registryServedDiagnostics(ctx, overlayEntryIds(state.appliedOverlay))).missing;
786
- json(res, 200, { configured, syncError: lastSyncError, refresh });
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: async (req, res) => {
634
+ handler: (req, res) => {
794
635
  if (!guard(req, res, "POST")) return;
795
- // 幂等契约:deleteRecord 对不存在记录是 no-op 且正常 resolve,
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
- // 手动刷新可用模型目录 · preview:只读,返回 diff + customizationReset + digest 组。
809
- // body 可带 { "mode": "overlay" } 用内置覆盖层出 diff(离线 bootstrap)。
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
- const mode = body?.mode;
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 inputs = await computeRefreshInputs(ctx, opts, mode);
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
- json(res, 500, { ok: false, error: String(err?.message ?? err) });
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
- const mode = body?.mode;
872
- if (mode !== undefined && mode !== "latest" && mode !== "overlay") {
873
- json(res, 400, { ok: false, error: "invalid-mode" });
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 refreshMutex(async () => {
878
- const inputs = await computeRefreshInputs(ctx, opts, mode);
879
- const got = body?.digests ?? {};
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, 500, { ok: false, error: String(err?.message ?? err) });
717
+ json(res, 200, out);
718
+ } catch (err) {
719
+ serviceError(res, err);
986
720
  }
987
721
  },
988
722
  });