@inventec/dsh-copilot-auth 1.2.6 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  - 🔄 **手动补充新模型目录条目**:GHC 设置页一键刷新——diff 预览 + 二次确认;可写安装树走数据级目录补丁(**pi-ai 版本不动**),**只读安装树(desktop `app.asar`)走进程内目录注册表注入**(ADR 0003:零写盘、无需重启),新模型(如 `gpt-6.1-sol`)即可加入、失效模型镜像移除
23
23
  - 🌐 **中/英双语界面**:跟随 DSH 语言设置自动切换
24
24
  - 🔑 **凭据安全托管**:存入 DSH 内置凭据库(文件强制 0600 权限),Copilot 临时 token 到期自动刷新
25
- - 🧪 **测试与 CI**:139 条单元测试(ubuntu/windows/macos 三平台矩阵);GitHub Actions 构建测试 + tag 触发自动发布(provenance)
25
+ - 🧪 **测试与 CI**:140 条单元测试(ubuntu/windows/macos 三平台矩阵);GitHub Actions 构建测试 + tag 触发自动发布(provenance)
26
26
 
27
27
  ## 前置要求
28
28
 
@@ -32,7 +32,8 @@
32
32
 
33
33
  | 本插件 | DSH 实测版本 | authorization 服务 | 说明 |
34
34
  |---|---|---|---|
35
- | v1.2.6+ | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **重启后自动恢复**:注入后以宿主 `listModels` 复核,未端出即同值触碰触发快照重建(修复 v1.2.5 的"重启后注入条目被宿主旧快照丢弃");`/status.settingsNotServed` 可直接诊断 |
35
+ | v1.2.7+ | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **重启后自动恢复**:注入后以宿主 `listModels` 复核,未端出即**净零切换**(`displayName` 改后复原;两次真内容变化 ⇒ 宿主必然重建快照,用户配置逐字节不变)触发重建;另加双路取实例 + `registryInstanceMismatch`/`registryVia` 诊断。修复 v1.2.6 的"同值写不改变配置身份 ⇒ 快照不重建" |
36
+ | v1.2.6 | `0.1.7-rc.2`+ | runtime 内置,插件不挂载 | 首次引入宿主侧复核与 `settingsNotServed` 诊断;重启自愈用的"同值触碰"**实测不足**(同值写不改变配置对象身份),重启后模型可能仍不出现——请用 v1.2.7 |
36
37
  | v1.2.5 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **desktop 只读形态可刷新**:进程内目录注册表注入(ADR 0003),无需重启、不碰官方产物;`0.2.0-rc.2` desktop **实机端到端已验**(注入条目完成真实推理请求);同批修复 pi-ai ≥0.99.0 的 `chat:` 目录键格式。**已知缺陷**:重启后若宿主快照先于注入定稿,注入条目会被丢弃(v1.2.6 修复) |
37
38
  | v1.2.4 | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | desktop asar 只读形态:目录刷新入口显式置灰(ADR 0002),登录/路由/settings 不受影响 |
38
39
  | v1.2.0 ~ v1.2.3 | `0.1.7-rc.2` ~ `0.2.0-rc.1`(web/服务形态) | runtime 内置,插件不挂载 | 适配 settings API 重塑后的 describe() 行形状;desktop asar 形态下目录刷新不可用且报错不友好 |
@@ -86,7 +87,7 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 ge
86
87
  - **可写性门禁(v1.2.4 起,ADR 0002)**:dsh-desktop(Electron)把整个运行时树打包进只读的 `resources/app.asar`,pi-ai 是其中唯一副本——读一切正常、写必被拒。插件在写盘前对目标做可写性分类(asar 路径识别 + 探针文件实测):不可写时 preview/`/status` 暴露 `catalogWritable:false`,**v1.2.4 起该形态按只读处理**(v1.2.3 及之前会抛 `ENOENT ... not found in ...app.asar` 天书并遗留 prepared journal)。Web/服务部署形态(pi-ai 在真实磁盘)行为不变。
87
88
  - **只读安装树的落地通道(v1.2.5 起,ADR 0003)**:只读目标不再意味着"刷新不可用"。写入面换成了 pi-ai 的**进程内目录注册表**(`MODELS["github-copilot"]`,DSH 每次构建快照都现读它):插件把增量条目注入该对象,内置 `GitHub Copilot` 路由即把新模型端出来——OAuth 凭据、消息翻译、三协议分派(按条目自带的 `api`)、picker/设置页全部复用既有链路,**不新增路由、不改宿主任何文件、不碰官方安装树、无需重启**。注入成功才写 settings,随后用 `ctx.llm.listModels` 端到端自证;任一条目端不出来即**回滚 settings** 并返回 500 `registry-not-effective`(绝不产出"能选中但发不出去"的假模型)。注册表面不可用(模块加载失败 / 注册表被冻结 / 条目非法)一律结构化报错并保留 journal,可 grep `registry-*`。`/status` 的 `catalogMode`(`file`|`registry`)与 `registryInjected` 可观测。**可写安装树完全不加载注册表面**,web 形态零新增副作用。
88
89
  - **远端目录形状规范化(v1.2.5 起)**:pi-ai ≥0.99.0 把目录 JSON 的键改成 `chat:<内层裸 id>`(运行时仍按裸 id 编键)。插件在 preview/apply 共用的取数层统一规范化回裸 id 形状,并跳过键与内层 id 冲突的条目——**不规范化时刷新会"拿到新目录却一个条目都加不进来"**,这条修复对 Web/服务形态同样生效。
89
- - **重启后的自愈与快照时序(v1.2.6 起)**:注册表注入只活在进程里,重启后由启动序列(boot1)重放。但 `dsh-llm-pi-ai` 在**挂载时**就把目录解析结果定稿并 memoize(只在 settings 配置对象身份变化时重建),而插件挂载晚于它——boot 期注入可能落在快照之后,表现为"settings 里还有该模型、picker 里却看不到"。v1.2.6 起注入后会用**宿主自己的** `listModels` 复核;缺失即对该路由 `models` 做一次**同值触碰**(写回与当前完全相同的值,只改配置对象身份、不改用户内容)触发重建,再复核。仍端不出来时记可 grep 的 `lastError: registry-not-served: <ids>`,并把结果暴露在 `/status.settingsNotServed`(宿主 API 不可用时为 `null`)。用户没引用的条目一律不触碰 settings——启动期不写用户配置的原则不破。
90
+ - **重启后的自愈与快照时序(v1.2.6 起,v1.2.7 修正)**:注册表注入只活在进程里,重启后由启动序列(boot1)重放。但 `dsh-llm-pi-ai` 在**挂载时**就把目录解析结果定稿并 memoize(只在 settings 配置对象**身份**变化时重建),而插件挂载晚于它——boot 期注入可能落在快照之后,表现为"settings 里还有该模型、picker 里却看不到"。插件注入后会立刻用**宿主自己的** `listModels` 复核;**同值写回无效**(实测:同值不改变配置身份 ⇒ 快照不重建),因此 v1.2.7 改为**净零切换**:把该路由 `displayName` 改成「原值 + 尾随空格」再改回原值(该键非用户所有则 `unset`)——两次都是真内容变化,**用户配置逐字节不变**,宿主必然重建快照。仍端不出来时记可 grep 的 `lastError: registry-not-served: <ids>`,并暴露 `/status.settingsNotServed`(宿主 API 不可用时 `null`)、`registryVia`(`bare`/`file`)与 `registryInstanceMismatch`(`true` = 进程里存在两份 pi-ai 模块实例)。用户没引用的条目一律不触碰 settings。
90
91
 
91
92
  ## 工作原理
92
93
 
@@ -101,7 +102,7 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 ge
101
102
 
102
103
  ```bash
103
104
  npm install
104
- npm test # node:test:patch 结构 + host/目录/状态机/启动序列/可写性门禁/注册表注入 共 139 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
105
+ npm test # node:test:patch 结构 + host/目录/状态机/启动序列/可写性门禁/注册表注入 共 140 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
105
106
  npm run build # esbuild 打包 client 到 lib/client.js(__ModuleLoader__ 信封)
106
107
  npm pack --dry-run
107
108
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.2.6",
3
+ "version": "1.2.7",
4
4
  "description": "为内置 Copilot 提供方补设备码登录/注销设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。适配 DSH 0.1.7+(settings API 重塑后;≤0.1.5 请用 1.1.x)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,21 @@ export function registryDistDir(install) {
37
37
  return dirname(dirname(dirname(install.catalogFile)));
38
38
  }
39
39
 
40
- // 依次尝试「裸 specifier → 绝对路径 file URL」加载目录注册表模块。
40
+ // 诊断:最近一次成功注入走的取路方式(bare = 宿主解析拦截给出的同一实例;
41
+ // file = asar 绝对路径),以及两条路是否给出了**不同**的模块实例((b) 类故障的直接证据)。
42
+ let lastVia = null;
43
+ let lastInstanceMismatch = false;
44
+ export function registryVia() {
45
+ return lastVia;
46
+ }
47
+ export function registryInstanceMismatch() {
48
+ return lastInstanceMismatch;
49
+ }
50
+
51
+ // 依次尝试「裸 specifier → 绝对路径 file URL」加载目录注册表模块。两条路都试:若它们
52
+ // 给出的注册表对象**不是同一个**,说明进程里存在两份模块实例,而宿主用的是它自己解析
53
+ // 出来的那份(裸 specifier 走宿主的解析拦截)——此时必须优先裸 specifier,否则我们注入
54
+ // 的是一份孤儿副本(现象:注入"成功"、路由永远端不出来)。
41
55
  // 两条路的返回都做形状校验:注册表必须是可写对象、getBuiltinModels 必须是函数。
42
56
  export async function loadRegistryModules(install, log) {
43
57
  const dist = registryDistDir(install);
@@ -50,6 +64,7 @@ export async function loadRegistryModules(install, log) {
50
64
  },
51
65
  ];
52
66
  const errors = [];
67
+ const loaded = [];
53
68
  for (const attempt of attempts) {
54
69
  try {
55
70
  const models = await import(attempt.models);
@@ -61,13 +76,25 @@ export async function loadRegistryModules(install, log) {
61
76
  if (typeof all?.getBuiltinModels !== "function") {
62
77
  throw new TypeError("getBuiltinModels unavailable");
63
78
  }
64
- return { via: attempt.via, registry, all, errors };
79
+ loaded.push({ ...attempt, registry, all });
65
80
  } catch (err) {
66
81
  errors.push(`${attempt.via}: ${err?.code ?? ""} ${err?.message ?? err}`.trim());
67
82
  }
68
83
  }
69
- log?.(`copilot-auth: registry modules unavailable — ${errors.join(" | ")}`);
70
- return { via: null, registry: null, all: null, errors };
84
+ if (loaded.length === 0) {
85
+ log?.(`copilot-auth: registry modules unavailable — ${errors.join(" | ")}`);
86
+ return { via: null, registry: null, all: null, errors, instanceMismatch: false };
87
+ }
88
+ const bare = loaded.find((x) => x.via === "bare");
89
+ const file = loaded.find((x) => x.via === "file");
90
+ const instanceMismatch = bare !== undefined && file !== undefined && bare.registry !== file.registry;
91
+ const chosen = bare ?? file; // 有裸 specifier 就用它(宿主实例优先)
92
+ if (instanceMismatch) {
93
+ log?.("copilot-auth: registry module instance mismatch — bare 与 file URL 给出两份实例,已选用裸 specifier(宿主实例)");
94
+ }
95
+ lastVia = chosen.via;
96
+ lastInstanceMismatch = instanceMismatch;
97
+ return { via: chosen.via, registry: chosen.registry, all: chosen.all, errors, instanceMismatch };
71
98
  }
72
99
 
73
100
  // 注入 catalog-shaped 增量({ api: { id: entry } }),返回报告对象(绝不抛)。
package/src/host.mjs CHANGED
@@ -10,7 +10,7 @@ import { CREDENTIAL_KEY, emptyState, routes } from "./shared.mjs";
10
10
  import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
11
11
  import { mergeCatalog, diffModels, digest, findPiAiInstallation, classifyCatalogTarget, normalizeRemoteCatalog, catalogShapeKeys } from "./catalog.mjs";
12
12
  import { fetchLatestCatalog } from "./catalog-fetch.mjs";
13
- import { injectCatalogEntries, registryModelIds } from "./catalog-registry.mjs";
13
+ import { injectCatalogEntries, registryModelIds, registryVia, registryInstanceMismatch } from "./catalog-registry.mjs";
14
14
  import { fetchLiveAvailableModelIds } from "./copilot-models.mjs";
15
15
  import { loadState, saveState, freshState, createJournal, advanceJournal, restartMarker, setLastError, clearLastError } from "./state.mjs";
16
16
 
@@ -315,7 +315,7 @@ function overlayEntryIds(overlay) {
315
315
  // 晚于它,动态 import pi-ai 模块图又要几秒——boot 期注入因此常常落在快照之后:注册表里
316
316
  // 有条目,路由仍按旧快照把它判为"目录不描述"并丢弃。注入成功后必须用**宿主自己的**
317
317
  // listModels 复核(不能用我们自己的模块实例自证,那是自指的),缺失就做一次
318
- // **同值 settings 触碰**触发快照重建——只改配置对象身份,绝不改用户内容。
318
+ // **净零切换**触发快照重建——两次真内容变化的写,最终内容与用户原配置完全一致。
319
319
 
320
320
  // 路由当前端出的模型 id 集合;宿主 API 不可用时返回 null(无从判断,不误报)。
321
321
  async function servedModelIds(ctx) {
@@ -342,7 +342,7 @@ async function registryServedDiagnostics(ctx, overlayIds) {
342
342
  return { referenced, missing: referenced.filter((id) => !served.has(id)) };
343
343
  }
344
344
 
345
- // 同值触碰后的短重试复核(快照重建是异步的)。
345
+ // 净零切换后的短重试复核(快照重建是异步的)。
346
346
  async function registryMissingWithRetry(ctx, ids, { attempts = 5, delayMs = 150 } = {}) {
347
347
  let missing = ids;
348
348
  for (let i = 0; i < attempts; i++) {
@@ -354,21 +354,34 @@ async function registryMissingWithRetry(ctx, ids, { attempts = 5, delayMs = 150
354
354
  return missing;
355
355
  }
356
356
 
357
- // boot 期注入后的自愈复核:缺失即同值触碰,再复核。返回 { ok, touched, referenced, missing }。
357
+ // 净零切换(ADR 0003 增补 v1.2.7):**同值写不会改变配置对象身份**——宿主快照因此不重建
358
+ // (2026-10-03 实测:boot 期同值触碰写了文件、宿主仍按旧快照丢弃注入条目;apply 写的也是
359
+ // 同值,同样 500 registry-not-effective)。所以这里做两次**真内容变化**的写把身份推两次:
360
+ // 先把该路由 `displayName` 改成当前值 + 一个尾随空格,再改回原状(该键非用户所有时 unset)。
361
+ // 净效果 = 用户配置一字不改,但宿主必然重建快照;displayName 不参与模型列表语义。
362
+ async function touchRouteIdentity(ctx, log) {
363
+ const describe = () => ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
364
+ const first = describe();
365
+ const route = first?.user?.providers?.["github-copilot"];
366
+ if (!first || !route || typeof route !== "object" || Array.isArray(route)) return false;
367
+ const hadOwn = Object.prototype.hasOwnProperty.call(route, "displayName");
368
+ const current = hadOwn ? route.displayName : undefined;
369
+ const probeBase = typeof current === "string" && current.length > 0 ? current : "GitHub Copilot";
370
+ const path = ["providers", "github-copilot", "displayName"];
371
+ await ctx.settings.mutate("llm-pi-ai", [{ op: "set", path, value: `${probeBase} ` }], first.revision);
372
+ const second = describe();
373
+ await ctx.settings.mutate("llm-pi-ai", [hadOwn ? { op: "set", path, value: current } : { op: "unset", path }], second?.revision);
374
+ return true;
375
+ }
376
+
377
+ // boot 期注入后的自愈复核:缺失即净零切换触发重建,再复核。返回 { ok, touched, referenced, missing }。
358
378
  async function ensureRegistryServed(ctx, overlayIds, log) {
359
379
  const before = await registryServedDiagnostics(ctx, overlayIds);
360
380
  if (before.referenced.length === 0) return { ok: true, touched: false, ...before };
361
381
  if (before.missing !== null && before.missing.length === 0) return { ok: true, touched: false, ...before };
362
382
  let touched = false;
363
383
  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
- }
384
+ touched = await touchRouteIdentity(ctx, log);
372
385
  } catch (err) {
373
386
  log?.(`copilot-auth: registry snapshot touch failed — ${String(err?.message ?? err)}`);
374
387
  }
@@ -377,7 +390,7 @@ async function ensureRegistryServed(ctx, overlayIds, log) {
377
390
  if (!ok) {
378
391
  log?.(
379
392
  `copilot-auth: registry-not-served — ${(missing ?? before.referenced).join(",")}`
380
- + (touched ? "" : " (同值触碰未执行:settings 未注入或该路由无 models)"),
393
+ + (touched ? "" : " (净零切换未执行:settings 未注入或该路由配置缺失)"),
381
394
  );
382
395
  }
383
396
  return { ok, touched, referenced: before.referenced, missing };
@@ -450,7 +463,7 @@ async function bootRefresh(ctx, opts) {
450
463
  }
451
464
  bootPatched = landed.wrote;
452
465
  // 注册表通道:注入只是把条目放进进程内注册表,宿主快照可能已在挂载时按旧目录
453
- // 定稿——用宿主 API 复核,缺失即同值触碰触发重建(ADR 0003 增补)。
466
+ // 定稿——用宿主 API 复核,缺失即净零切换触发重建(ADR 0003 增补)。
454
467
  if (landed.mode === "registry") {
455
468
  await ensureRegistryServed(ctx, overlayEntryIds(state.journal.pendingOverlay), log);
456
469
  }
@@ -495,7 +508,7 @@ async function bootRefresh(ctx, opts) {
495
508
  state = clearLastError(state);
496
509
  saveState(stateFile, state);
497
510
  // 注入通常落在宿主快照之后(宿主在挂载时即定稿目录解析结果)——用宿主
498
- // 自己的 listModels 复核,缺失即同值触碰触发重建;仍缺失则记可 grep 的
511
+ // 自己的 listModels 复核,缺失即净零切换触发重建;仍缺失则记可 grep 的
499
512
  // lastError(不写盘、不阻断挂载)。
500
513
  if (landed.mode === "registry") {
501
514
  const served = await ensureRegistryServed(ctx, overlayEntryIds(state.appliedOverlay), log);
@@ -783,6 +796,10 @@ export function apply(ctx, opts = {}) {
783
796
  // 诊断口径(ADR 0003 增补):已注入、被 settings 引用、却没能从路由端出来的 id。
784
797
  // "注册表里有了但 picker 里看不到"这类问题的唯一直接证据;宿主 API 不可用时为 null。
785
798
  refresh.settingsNotServed = (await registryServedDiagnostics(ctx, overlayEntryIds(state.appliedOverlay))).missing;
799
+ // 注入取路面:bare(宿主解析拦截给出的同一实例,首选)/ file(asar 绝对路径)。
800
+ // instanceMismatch=true 说明进程里存在两份模块实例——(b) 类故障的直接证据。
801
+ refresh.registryVia = registryVia();
802
+ refresh.registryInstanceMismatch = registryInstanceMismatch();
786
803
  json(res, 200, { configured, syncError: lastSyncError, refresh });
787
804
  },
788
805
  });