@inventec/dsh-copilot-auth 1.2.3 → 1.2.5

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,22 @@
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**:136 条单元测试(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.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:` 目录键格式 |
36
+ | 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
+ | v1.2.0 ~ v1.2.3 | `0.1.7-rc.2` ~ `0.2.0-rc.1`(web/服务形态) | runtime 内置,插件不挂载 | 适配 settings API 重塑后的 describe() 行形状;desktop asar 形态下目录刷新不可用且报错不友好 |
36
38
  | v1.1.x | `0.1.2-rc.1` ~ `0.1.5-rc.2` | 由 cordis.patch.yml insert 挂载 | v1.2.0 起移除该补丁,旧版 DSH 上无法激活 |
37
39
 
38
40
  - 有效的 GitHub Copilot 订阅(公司分配的 github.com 组织账号,SSO 登录)
@@ -75,12 +77,14 @@ dsh plugin --profile web add @inventec/dsh-copilot-auth
75
77
 
76
78
  pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 gemini-3.8-flash)在本机无法解析、失效模型残留。「刷新可用模型目录」按钮(GHC 设置页,登录后可见)在不升级 pi-ai 的前提下解决这一问题。
77
79
 
78
- - **两阶段生效**:确认后先对本机 `github-copilot.json` 做原子数据级补丁(pi-ai 代码版本不变);**重启 dsh web 后**插件再把 settings 模型目录镜像重建为「账号可用 ∩ 目录可解析」集合(目录在模块加载时读取,同 boot 内写完目录不会同步 settings)。
80
+ - **生效时机按落地通道分叉**:可写安装树(web/服务)→ 先对本机 `github-copilot.json` 做原子数据级补丁(pi-ai 代码版本不变),**重启 dsh web 后**插件再把 settings 模型目录镜像重建为「账号可用 ∩ 目录可解析」集合(目录在模块加载时读取,同 boot 内写完目录不会同步 settings);只读安装树(desktop `app.asar`)→ 进程内注册表注入(ADR 0003),**当次生效、无需重启**。
79
81
  - **diff 预览 + 二次确认**:弹窗展示新增(绿)/移除(红)/保留计数、被校验跳过的上游条目、以及**将被重置的定制名单**(字段微调与 modelOverrides)。确认前请核对三条风险:失效模型将消失、你的定制将被重置、需重启后才生效。
80
- - **激活边界**:插件首次安装/升级后处于未激活态,启动时不触碰 pi-ai 安装树;只有成功执行过一次手动刷新后才激活。DSH 升级冲掉补丁后,已激活实例启动时自愈重放**历史上实际应用过**的补丁条目(内置覆盖层仅作离线数据源,绝不自动应用)。
81
- - **数据源与回退**:账号可用模型现场拉取(失败回退凭证缓存);目录数据从 npm 拉取最新 pi-ai tarball(强制 `dist.integrity` 校验、超时与大小上限、tar 严格解析),失败时可改用内置覆盖层(0.85.1 收割,integrity `sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==`)出 diff。
82
+ - **激活边界**:插件首次安装/升级后处于未激活态,启动时不触碰 pi-ai 安装树;只有成功执行过一次手动刷新后才激活。DSH 升级冲掉补丁后,已激活实例启动时自愈重放**历史上实际应用过**的补丁条目(内置覆盖层仅作离线数据源,绝不自动应用)。- **数据源与回退**:账号可用模型现场拉取(失败回退凭证缓存);目录数据从 npm 拉取最新 pi-ai tarball(强制 `dist.integrity` 校验、超时与大小上限、tar 严格解析),失败时可改用内置覆盖层(0.85.1 收割,integrity `sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==`)出 diff。
82
83
  - **只增不更新**:合并只补充本机从未见过的模型条目;上游对已有 id 的元数据修正(api 归属、contextWindow 等)不会同步——这是「永不覆盖」语义的代价(ADR 0001)。
83
84
  - **digest 绑定**:preview 与 apply 之间以输入 digest(settings 完整配置 / 可用模型 / 本地目录 / 目录来源)绑定,任何漂移即 409 并要求重新预览确认。
85
+ - **可写性门禁(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
+ - **只读安装树的落地通道(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
+ - **远端目录形状规范化(v1.2.5 起)**:pi-ai ≥0.99.0 把目录 JSON 的键改成 `chat:<内层裸 id>`(运行时仍按裸 id 编键)。插件在 preview/apply 共用的取数层统一规范化回裸 id 形状,并跳过键与内层 id 冲突的条目——**不规范化时刷新会"拿到新目录却一个条目都加不进来"**,这条修复对 Web/服务形态同样生效。
84
88
 
85
89
  ## 工作原理
86
90
 
@@ -95,7 +99,7 @@ pi-ai 的模型目录是打包时硬编码的 JSON:上游新增模型(如 ge
95
99
 
96
100
  ```bash
97
101
  npm install
98
- npm test # node:test:patch 结构 + host/目录/状态机/启动序列共 107 条
102
+ npm test # node:test:patch 结构 + host/目录/状态机/启动序列/可写性门禁/注册表注入 共 136 条(CI 跑 ubuntu/windows/macos 三平台矩阵)
99
103
  npm run build # esbuild 打包 client 到 lib/client.js(__ModuleLoader__ 信封)
100
104
  npm pack --dry-run
101
105
  ```
@@ -104,6 +108,7 @@ npm pack --dry-run
104
108
  |---|---|
105
109
  | `cordis.patch.yml` | 两段 patch(本插件 entry / 预置路由;authorization 服务 0.1.7+ 由 runtime 内置,不再挂载) |
106
110
  | `src/host.mjs` · `src/shared.mjs` | host 半区:登录状态机、6 条本地路由、模型目录同步、刷新 preview/apply 与启动序列 |
111
+ | `src/catalog-registry.mjs` | 只读安装树的落地通道:进程内目录注册表注入(ADR 0003) |
107
112
  | `src/client.jsx` | client 半区:settings.section 插槽 + 设备码交互 + 双语文案 |
108
113
  | `scripts/build-client.mjs` | client 打包(`__ModuleLoader__` CJS 工厂信封) |
109
114
  | `lib/client.js` | 构建产物(入库,使 git 安装免构建) |
@@ -123,8 +128,9 @@ npm pack --dry-run
123
128
  - 路由前缀 `/copilot-auth` 两侧硬编码,不可配置
124
129
  - 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`
125
130
  - 模型目录只在「尚不存在」时由登录成功兜底填充一次,填充后归用户所有:账号新增的模型不会自动出现——用 GHC 设置页的「**刷新可用模型目录**」同步(见上节),或在 Models 页手动添加
126
- - 模型目录只写入 pi-ai 内置目录已描述的模型:目录快照外的新模型需先经「刷新可用模型目录」补入目录条目(数据级补丁,pi-ai 版本不变)才会出现在可选列表
131
+ - 模型目录只写入「可解析」的模型:目录快照外的新模型须先经「刷新可用模型目录」补入——可写安装树补进目录文件、只读安装树(desktop)注入进程内注册表(ADR 0003),两条通道都保持 pi-ai 版本不变
127
132
  - 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)
128
134
  - 并发保护为宿主单进程内 mutex,**不支持多实例/多进程并发刷新**
129
135
  - 状态落盘后的目录 fsync 仅在 POSIX 执行:Windows 无目录 fsync 语义(对目录句柄 fsync 必然 `EPERM`),win32 直接跳过、不再刷告警,目录项一致性由 NTFS 元数据日志保证(v1.2.3 起,issue #1);POSIX 行为不变
130
136
 
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" });
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":
@@ -59,8 +59,12 @@ function reduce(state, event) {
59
59
  const refresh = event.status?.refresh;
60
60
  if (!event.status) return [state, null];
61
61
  if (refresh?.lastError === "state-corrupt") return [{ name: "failed", error: "state-corrupt" }, null];
62
- if (refresh?.pendingRestart === true) return [{ name: "restartNeeded" }, null];
63
- return state.name === "idle" ? [state, null] : [state, null];
62
+ if (refresh?.pendingRestart === true) {
63
+ return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null, catalogMode: refresh?.catalogMode ?? null }, null];
64
+ }
65
+ const writable = refresh && "catalogWritable" in refresh ? refresh.catalogWritable : state.catalogWritable ?? null;
66
+ const mode = refresh && "catalogMode" in refresh ? refresh.catalogMode : state.catalogMode ?? null;
67
+ return [{ ...state, catalogWritable: writable, catalogMode: mode }, null];
64
68
  }
65
69
  return [state, null];
66
70
  }
@@ -85,7 +89,9 @@ function reduce(state, event) {
85
89
  return [state, null];
86
90
  }
87
91
  case "applying": {
88
- 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
+ }
89
95
  if (event.type === "apply-stale") {
90
96
  return [{ name: "previewing", mode: state.mode, stale: true }, { type: "preview", mode: state.mode }];
91
97
  }
@@ -120,7 +126,12 @@ async function runEffect(effect, fetchImpl) {
120
126
  });
121
127
  const body = await res.json().catch(() => null);
122
128
  if (res.status === 409) return { type: "apply-stale" };
123
- 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}` };
124
135
  } catch (err) {
125
136
  return { type: "apply-fail", error: String(err?.message ?? err) };
126
137
  }
@@ -192,7 +203,12 @@ var DICTS = {
192
203
  srcLatest: "Catalog source: latest pi-ai from npm",
193
204
  srcLocal: "Catalog source: local only (npm fetch failed)",
194
205
  srcOverlay: "Catalog source: bundled overlay (offline bootstrap)",
195
- none: "(none)"
206
+ none: "(none)",
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."
196
212
  },
197
213
  zh: {
198
214
  nav: "GHC\u8BBE\u7F6E",
@@ -235,10 +251,21 @@ var DICTS = {
235
251
  srcLatest: "\u76EE\u5F55\u6765\u6E90\uFF1Anpm \u6700\u65B0 pi-ai",
236
252
  srcLocal: "\u76EE\u5F55\u6765\u6E90\uFF1A\u4EC5\u672C\u5730\u76EE\u5F55\uFF08npm \u62C9\u53D6\u5931\u8D25\uFF09",
237
253
  srcOverlay: "\u76EE\u5F55\u6765\u6E90\uFF1A\u5185\u7F6E\u8986\u76D6\u5C42\uFF08\u79BB\u7EBF bootstrap\uFF09",
238
- none: "\uFF08\u65E0\uFF09"
254
+ none: "\uFF08\u65E0\uFF09",
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"
239
260
  }
240
261
  };
241
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
+ }
242
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>';
243
270
  function enforceNavIcon(labelText) {
244
271
  if (!labelText) return;
@@ -420,14 +447,31 @@ function RefreshModal({ t, flow, onConfirm, onCancel, onOverlay }) {
420
447
  "\u26A0 ",
421
448
  t("riskReset")
422
449
  ] }),
423
- /* @__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: [
424
451
  "\u26A0 ",
425
452
  t("riskRestart")
426
453
  ] }),
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: [
459
+ "\u26A0 ",
460
+ t("notWritable")
461
+ ] }),
427
462
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.modalActions, children: [
428
463
  p.catalogSource === "local" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary, marginRight: "auto" }, onClick: onOverlay, children: t("overlayBtn") }),
429
464
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.secondary }, onClick: onCancel, children: t("cancel") }),
430
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", style: { ...styles.button, ...styles.primary }, onClick: onConfirm, children: t("confirmRefresh") })
465
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
466
+ "button",
467
+ {
468
+ type: "button",
469
+ style: { ...styles.button, ...styles.primary, ...refreshBlocked(p) ? { opacity: 0.5, cursor: "not-allowed" } : null },
470
+ disabled: refreshBlocked(p),
471
+ onClick: onConfirm,
472
+ children: t("confirmRefresh")
473
+ }
474
+ )
431
475
  ] })
432
476
  ] }) });
433
477
  }
@@ -551,18 +595,27 @@ function CopilotSection({ t = (key) => DICTS.en[key] ?? key }) {
551
595
  "button",
552
596
  {
553
597
  type: "button",
554
- style: { ...styles.button, ...styles.primary },
555
- disabled: 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",
556
600
  onClick: () => drive({ type: "start" }),
557
601
  children: flow.name === "previewing" ? t("refreshing") : flow.name === "applying" ? t("applying") : t("refreshNow")
558
602
  }
559
603
  )
560
604
  ] }),
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: [
610
+ "\u26A0 ",
611
+ t("notWritable")
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)) }),
561
614
  page === "authorized" && flow.name === "restartNeeded" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { style: styles.banner, children: [
562
615
  "\u26A0 ",
563
616
  t("restartNeeded")
564
617
  ] }),
565
- page === "authorized" && flow.name === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: styles.error, children: flow.error === "state-corrupt" ? t("stateCorrupt") : 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 }),
566
619
  flow.name === "confirming" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
567
620
  RefreshModal,
568
621
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
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
@@ -1,7 +1,7 @@
1
1
  // catalog.mjs — 数据核心:tgz 严格解析、条目校验、只增不更新合并、diff、digest、
2
2
  // pi-ai 安装定位。合并语义「只增不更新」(ADR 0001):已有 id 的元数据永不覆盖。
3
- import { createHash } from "node:crypto";
4
- import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { closeSync, existsSync, openSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { gunzipSync } from "node:zlib";
7
7
 
@@ -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) {
@@ -154,3 +222,45 @@ export function findPiAiInstallation({ startPath } = {}) {
154
222
  } catch { /* 安装树结构变化 → null(调用方降级) */ }
155
223
  return null;
156
224
  }
225
+
226
+ // ==================== 安装目标可写性分类(desktop asar 只读问题,ADR 0002) ====================
227
+ // dsh-desktop(Electron)把整个运行时树打进 resources/app.asar,pi-ai 是其中唯一副本:
228
+ // asar 虚拟文件系统读没问题、创建/写入必被拒(ENOENT "... not found in <archive>")。
229
+ // 数据级目录补丁在写盘前必须先分类目标,把"物理不可写"变成结构化错误而不是天书 ENOENT。
230
+
231
+ // Electron 的 asar 拦截按"路径中任一段以 .asar 结尾"判定(大小写不敏感,分隔符 / 与 \ 都认,
232
+ // Win 路径在 POSIX 主机上测试时也要能识别)。.asar.unpacked 不以 .asar 结尾,不会误判。
233
+ export function isAsarPath(p) {
234
+ const s = String(p ?? "");
235
+ if (/\.asar$/i.test(s)) return true;
236
+ return s.split(/[\\/]/).some((seg) => /\.asar$/i.test(seg));
237
+ }
238
+
239
+ // 功能性可写探测:在目录内排他创建 + 删除一个探针文件。覆盖 asar 之外的不可写形态
240
+ // (只读卷、根属主安装、ACL 拒绝——win32 chmod 弱语义下唯一可靠的通用手段)。
241
+ // 探针文件名带 UUID,排他创建,任何失败路径都尝试清理并返回 false。
242
+ export function probeWritable(dir) {
243
+ const probe = join(dir, `.copilot-auth-write-probe-${randomUUID()}`);
244
+ let fd;
245
+ try {
246
+ fd = openSync(probe, "wx");
247
+ closeSync(fd);
248
+ fd = undefined;
249
+ return true;
250
+ } catch {
251
+ return false;
252
+ } finally {
253
+ if (fd !== undefined) {
254
+ try { closeSync(fd); } catch { /* 已关闭 */ }
255
+ }
256
+ try { unlinkSync(probe); } catch { /* 探针可能未创建成功 */ }
257
+ }
258
+ }
259
+
260
+ // 分类结果平铺进安装对象:writable: boolean;不可写时 unwritableReason: "asar" | "probe-failed"。
261
+ // writableProbe 仅作测试注入钩子(生产恒用默认实现)。
262
+ export function classifyCatalogTarget(catalogFile, writableProbe = probeWritable) {
263
+ if (isAsarPath(catalogFile)) return { writable: false, unwritableReason: "asar" };
264
+ if (!writableProbe(dirname(catalogFile))) return { writable: false, unwritableReason: "probe-failed" };
265
+ return { writable: true, unwritableReason: null };
266
+ }
package/src/host.mjs CHANGED
@@ -1,15 +1,16 @@
1
1
  // host 半区:cordis 插件。注册 6 条 exact 路由,把内置 github-copilot 的
2
2
  // OAuth 设备码流(ctx.authorization)暴露给 Web client,并提供「手动刷新可用
3
3
  // 模型目录」(数据级目录补丁,pi-ai 代码版本不动;只读 GET /models 适配显式
4
- // 耦合 pi-ai 0.84.4,详见 CONTEXT.md 与 ADR 0001)。
4
+ // 耦合 pi-ai 0.84.4,详见 GLOSSARY.md 与 ADR 0001)。
5
5
  import { existsSync, readFileSync, realpathSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  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 } 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);
@@ -163,22 +172,46 @@ function readBody(req) {
163
172
  // 版本与 catalog 同根解析(R3-8):注入 catalogFile 时 version 取其所在根的
164
173
  // package.json(opts.piAiVersion 为测试钩子);生产走 findPiAiInstallation()。
165
174
  function resolveInstall(opts) {
175
+ let install;
166
176
  if (opts.catalogFile) {
167
- if (opts.piAiVersion) return { catalogFile: opts.catalogFile, version: opts.piAiVersion };
168
- const root = dirname(dirname(dirname(dirname(opts.catalogFile))));
169
- const packageJsonFile = join(root, "package.json");
170
- const pkg = JSON.parse(readFileSync(packageJsonFile, "utf8"));
171
- return { catalogFile: opts.catalogFile, packageJsonFile, version: pkg.version };
177
+ if (opts.piAiVersion) {
178
+ install = { catalogFile: opts.catalogFile, version: opts.piAiVersion };
179
+ } else {
180
+ const root = dirname(dirname(dirname(dirname(opts.catalogFile))));
181
+ const packageJsonFile = join(root, "package.json");
182
+ const pkg = JSON.parse(readFileSync(packageJsonFile, "utf8"));
183
+ install = { catalogFile: opts.catalogFile, packageJsonFile, version: pkg.version };
184
+ }
185
+ } else {
186
+ const found = findPiAiInstallation();
187
+ if (!found) throw new Error("pi-ai installation not found");
188
+ install = found;
189
+ }
190
+ // 可写性门禁(ADR 0002):desktop asar 打包形态下 catalogFile 在只读归档内——读一切
191
+ // 正常、写必被拒。这里统一分类,apply/boot 据此结构化快速失败而非抛天书 ENOENT。
192
+ // opts.writableProbe 为测试注入钩子(生产恒用默认探测实现)。
193
+ return { ...install, ...classifyCatalogTarget(install.catalogFile, opts.writableProbe) };
194
+ }
195
+
196
+ // 定位失败不抛(boot/同步/settings 都必须能在安装树异常时降级)。
197
+ function safeInstall(opts = {}) {
198
+ try {
199
+ return resolveInstall(opts);
200
+ } catch {
201
+ return null;
172
202
  }
173
- const found = findPiAiInstallation();
174
- if (!found) throw new Error("pi-ai installation not found");
175
- return found;
176
203
  }
177
204
 
178
205
  function readLocalCatalog(opts) {
179
- const file = resolveInstall(opts).catalogFile;
180
- const bytes = readFileSync(file);
181
- return { file, bytes, catalog: JSON.parse(bytes.toString("utf8")) };
206
+ const install = resolveInstall(opts);
207
+ const bytes = readFileSync(install.catalogFile);
208
+ return {
209
+ file: install.catalogFile,
210
+ bytes,
211
+ catalog: JSON.parse(bytes.toString("utf8")),
212
+ writable: install.writable,
213
+ unwritableReason: install.unwritableReason,
214
+ };
182
215
  }
183
216
 
184
217
  // 账号可用模型:现场拉取(显式耦合 0.84.4)→ 任何失败回退凭证缓存 availableModelIds
@@ -195,12 +228,16 @@ async function resolveAvailable(ctx, fetchImpl) {
195
228
 
196
229
  // R3-3 严格分支:仅 mode==="overlay" 读内置覆盖层;normal npm 失败只回
197
230
  // catalogSource:"local"(绝不隐式读 overlay);未知 mode → 400。
198
- async function resolveCatalogSource(opts, mode, fetchImpl) {
231
+ async function resolveCatalogSource(opts, mode, fetchImpl, allowedKeys) {
199
232
  if (mode === "overlay") {
200
- const catalog = readJson(opts.overlayFile ?? defaultOverlayFile());
201
- 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 });
202
238
  return {
203
- catalog,
239
+ catalog: normalized.catalog,
240
+ normalized: { renamed: normalized.renamed, skipped: normalized.skipped },
204
241
  catalogSource: "overlay",
205
242
  catalogError: null,
206
243
  provenance: { kind: "overlay", piAiVersion: OVERLAY_SOURCE_VERSION, integrity: null },
@@ -209,8 +246,10 @@ async function resolveCatalogSource(opts, mode, fetchImpl) {
209
246
  if (mode !== undefined && mode !== "latest") throw new InvalidModeError(String(mode));
210
247
  try {
211
248
  const r = await fetchLatestCatalog({ fetchImpl });
249
+ const normalized = normalizeRemoteCatalog(r.catalog, { allowedKeys });
212
250
  return {
213
- catalog: r.catalog,
251
+ catalog: normalized.catalog,
252
+ normalized: { renamed: normalized.renamed, skipped: normalized.skipped },
214
253
  catalogSource: "latest",
215
254
  catalogError: null,
216
255
  provenance: { kind: "latest", piAiVersion: r.piAiVersion, integrity: r.integrity },
@@ -218,6 +257,7 @@ async function resolveCatalogSource(opts, mode, fetchImpl) {
218
257
  } catch (error) {
219
258
  return {
220
259
  catalog: readLocalCatalog(opts).catalog,
260
+ normalized: null,
221
261
  catalogSource: "local",
222
262
  catalogError: String(error?.message ?? error),
223
263
  provenance: { kind: "local" },
@@ -289,6 +329,32 @@ async function bootRefresh(ctx, opts) {
289
329
  let bootPatched = false;
290
330
  const readDiskCatalog = () => JSON.parse(readFileSync(install.catalogFile, "utf8"));
291
331
  const diskIds = (disk) => new Set(Object.values(disk).flatMap((s) => Object.keys(s ?? {})));
332
+ const log = (m) => ctx.logger?.info?.(m);
333
+ // 测试注入钩子:opts.injectRegistry / opts.registryIds(生产恒用真实实现)。
334
+ const injectRegistry = opts.injectRegistry ?? injectCatalogEntries;
335
+ const readRegistryIds = opts.registryIds ?? registryModelIds;
336
+ // 落地一次目录增量:可写安装树走原子写盘(ADR 0001);只读安装树(desktop asar)
337
+ // 走进程内目录注册表注入(ADR 0003)。返回 { ok, wrote, mode, reason },绝不抛。
338
+ const landIncrement = async (overlay, merged) => {
339
+ if (install.writable) {
340
+ writeJsonAtomic(install.catalogFile, merged.merged);
341
+ return { ok: true, wrote: true, mode: "file", reason: null };
342
+ }
343
+ const r = await injectRegistry(install, overlay, {
344
+ log,
345
+ allowedKeys: catalogShapeKeys(readDiskCatalog()),
346
+ });
347
+ return { ok: r.ok, wrote: false, mode: "registry", reason: r.reason, detail: r };
348
+ };
349
+ // 运行时实际可解析的模型 id:目录文件 ∪ 进程内注册表(ADR 0003——注入的条目
350
+ // 不在文件里,任何"目录可解析"判定都必须走这一面)。
351
+ const servedIds = async () => {
352
+ const ids = diskIds(readDiskCatalog());
353
+ // 同 readCatalogModelIds:只有只读安装树才需要并注册表面(可写树下两者同源)。
354
+ const reg = install.writable === true ? null : await readRegistryIds(install, log);
355
+ if (reg) for (const id of reg) ids.add(id);
356
+ return ids;
357
+ };
292
358
 
293
359
  // boot 0:journal=prepared 崩溃恢复(先于激活门,但先过兼容 gate,R3-1)
294
360
  if (state.journal?.phase === "prepared") {
@@ -299,8 +365,16 @@ async function bootRefresh(ctx, opts) {
299
365
  }
300
366
  const replay = mergeCatalog(readDiskCatalog(), state.journal.pendingOverlay);
301
367
  if (replay.added.length > 0) {
302
- writeJsonAtomic(install.catalogFile, replay.merged);
303
- bootPatched = true;
368
+ const landed = await landIncrement(state.journal.pendingOverlay, replay);
369
+ if (!landed.ok) {
370
+ // 落地失败(不可写且注册表注入失败):journal 原样保留、零写入、可 grep 的
371
+ // lastError,下次 boot 重试;安装树将来变可写后本恢复自动完成。
372
+ const why = landed.mode === "file" ? `catalog-not-writable (${install.unwritableReason})` : `registry-inject-failed (${landed.reason})`;
373
+ state = setLastError(state, `${why}: pi-ai catalog is not landable; prepared journal kept`);
374
+ saveState(stateFile, state);
375
+ return;
376
+ }
377
+ bootPatched = landed.wrote;
304
378
  }
305
379
  state.appliedOverlay = mergeCatalog(state.appliedOverlay, state.journal.pendingOverlay).merged;
306
380
  state.appliedProvenance = {
@@ -310,7 +384,9 @@ async function bootRefresh(ctx, opts) {
310
384
  catalogSchemaVersion: 1,
311
385
  };
312
386
  state.activated = true;
313
- state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
387
+ // file 通道才需要重启标记(下个 boot 由 2a 把 settings 镜像到 target);
388
+ // 注册表注入当次生效,boot2a 在本 boot 就会同步 settings(ADR 0003)。
389
+ if (install.writable) state.restartState = restartMarker("refresh", digest(state.appliedOverlay));
314
390
  state.journal = advanceJournal(state.journal);
315
391
  state = clearLastError(state);
316
392
  saveState(stateFile, state);
@@ -327,14 +403,23 @@ async function bootRefresh(ctx, opts) {
327
403
  } else if (install.version === baseline) {
328
404
  const replay = mergeCatalog(readDiskCatalog(), state.appliedOverlay);
329
405
  if (replay.added.length > 0) {
330
- writeJsonAtomic(install.catalogFile, replay.merged); // 原子重放
331
- bootPatched = true;
332
- state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
333
- state = clearLastError(state);
334
- saveState(stateFile, state);
406
+ const landed = await landIncrement(state.appliedOverlay, replay);
407
+ if (!landed.ok) {
408
+ // 自愈失败不抛错、不阻断启动序列:appliedOverlay 保留,下次 boot 重放。
409
+ const why = landed.mode === "file" ? `catalog-not-writable (${install.unwritableReason})` : `registry-inject-failed (${landed.reason})`;
410
+ state = setLastError(state, `${why}: self-heal deferred, overlay entries stay pending`);
411
+ saveState(stateFile, state);
412
+ } else {
413
+ bootPatched = landed.wrote;
414
+ // 注册表注入是进程内的、当次 boot 即生效,不需要重启标记(ADR 0003)。
415
+ if (landed.wrote) state.restartState = restartMarker("self-heal", digest(state.appliedOverlay));
416
+ state = clearLastError(state);
417
+ saveState(stateFile, state);
418
+ }
335
419
  }
336
420
  } else {
337
- const missing = overlayIds.filter((id) => !diskIds(readDiskCatalog()).has(id));
421
+ const served = await servedIds();
422
+ const missing = overlayIds.filter((id) => !served.has(id));
338
423
  if (missing.length > 0) {
339
424
  // 跨版本有缺失:上报 self-heal-incompatible,不改安装树
340
425
  state = setLastError(state, `self-heal-incompatible: pi-ai ${install.version} != baseline ${baseline}, missing ${missing.join(",")}`);
@@ -355,7 +440,7 @@ async function bootRefresh(ctx, opts) {
355
440
  try {
356
441
  if (!install) throw new Error("pi-ai installation not found");
357
442
  // 语义前置(Y2-3):targetIds 全部可由当前目录解析;whole-file digest 仅诊断
358
- const ids = diskIds(readDiskCatalog());
443
+ const ids = await servedIds();
359
444
  const unresolvable = j.targetIds.filter((id) => !ids.has(id));
360
445
  if (unresolvable.length > 0) throw new Error(`target-unresolvable: ${unresolvable.join(",")}`);
361
446
  const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
@@ -389,7 +474,7 @@ async function bootRefresh(ctx, opts) {
389
474
  // 2b. restartState 非空且 expected entries 已在目录 → 仅清 restartState
390
475
  // (不携带 settings 意图:单独存在时不得触碰 describe/mutate)
391
476
  if (state.restartState && install) {
392
- const ids = diskIds(readDiskCatalog());
477
+ const ids = await servedIds();
393
478
  if (overlayEntryIds(state.appliedOverlay).every((id) => ids.has(id))) {
394
479
  state.restartState = null;
395
480
  state = clearLastError(state);
@@ -424,7 +509,7 @@ async function computeRefreshInputs(ctx, opts, mode) {
424
509
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch?.bind(globalThis);
425
510
  const { ids, source } = await resolveAvailable(ctx, fetchImpl);
426
511
  const local = readLocalCatalog(opts);
427
- const resolved = await resolveCatalogSource(opts, mode, fetchImpl);
512
+ const resolved = await resolveCatalogSource(opts, mode, fetchImpl, catalogShapeKeys(local.catalog));
428
513
  const merged = mergeCatalog(local.catalog, resolved.catalog);
429
514
  const catalogIds = new Set(Object.values(merged.merged).flatMap((s) => Object.keys(s ?? {})));
430
515
  const view = rawSettingsView(ctx);
@@ -450,6 +535,40 @@ async function computeRefreshInputs(ctx, opts, mode) {
450
535
  return { view, ids, source, local, resolved, merged, diff, customizationReset, digests };
451
536
  }
452
537
 
538
+ // 端到端自证(ADR 0003):注入 + settings 写入后,路由必须真的把 target 端出来。
539
+ // settings→快照重建是异步的,故带短重试。返回 { ok, missing, served }。
540
+ async function waitForServedModels(ctx, targetIds, { attempts = 6, delayMs = 150 } = {}) {
541
+ const want = [...new Set(targetIds)];
542
+ let served = new Set();
543
+ for (let i = 0; i < attempts; i++) {
544
+ try {
545
+ served = new Set((await ctx.llm.listModels("github-copilot")).map((m) => m?.id).filter(Boolean));
546
+ } catch (err) {
547
+ if (i === attempts - 1) return { ok: false, missing: want, served: [...served], error: String(err?.message ?? err) };
548
+ }
549
+ const missing = want.filter((id) => !served.has(id));
550
+ if (missing.length === 0) return { ok: true, missing: [], served: [...served] };
551
+ if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
552
+ }
553
+ return { ok: false, missing: want.filter((id) => !served.has(id)), served: [...served] };
554
+ }
555
+
556
+ // 自证失败时的 best-effort 回滚:把用户 settings 的 models 恢复成刷新前的视图。
557
+ // 回滚本身失败不掩盖原错误——调用方以结构化错误(rolledBack:false)上报。
558
+ async function rollbackSettings(ctx, previous) {
559
+ try {
560
+ const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
561
+ if (!desc) return false;
562
+ const ops = previous?.modelsPresent
563
+ ? [{ op: "set", path: ["providers", "github-copilot", "models"], value: previous.models }]
564
+ : [{ op: "unset", path: ["providers", "github-copilot", "models"] }];
565
+ await ctx.settings.mutate("llm-pi-ai", ops, desc.revision);
566
+ return true;
567
+ } catch {
568
+ return false;
569
+ }
570
+ }
571
+
453
572
  export function apply(ctx, opts = {}) {
454
573
  const r = routes();
455
574
  let attempt = emptyState();
@@ -467,7 +586,7 @@ export function apply(ctx, opts = {}) {
467
586
  // 模型目录同步只在登录成功后执行(2026-09-05 对齐结论:挂载不再同步)。
468
587
  // 插件启动/重启绝不触碰用户 settings,用户精简过的目录不会被动重置。
469
588
  // 失败不静默:错误经 /status 的 syncError 字段暴露,便于诊断。
470
- const sync = () => syncAvailableModels(ctx).then(() => { lastSyncError = undefined; }).catch((err) => {
589
+ const sync = () => syncAvailableModels(ctx, opts).then(() => { lastSyncError = undefined; }).catch((err) => {
471
590
  lastSyncError = String(err?.message ?? err);
472
591
  ctx.logger?.warn?.("copilot-auth: model sync failed: %s", lastSyncError);
473
592
  });
@@ -566,6 +685,11 @@ export function apply(ctx, opts = {}) {
566
685
  piAiVersion: install?.version ?? null,
567
686
  catalogDigest,
568
687
  catalogFile: install?.catalogFile ?? null,
688
+ catalogWritable: install ? install.writable === true : null,
689
+ // 落地通道(ADR 0003):file = 可写安装树走目录补丁;registry = 只读安装树
690
+ // (desktop asar)走进程内目录注册表注入。registryInjected = 已注入条目数。
691
+ catalogMode: install ? (install.writable === true ? "file" : "registry") : null,
692
+ registryInjected: overlayEntryIds(state.appliedOverlay).length,
569
693
  };
570
694
  json(res, 200, { configured, syncError: lastSyncError, refresh });
571
695
  },
@@ -615,6 +739,9 @@ export function apply(ctx, opts = {}) {
615
739
  source: inputs.source,
616
740
  catalogSource: inputs.resolved.catalogSource,
617
741
  catalogError: inputs.resolved.catalogError,
742
+ catalogWritable: inputs.local.writable,
743
+ catalogMode: inputs.local.writable ? "file" : "registry",
744
+ catalogNormalized: inputs.resolved.normalized,
618
745
  skipped: inputs.merged.skipped,
619
746
  added: inputs.diff.added,
620
747
  removed: inputs.diff.removed,
@@ -666,6 +793,68 @@ export function apply(ctx, opts = {}) {
666
793
  // digest 全部一致后才解析版本(写入失败点上移,500 不留 journal)
667
794
  const install = resolveInstall(opts);
668
795
  const stateFile = opts.stateFile ?? defaultStateFile();
796
+ if (!install.writable) {
797
+ // 只读安装树(desktop asar):ADR 0002 的写盘门禁之外再开一条**不写盘**的
798
+ // 落地通道——进程内目录注册表注入(ADR 0003)。注入成功才写 settings,
799
+ // 再以 listModels 端到端自证;自证失败回滚 settings,绝不产出
800
+ // "能选中但发不出去"的假模型。
801
+ const injectRegistry = opts.injectRegistry ?? injectCatalogEntries;
802
+ const injected = await injectRegistry(install, inputs.merged.addedOverlay, {
803
+ log: (m) => ctx.logger?.info?.(m),
804
+ allowedKeys: catalogShapeKeys(inputs.local.catalog),
805
+ });
806
+ if (!injected.ok) {
807
+ return {
808
+ code: 400,
809
+ payload: {
810
+ ok: false,
811
+ error: "registry-inject-failed",
812
+ reason: injected.reason,
813
+ missing: injected.missing,
814
+ rejected: injected.rejected,
815
+ catalogFile: install.catalogFile,
816
+ },
817
+ };
818
+ }
819
+ const desc = ctx.settings?.describe?.()?.find?.((x) => x?.ns === "llm-pi-ai");
820
+ if (!desc) {
821
+ return { code: 500, payload: { ok: false, error: "settings namespace llm-pi-ai unavailable" } };
822
+ }
823
+ await ctx.settings.mutate("llm-pi-ai", [
824
+ { op: "set", path: ["providers", "github-copilot", "models"], value: inputs.diff.target.map((id) => ({ id })) },
825
+ ], desc.revision);
826
+ const served = await waitForServedModels(ctx, inputs.diff.target);
827
+ if (!served.ok) {
828
+ const rolledBack = await rollbackSettings(ctx, inputs.view);
829
+ return {
830
+ code: 500,
831
+ payload: { ok: false, error: "registry-not-effective", missing: served.missing, rolledBack },
832
+ };
833
+ }
834
+ let state = loadState(stateFile);
835
+ state.appliedOverlay = mergeCatalog(state.appliedOverlay, inputs.merged.addedOverlay).merged;
836
+ state.appliedProvenance = {
837
+ sourcePiAiVersion: inputs.resolved.provenance.piAiVersion ?? null,
838
+ integrity: inputs.resolved.provenance.integrity ?? null,
839
+ appliedAgainstPiAiVersion: install.version,
840
+ catalogSchemaVersion: 1,
841
+ };
842
+ state.activated = true;
843
+ state.journal = null; // 注册表注入当次生效,无写盘、无重启相位
844
+ state = clearLastError(state);
845
+ saveState(stateFile, state);
846
+ return {
847
+ code: 200,
848
+ payload: {
849
+ ok: true,
850
+ restartRequired: false,
851
+ mode: "registry",
852
+ registryVia: injected.via,
853
+ injected: [...injected.injected, ...injected.present],
854
+ target: inputs.diff.target.length,
855
+ },
856
+ };
857
+ }
669
858
  const patchedBytes = Buffer.from(JSON.stringify(inputs.merged.merged, null, 2) + "\n", "utf8");
670
859
  let state = loadState(stateFile);
671
860
  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" });
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"}
@@ -24,8 +24,16 @@ export function reduce(state, event) {
24
24
  const refresh = event.status?.refresh;
25
25
  if (!event.status) return [state, null]; // status 拉取失败不硬失败
26
26
  if (refresh?.lastError === "state-corrupt") return [{ name: "failed", error: "state-corrupt" }, null];
27
- if (refresh?.pendingRestart === true) return [{ name: "restartNeeded" }, null];
28
- return state.name === "idle" ? [state, null] : [state, null];
27
+ if (refresh?.pendingRestart === true) {
28
+ return [{ name: "restartNeeded", catalogWritable: refresh?.catalogWritable ?? null, catalogMode: refresh?.catalogMode ?? null }, null];
29
+ }
30
+ // 干净水合:catalogWritable / catalogMode 随 /status 落态。desktop asar 只读
31
+ // 形态下 catalogWritable=false 且 catalogMode="registry"(ADR 0003:刷新走
32
+ // 进程内目录注册表注入,入口保持可用且无需重启);旧宿主不携带这两个字段时
33
+ // 保持现值不回退(不回退为"可写",避免误导)。
34
+ const writable = refresh && "catalogWritable" in refresh ? refresh.catalogWritable : state.catalogWritable ?? null;
35
+ const mode = refresh && "catalogMode" in refresh ? refresh.catalogMode : state.catalogMode ?? null;
36
+ return [{ ...state, catalogWritable: writable, catalogMode: mode }, null];
29
37
  }
30
38
  return [state, null];
31
39
  }
@@ -52,7 +60,13 @@ export function reduce(state, event) {
52
60
  return [state, null];
53
61
  }
54
62
  case "applying": {
55
- 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
+ }
56
70
  if (event.type === "apply-stale") {
57
71
  // 409 preview-stale:以原 mode 重新 preview(保持 overlay 来源,R2-5)
58
72
  return [{ name: "previewing", mode: state.mode, stale: true }, { type: "preview", mode: state.mode }];
@@ -94,7 +108,12 @@ export async function runEffect(effect, fetchImpl) {
94
108
  const body = await res.json().catch(() => null);
95
109
  if (res.status === 409) return { type: "apply-stale" };
96
110
  return res.status === 200 && body?.ok
97
- ? { 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
+ }
98
117
  : { type: "apply-fail", error: body?.error ?? `HTTP ${res.status}` };
99
118
  } catch (err) {
100
119
  return { type: "apply-fail", error: String(err?.message ?? err) };