@inventec/dsh-copilot-auth 1.2.5 → 1.2.6

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**:136 条单元测试(ubuntu/windows/macos 三平台矩阵);GitHub Actions 构建测试 + tag 触发自动发布(provenance)
25
+ - 🧪 **测试与 CI**:139 条单元测试(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.5+ | `0.1.7-rc.2`+(`0.2.0-rc.1` web 与 `0.2.0-rc.2` desktop 已实测) | runtime 内置,插件不挂载 | **desktop 只读形态可刷新**:进程内目录注册表注入(ADR 0003),无需重启、不碰官方产物;同批修复 pi-ai ≥0.99.0 的 `chat:` 目录键格式 |
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` 可直接诊断 |
36
+ | 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 修复) |
36
37
  | 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 不受影响 |
37
38
  | v1.2.0 ~ v1.2.3 | `0.1.7-rc.2` ~ `0.2.0-rc.1`(web/服务形态) | runtime 内置,插件不挂载 | 适配 settings API 重塑后的 describe() 行形状;desktop asar 形态下目录刷新不可用且报错不友好 |
38
39
  | v1.1.x | `0.1.2-rc.1` ~ `0.1.5-rc.2` | 由 cordis.patch.yml insert 挂载 | v1.2.0 起移除该补丁,旧版 DSH 上无法激活 |
@@ -85,6 +86,7 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 ge
85
86
  - **可写性门禁(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 在真实磁盘)行为不变。
86
87
  - **只读安装树的落地通道(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 形态零新增副作用。
87
88
  - **远端目录形状规范化(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——启动期不写用户配置的原则不破。
88
90
 
89
91
  ## 工作原理
90
92
 
@@ -99,7 +101,7 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 ge
99
101
 
100
102
  ```bash
101
103
  npm install
102
- npm test # node:test:patch 结构 + host/目录/状态机/启动序列/可写性门禁/注册表注入 共 136 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
104
+ npm test # node:test:patch 结构 + host/目录/状态机/启动序列/可写性门禁/注册表注入 共 139 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
103
105
  npm run build # esbuild 打包 client 到 lib/client.js(__ModuleLoader__ 信封)
104
106
  npm pack --dry-run
105
107
  ```
@@ -130,7 +132,7 @@ npm pack --dry-run
130
132
  - 模型目录只在「尚不存在」时由登录成功兜底填充一次,填充后归用户所有:账号新增的模型不会自动出现——用 GHC 设置页的「**刷新可用模型目录**」同步(见上节),或在 Models 页手动添加
131
133
  - 模型目录只写入「可解析」的模型:目录快照外的新模型须先经「刷新可用模型目录」补入——可写安装树补进目录文件、只读安装树(desktop)注入进程内注册表(ADR 0003),两条通道都保持 pi-ai 版本不变
132
134
  - DSH 升级后自愈仅在 pi-ai 基线版本不变(0.84.4)时重放;跨版本且条目未原生存在时上报 `self-heal-incompatible`,不修改安装树(重新执行一次手动刷新即可在新基线上激活)
133
- - **dsh-desktop(Electron 桌面版)可刷新模型目录(v1.2.5 起)**:运行时树打包在只读的 `app.asar` 内,写盘通道物理不可用,但刷新改走进程内目录注册表注入(ADR 0003)——当次生效、无需重启、不碰官方产物。前提是该进程的 pi-ai 目录注册表形态与实测一致(`MODELS[provider]` 未冻结、扁平、键=模型 id);上游改形状时注入守卫会拦下并报 `registry-*` 结构化错误,模型列表保持原样(不写 settings)。v1.2.4 及之前该形态只能置灰刷新入口(`catalog-not-writable`,另见 ADR 0002)
135
+ - **dsh-desktop(Electron 桌面版)可刷新模型目录(v1.2.5 起)**:运行时树打包在只读的 `app.asar` 内,写盘通道物理不可用,但刷新改走进程内目录注册表注入(ADR 0003)——当次生效、无需重启、不碰官方产物;重启后由启动序列重放,并按 v1.2.6 的复核/同值触碰机制保证路由真的端出这些模型。前提是该进程的 pi-ai 目录注册表形态与实测一致(`MODELS[provider]` 未冻结、扁平、键=模型 id);上游改形状时注入守卫会拦下并报 `registry-*` 结构化错误,模型列表保持原样(不写 settings)。v1.2.4 及之前该形态只能置灰刷新入口(`catalog-not-writable`,另见 ADR 0002)
134
136
  - 并发保护为宿主单进程内 mutex,**不支持多实例/多进程并发刷新**
135
137
  - 状态落盘后的目录 fsync 仅在 POSIX 执行:Windows 无目录 fsync 语义(对目录句柄 fsync 必然 `EPERM`),win32 直接跳过、不再刷告警,目录项一致性由 NTFS 元数据日志保证(v1.2.3 起,issue #1);POSIX 行为不变
136
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.2.5",
3
+ "version": "1.2.6",
4
4
  "description": "为内置 Copilot 提供方补设备码登录/注销设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。适配 DSH 0.1.7+(settings API 重塑后;≤0.1.5 请用 1.1.x)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/host.mjs CHANGED
@@ -309,6 +309,80 @@ function overlayEntryIds(overlay) {
309
309
  return Object.values(overlay ?? {}).flatMap((s) => Object.keys(s ?? {}));
310
310
  }
311
311
 
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
+ // 路由当前端出的模型 id 集合;宿主 API 不可用时返回 null(无从判断,不误报)。
321
+ async function servedModelIds(ctx) {
322
+ try {
323
+ return new Set((await ctx.llm.listModels("github-copilot")).map((m) => m?.id).filter(Boolean));
324
+ } catch {
325
+ return null;
326
+ }
327
+ }
328
+
329
+ // 用户 settings 里当前引用的模型 id(只有它们才需要"端得出来")。
330
+ function configuredModelIds(ctx) {
331
+ const models = readConfiguredRoute(ctx).models;
332
+ return new Set((Array.isArray(models) ? models : []).map((m) => m?.id).filter(Boolean));
333
+ }
334
+
335
+ // 诊断口径:已注入条目中,被 settings 引用却没能从路由端出来的 id。
336
+ // 返回 { referenced, missing };宿主 API 不可用时 missing 为 null。
337
+ async function registryServedDiagnostics(ctx, overlayIds) {
338
+ const referenced = [...new Set(overlayIds)].filter((id) => configuredModelIds(ctx).has(id));
339
+ if (referenced.length === 0) return { referenced, missing: [] };
340
+ const served = await servedModelIds(ctx);
341
+ if (served === null) return { referenced, missing: null };
342
+ return { referenced, missing: referenced.filter((id) => !served.has(id)) };
343
+ }
344
+
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;
355
+ }
356
+
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 };
384
+ }
385
+
312
386
  // 启动序列(严格按序 boot 0→1→2;整体 try/catch,失败只记 lastError +
313
387
  // logger.warn,绝不阻断挂载;所有写入幂等,崩溃现场下个 boot 安全重试)。
314
388
  async function bootRefresh(ctx, opts) {
@@ -375,6 +449,11 @@ async function bootRefresh(ctx, opts) {
375
449
  return;
376
450
  }
377
451
  bootPatched = landed.wrote;
452
+ // 注册表通道:注入只是把条目放进进程内注册表,宿主快照可能已在挂载时按旧目录
453
+ // 定稿——用宿主 API 复核,缺失即同值触碰触发重建(ADR 0003 增补)。
454
+ if (landed.mode === "registry") {
455
+ await ensureRegistryServed(ctx, overlayEntryIds(state.journal.pendingOverlay), log);
456
+ }
378
457
  }
379
458
  state.appliedOverlay = mergeCatalog(state.appliedOverlay, state.journal.pendingOverlay).merged;
380
459
  state.appliedProvenance = {
@@ -415,6 +494,16 @@ async function bootRefresh(ctx, opts) {
415
494
  if (landed.wrote) state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
416
495
  state = clearLastError(state);
417
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
+ }
418
507
  }
419
508
  }
420
509
  } else {
@@ -691,6 +780,9 @@ export function apply(ctx, opts = {}) {
691
780
  catalogMode: install ? (install.writable === true ? "file" : "registry") : null,
692
781
  registryInjected: overlayEntryIds(state.appliedOverlay).length,
693
782
  };
783
+ // 诊断口径(ADR 0003 增补):已注入、被 settings 引用、却没能从路由端出来的 id。
784
+ // "注册表里有了但 picker 里看不到"这类问题的唯一直接证据;宿主 API 不可用时为 null。
785
+ refresh.settingsNotServed = (await registryServedDiagnostics(ctx, overlayEntryIds(state.appliedOverlay))).missing;
694
786
  json(res, 200, { configured, syncError: lastSyncError, refresh });
695
787
  },
696
788
  });