@inventec/dsh-copilot-auth 1.2.4 → 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
@@ -4,7 +4,7 @@
4
4
  [![npm](https://img.shields.io/npm/v/%40inventec%2Fdsh-copilot-auth?label=npm)](https://www.npmjs.com/package/@inventec/dsh-copilot-auth)
5
5
  [![CI](https://img.shields.io/github/actions/workflow/status/iasiv5/dsh-copilot-auth/ci.yml?branch=main&label=CI)](https://github.com/iasiv5/dsh-copilot-auth/actions/workflows/ci.yml)
6
6
  [![License](https://img.shields.io/github/license/iasiv5/dsh-copilot-auth?label=License)](https://github.com/iasiv5/dsh-copilot-auth/blob/main/LICENSE)
7
- [![DSH Web](https://img.shields.io/badge/DSH%20Web-0.1.7--rc.2%20%2F%200.2.0--rc.1%20verified-2563eb)](#前置要求)
7
+ [![DSH](https://img.shields.io/badge/DSH-0.1.7--rc.2%20%7C%200.2.0--rc.1%2Frc.2%20verified-2563eb)](#前置要求)
8
8
 
9
9
  为 DSH(DeepSeek Harness)内置的 GitHub Copilot LLM provider 补上 Web 端设备码(device flow)登录/注销入口,并预置一条开箱即用的 GitHub Copilot 提供方路由。
10
10
 
@@ -19,20 +19,23 @@
19
19
  - 🔐 **设备码登录/注销**:网页 + user code,免 API Token;SSO 组织授权友好
20
20
  - 🧩 **预置提供方路由**:安装即出现在 Models 页,无需手动配置
21
21
  - 📦 **模型目录兜底填充**:登录成功时,若 GitHub Copilot 路由还没有模型目录,自动填入账号全部可用模型;你已精简/定制过的目录**永不覆盖**(重启、重新登录均不重置)
22
- - 🔄 **手动补充新模型目录条目**:GHC 设置页一键刷新——diff 预览 + 二次确认 + 数据级目录补丁(**pi-ai 版本不动**),新模型(如 gemini-3.8-flash)即可加入、失效模型镜像移除
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**:107 条单元测试;GitHub Actions 构建测试 + tag 触发自动发布(provenance)
25
+ - 🧪 **测试与 CI**:139 条单元测试(ubuntu/windows/macos 三平台矩阵);GitHub Actions 构建测试 + tag 触发自动发布(provenance)
26
26
 
27
27
  ## 前置要求
28
28
 
29
- - DSH `0.1.7-rc.2` / `0.2.0-rc.1`(实测版本,v1.2.x 的适配目标;0.2.0 起注意插件 peer 精确版本守卫,见下文版本矩阵)
29
+ - DSH `0.1.7-rc.2` / `0.2.0-rc.1` / `0.2.0-rc.2`(实测版本,v1.2.x 的适配目标;0.2.0 起注意插件 peer 精确版本守卫,见下文版本矩阵)
30
30
  - 版本矩阵:`0.1.2-rc.1` ~ `0.1.5-rc.2` 请用 **v1.1.x**(v1.2.0 移除了旧版必需的 authorization 挂载补丁,在旧版上无法激活)
31
31
  - v1.2.0 适配了 0.1.7 的 settings API 重塑(`settings.get` 移除),目录保护逻辑改走 `describe()`:**`describe()` 读取在 value 层缺失时回退 user 层,兼容旧版 describe 行形状**;DSH 版本支持矩阵见下表
32
32
 
33
33
  | 本插件 | DSH 实测版本 | authorization 服务 | 说明 |
34
34
  |---|---|---|---|
35
- | v1.2.x | `0.1.7-rc.2`+(`0.2.0-rc.1` 已实测通过) | runtime 内置,插件不挂载 | 适配 settings API 重塑后的 describe() 行形状 |
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 修复) |
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 不受影响 |
38
+ | v1.2.0 ~ v1.2.3 | `0.1.7-rc.2` ~ `0.2.0-rc.1`(web/服务形态) | runtime 内置,插件不挂载 | 适配 settings API 重塑后的 describe() 行形状;desktop asar 形态下目录刷新不可用且报错不友好 |
36
39
  | v1.1.x | `0.1.2-rc.1` ~ `0.1.5-rc.2` | 由 cordis.patch.yml insert 挂载 | v1.2.0 起移除该补丁,旧版 DSH 上无法激活 |
37
40
 
38
41
  - 有效的 GitHub Copilot 订阅(公司分配的 github.com 组织账号,SSO 登录)
@@ -75,13 +78,15 @@ dsh plugin --profile web add @inventec/dsh-copilot-auth
75
78
 
76
79
  pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 gemini-3.8-flash)在本机无法解析、失效模型残留。「刷新可用模型目录」按钮(GHC 设置页,登录后可见)在不升级 pi-ai 的前提下解决这一问题。
77
80
 
78
- - **两阶段生效**:确认后先对本机 `github-copilot.json` 做原子数据级补丁(pi-ai 代码版本不变);**重启 dsh web 后**插件再把 settings 模型目录镜像重建为「账号可用 ∩ 目录可解析」集合(目录在模块加载时读取,同 boot 内写完目录不会同步 settings)。
81
+ - **生效时机按落地通道分叉**:可写安装树(web/服务)→ 先对本机 `github-copilot.json` 做原子数据级补丁(pi-ai 代码版本不变),**重启 dsh web 后**插件再把 settings 模型目录镜像重建为「账号可用 ∩ 目录可解析」集合(目录在模块加载时读取,同 boot 内写完目录不会同步 settings);只读安装树(desktop `app.asar`)→ 进程内注册表注入(ADR 0003),**当次生效、无需重启**。
79
82
  - **diff 预览 + 二次确认**:弹窗展示新增(绿)/移除(红)/保留计数、被校验跳过的上游条目、以及**将被重置的定制名单**(字段微调与 modelOverrides)。确认前请核对三条风险:失效模型将消失、你的定制将被重置、需重启后才生效。
80
- - **激活边界**:插件首次安装/升级后处于未激活态,启动时不触碰 pi-ai 安装树;只有成功执行过一次手动刷新后才激活。DSH 升级冲掉补丁后,已激活实例启动时自愈重放**历史上实际应用过**的补丁条目(内置覆盖层仅作离线数据源,绝不自动应用)。
81
- - **数据源与回退**:账号可用模型现场拉取(失败回退凭证缓存);目录数据从 npm 拉取最新 pi-ai tarball(强制 `dist.integrity` 校验、超时与大小上限、tar 严格解析),失败时可改用内置覆盖层(0.85.1 收割,integrity `sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==`)出 diff。
83
+ - **激活边界**:插件首次安装/升级后处于未激活态,启动时不触碰 pi-ai 安装树;只有成功执行过一次手动刷新后才激活。DSH 升级冲掉补丁后,已激活实例启动时自愈重放**历史上实际应用过**的补丁条目(内置覆盖层仅作离线数据源,绝不自动应用)。- **数据源与回退**:账号可用模型现场拉取(失败回退凭证缓存);目录数据从 npm 拉取最新 pi-ai tarball(强制 `dist.integrity` 校验、超时与大小上限、tar 严格解析),失败时可改用内置覆盖层(0.85.1 收割,integrity `sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==`)出 diff。
82
84
  - **只增不更新**:合并只补充本机从未见过的模型条目;上游对已有 id 的元数据修正(api 归属、contextWindow 等)不会同步——这是「永不覆盖」语义的代价(ADR 0001)。
83
85
  - **digest 绑定**:preview 与 apply 之间以输入 digest(settings 完整配置 / 可用模型 / 本地目录 / 目录来源)绑定,任何漂移即 409 并要求重新预览确认。
84
- - **可写性门禁(v1.2.4 起,ADR 0002)**:dsh-desktop(Electron)把整个运行时树打包进只读的 `resources/app.asar`,pi-ai 是其中唯一副本——读一切正常、写必被拒。插件在写盘前对目标做可写性分类(asar 路径识别 + 探针文件实测):不可写时 preview/`/status` 暴露 `catalogWritable:false`、UI 置灰刷新入口并给出解释文案,apply 在 write-ahead **之前**以 400 `catalog-not-writable` 结构化快速失败(绝不留下 prepared journal);boot 序列遇到只读目标同样不重试写盘,journal/overlay 原样保留,目标变可写(如桌面版改为解包 pi-ai)后恢复/自愈自动完成。Web/服务部署形态(pi-ai 在真实磁盘)行为不变。
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 在真实磁盘)行为不变。
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 形态零新增副作用。
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——启动期不写用户配置的原则不破。
85
90
 
86
91
  ## 工作原理
87
92
 
@@ -96,7 +101,7 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 ge
96
101
 
97
102
  ```bash
98
103
  npm install
99
- npm test # node:test:patch 结构 + host/目录/状态机/启动序列/可写性门禁共 114 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
104
+ npm test # node:test:patch 结构 + host/目录/状态机/启动序列/可写性门禁/注册表注入 共 139 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
100
105
  npm run build # esbuild 打包 client 到 lib/client.js(__ModuleLoader__ 信封)
101
106
  npm pack --dry-run
102
107
  ```
@@ -105,6 +110,7 @@ npm pack --dry-run
105
110
  |---|---|
106
111
  | `cordis.patch.yml` | 两段 patch(本插件 entry / 预置路由;authorization 服务 0.1.7+ 由 runtime 内置,不再挂载) |
107
112
  | `src/host.mjs` · `src/shared.mjs` | host 半区:登录状态机、6 条本地路由、模型目录同步、刷新 preview/apply 与启动序列 |
113
+ | `src/catalog-registry.mjs` | 只读安装树的落地通道:进程内目录注册表注入(ADR 0003) |
108
114
  | `src/client.jsx` | client 半区:settings.section 插槽 + 设备码交互 + 双语文案 |
109
115
  | `scripts/build-client.mjs` | client 打包(`__ModuleLoader__` CJS 工厂信封) |
110
116
  | `lib/client.js` | 构建产物(入库,使 git 安装免构建) |
@@ -124,9 +130,9 @@ npm pack --dry-run
124
130
  - 路由前缀 `/copilot-auth` 两侧硬编码,不可配置
125
131
  - DSH rc 版本耦合:实测 `0.1.7-rc.2` 与 `0.2.0-rc.1`(v1.2.x;`0.1.2-rc.1` 由 v1.1.x 实测),peer 仅 `@deepseek-ai/cordis@^4.0.2`
126
132
  - 模型目录只在「尚不存在」时由登录成功兜底填充一次,填充后归用户所有:账号新增的模型不会自动出现——用 GHC 设置页的「**刷新可用模型目录**」同步(见上节),或在 Models 页手动添加
127
- - 模型目录只写入 pi-ai 内置目录已描述的模型:目录快照外的新模型需先经「刷新可用模型目录」补入目录条目(数据级补丁,pi-ai 版本不变)才会出现在可选列表
133
+ - 模型目录只写入「可解析」的模型:目录快照外的新模型须先经「刷新可用模型目录」补入——可写安装树补进目录文件、只读安装树(desktop)注入进程内注册表(ADR 0003),两条通道都保持 pi-ai 版本不变
128
134
  - DSH 升级后自愈仅在 pi-ai 基线版本不变(0.84.4)时重放;跨版本且条目未原生存在时上报 `self-heal-incompatible`,不修改安装树(重新执行一次手动刷新即可在新基线上激活)
129
- - **dsh-desktop(Electron 桌面版)无法刷新模型目录**:运行时树(含 pi-ai 唯一副本)打包在只读的 `app.asar` 内,数据级目录补丁物理不可写。v1.2.4 起该形态被显式识别:刷新入口置灰 + `catalog-not-writable` 结构化报错(v1.2.3 及之前会抛 `ENOENT ... not found in ...app.asar` 天书并遗留 prepared journal)。登录/注销/settings 模型目录镜像不受影响;目录刷新请用 Web/服务部署形态(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)
130
136
  - 并发保护为宿主单进程内 mutex,**不支持多实例/多进程并发刷新**
131
137
  - 状态落盘后的目录 fsync 仅在 POSIX 执行:Windows 无目录 fsync 语义(对目录句柄 fsync 必然 `EPERM`),win32 直接跳过、不再刷告警,目录项一致性由 NTFS 元数据日志保证(v1.2.3 起,issue #1);POSIX 行为不变
132
138
 
package/lib/client.js CHANGED
@@ -44,7 +44,7 @@ var routes = () => ({
44
44
  });
45
45
 
46
46
  // src/refresh-flow.mjs
47
- var initial = Object.freeze({ name: "idle", catalogWritable: null });
47
+ var initial = Object.freeze({ name: "idle", catalogWritable: null, catalogMode: null });
48
48
  function reduce(state, event) {
49
49
  switch (state.name) {
50
50
  case "idle":
@@ -60,10 +60,11 @@ function reduce(state, event) {
60
60
  if (!event.status) return [state, null];
61
61
  if (refresh?.lastError === "state-corrupt") return [{ name: "failed", error: "state-corrupt" }, null];
62
62
  if (refresh?.pendingRestart === true) {
63
- return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null }, null];
63
+ return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null, catalogMode: refresh?.catalogMode ?? null }, null];
64
64
  }
65
65
  const writable = refresh && "catalogWritable" in refresh ? refresh.catalogWritable : state.catalogWritable ?? null;
66
- return [{ ...state, catalogWritable: writable }, null];
66
+ const mode = refresh && "catalogMode" in refresh ? refresh.catalogMode : state.catalogMode ?? null;
67
+ return [{ ...state, catalogWritable: writable, catalogMode: mode }, null];
67
68
  }
68
69
  return [state, null];
69
70
  }
@@ -88,7 +89,9 @@ function reduce(state, event) {
88
89
  return [state, null];
89
90
  }
90
91
  case "applying": {
91
- if (event.type === "apply-ok") return [{ name: "restartNeeded" }, null];
92
+ if (event.type === "apply-ok") {
93
+ return event.restartRequired === false ? [{ name: "applied", mode: event.mode ?? null, injected: event.injected ?? 0 }, null] : [{ name: "restartNeeded", catalogMode: state.catalogMode ?? null }, null];
94
+ }
92
95
  if (event.type === "apply-stale") {
93
96
  return [{ name: "previewing", mode: state.mode, stale: true }, { type: "preview", mode: state.mode }];
94
97
  }
@@ -123,7 +126,12 @@ async function runEffect(effect, fetchImpl) {
123
126
  });
124
127
  const body = await res.json().catch(() => null);
125
128
  if (res.status === 409) return { type: "apply-stale" };
126
- return res.status === 200 && body?.ok ? { type: "apply-ok" } : { type: "apply-fail", error: body?.error ?? `HTTP ${res.status}` };
129
+ return res.status === 200 && body?.ok ? {
130
+ type: "apply-ok",
131
+ restartRequired: body.restartRequired !== false,
132
+ mode: body.mode ?? null,
133
+ injected: Array.isArray(body.injected) ? body.injected.length : 0
134
+ } : { type: "apply-fail", error: body?.error ?? `HTTP ${res.status}` };
127
135
  } catch (err) {
128
136
  return { type: "apply-fail", error: String(err?.message ?? err) };
129
137
  }
@@ -196,7 +204,11 @@ var DICTS = {
196
204
  srcLocal: "Catalog source: local only (npm fetch failed)",
197
205
  srcOverlay: "Catalog source: bundled overlay (offline bootstrap)",
198
206
  none: "(none)",
199
- notWritable: "This DSH installation packages the model catalog read-only (inside app.asar) \u2014 the refresh cannot be written. Use a web/service deployment for catalog refresh, or wait for a desktop build that unpacks pi-ai."
207
+ notWritable: "This DSH installation packages the model catalog read-only (inside app.asar) \u2014 the refresh cannot be written. Use a web/service deployment for catalog refresh, or wait for a desktop build that unpacks pi-ai.",
208
+ registryMode: "This installation's app.asar is read-only, so the refresh does not write the catalog file: new entries are injected into the running catalog registry instead. No restart needed.",
209
+ registryApplied: "Injected %s new model(s) into the running catalog \u2014 live now. The boot sequence replays them after a restart.",
210
+ registryFailed: "Catalog registry injection failed \u2014 nothing was written and your model list was left unchanged.",
211
+ riskImmediate: "Takes effect immediately in this process (registry injection); the boot sequence replays it after a restart."
200
212
  },
201
213
  zh: {
202
214
  nav: "GHC\u8BBE\u7F6E",
@@ -240,10 +252,20 @@ var DICTS = {
240
252
  srcLocal: "\u76EE\u5F55\u6765\u6E90\uFF1A\u4EC5\u672C\u5730\u76EE\u5F55\uFF08npm \u62C9\u53D6\u5931\u8D25\uFF09",
241
253
  srcOverlay: "\u76EE\u5F55\u6765\u6E90\uFF1A\u5185\u7F6E\u8986\u76D6\u5C42\uFF08\u79BB\u7EBF bootstrap\uFF09",
242
254
  none: "\uFF08\u65E0\uFF09",
243
- notWritable: "\u5F53\u524D DSH \u5B89\u88C5\u628A\u6A21\u578B\u76EE\u5F55\u6253\u5305\u5728\u53EA\u8BFB\u7684 app.asar \u5185\uFF0C\u5237\u65B0\u65E0\u6CD5\u5199\u5165\u3002\u8BF7\u5728 Web/\u670D\u52A1\u90E8\u7F72\u5F62\u6001\u4E0B\u5237\u65B0\u76EE\u5F55\uFF0C\u6216\u7B49\u5F85\u684C\u9762\u7248\u63D0\u4F9B\u53EF\u5199\u5E03\u5C40\u540E\u91CD\u8BD5\u3002"
255
+ notWritable: "\u5F53\u524D DSH \u5B89\u88C5\u628A\u6A21\u578B\u76EE\u5F55\u6253\u5305\u5728\u53EA\u8BFB\u7684 app.asar \u5185\uFF0C\u5237\u65B0\u65E0\u6CD5\u5199\u5165\u3002\u8BF7\u5728 Web/\u670D\u52A1\u90E8\u7F72\u5F62\u6001\u4E0B\u5237\u65B0\u76EE\u5F55\uFF0C\u6216\u7B49\u5F85\u684C\u9762\u7248\u63D0\u4F9B\u53EF\u5199\u5E03\u5C40\u540E\u91CD\u8BD5\u3002",
256
+ registryMode: "\u5F53\u524D\u5B89\u88C5\u7684 app.asar \u53EA\u8BFB\uFF0C\u5237\u65B0\u4E0D\u5199\u76EE\u5F55\u6587\u4EF6\uFF1A\u65B0\u6761\u76EE\u76F4\u63A5\u6CE8\u5165\u8FD0\u884C\u4E2D\u7684\u76EE\u5F55\u6CE8\u518C\u8868\u3002\u65E0\u9700\u91CD\u542F\u3002",
257
+ registryApplied: "\u5DF2\u5411\u8FD0\u884C\u4E2D\u7684\u76EE\u5F55\u6CE8\u5165 %s \u4E2A\u65B0\u6A21\u578B\u2014\u2014\u5373\u523B\u751F\u6548\uFF1B\u91CD\u542F\u540E\u7531\u542F\u52A8\u5E8F\u5217\u81EA\u52A8\u91CD\u653E\u3002",
258
+ registryFailed: "\u76EE\u5F55\u6CE8\u518C\u8868\u6CE8\u5165\u5931\u8D25\u2014\u2014\u672A\u5199\u5165\u4EFB\u4F55\u5185\u5BB9\uFF0C\u6A21\u578B\u5217\u8868\u4FDD\u6301\u539F\u6837\u3002",
259
+ riskImmediate: "\u672C\u8FDB\u7A0B\u5185\u5373\u523B\u751F\u6548\uFF08\u6CE8\u518C\u8868\u6CE8\u5165\uFF09\uFF1B\u91CD\u542F\u540E\u7531\u542F\u52A8\u5E8F\u5217\u81EA\u52A8\u91CD\u653E\u3002"
244
260
  }
245
261
  };
246
262
  var NAV_TEXTS = Object.keys(DICTS).map((locale) => DICTS[locale].nav);
263
+ function isRegistryMode(flags) {
264
+ return flags?.catalogWritable === false && flags?.catalogMode === "registry";
265
+ }
266
+ function refreshBlocked(flags) {
267
+ return flags?.catalogWritable === false && flags?.catalogMode !== "registry";
268
+ }
247
269
  var COPILOT_ICON_SVG = '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="flex:none"><path d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z"/><path d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z"/></svg>';
248
270
  function enforceNavIcon(labelText) {
249
271
  if (!labelText) return;
@@ -425,11 +447,15 @@ function RefreshModal({ t, flow, onConfirm, onCancel, onOverlay }) {
425
447
  "\u26A0 ",
426
448
  t("riskReset")
427
449
  ] }),
428
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.risk, children: [
450
+ isRegistryMode(p) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.modalText, children: t("riskImmediate") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.risk, children: [
429
451
  "\u26A0 ",
430
452
  t("riskRestart")
431
453
  ] }),
432
- p.catalogWritable === false && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.error, children: [
454
+ isRegistryMode(p) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
455
+ "\u24D8 ",
456
+ t("registryMode")
457
+ ] }),
458
+ refreshBlocked(p) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.error, children: [
433
459
  "\u26A0 ",
434
460
  t("notWritable")
435
461
  ] }),
@@ -440,8 +466,8 @@ function RefreshModal({ t, flow, onConfirm, onCancel, onOverlay }) {
440
466
  "button",
441
467
  {
442
468
  type: "button",
443
- style: { ...styles.button, ...styles.primary, ...p.catalogWritable === false ? { opacity: 0.5, cursor: "not-allowed" } : null },
444
- disabled: p.catalogWritable === false,
469
+ style: { ...styles.button, ...styles.primary, ...refreshBlocked(p) ? { opacity: 0.5, cursor: "not-allowed" } : null },
470
+ disabled: refreshBlocked(p),
445
471
  onClick: onConfirm,
446
472
  children: t("confirmRefresh")
447
473
  }
@@ -569,22 +595,27 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
569
595
  "button",
570
596
  {
571
597
  type: "button",
572
- style: { ...styles.button, ...styles.primary, ...flow.catalogWritable === false ? { opacity: 0.5, cursor: "not-allowed" } : null },
573
- disabled: flow.catalogWritable === false || flow.name === "previewing" || flow.name === "applying",
598
+ style: { ...styles.button, ...styles.primary, ...refreshBlocked(flow) ? { opacity: 0.5, cursor: "not-allowed" } : null },
599
+ disabled: refreshBlocked(flow) || flow.name === "previewing" || flow.name === "applying",
574
600
  onClick: () => drive({ type: "start" }),
575
601
  children: flow.name === "previewing" ? t("refreshing") : flow.name === "applying" ? t("applying") : t("refreshNow")
576
602
  }
577
603
  )
578
604
  ] }),
579
- page === "authorized" && flow.catalogWritable === false && flow.name !== "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
605
+ page === "authorized" && isRegistryMode(flow) && flow.name !== "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
606
+ "\u24D8 ",
607
+ t("registryMode")
608
+ ] }),
609
+ page === "authorized" && refreshBlocked(flow) && flow.name !== "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
580
610
  "\u26A0 ",
581
611
  t("notWritable")
582
612
  ] }),
613
+ page === "authorized" && flow.name === "applied" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.banner, children: t("registryApplied").replace("%s", String(flow.injected ?? 0)) }),
583
614
  page === "authorized" && flow.name === "restartNeeded" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
584
615
  "\u26A0 ",
585
616
  t("restartNeeded")
586
617
  ] }),
587
- page === "authorized" && flow.name === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, children: flow.error === "state-corrupt" ? t("stateCorrupt") : flow.error === "catalog-not-writable" ? t("notWritable") : flow.error }),
618
+ page === "authorized" && flow.name === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, children: flow.error === "state-corrupt" ? t("stateCorrupt") : flow.error === "catalog-not-writable" ? t("notWritable") : flow.error === "registry-inject-failed" || flow.error === "registry-not-effective" ? t("registryFailed") : flow.error }),
588
619
  flow.name === "confirming" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
589
620
  RefreshModal,
590
621
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.2.4",
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",
@@ -23,6 +23,7 @@
23
23
  "README.md",
24
24
  "LICENSE",
25
25
  "src/catalog.mjs",
26
+ "src/catalog-registry.mjs",
26
27
  "src/catalog-fetch.mjs",
27
28
  "src/copilot-models.mjs",
28
29
  "src/atomic-json.mjs",
@@ -0,0 +1,144 @@
1
+ // catalog-registry.mjs — desktop/asar「只读安装树」的不写盘通道(ADR 0003)。
2
+ //
3
+ // 背景:dsh-desktop(Electron)把整个运行时打进只读 app.asar,数据级目录补丁
4
+ // (ADR 0001)无法写 `.../pi-ai/dist/providers/data/github-copilot.json`。但 pi-ai
5
+ // 的目录在进程内还有第二个可用面:`MODELS["github-copilot"]`(即
6
+ // `GITHUB_COPILOT_MODELS`,一个未冻结的普通对象,键 = 模型 id),而 DSH 的
7
+ // `catalogModels()` 每次构建快照都现读它(`Object.values(MODELS[provider])`)。
8
+ //
9
+ // 把增量条目注入这个对象,内置 github-copilot 路由就会把新模型端出来:鉴权
10
+ // (OAuth 凭据记录)、DSH↔pi-ai 消息翻译、三协议分派、picker/设置页全部复用宿主
11
+ // 既有链路——不新增路由、不写任何文件、不改任何官方产物。
12
+ //
13
+ // 三条硬约束(ADR 0003):
14
+ // 1. 注入后必须能被 getBuiltinModels() 看见,否则报告失败;调用方**不得**把该
15
+ // id 写进 settings——绝不产出"能选中但发不出去"的假模型;
16
+ // 2. 条目按本机目录已见过的字段集合裁剪(pi-ai ≥0.99.0 新增 `type: "chat"` 之类
17
+ // 的字段不进旧运行时的模型对象);
18
+ // 3. 全程 try/catch,任何失败只返回报告,绝不抛出、绝不阻断插件挂载。
19
+ import { dirname, join } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+ import { validateCatalog, findPiAiInstallation, trimEntryToShape } from "./catalog.mjs";
22
+
23
+ // 注入面只服务内置 github-copilot 路由(其他 provider 无既有凭据/翻译链路复用)。
24
+ export const REGISTRY_ROUTE = "github-copilot";
25
+
26
+ // 裸 specifier:由宿主 dsh-app-boot 的解析拦截路由到安装树内的同一份包,模块实例
27
+ // 与宿主完全同一(首选);asar 绝对路径 file URL 为退路(A2/A3 探针已验证可加载,
28
+ // 但模块实例是否与宿主同一需要 getBuiltinModels 回读确认)。
29
+ const MODELS_SPECIFIER = "@earendil-works/pi-ai/providers/github-copilot.models";
30
+ const ALL_SPECIFIER = "@earendil-works/pi-ai/providers/all";
31
+
32
+ const MODELS_ENTRY = join("providers", "github-copilot.models.js");
33
+ const ALL_ENTRY = join("providers", "all.js");
34
+
35
+ // catalogFile = <root>/dist/providers/data/github-copilot.json → <root>/dist
36
+ export function registryDistDir(install) {
37
+ return dirname(dirname(dirname(install.catalogFile)));
38
+ }
39
+
40
+ // 依次尝试「裸 specifier → 绝对路径 file URL」加载目录注册表模块。
41
+ // 两条路的返回都做形状校验:注册表必须是可写对象、getBuiltinModels 必须是函数。
42
+ export async function loadRegistryModules(install, log) {
43
+ const dist = registryDistDir(install);
44
+ const attempts = [
45
+ { via: "bare", models: MODELS_SPECIFIER, all: ALL_SPECIFIER },
46
+ {
47
+ via: "file",
48
+ models: pathToFileURL(join(dist, MODELS_ENTRY)).href,
49
+ all: pathToFileURL(join(dist, ALL_ENTRY)).href,
50
+ },
51
+ ];
52
+ const errors = [];
53
+ for (const attempt of attempts) {
54
+ try {
55
+ const models = await import(attempt.models);
56
+ const all = await import(attempt.all);
57
+ const registry = models?.GITHUB_COPILOT_MODELS;
58
+ if (!registry || typeof registry !== "object" || Object.isFrozen(registry)) {
59
+ throw new TypeError(`registry object unusable (frozen=${Object.isFrozen(registry)})`);
60
+ }
61
+ if (typeof all?.getBuiltinModels !== "function") {
62
+ throw new TypeError("getBuiltinModels unavailable");
63
+ }
64
+ return { via: attempt.via, registry, all, errors };
65
+ } catch (err) {
66
+ errors.push(`${attempt.via}: ${err?.code ?? ""} ${err?.message ?? err}`.trim());
67
+ }
68
+ }
69
+ log?.(`copilot-auth: registry modules unavailable — ${errors.join(" | ")}`);
70
+ return { via: null, registry: null, all: null, errors };
71
+ }
72
+
73
+ // 注入 catalog-shaped 增量({ api: { id: entry } }),返回报告对象(绝不抛)。
74
+ // 已存在的 id(原生条目或本进程早前注入)视为成功,不覆盖——与目录补丁的
75
+ // 「只增不更新」语义一致(ADR 0001)。
76
+ export async function injectCatalogEntries(install, overlay, { log, allowedKeys } = {}) {
77
+ const wanted = [];
78
+ for (const [api, section] of Object.entries(overlay ?? {})) {
79
+ for (const [id, entry] of Object.entries(section ?? {})) wanted.push({ api, id, entry });
80
+ }
81
+ if (wanted.length === 0) {
82
+ return { ok: true, via: null, injected: [], present: [], rejected: [], missing: [], reason: null, errors: [] };
83
+ }
84
+ const modules = await loadRegistryModules(install, log);
85
+ if (!modules.via) {
86
+ return { ok: false, via: null, injected: [], present: [], rejected: [], missing: [], reason: "registry-modules-unavailable", errors: modules.errors };
87
+ }
88
+ const injected = [];
89
+ const present = [];
90
+ const rejected = [];
91
+ for (const { api, id, entry } of wanted) {
92
+ if (modules.registry[id] !== undefined) {
93
+ present.push(id);
94
+ continue;
95
+ }
96
+ const trimmed = trimEntryToShape(entry, allowedKeys);
97
+ const why = validateCatalog(api, id, trimmed);
98
+ if (why !== true) {
99
+ rejected.push({ api, id, reason: why });
100
+ continue;
101
+ }
102
+ modules.registry[id] = trimmed;
103
+ injected.push(id);
104
+ }
105
+ const visible = new Set(modules.all.getBuiltinModels(REGISTRY_ROUTE).map((m) => m?.id).filter(Boolean));
106
+ const missing = [...injected, ...present].filter((id) => !visible.has(id));
107
+ const ok = missing.length === 0 && rejected.length === 0;
108
+ if (!ok) {
109
+ log?.(
110
+ `copilot-auth: registry inject incomplete (via=${modules.via}) injected=${injected.length} missing=${missing.join(",") || "-"} rejected=${rejected.map((r) => `${r.id}:${r.reason}`).join(",") || "-"}`,
111
+ );
112
+ }
113
+ return {
114
+ ok,
115
+ via: modules.via,
116
+ injected,
117
+ present,
118
+ rejected,
119
+ missing,
120
+ reason: ok ? null : missing.length > 0 ? "not-visible-after-inject" : "invalid-entries",
121
+ errors: modules.errors,
122
+ };
123
+ }
124
+
125
+ // 当前注册表里 github-copilot 的模型 id 集合(注入面的事实来源;不可用时 null)。
126
+ export async function registryModelIds(install, log) {
127
+ const modules = await loadRegistryModules(install, log);
128
+ if (!modules.via) return null;
129
+ try {
130
+ return new Set(modules.all.getBuiltinModels(REGISTRY_ROUTE).map((m) => m?.id).filter(Boolean));
131
+ } catch (err) {
132
+ log?.(`copilot-auth: registry read failed — ${String(err?.message ?? err)}`);
133
+ return null;
134
+ }
135
+ }
136
+
137
+ // 便捷入口:从进程入口定位安装树后注入。
138
+ export async function injectIntoInstallation(overlay, { startPath, log, allowedKeys } = {}) {
139
+ const install = findPiAiInstallation({ startPath });
140
+ if (!install) {
141
+ return { ok: false, via: null, injected: [], present: [], rejected: [], missing: [], reason: "pi-ai-installation-not-found", errors: [] };
142
+ }
143
+ return injectCatalogEntries(install, overlay, { log, allowedKeys });
144
+ }
package/src/catalog.mjs CHANGED
@@ -100,6 +100,74 @@ export function mergeCatalog(local, remote) {
100
100
  return { merged, added, addedOverlay, skipped };
101
101
  }
102
102
 
103
+ // ==================== 远端目录的形状规范化(pi-ai ≥0.99.0 的 chat: 键) ====================
104
+ // pi-ai ≥0.99.0(实测 0.99.2 / 1.0.0,两份字节相同)把目录 JSON 的键改成
105
+ // `chat:<内层裸 id>`(flattenChatModelCatalog 的产物):这不是改名——运行时仍按条目
106
+ // 内层的裸 id 编键,github-copilot 的 filterModels 也按裸 id 与账号可用集匹配。
107
+ // 本模块的 mergeCatalog/validateCatalog 假定「键 == 条目内层 id」,因此远端目录
108
+ // 必须先规范化回三段式裸 id 形状,两个世代的语义才一致。
109
+ export const CHAT_KEY_PREFIX = "chat:";
110
+
111
+ // 把远端目录(0.87.x 裸键 / ≥0.99.0 `chat:` 键)规范化为 `{ api: { 裸id: entry } }`。
112
+ // 条目内层 id 与键冲突的条目一律跳过并记录(宁可少一个模型,不可写坏目录)。
113
+ // allowedKeys 非空时按本机目录已见字段集合裁剪(见 trimEntryToShape)。
114
+ // 返回 { catalog, renamed, skipped }。
115
+ export function normalizeRemoteCatalog(data, { allowedKeys } = {}) {
116
+ const catalog = {};
117
+ const skipped = [];
118
+ let renamed = 0;
119
+ for (const [api, section] of Object.entries(data ?? {})) {
120
+ if (!section || typeof section !== "object" || Array.isArray(section)) {
121
+ skipped.push({ api, id: null, reason: "section is not an object" });
122
+ continue;
123
+ }
124
+ for (const [key, entry] of Object.entries(section)) {
125
+ const prefixed = key.startsWith(CHAT_KEY_PREFIX);
126
+ const id = prefixed ? key.slice(CHAT_KEY_PREFIX.length) : key;
127
+ if (id.length === 0) {
128
+ skipped.push({ api, id: key, reason: "empty id after prefix strip" });
129
+ continue;
130
+ }
131
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
132
+ skipped.push({ api, id, reason: "entry is not an object" });
133
+ continue;
134
+ }
135
+ if (typeof entry.id === "string" && entry.id !== id) {
136
+ skipped.push({ api, id, reason: `id mismatch: key=${key} entry.id=${entry.id}` });
137
+ continue;
138
+ }
139
+ if (prefixed) renamed += 1;
140
+ (catalog[api] ??= {})[id] = trimEntryToShape(entry, allowedKeys);
141
+ }
142
+ }
143
+ return { catalog, renamed, skipped };
144
+ }
145
+
146
+ // 把条目裁剪到本机运行时已见过的字段集合(allowedKeys 来自 catalogShapeKeys)。
147
+ // 典型漂移:≥0.99.0 的条目多一个 `type: "chat"`,0.87.x 的模型对象里没有这个概念。
148
+ // allowedKeys 为空集时不裁剪——拿不到参考形状时宁可不裁剪,也不误删必需字段。
149
+ export function trimEntryToShape(entry, allowedKeys) {
150
+ if (!allowedKeys || allowedKeys.size === 0) return { ...entry };
151
+ const out = {};
152
+ for (const [k, v] of Object.entries(entry)) {
153
+ if (allowedKeys.has(k)) out[k] = v;
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // 本机目录条目的字段并集——注入/规范化条目的裁剪基准。
159
+ export function catalogShapeKeys(localCatalog) {
160
+ const keys = new Set();
161
+ for (const section of Object.values(localCatalog ?? {})) {
162
+ for (const entry of Object.values(section ?? {})) {
163
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
164
+ for (const k of Object.keys(entry)) keys.add(k);
165
+ }
166
+ }
167
+ }
168
+ return keys;
169
+ }
170
+
103
171
  // R3:removed 以 target 为准,不以 available 为准——available 但目录不可解析的
104
172
  // 当前模型必须进 removed(settings 校验拒绝目录外 id,保留必报错)。
105
173
  export function diffModels(currentIds, availableIds, catalogIds) {
package/src/host.mjs CHANGED
@@ -8,8 +8,9 @@ import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { CREDENTIAL_KEY, emptyState, routes } from "./shared.mjs";
10
10
  import { readJson, writeJsonAtomic, createMutex } from "./atomic-json.mjs";
11
- import { mergeCatalog, diffModels, digest, findPiAiInstallation, classifyCatalogTarget } from "./catalog.mjs";
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
14
  import { fetchLiveAvailableModelIds } from "./copilot-models.mjs";
14
15
  import { loadState, saveState, freshState, createJournal, advanceJournal, restartMarker, setLastError, clearLastError } from "./state.mjs";
15
16
 
@@ -35,20 +36,28 @@ function findCatalogFile() {
35
36
  return null;
36
37
  }
37
38
 
38
- // 读取 pi-ai 内置目录的 github-copilot 模型 id → 协议映射。只读不改。
39
- // 定位/解析失败返回 null(调用方回退 listModels 交集,见下)。
40
- function readCatalogModelIds() {
39
+ // 读取 github-copilot 的「可解析模型 id → 协议」映射。只读不改。
40
+ // 两个来源的并集:① 安装树内的目录数据文件;② 进程内目录注册表(ADR 0003——desktop
41
+ // 只读安装树下,刷新出来的增量条目只存在于注册表,目录文件里永远看不到)。两者都
42
+ // 拿不到才返回 null(调用方回退 listModels 交集)。
43
+ async function readCatalogModelIds(install, log) {
44
+ const byId = {};
41
45
  try {
42
- const dataFile = findCatalogFile();
43
- if (!dataFile) return null;
44
- const data = JSON.parse(readFileSync(dataFile, "utf8"));
45
- const byId = {};
46
- for (const [api, section] of Object.entries(data)) {
47
- for (const id of Object.keys(section ?? {})) byId[id] ??= api;
46
+ const dataFile = install?.catalogFile ?? findCatalogFile();
47
+ if (dataFile) {
48
+ const data = JSON.parse(readFileSync(dataFile, "utf8"));
49
+ for (const [api, section] of Object.entries(data)) {
50
+ for (const id of Object.keys(section ?? {})) byId[id] ??= api;
51
+ }
48
52
  }
49
- return byId;
50
53
  } catch { /* 安装树结构变化时回退 */ }
51
- return null;
54
+ try {
55
+ // 注册表面只在**只读安装树**(desktop asar)上才有意义:可写安装树下目录文件就是
56
+ // 运行时加载的副本,多读一次模块只是白白把 pi-ai 的模块图拉进本进程。
57
+ const ids = install && install.writable !== true ? await registryModelIds(install, log) : null;
58
+ if (ids) for (const id of ids) byId[id] ??= "registry";
59
+ } catch { /* 注册表不可用不阻断同步 */ }
60
+ return Object.keys(byId).length > 0 ? byId : null;
52
61
  }
53
62
 
54
63
  // 登录成功后,把账号可用模型写入用户 settings 的模型目录
@@ -78,7 +87,7 @@ function readConfiguredRoute(ctx) {
78
87
  }
79
88
  }
80
89
 
81
- async function syncAvailableModels(ctx) {
90
+ async function syncAvailableModels(ctx, opts = {}) {
82
91
  const record = await ctx.credentials.readRecord(CREDENTIAL_KEY);
83
92
  const available = record?.payload?.availableModelIds;
84
93
  if (!Array.isArray(available) || available.length === 0) return;
@@ -88,7 +97,7 @@ async function syncAvailableModels(ctx) {
88
97
  && typeof route.modelOverrides === "object"
89
98
  && Object.keys(route.modelOverrides).length > 0;
90
99
  if (modelsConfigured || overridesConfigured) return;
91
- const catalog = readCatalogModelIds();
100
+ const catalog = await readCatalogModelIds(safeInstall(opts), (m) => ctx.logger?.info?.(m));
92
101
  let ids;
93
102
  if (catalog) {
94
103
  ids = available.filter((id) => catalog[id] !== undefined);
@@ -184,6 +193,15 @@ function resolveInstall(opts) {
184
193
  return { ...install, ...classifyCatalogTarget(install.catalogFile, opts.writableProbe) };
185
194
  }
186
195
 
196
+ // 定位失败不抛(boot/同步/settings 都必须能在安装树异常时降级)。
197
+ function safeInstall(opts = {}) {
198
+ try {
199
+ return resolveInstall(opts);
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
187
205
  function readLocalCatalog(opts) {
188
206
  const install = resolveInstall(opts);
189
207
  const bytes = readFileSync(install.catalogFile);
@@ -210,12 +228,16 @@ async function resolveAvailable(ctx, fetchImpl) {
210
228
 
211
229
  // R3-3 严格分支:仅 mode==="overlay" 读内置覆盖层;normal npm 失败只回
212
230
  // catalogSource:"local"(绝不隐式读 overlay);未知 mode → 400。
213
- async function resolveCatalogSource(opts, mode, fetchImpl) {
231
+ async function resolveCatalogSource(opts, mode, fetchImpl, allowedKeys) {
214
232
  if (mode === "overlay") {
215
- const catalog = readJson(opts.overlayFile ?? defaultOverlayFile());
216
- if (catalog === undefined) throw new Error("bundled overlay file missing");
233
+ const raw = readJson(opts.overlayFile ?? defaultOverlayFile());
234
+ 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
+ const normalized = normalizeRemoteCatalog(raw, { allowedKeys });
217
238
  return {
218
- catalog,
239
+ catalog: normalized.catalog,
240
+ normalized: { renamed: normalized.renamed, skipped: normalized.skipped },
219
241
  catalogSource: "overlay",
220
242
  catalogError: null,
221
243
  provenance: { kind: "overlay", piAiVersion: OVERLAY_SOURCE_VERSION, integrity: null },
@@ -224,8 +246,10 @@ async function resolveCatalogSource(opts, mode, fetchImpl) {
224
246
  if (mode !== undefined && mode !== "latest") throw new InvalidModeError(String(mode));
225
247
  try {
226
248
  const r = await fetchLatestCatalog({ fetchImpl });
249
+ const normalized = normalizeRemoteCatalog(r.catalog, { allowedKeys });
227
250
  return {
228
- catalog: r.catalog,
251
+ catalog: normalized.catalog,
252
+ normalized: { renamed: normalized.renamed, skipped: normalized.skipped },
229
253
  catalogSource: "latest",
230
254
  catalogError: null,
231
255
  provenance: { kind: "latest", piAiVersion: r.piAiVersion, integrity: r.integrity },
@@ -233,6 +257,7 @@ async function resolveCatalogSource(opts, mode, fetchImpl) {
233
257
  } catch (error) {
234
258
  return {
235
259
  catalog: readLocalCatalog(opts).catalog,
260
+ normalized: null,
236
261
  catalogSource: "local",
237
262
  catalogError: String(error?.message ?? error),
238
263
  provenance: { kind: "local" },
@@ -284,6 +309,80 @@ function overlayEntryIds(overlay) {
284
309
  return Object.values(overlay ?? {}).flatMap((s) => Object.keys(s ?? {}));
285
310
  }
286
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
+
287
386
  // 启动序列(严格按序 boot 0→1→2;整体 try/catch,失败只记 lastError +
288
387
  // logger.warn,绝不阻断挂载;所有写入幂等,崩溃现场下个 boot 安全重试)。
289
388
  async function bootRefresh(ctx, opts) {
@@ -304,6 +403,32 @@ async function bootRefresh(ctx, opts) {
304
403
  let bootPatched = false;
305
404
  const readDiskCatalog = () => JSON.parse(readFileSync(install.catalogFile, "utf8"));
306
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
+ };
307
432
 
308
433
  // boot 0:journal=prepared 崩溃恢复(先于激活门,但先过兼容 gate,R3-1)
309
434
  if (state.journal?.phase === "prepared") {
@@ -314,15 +439,21 @@ async function bootRefresh(ctx, opts) {
314
439
  }
315
440
  const replay = mergeCatalog(readDiskCatalog(), state.journal.pendingOverlay);
316
441
  if (replay.added.length > 0) {
317
- // 可写性门禁(ADR 0002):不可写目标不重试写盘——journal 原样保留,安装将来
318
- // 变可写(如桌面版改为解包 pi-ai)后本恢复自动完成。零写入、可 grep 的 lastError。
319
- if (!install.writable) {
320
- state = setLastError(state, `catalog-not-writable (${install.unwritableReason}): pi-ai catalog is read-only; prepared journal kept, refresh will complete automatically once the target becomes writable`);
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`);
321
448
  saveState(stateFile, state);
322
449
  return;
323
450
  }
324
- writeJsonAtomic(install.catalogFile, replay.merged);
325
- bootPatched = true;
451
+ bootPatched = landed.wrote;
452
+ // 注册表通道:注入只是把条目放进进程内注册表,宿主快照可能已在挂载时按旧目录
453
+ // 定稿——用宿主 API 复核,缺失即同值触碰触发重建(ADR 0003 增补)。
454
+ if (landed.mode === "registry") {
455
+ await ensureRegistryServed(ctx, overlayEntryIds(state.journal.pendingOverlay), log);
456
+ }
326
457
  }
327
458
  state.appliedOverlay = mergeCatalog(state.appliedOverlay, state.journal.pendingOverlay).merged;
328
459
  state.appliedProvenance = {
@@ -332,7 +463,9 @@ async function bootRefresh(ctx, opts) {
332
463
  catalogSchemaVersion: 1,
333
464
  };
334
465
  state.activated = true;
335
- state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
466
+ // file 通道才需要重启标记(下个 boot 由 2a 把 settings 镜像到 target);
467
+ // 注册表注入当次生效,boot2a 在本 boot 就会同步 settings(ADR 0003)。
468
+ if (install.writable) state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
336
469
  state.journal = advanceJournal(state.journal);
337
470
  state = clearLastError(state);
338
471
  saveState(stateFile, state);
@@ -349,21 +482,33 @@ async function bootRefresh(ctx, opts) {
349
482
  } else if (install.version === baseline) {
350
483
  const replay = mergeCatalog(readDiskCatalog(), state.appliedOverlay);
351
484
  if (replay.added.length > 0) {
352
- // 可写性门禁(ADR 0002):自愈同理不重试写盘——appliedOverlay 保留,
353
- // 目标可写后下一 boot 重放;不抛错、不阻断启动序列。
354
- if (!install.writable) {
355
- state = setLastError(state, `catalog-not-writable (${install.unwritableReason}): pi-ai catalog is read-only; self-heal deferred, overlay entries stay pending`);
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`);
356
490
  saveState(stateFile, state);
357
491
  } else {
358
- writeJsonAtomic(install.catalogFile, replay.merged); // 原子重放
359
- bootPatched = true;
360
- state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
492
+ bootPatched = landed.wrote;
493
+ // 注册表注入是进程内的、当次 boot 即生效,不需要重启标记(ADR 0003)。
494
+ if (landed.wrote) state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
361
495
  state = clearLastError(state);
362
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
+ }
363
507
  }
364
508
  }
365
509
  } else {
366
- const missing = overlayIds.filter((id) => !diskIds(readDiskCatalog()).has(id));
510
+ const served = await servedIds();
511
+ const missing = overlayIds.filter((id) => !served.has(id));
367
512
  if (missing.length > 0) {
368
513
  // 跨版本有缺失:上报 self-heal-incompatible,不改安装树
369
514
  state = setLastError(state, `self-heal-incompatible: pi-ai ${install.version} != baseline ${baseline}, missing ${missing.join(",")}`);
@@ -384,7 +529,7 @@ async function bootRefresh(ctx, opts) {
384
529
  try {
385
530
  if (!install) throw new Error("pi-ai installation not found");
386
531
  // 语义前置(Y2-3):targetIds 全部可由当前目录解析;whole-file digest 仅诊断
387
- const ids = diskIds(readDiskCatalog());
532
+ const ids = await servedIds();
388
533
  const unresolvable = j.targetIds.filter((id) => !ids.has(id));
389
534
  if (unresolvable.length > 0) throw new Error(`target-unresolvable: ${unresolvable.join(",")}`);
390
535
  const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
@@ -418,7 +563,7 @@ async function bootRefresh(ctx, opts) {
418
563
  // 2b. restartState 非空且 expected entries 已在目录 → 仅清 restartState
419
564
  // (不携带 settings 意图:单独存在时不得触碰 describe/mutate)
420
565
  if (state.restartState && install) {
421
- const ids = diskIds(readDiskCatalog());
566
+ const ids = await servedIds();
422
567
  if (overlayEntryIds(state.appliedOverlay).every((id) => ids.has(id))) {
423
568
  state.restartState = null;
424
569
  state = clearLastError(state);
@@ -453,7 +598,7 @@ async function computeRefreshInputs(ctx, opts, mode) {
453
598
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch?.bind(globalThis);
454
599
  const { ids, source } = await resolveAvailable(ctx, fetchImpl);
455
600
  const local = readLocalCatalog(opts);
456
- const resolved = await resolveCatalogSource(opts, mode, fetchImpl);
601
+ const resolved = await resolveCatalogSource(opts, mode, fetchImpl, catalogShapeKeys(local.catalog));
457
602
  const merged = mergeCatalog(local.catalog, resolved.catalog);
458
603
  const catalogIds = new Set(Object.values(merged.merged).flatMap((s) => Object.keys(s ?? {})));
459
604
  const view = rawSettingsView(ctx);
@@ -479,6 +624,40 @@ async function computeRefreshInputs(ctx, opts, mode) {
479
624
  return { view, ids, source, local, resolved, merged, diff, customizationReset, digests };
480
625
  }
481
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) };
637
+ }
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
+ }
642
+ return { ok: false, missing: want.filter((id) => !served.has(id)), served: [...served] };
643
+ }
644
+
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
+ }
660
+
482
661
  export function apply(ctx, opts = {}) {
483
662
  const r = routes();
484
663
  let attempt = emptyState();
@@ -496,7 +675,7 @@ export function apply(ctx, opts = {}) {
496
675
  // 模型目录同步只在登录成功后执行(2026-09-05 对齐结论:挂载不再同步)。
497
676
  // 插件启动/重启绝不触碰用户 settings,用户精简过的目录不会被动重置。
498
677
  // 失败不静默:错误经 /status 的 syncError 字段暴露,便于诊断。
499
- const sync = () => syncAvailableModels(ctx).then(() => { lastSyncError = undefined; }).catch((err) => {
678
+ const sync = () => syncAvailableModels(ctx, opts).then(() => { lastSyncError = undefined; }).catch((err) => {
500
679
  lastSyncError = String(err?.message ?? err);
501
680
  ctx.logger?.warn?.("copilot-auth: model sync failed: %s", lastSyncError);
502
681
  });
@@ -596,7 +775,14 @@ export function apply(ctx, opts = {}) {
596
775
  catalogDigest,
597
776
  catalogFile: install?.catalogFile ?? null,
598
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,
599
782
  };
783
+ // 诊断口径(ADR 0003 增补):已注入、被 settings 引用、却没能从路由端出来的 id。
784
+ // "注册表里有了但 picker 里看不到"这类问题的唯一直接证据;宿主 API 不可用时为 null。
785
+ refresh.settingsNotServed = (await registryServedDiagnostics(ctx, overlayEntryIds(state.appliedOverlay))).missing;
600
786
  json(res, 200, { configured, syncError: lastSyncError, refresh });
601
787
  },
602
788
  });
@@ -646,6 +832,8 @@ export function apply(ctx, opts = {}) {
646
832
  catalogSource: inputs.resolved.catalogSource,
647
833
  catalogError: inputs.resolved.catalogError,
648
834
  catalogWritable: inputs.local.writable,
835
+ catalogMode: inputs.local.writable ? "file" : "registry",
836
+ catalogNormalized: inputs.resolved.normalized,
649
837
  skipped: inputs.merged.skipped,
650
838
  added: inputs.diff.added,
651
839
  removed: inputs.diff.removed,
@@ -696,15 +884,69 @@ export function apply(ctx, opts = {}) {
696
884
  }
697
885
  // digest 全部一致后才解析版本(写入失败点上移,500 不留 journal)
698
886
  const install = resolveInstall(opts);
699
- // 可写性门禁(ADR 0002):不可写目标在 write-ahead 之前快速失败——绝不留
700
- // prepared journal(desktop asar 形态下那会让每个 boot 都重试写归档)。
887
+ const stateFile = opts.stateFile ?? defaultStateFile();
701
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);
702
938
  return {
703
- code: 400,
704
- payload: { ok: false, error: "catalog-not-writable", reason: install.unwritableReason, catalogFile: install.catalogFile },
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
+ },
705
948
  };
706
949
  }
707
- const stateFile = opts.stateFile ?? defaultStateFile();
708
950
  const patchedBytes = Buffer.from(JSON.stringify(inputs.merged.merged, null, 2) + "\n", "utf8");
709
951
  let state = loadState(stateFile);
710
952
  state.journal = createJournal({
@@ -6,7 +6,7 @@
6
6
  // POST preview)→ 成功进 confirming(staleNotice);restartNeeded 可由 /status 水合。
7
7
  import { routes } from "./shared.mjs";
8
8
 
9
- export const initial = Object.freeze({ name: "idle", catalogWritable: null });
9
+ export const initial = Object.freeze({ name: "idle", catalogWritable: null, catalogMode: null });
10
10
 
11
11
  // reduce(state, event) → [nextState, effect | null]
12
12
  // effect 为数据化指令:{type:"preview",mode} | {type:"apply",mode,digests} | {type:"status"}
@@ -25,12 +25,15 @@ export function reduce(state, event) {
25
25
  if (!event.status) return [state, null]; // status 拉取失败不硬失败
26
26
  if (refresh?.lastError === "state-corrupt") return [{ name: "failed", error: "state-corrupt" }, null];
27
27
  if (refresh?.pendingRestart === true) {
28
- return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null }, null];
28
+ return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null, catalogMode: refresh?.catalogMode ?? null }, null];
29
29
  }
30
- // 干净水合:catalogWritable 随 /status 落态(desktop asar 只读形态 → false,
31
- // UI 置灰刷新入口);旧宿主不携带该字段时保持现值不回退。
30
+ // 干净水合:catalogWritable / catalogMode 随 /status 落态。desktop asar 只读
31
+ // 形态下 catalogWritable=false 且 catalogMode="registry"(ADR 0003:刷新走
32
+ // 进程内目录注册表注入,入口保持可用且无需重启);旧宿主不携带这两个字段时
33
+ // 保持现值不回退(不回退为"可写",避免误导)。
32
34
  const writable = refresh && "catalogWritable" in refresh ? refresh.catalogWritable : state.catalogWritable ?? null;
33
- return [{ ...state, catalogWritable: writable }, null];
35
+ const mode = refresh && "catalogMode" in refresh ? refresh.catalogMode : state.catalogMode ?? null;
36
+ return [{ ...state, catalogWritable: writable, catalogMode: mode }, null];
34
37
  }
35
38
  return [state, null];
36
39
  }
@@ -57,7 +60,13 @@ export function reduce(state, event) {
57
60
  return [state, null];
58
61
  }
59
62
  case "applying": {
60
- if (event.type === "apply-ok") return [{ name: "restartNeeded" }, null];
63
+ // apply-ok 携带 restartRequired / mode(ADR 0003):registry 通道当次生效,
64
+ // 不要求重启;file 通道仍是两阶段(下个 boot 同步 settings)。
65
+ if (event.type === "apply-ok") {
66
+ return event.restartRequired === false
67
+ ? [{ name: "applied", mode: event.mode ?? null, injected: event.injected ?? 0 }, null]
68
+ : [{ name: "restartNeeded", catalogMode: state.catalogMode ?? null }, null];
69
+ }
61
70
  if (event.type === "apply-stale") {
62
71
  // 409 preview-stale:以原 mode 重新 preview(保持 overlay 来源,R2-5)
63
72
  return [{ name: "previewing", mode: state.mode, stale: true }, { type: "preview", mode: state.mode }];
@@ -99,7 +108,12 @@ export async function runEffect(effect, fetchImpl) {
99
108
  const body = await res.json().catch(() => null);
100
109
  if (res.status === 409) return { type: "apply-stale" };
101
110
  return res.status === 200 && body?.ok
102
- ? { type: "apply-ok" }
111
+ ? {
112
+ type: "apply-ok",
113
+ restartRequired: body.restartRequired !== false,
114
+ mode: body.mode ?? null,
115
+ injected: Array.isArray(body.injected) ? body.injected.length : 0,
116
+ }
103
117
  : { type: "apply-fail", error: body?.error ?? `HTTP ${res.status}` };
104
118
  } catch (err) {
105
119
  return { type: "apply-fail", error: String(err?.message ?? err) };