@sema-agent/settings-schema 14.3.0 → 14.5.0

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/CHANGELOG.md CHANGED
@@ -8,7 +8,71 @@
8
8
 
9
9
  ## Unreleased
10
10
 
11
- (空)
11
+ ## 14.5.0 — 2026-10-23 — minor:同步单口 `migrateConfigDocuments`(存量文档迁移 + 整域终判一次调用)· URL 内嵌凭据判官上移契约包(`findUrlCredential` 等)· 文件系统失败句的出路顺序(不可变标志先行)· `GLOBAL_SCOPE` 注释改真
12
+
13
+ > 档位(按发版纪律):**minor** —— 加性:新导出 `migrateConfigDocuments` + 型 `ConfigDocumentsMigration` / `MigrateConfigDocumentsOptions`(`./node`);新导出 `findUrlCredential` · `findUrlUserinfo` · `urlCredentialRule` · `isCredentialParamName` ·
14
+ > `URL_CREDENTIAL_REASONS` · `CREDENTIAL_PARAM_NAMES` + 型 `UrlCredential` / `UrlCredentialReason` / `CredentialParamName`(包根);文件系统失败句的出路顺序与一处 JSDoc 改真是文案(`./node` 的 `removeFile` 抛出的错对象形状变了,见第四条)。既有导出对既有输入的答案不变(文案两处见下面第三、四条)。
15
+
16
+ - **同步单口 `migrateConfigDocuments(docs, { documents })`(`./node`):「存量文档迁移 + 整域终判」一次调用,无 io**。此前这件事只有目录形(`migrateConfigDirectory`,异步、带 io)一个出口;手里已经有各层存量文档原文、不经目录的调用方
17
+ (命令行壳按层读 `settings` 里的 models 域)只能自己把 `migrateStoredDocuments`(纯改写)与 `parseDomain`(整域终判)按「迁移步拒 → 无改动 ⇒ noop → 改过的文档整域终判」的序再组装一遍 ——
18
+ 第二份同语义,包内那只判一变就悄悄分叉。现在目录形读完目录后调的就是这只函数的内核(语义只此一处),同步口把同一个闭集的**无 io 形**交出去:
19
+ `unreadable`(覆盖面内某份文档不是 JSON 对象 ⇒ 不抛,点名 `document`,句式与坏文件同一句)· `no-documents` · `refused`(迁移步拒或整域终判拒;`refused[]` 同目录形,`docs` 仍交回迁移后的那一份)· `noop`(`docs` 里每份都是入参的同一引用)·
20
+ `migrated`(`docs` = 迁移后的覆盖面内文档全集,`applied` 逐条收据,`changed` = 改过 / 新建的文档名,`removed` = 被移除的退役域)。覆盖面 `documents` 与 `coverage` 的判(SS-M1 要写的目标域都在覆盖面里才算「一个 scope 的全集」)、「没改的文档不终判」、
21
+ 「迁移不许越过覆盖面去写」与目录形逐字同;入参从不被改,重跑迁移后的 `docs` ⇒ `noop`。**一层一次调用**:层序 / 覆盖 / 托管锁的合并读法不在这里。
22
+ 三问 —— **谁需要**:命令行壳(「引擎起服那一次迁移之后,models 域整域读不读得进」的读法,今天自己组装),以及任何手里已有存量文档原文的消费方;**谁受伤**:无(新导出;目录形的返回形与行为逐字不变,由原有钉守);**补偿**:无需 ——
23
+ 换读 = 把自己组装的那一段删掉,`refused` ⇒ 引擎同样不写回、整域落缺省(反向不成立:引擎对整个目录一判,别的文档被拒也会让整目录不写),`docs` 即壳的写手要的那一份。
24
+ 钉:`test/config-documents-migration-14.5.0.test.ts`(models 域五形 · 坏形不抛 · 入参冻结也能迁 · 对一致性语料每个 case 目录与目录形同一个答案、真跑写出的文件 = `changed` 的文档文本)。
25
+ - **URL 内嵌凭据判官上移契约包(包根):`findUrlCredential(raw)` · `findUrlUserinfo(raw)` · `urlCredentialRule(hit)` · `isCredentialParamName(name)` · `URL_CREDENTIAL_REASONS` · `CREDENTIAL_PARAM_NAMES`**(lift 自服务端的同名判官,规则、拒因词与规则句逐字)。
26
+ 规则一条:URL 里的凭据 = 日志脱敏器已经在用的那个定义 —— userinfo(只有用户名也算,判由本包 URL 形判官 `httpUrlFlaws`),或 query / fragment 里名字整名在凭据名表(23 词)上的参数(名字按收方的读法读:
27
+ `URLSearchParams` 解一次码,ASCII 大小写不敏感)。答首个命中(userinfo → query → fragment),拒因闭集两词 `url_userinfo` / `url_credential_param`;**值从不进答案**,参数名只回显表里的那个词(小写);不透明形 / 解析不了的值答「没有」(收不收是座位自己的律)。
28
+ **判官只判有没有,载体律留在座位**:同一个键在新提交门拒、在续跑重放存量体的腿不拒(拒会砖死升级前停驻的任务)、部署 env 只因 userinfo 拒 —— 所以判官**没有**接进任何读已落盘 / 重放字节的 schema 或读器(`readRequestMcpTransport` · `HttpHookEntry.url`),
29
+ 那会把载体律焊死成读器律,是破坏性变更;座位在自己的新提交门调它,拒体的形(HTTP 码、字段坐标、出路句)仍是座位自己的铸点。与 `NON_SECRET_URL` 是**两个问题、互补**:本判官按参数**名**判(名字整名在凭据名表上,不论值长什么样),`NON_SECRET_URL` 的 `urlQueryFragmentHasSecret` 按**值形**判密钥(值长得像密钥,不论参数叫什么,并禁 userinfo / fragment),所以 `?key=abc` 本判官命中而 `NON_SECRET_URL` 放行,`?x=sk-live-…` 反之,谁也不包含谁;存量域文档位(`mcp` 的 `transport.url` 等)读后者、不受本次影响,把两个问题合成一只定义是另一件事(本版不做)。
30
+ 包里**没有**承载 A2A 对端 `url` / `cardUrl` 与搜索后端 `endpoint` 的 schema —— 这两座只有服务端的请求面,它们读这只判官即可。
31
+ 三问 —— **谁需要**:客户端的云端腿 / 管理面等「别的消费方」(此前各自再判或不判);服务端(到货后退役本地那一份判官,拒形保留);**谁受伤**:无(新导出,既有导出行为不变);
32
+ **补偿**:凭据名表的第二份(服务端日志脱敏器的锚强度表)与本表须同键 —— 脱敏器的表键钉在 `CREDENTIAL_PARAM_NAMES` 上(`Record<CredentialParamName, …>` 的穷尽映射,加 / 删词不同步即编译红)是消费方的活。
33
+ 钉:`test/url-credential-14.5.0.test.ts`(导出面 · 23 词逐字 · userinfo 三形 · 九词与整表 query / fragment · 读名同收方 · 判序 · 正控 · 不透明形 · 复合名今天不是凭据名〔🚧 held〕· 规则句逐字)。
34
+ - **文件系统失败句的出路顺序(原子写器 / 目录迁移的 create · remove · replace 三句同一条)**。此前同一个 `EPERM` 在 create / remove 两句里仍以「make the directory writable」开头,目录模式已经可写时 chmod 无效,不可变标志只在句尾「may be」一提、不给清法;
35
+ replace 句则明说「clear an immutable flag」—— 三句三个序。现在三句只有一条顺序(`permissionWayOut`,句子的唯一铸点):`EPERM` ∧ 目录的权限位对本进程可写 ⇒ 真因只能是不可变标志(或粘滞位),**首选出路 = 清标志**(macOS `chflags nouchg`、Linux `chattr -i`),点名清谁,并说明 chmod 无效;
36
+ 模式不可写,或没读过模式(注入的替身 I/O)⇒ 照旧先让目录可写,句尾提示「只读 / 不可变」的可能;`EROFS` 的出路不变(只读文件系统清不掉),create / remove 上的 `EACCES` 是纯权限位、不吃这条规则。
37
+ 「目录权限位可写」这个事实由 Node 一侧读(`directoryAdmitsWrite`),经落点的可选格 `directoryWritable?` 递给铸点(缺席 = 没读过,按「不可写」走);**只有 create(`dir-create-temp`)与 remove 两个落点有这一格**。
38
+ **replace 句的改动面(如实)**:走到 `rename` 时临时文件刚在同一目录建成,「目录可写」是已证明的事实(铸点自己给,写器不对 rename 落点读模式位 —— 一个来源),所以 replace 句**不**走「先 chmod」那一支:
39
+ `EPERM` ⇒ 清标志先行,句子由 14.4.0 的「make the file replaceable — clear an immutable flag; in a sticky directory only the file's owner may replace it」换成「clear the immutable flag on the file or its directory (macOS: chflags nouchg; Linux: chattr -i) — the directory's permission bits already admit this process, so chmod will not help; in a sticky directory …」(多了命令与「chmod 无效」);
40
+ `EACCES` ⇒ 14.4.0 的原句逐字不变(文件向出路 + 粘滞目录一句,不说「make the directory writable」)。其余 errno 给 Node 自己的句子,与 14.4.0 同。
41
+ **另一处可见改动**:目录迁移的真 I/O 绑定 `nodeConfigDirectoryIo`(`./node`)的 `removeFile` 删失败,此前抛 `node:fs` 的原生错,现在抛 `FsFailure`(`message` 是铸点句 `cannot remove <file> from directory <dir> (<errno>): …`,`code` 照旧;`name` 由 `Error` 变 `FsFailure`;原生错对象上的 `errno` / `syscall` / `path` 不再在顶层,只在新加的 `cause` 上 —— 消费方若把 `${message} (${code})` 拼进日志会打两遍 errno,只拼 `message` 即可)—— 与原子写器同一个错型;
42
+ 消费方若自己拼 `${message} (${code})` 会把 errno 打两遍(铸点句里已经带一遍),别再拼。
43
+ 三问 —— **谁需要**:被不可变标志挡住写 / 删 / 换 `config.d` 文档的运维(句子第一眼就是对的命令);**谁受伤**:按失败句原文字面匹配的脚本(句子是文案,`code` / `cause` 不变,不承诺字面);自己拼 `message` + `code` 的删口消费方(见上);**补偿**:按 `code`(`EPERM` 等)分流,别拼 errno。
44
+ 钉:`test/fs-failure-way-out-order-14.5.0.test.ts`(create / remove × 可写 / 不可写 / 未读模式 × `EPERM` / `EROFS` / `EACCES`;replace 的 `EPERM` / `EACCES` 整句逐字,以及「模式读口调成不可写,rename 失败句仍同句」的端到端一来源钉;模式读口 `directoryAdmitsWrite` 的属主 / 属组 / 其他三档与 root);`test/fs-failure-mint-12.0.1.test.ts` 的 rename 格。
45
+ - **`GLOBAL_SCOPE` 的 JSDoc 改真(`./api/scopes`,文案)**。注释此前写「every token without a `scope` claim lives here」—— 与同一文件头 11.0.0 起的契约自相矛盾(缺 `scope` 声明的令牌被**拒**,不读成 `global`)。现在只剩「The reserved default scope. Always exists.」;
46
+ `docs/openapi-scopes.yaml` 无同句(其头注已写「缺声明 ⇒ 拒 401,不读成 global」)。三问 —— **谁需要**:读 `dist/api/scopes.d.ts` 接线的消费方(别照旧注释把缺声明令牌当 global);**谁受伤**:无(行为不变);**补偿**:无需。
47
+ 钉:`test/scope-global-jsdoc-truth-14.5.0.test.ts`(紧邻 JSDoc 块不含旧话、含新句;旧话还原的变异读数 2 红)。
48
+
49
+ ## 14.4.0 — 2026-10-21 — minor:技能条目加两只可选工具名单 `SkillSpec.allowedTools?` / `disallowedTools?`(与引擎本地清单同名同形、逐条按作者所写承运;manifest 自动随到;读腿与兄弟列表同一条逐元素抢救律)
50
+
51
+ > 档位(按发版纪律):**minor** —— 加性:`SkillSpec` 多两个可选键 `allowedTools` / `disallowedTools`;既有清单(不带这两个键)的解析结果、`/effective` 的 `skills[]`、一致性语料的读数逐字节不变。
52
+
53
+ - **技能条目可以自带两只工具名单:`SkillSpec.allowedTools?` / `SkillSpec.disallowedTools?`(`string[]`,元素非空串)**。与引擎本地 `.sema/skills` 清单里同名两键同名同形(元素形与键名;读法见下) ——
54
+ 此前中心分发的技能没有这两个位,引擎本地目录里写好的 `allowed-tools` / `disallowed-tools` 搬到中心就只剩正文,名单在 zod 的默认剥键下无声消失。
55
+ 现在两只名单是 `SkillSpec` 的成员,**逐条按作者所写承运**:不去重、不排序、不规范化拼写,也不在这里判每一条是不是合法的规则拼写
56
+ (一条工具名、一段命名空间、一条带括号的规则各是什么意思,由消费方的授权判官在它那一处读;本包判的只有「非空字符串」)。
57
+ **消费方读法一句话**:**缺席 ⇒ 键不出现(不会被填成 `[]`);在场 ⇒ 整只数组原样交给消费方的判官**。`[]` 与缺席是两种承运形,读成什么由判官定 ——
58
+ 引擎本地目录把空名单读作「未声明」,所以别把 `allowedTools: []` 实现成「一个工具都不许」。同一段名单文本在本地目录与中心分发两处被接受的范围也可能不同:
59
+ 本地目录的装载器对不是规则拼写的条目会拒掉那条技能(不会以缩短的名单装上),本包照原样承运这样的条目、交给判官。
60
+ 坏形(不是数组 / 元素不是字符串 / 元素是空串 / `null`)⇒ 写腿拒,拒句坐标点名到字段(`skills[i].allowedTools`),错在元素上时点名到元素(`skills[i].allowedTools[j]`);
61
+ `/effective` 的 manifest(`SkillsManifestSchema`、`toSkillsManifest`)本来就由 `SkillSpec` 派生(去掉 `content`、加 `contentHash`),所以两只名单自动随到 `skills[]`,没有第二份键表。
62
+ **读腿(`buildEffective` / `readEffectiveWire` 的逐条容错)里,两只名单是普通的嵌套集合,与 `scenarios` / `rosters` / MCP `allowTools` 走同一条既有律,没有为它们另立的读法**:
63
+ 坏元素逐元素丢,告警 `entry-dropped` 的坐标到元素(`skills[i].disallowedTools[j]`),技能带着剩下的好元素存活;整只名单一个好元素都没有 ⇒ 不凭空造出 `[]`,
64
+ 这条技能整条丢并告警 `entry-dropped`(坐标 `skills[i]`);若域里因此没有任何技能幸存(含它是唯一一条)⇒ 既有的零幸存者律:不发 `entry-dropped`,只发 `domain-defaulted`(`skills` 退成空清单)。
65
+ **如实披露的限度**:这条律落在拒绝名单上,意思是读腿上一只带坏元素的 `disallowedTools` 会被截短、技能照装(比作者所写更松),告警是响的;坏元素只可能来自写腿本会拒掉的文档
66
+ (手改的存量文件、别的写者),消费方可以把坐标落在 `…allowedTools[j]` / `…disallowedTools[j]` 的 `entry-dropped` 当硬停信号处理。纯值列表的抢救律(整只原子 vs 逐元素)在下一个 major 统一收口。
67
+ 三问 —— **谁需要**:把引擎本地技能目录里的 `allowed-tools` / `disallowed-tools` 搬到中心分发的人(管理面写、服务端经 `/effective` 分发、worker 读);
68
+ **谁受伤**:① 不升级的**读取方** —— 旧读者(≤14.3.0 的 manifest 读者)不认这两个键,zod 默认剥掉,manifest 路径上没有剥键告警,于是名单在旧读者那里静默丢未知位
69
+ (`disallowedTools` 丢了 = 比作者所写更松);② **升了包的服务端与 client-core 的云端腿也还拿不到名单** —— 两处都是从 manifest 条目按手写键(name / description / content)建技能,没有这两个键;两位过境随各自的后续版本到货,
70
+ 到货前,走这两条路的 worker 上两位无效;③ 存量文档里**已经有同名键但不是数组形**的技能(手写 `config.d/skills.json` 照搬引擎 frontmatter 的逗号串形
71
+ `allowedTools: "Read, Grep"`):此前这个键是未知键,技能存活并发 `unknown-keys-dropped`;现在这个键有了形状,不是数组 ⇒ 这条技能读腿上整条丢(`entry-dropped`,域里没有任何技能幸存则 `domain-defaulted`),写腿拒;
72
+ **补偿**:管理面写入名单之前,先确认读这条链的每一跳都已升级并接入这两个键 —— 升级顺序就是旋钮,本包不另设开关;存量文档先把同名键改成数组形或去掉。
73
+ 钉:`test/skill-tool-lists-14.4.0.test.ts`(域形与 manifest 往返含空数组与缺席两形 · 重复 / 乱序 / 不配对拼写原样 · 缺席不物化键 · 域读与 `parseDomainLoud` 同一份零告警 ·
74
+ `buildEffective` → `resolveEffectiveForWorker` → `readEffectiveWire` 端到端 · 七种坏形 × 域形 / manifest 两条判据的坐标 · 读腿两条逐元素 ⇒ 告警到元素 · 与 `scenarios` 同一条律 ·
75
+ 整只名单全坏 ⇒ 整条丢、域里无幸存者(唯一一条或每条都坏)⇒ `domain-defaulted` · 三份导出 schema 转 JSON Schema 两只名单零非标关键字)。
12
76
 
13
77
  ## 14.3.0 — 2026-10-21 — minor:信任阶梯词表做纯常量 `MIN_TRUST_WORDS`(`MinTrust` 由它派生;`trustRank` / `isAtLeast` 闭包零 zod,整函数抄走的消费方只登记一只全局)
14
78
 
package/README.md CHANGED
@@ -92,6 +92,12 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
92
92
  覆盖面要迁 `runtime` 就得把 `governance` 与 `limits` 一并放进来(迁出的旧值要写那两份,不能覆盖面外去写;缺了就按 `partial` 拒迁 `runtime`、一字不写)。
93
93
  `io` 注入 `{ readDir, readFile, writeFileAtomic, removeFile }`,真绑定是 `nodeConfigDirectoryIo`(`node:fs` + 本包唯一的原子写器与它的模式表)。返回一个闭集
94
94
  (`unreadable` / `no-documents` / `refused` / `noop` / `pending` / `applied`),调用方穷举;写盘阶段的 I/O 错误原样抛出(已写完的留在新位上,再跑一次幂等收尾)。
95
+ - **同步单口**(`./node`,14.5.0):`migrateConfigDocuments(docs, { documents })` —— 手里已经有存量文档原文(文档名 → 文档,例:一层 `settings` 里的 models 域)、不经目录的调用方,
96
+ 「迁 → 整域终判」一次调用、无 io;与目录形共用同一个内核(读目录之后的那一段语义只此一处),`documents` 的律逐字同。返回同一个闭集的无 io 形(`unreadable`〔覆盖面内某份文档不是 JSON 对象,不抛〕/ `no-documents` /
97
+ `refused`〔`docs` 仍交回迁移后的那一份〕/ `noop`〔`docs` 是入参的同一引用〕/ `migrated`〔`docs` + `applied` + `changed` + `removed`〕),调用方穷举;入参不被改,重跑 `docs` ⇒ `noop`。
98
+ 一层一次调用:层序 / 覆盖 / 托管锁的合并读法不在这里,也别自己再把 `migrateStoredDocuments` 与 `parseDomain` 按这个顺序组装一遍。
99
+ - **URL 内嵌凭据判官**(包根,14.5.0):`findUrlCredential(raw)` 判一个 URL 是否带凭据 —— userinfo,或 query / fragment 里名字整名在凭据名表 `CREDENTIAL_PARAM_NAMES` 上的参数(`URLSearchParams` 解一次码、ASCII 大小写不敏感);
100
+ 首个命中 `{ reason: "url_userinfo" }` / `{ reason: "url_credential_param", at, name }`(`URL_CREDENTIAL_REASONS` 闭集两词),值从不进答案,不透明形 ⇒ `undefined`。判官只判有没有:新提交门拒、续跑重放存量体的腿不拒这类载体律是座位的事,拒体的形也是座位的铸点。
95
101
  - **迁什么**(七条,`ONE_TIME_MIGRATIONS` 每行写着改写规则、删法、删后由谁接住):退役 `runtime` 域(治理三闸 → `governance`、五个限额键 → `limits`)·
96
102
  hosts 超界 · 调度店 durable 键(读时改写,下一个写者落盘)· workers 6.x 行 · 模型钥匙双设 · `atModelAllowlist` → `atMention` · URL fragment。
97
103
  - **什么时候删**:每条的窗 = 11.0.0 发布日 + 30 天,窗关之后的那个 major 整族删(删法与删后的响亮臂见登记表那一行);本包的到期门到点即红。
@@ -645,7 +651,7 @@ defaults. Each `config.d/<domain>.json` is exactly that domain's zod object.
645
651
  minor,patch 只装 additive/修 bug/文档」。0.7.1(2026-07-10)曾携带两件预协调 BREAKING(撤 governance
646
652
  effective 双写镜像、撤 QuotaLease 0.5 `tokens` 别名 shim),名义上违反该条;当时下游(web-admin/service
647
653
  1.150.0)均已显式迁移、实际破坏面为零,PM 拍板不重发 0.8.0(纯扰动)。该例外随 1.x 政策一并归档。
648
- - 发布(单腿 npmjs,内网腿已废止):`npm run publish:npm`;发后必须 curl 直打 npmjs 复验 shasum。
654
+ - 发布(单腿 npmjs,内网腿已废止):在要发的提交上打 tag `v<version>`,再 `bash scripts/publish-local.sh <version>`(先加 `--dry-run` 跑一遍:跑到包清单门止,不发、不要令牌)。构建与全量测试在远端构建机上跑(`GATE_HOST`),本机只发;令牌只从环境 `NPM_TOKEN` 读。发后复验由脚本自己做 —— curl 直打 npmjs 回读 shasum、tarball 字节与 gitHead,对不上即红。
649
655
 
650
656
  ## 贡献指引
651
657
 
@@ -1 +1 @@
1
- 14.3.0
1
+ 14.5.0
@@ -45,7 +45,7 @@
45
45
  */
46
46
  import { z } from "zod";
47
47
  import { type UserRole } from "../config-fns.js";
48
- /** The reserved default scope. Always exists; every token without a `scope` claim lives here. */
48
+ /** The reserved default scope. Always exists. */
49
49
  export declare const GLOBAL_SCOPE = "global";
50
50
  /** Scope ids that can never be created (or deleted) through the API. */
51
51
  export declare const RESERVED_SCOPE_IDS: readonly ["global"];
@@ -49,7 +49,7 @@ import { buildQuotedIssue, fixed } from "../quoted-issue.js";
49
49
  import { USER_ROLES } from "../config-fns.js";
50
50
  import { ACCESS_TOKEN_TTL_SECONDS } from "./auth.js";
51
51
  // ── constants ────────────────────────────────────────────────────────────────────────────────────────────
52
- /** The reserved default scope. Always exists; every token without a `scope` claim lives here. */
52
+ /** The reserved default scope. Always exists. */
53
53
  export const GLOBAL_SCOPE = "global";
54
54
  /** Scope ids that can never be created (or deleted) through the API. */
55
55
  export const RESERVED_SCOPE_IDS = [GLOBAL_SCOPE];
@@ -18,3 +18,14 @@ export declare function danglingSymlinkSentence(link: string, pointsAt: string):
18
18
  export declare function resolveSymlinkTarget(target: string): string;
19
19
  /** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
20
20
  export declare function writeFileAtomic(target: string, text: string, kind: AtomicFileClass): void;
21
+ /**
22
+ * **目录的权限位对本进程是否可写** —— 文件系统失败句的出路顺序要的那一个事实(`./fs-failure` 的铸点是纯函数、没有 `node:`,读模式的事在这里,事实经落点的 `directoryWritable` 递过去;
23
+ * 写盘的模式判与模式字面只在本文件一处,所以目录迁移的删除口 `./config-dir-io` 也从这里取)。
24
+ *
25
+ * 为什么不是 `access(2)`:不可变标志(macOS `chflags uchg` / Linux `chattr +i`)下 `access(W_OK)` 同样报 `EPERM`,分不出「模式不让写」与「标志不让写」;
26
+ * 这里只看**权限位**:目录对本进程的属主 / 属组 / 其他三档里适用的那一档,删建目录项要 w + x 两位都在。root(有效 uid 0)不受权限位约束 ⇒ 恒真
27
+ * (root 撞 `EPERM` 的常见原因恰恰是不可变标志)。没有 POSIX 身份(Windows)/ 读不到模式 ⇒ 假 —— 不替没读到的事实说「可写」。ACL 不看。
28
+ *
29
+ * 入参 `dir` 是已解析到真目录的路径;返回目录的权限位是否让本进程能增删其中的目录项。
30
+ */
31
+ export declare function directoryAdmitsWrite(dir: string): boolean;
@@ -23,7 +23,8 @@
23
23
  * ## 写失败(12.0.0;句子归文件系统失败句的唯一铸点)
24
24
  * 两步各一句({@link FsFailure},句子在 `./fs-failure`):临时文件建不出 ⇒ 「cannot write into directory <dir> (<errno>): …」(点名目录 —— 目录不可写时
25
25
  * 文件本身没错);`rename` 失败 ⇒ 「cannot replace <file> (<errno>): …」(点名真文件)。认得的 errno 给对应出路,别的给 Node 自己的句子;`code` / `cause` 留着。
26
- * 可写目录里的只读文件照常写得进(`rename` 换的是目录项)。
26
+ * 可写目录里的只读文件照常写得进(`rename` 换的是目录项)。权限类 errno 的出路顺序要一个只有 Node 一侧读得到的事实 —— 失败那一刻目录的权限位对本进程是否可写({@link directoryAdmitsWrite},模式判在本文件、与模式表同处),
27
+ * 写器在建临时文件那一步读了经落点递给铸点(`EPERM` ∧ 可写 ⇒ 真因是不可变标志,首选出路是清标志;否则先 chmod);`rename` 那一步不读 —— 临时文件刚在同目录建成,可写是已证明的事实,铸点自己给。
27
28
  */
28
29
  import { randomBytes } from "node:crypto";
29
30
  import { chmodSync, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
@@ -102,6 +103,35 @@ export function writeFileAtomic(target, text, kind) {
102
103
  catch {
103
104
  /* 临时文件没建成:没什么可清 */
104
105
  }
105
- throw new FsFailure(site, e); // the sentence names the step that failed and its way out (`./fs-failure`, the one mint)
106
+ // the sentence names the step that failed and its way out (`./fs-failure`, the one mint); the way-out order needs one Node-side fact — do the directory's mode bits admit this process —
107
+ // read only at the create step: by the time `rename` runs the temporary file has just been created in the same directory, so "writable" is proven there and the mint says so itself
108
+ throw new FsFailure(site.landing === "dir-create-temp" ? { ...site, directoryWritable: directoryAdmitsWrite(dir) } : site, e);
106
109
  }
107
110
  }
111
+ /**
112
+ * **目录的权限位对本进程是否可写** —— 文件系统失败句的出路顺序要的那一个事实(`./fs-failure` 的铸点是纯函数、没有 `node:`,读模式的事在这里,事实经落点的 `directoryWritable` 递过去;
113
+ * 写盘的模式判与模式字面只在本文件一处,所以目录迁移的删除口 `./config-dir-io` 也从这里取)。
114
+ *
115
+ * 为什么不是 `access(2)`:不可变标志(macOS `chflags uchg` / Linux `chattr +i`)下 `access(W_OK)` 同样报 `EPERM`,分不出「模式不让写」与「标志不让写」;
116
+ * 这里只看**权限位**:目录对本进程的属主 / 属组 / 其他三档里适用的那一档,删建目录项要 w + x 两位都在。root(有效 uid 0)不受权限位约束 ⇒ 恒真
117
+ * (root 撞 `EPERM` 的常见原因恰恰是不可变标志)。没有 POSIX 身份(Windows)/ 读不到模式 ⇒ 假 —— 不替没读到的事实说「可写」。ACL 不看。
118
+ *
119
+ * 入参 `dir` 是已解析到真目录的路径;返回目录的权限位是否让本进程能增删其中的目录项。
120
+ */
121
+ export function directoryAdmitsWrite(dir) {
122
+ if (typeof process.geteuid !== "function" || typeof process.getegid !== "function")
123
+ return false;
124
+ let st;
125
+ try {
126
+ st = statSync(dir);
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ const euid = process.geteuid();
132
+ if (euid === 0)
133
+ return true;
134
+ const group = st.gid === process.getegid() || (typeof process.getgroups === "function" && process.getgroups().includes(st.gid));
135
+ const shift = st.uid === euid ? 6 : group ? 3 : 0;
136
+ return ((st.mode >> shift) & 0o3) === 0o3;
137
+ }
@@ -14,6 +14,10 @@ export type ConfigDDocumentName = DomainName | RetiredDomainName | typeof REMOTE
14
14
  * 不是对象 ⇒ 抛,每种一句(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
15
15
  * 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
16
16
  export declare function judgeConfigDText(name: ConfigDDocumentName, text: string, file: string): Record<string, unknown>;
17
+ /** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
18
+ * 非朴素对象 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
19
+ * `label` = 句首的称呼(文本路径给文件的真实路径,值路径给 `the <名> document`)。 */
20
+ export declare function judgeConfigDValue(name: ConfigDDocumentName, value: unknown, label: string): Record<string, unknown>;
17
21
  /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
18
22
  export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
19
23
  /**
@@ -12,6 +12,7 @@
12
12
  import { readFile } from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
15
+ import { isPlainJsonObject } from "./plain-json-object.js";
15
16
  import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
16
17
  import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
17
18
  /** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
@@ -57,11 +58,17 @@ export function judgeConfigDText(name, text, file) {
57
58
  catch {
58
59
  throw new Error(`invalid JSON in ${file} — ${documentWayOut(name)}`); // sanitized: never echo the raw file text into the error
59
60
  }
60
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
61
- const kind = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
62
- throw new Error(`${file} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`);
61
+ return judgeConfigDValue(name, parsed, file);
62
+ }
63
+ /** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
64
+ * 非朴素对象 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
65
+ * `label` = 句首的称呼(文本路径给文件的真实路径,值路径给 `the <名> document`)。 */
66
+ export function judgeConfigDValue(name, value, label) {
67
+ if (!isPlainJsonObject(value)) {
68
+ const kind = value === null ? "null" : Array.isArray(value) ? "array" : typeof value === "object" ? "non-plain object" : typeof value;
69
+ throw new Error(`${label} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`);
63
70
  }
64
- return parsed;
71
+ return value;
65
72
  }
66
73
  /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
67
74
  export async function readConfigDFile(root, name) {
@@ -5,10 +5,20 @@
5
5
  * 命令行入口与引擎起服的目录迁移注入同一份,写法与文件模式因此只在原子写器那一处定。
6
6
  */
7
7
  import { readdir, readFile, unlink } from "node:fs/promises";
8
- import { resolveSymlinkTarget, writeFileAtomic } from "./atomic-write.js";
8
+ import { dirname } from "node:path";
9
+ import { directoryAdmitsWrite, resolveSymlinkTarget, writeFileAtomic } from "./atomic-write.js";
10
+ import { FsFailure } from "./fs-failure.js";
9
11
  export const nodeConfigDirectoryIo = {
10
12
  readDir: async (path) => readdir(resolveSymlinkTarget(path)),
11
13
  readFile: async (path) => readFile(resolveSymlinkTarget(path), "utf8"),
12
14
  writeFileAtomic: async (path, text) => writeFileAtomic(path, text, "config-document"), // the files this migration writes are config.d domain documents
13
- removeFile: (path) => unlink(path),
15
+ // a failed removal is minted here, where the directory's mode can be read (`./fs-failure` is pure): the same `FsFailure` the atomic writer throws, so the command line prints it as is
16
+ removeFile: async (file) => {
17
+ try {
18
+ await unlink(file);
19
+ }
20
+ catch (e) {
21
+ throw new FsFailure({ landing: "remove", path: file, directoryWritable: directoryAdmitsWrite(dirname(file)) }, e);
22
+ }
23
+ },
14
24
  };
@@ -49,6 +49,45 @@ export interface MigrateConfigDirectoryOptions {
49
49
  /** 只判、不写:有改动且整域判得过 ⇒ `pending`。默认 `false`。 */
50
50
  readonly check?: boolean;
51
51
  }
52
+ export interface MigrateConfigDocumentsOptions {
53
+ /** 覆盖面:调用方读的文档名集 —— 与 {@link MigrateConfigDirectoryOptions.documents} 同一个词、同一个律(覆盖面内没给的文档 = 从未设过;覆盖面外的键不读、不判、不进结果;拼错的名字 ⇒ 抛 `TypeError`)。 */
54
+ readonly documents: readonly ConfigDirectoryDocument[];
55
+ }
56
+ /**
57
+ * 同步口的结果 —— {@link ConfigDirectoryMigration} 的无 io 形,**同一个闭集的词**,调用方穷举:
58
+ * · `unreadable` —— 覆盖面内某份文档不是 JSON 对象(`null` / 数组 / 数 / 串 / 布尔):`document` 点名是哪一份,`reason` 与坏文件同一句式。不抛、不迁(其余文档是别的调用的事:一层一次调用)。
59
+ * · `no-documents`—— 覆盖面内一份文档都没给(缺席与 `undefined` 同)。
60
+ * · `refused` —— 迁移步拒(收据 `refused`)**或**改过的文档整域读不进(整域终判)。`docs` 仍交回迁移后的那一份(迁得动的坐标改新形、迁不动的原样 ——
61
+ * 写手要的是这一份;落不落盘、引擎认不认是 `kind` 的事:`refused` ⇒ 引擎同样不写回、整域落缺省;反向不成立 —— 引擎对整个目录一判,别的文档被拒也会让整目录不写)。
62
+ * · `noop` —— 零改动:`docs` 里每份文档都是入参里的**同一引用**;没改的文档不终判(读不读得进是读者的事,与目录形同一律)。
63
+ * · `migrated` —— 有改动、整域判得过:`docs` 是迁移后的覆盖面内文档全集,`changed` = 改过 / 新建的文档名,`removed` = 被移除的(retired 域),`applied` = 逐条收据。
64
+ * `read` = 覆盖面内在场的文档名(升序)。入参从不被改;重跑 `docs` ⇒ `noop`。
65
+ */
66
+ export type ConfigDocumentsMigration = {
67
+ readonly kind: "unreadable";
68
+ readonly document: ConfigDirectoryDocument;
69
+ readonly reason: string;
70
+ } | {
71
+ readonly kind: "no-documents";
72
+ } | {
73
+ readonly kind: "refused";
74
+ readonly read: readonly string[];
75
+ readonly docs: Readonly<Record<string, unknown>>;
76
+ readonly applied: readonly MigrationReceipt[];
77
+ readonly refused: readonly ConfigDirectoryRefusal[];
78
+ readonly why: string;
79
+ } | {
80
+ readonly kind: "noop";
81
+ readonly read: readonly string[];
82
+ readonly docs: Readonly<Record<string, unknown>>;
83
+ } | {
84
+ readonly kind: "migrated";
85
+ readonly read: readonly string[];
86
+ readonly docs: Readonly<Record<string, unknown>>;
87
+ readonly applied: readonly MigrationReceipt[];
88
+ readonly changed: readonly string[];
89
+ readonly removed: readonly string[];
90
+ };
52
91
  /** 目录迁移能读写的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`)—— 就是 `config.d` 里能放的文档名。 */
53
92
  export type ConfigDirectoryDocument = ConfigDDocumentName;
54
93
  /** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
@@ -59,6 +98,11 @@ type RefusedReceipt = Extract<MigrationReceipt, {
59
98
  /** 一张收据的出门形(坐标只点名、不带值):`refused` 带 `reason`(= {@link ConfigDirectoryRefusal} 的形),`moved` 带 `to`。包内私有 —— 命令行报告用它渲染 `applied`。 */
60
99
  export declare function reportReceipt(r: RefusedReceipt): ConfigDirectoryRefusal;
61
100
  export declare function reportReceipt(r: MigrationReceipt): Record<string, unknown>;
101
+ /**
102
+ * 对一组**已有的**存量文档(`docs`:文档名 → 文档,例:一层 `settings` 里的 `models` 域原文)跑一次一次性迁移 + 整域终判 —— 同步、无 io;语义与结果见文件头「同步口」与 {@link ConfigDocumentsMigration}。
103
+ * 覆盖面外的键不读、不判、不进结果;覆盖面内在场(非 `undefined`)的文档必须是 JSON 对象,否则回 `unreadable`(不抛)。`docs` 不是对象 / 覆盖面拼错 ⇒ `TypeError`(调用方的错,不是数据的错)。
104
+ */
105
+ export declare function migrateConfigDocuments(docs: Readonly<Partial<Record<ConfigDirectoryDocument, unknown>>>, opts: MigrateConfigDocumentsOptions): ConfigDocumentsMigration;
62
106
  /**
63
107
  * 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
64
108
  */
@@ -20,10 +20,16 @@
20
20
  * · `pending` —— `check: true` 且有改动、整域判得过:一字不写。
21
21
  * · `applied` —— 已写回:先写改过 / 新建的文档,最后删被移除的 retired 域文件 —— 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
22
22
  * 写盘阶段的 I/O 错误**原样抛出**(不吞、不改写):已写完的文档留在新位上,再跑一次幂等收尾。
23
+ *
24
+ * ## 同步口 {@link migrateConfigDocuments}(14.5.0)
25
+ * 「迁 → 整域终判」那一段就是一只同步函数:入参是调用方手里**已有**的存量文档原文(域名 → 文档),出参是同一个闭集的无 io 形 —— 迁移后的文档、逐条收据、
26
+ * 整域终判的拒。目录形读完目录后调的就是它的内核(语义只此一处);不经目录的调用方(例:命令行壳按层读 `settings` 文档)调它,不再自己把
27
+ * `migrateStoredDocuments`(纯改写)与 `parseDomain`(整域终判)按「迁移步拒 → 无改动 ⇒ noop → 改过的文档整域终判」的序再组装一遍。
28
+ * 覆盖面、`coverage` 的判、「没改的文档不判」「迁移不许越过覆盖面去写」与目录形逐字同;层的合并(层序 / 覆盖 / 托管锁)不在这里 —— 一层一次调用。
23
29
  */
24
30
  import { z } from "zod";
25
31
  import { formatConfigPath, parseDomain } from "./config-fns.js";
26
- import { configDDocumentText, judgeConfigDText, parseRemoteExecDocument } from "./config-d-file.js";
32
+ import { configDDocumentText, judgeConfigDText, judgeConfigDValue, parseRemoteExecDocument } from "./config-d-file.js";
27
33
  import { fsFailureMessage } from "./fs-failure.js";
28
34
  import { migrateStoredDocuments } from "./one-time-migrations.js";
29
35
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
@@ -94,16 +100,78 @@ function verdictOf(input, migrated, receipts) {
94
100
  return { kind: "changes" };
95
101
  }
96
102
  const messageOf = (err) => (err instanceof Error ? err.message : String(err));
97
- /**
98
- * 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
99
- */
100
- export async function migrateConfigDirectory(dir, opts) {
103
+ /** 覆盖面的名字集(校验在这一处):拼错的名字 ⇒ `TypeError`,不让它悄悄变成「什么都没覆盖」。 */
104
+ function coveredDocuments(caller, documents) {
101
105
  const covered = new Set();
102
- for (const name of opts.documents) {
106
+ for (const name of documents) {
103
107
  if (!isConfigDirectoryDocument(name))
104
- throw new TypeError(`migrateConfigDirectory: "${String(name)}" is not a config domain, a retired domain or remote-exec — the coverage names the documents the caller reads`);
108
+ throw new TypeError(`${caller}: "${String(name)}" is not a config domain, a retired domain or remote-exec — the coverage names the documents the caller reads`);
105
109
  covered.add(name);
106
110
  }
111
+ return covered;
112
+ }
113
+ /**
114
+ * 「迁 → 整域终判」的内核(语义只此一处):`docs` = 覆盖面内**在场且已判过形**的文档(都是 JSON 对象),`covered` = 覆盖面。目录形读完目录后调它,同步口 {@link migrateConfigDocuments} 判完形后调它。
115
+ */
116
+ function migrateCovered(caller, docs, covered) {
117
+ const read = Object.keys(docs);
118
+ if (read.length === 0)
119
+ return { kind: "no-documents" };
120
+ // 目录/文档集是不是「一个 scope 的全集」只看一件事:SS-M1 要写的目标域是不是都在覆盖面里(见文件头);不是 ⇒ partial,runtime 的迁移被它自己拒掉。
121
+ const coverage = RUNTIME_LIFT_TARGETS.every((target) => covered.has(target)) ? "scope" : "partial";
122
+ const m = migrateStoredDocuments(docs, { coverage });
123
+ const outside = Object.keys(m.docs).filter((name) => !Object.prototype.hasOwnProperty.call(docs, name) && !covered.has(name));
124
+ if (outside.length > 0) {
125
+ // 守卫:迁移新建了覆盖面外的文档。那份文档调用方没读过,「从未设过」只对覆盖面内成立 —— 写下去会覆盖它。今天不会发生(见文件头);发生了就是迁移步的写入域变了,这里先红。
126
+ throw new Error(`${caller}: the migration produced document(s) outside the coverage (${outside.join(", ")}) — refusing to write files the caller did not read`);
127
+ }
128
+ const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
129
+ const verdict = verdictOf(docs, m.docs, m.receipts);
130
+ switch (verdict.kind) {
131
+ case "refused":
132
+ return { kind: "refused", read, docs: m.docs, applied, refused: verdict.refused, why: verdict.why };
133
+ case "noop":
134
+ return { kind: "noop", read, docs: m.docs };
135
+ case "changes":
136
+ break;
137
+ default: {
138
+ const unhandled = verdict;
139
+ throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
140
+ }
141
+ }
142
+ const changed = Object.entries(m.docs)
143
+ .filter(([name, doc]) => !(Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc))
144
+ .map(([name]) => name);
145
+ const removed = Object.keys(docs).filter((name) => !Object.prototype.hasOwnProperty.call(m.docs, name));
146
+ return { kind: "migrated", read, docs: m.docs, applied, changed, removed };
147
+ }
148
+ /**
149
+ * 对一组**已有的**存量文档(`docs`:文档名 → 文档,例:一层 `settings` 里的 `models` 域原文)跑一次一次性迁移 + 整域终判 —— 同步、无 io;语义与结果见文件头「同步口」与 {@link ConfigDocumentsMigration}。
150
+ * 覆盖面外的键不读、不判、不进结果;覆盖面内在场(非 `undefined`)的文档必须是 JSON 对象,否则回 `unreadable`(不抛)。`docs` 不是对象 / 覆盖面拼错 ⇒ `TypeError`(调用方的错,不是数据的错)。
151
+ */
152
+ export function migrateConfigDocuments(docs, opts) {
153
+ if (docs === null || typeof docs !== "object" || Array.isArray(docs))
154
+ throw new TypeError("migrateConfigDocuments: `docs` must be a { document: value } object");
155
+ const covered = coveredDocuments("migrateConfigDocuments", opts.documents);
156
+ const present = {};
157
+ for (const name of [...covered].sort()) {
158
+ const value = Object.prototype.hasOwnProperty.call(docs, name) ? docs[name] : undefined;
159
+ if (value === undefined)
160
+ continue;
161
+ try {
162
+ present[name] = judgeConfigDValue(name, value, `the ${name} document`); // the one config.d "is it an object" judge (the file-text path ends in the same one)
163
+ }
164
+ catch (err) {
165
+ return { kind: "unreadable", document: name, reason: messageOf(err) };
166
+ }
167
+ }
168
+ return migrateCovered("migrateConfigDocuments", present, covered);
169
+ }
170
+ /**
171
+ * 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
172
+ */
173
+ export async function migrateConfigDirectory(dir, opts) {
174
+ const covered = coveredDocuments("migrateConfigDirectory", opts.documents);
107
175
  const { io } = opts;
108
176
  let names;
109
177
  try {
@@ -117,7 +185,7 @@ export async function migrateConfigDirectory(dir, opts) {
117
185
  if (name.startsWith(".") || !name.endsWith(".json"))
118
186
  continue;
119
187
  const base = name.slice(0, -".json".length);
120
- if (!covered.has(base) || !isConfigDirectoryDocument(base))
188
+ if (!isConfigDirectoryDocument(base) || !covered.has(base))
121
189
  continue;
122
190
  let text;
123
191
  const file = joinDirPath(dir, name);
@@ -134,46 +202,32 @@ export async function migrateConfigDirectory(dir, opts) {
134
202
  return { kind: "unreadable", reason: messageOf(err) };
135
203
  }
136
204
  }
137
- const read = Object.keys(docs);
138
- if (read.length === 0)
139
- return { kind: "no-documents", entries: names };
140
- // 目录是不是「一个 scope 的全集」只看一件事:SS-M1 要写的目标域是不是都在覆盖面里(见文件头);不是 ⇒ partial,runtime 的迁移被它自己拒掉。
141
- const coverage = RUNTIME_LIFT_TARGETS.every((target) => covered.has(target)) ? "scope" : "partial";
142
- const m = migrateStoredDocuments(docs, { coverage });
143
- const outside = Object.keys(m.docs).filter((name) => !Object.prototype.hasOwnProperty.call(docs, name) && !covered.has(name));
144
- if (outside.length > 0) {
145
- // 守卫:迁移新建了覆盖面外的文档。那份文件调用方没读过,「从未设过」只对覆盖面内成立 —— 写下去会覆盖它。今天不会发生(见文件头);发生了就是迁移步的写入域变了,这里先红。
146
- throw new Error(`migrateConfigDirectory: the migration produced document(s) outside the coverage (${outside.join(", ")}) — refusing to write files the caller did not read`);
147
- }
148
- const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
149
- const verdict = verdictOf(docs, m.docs, m.receipts);
150
- switch (verdict.kind) {
205
+ const m = migrateCovered("migrateConfigDirectory", docs, covered);
206
+ switch (m.kind) {
207
+ case "no-documents":
208
+ return { kind: "no-documents", entries: names };
151
209
  case "refused":
152
- return { kind: "refused", read, applied, refused: verdict.refused, why: verdict.why };
210
+ return { kind: "refused", read: m.read, applied: m.applied, refused: m.refused, why: m.why };
153
211
  case "noop":
154
- return { kind: "noop", read };
155
- case "changes":
212
+ return { kind: "noop", read: m.read };
213
+ case "migrated":
156
214
  break;
157
215
  default: {
158
- const unhandled = verdict;
159
- throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
216
+ const unhandled = m;
217
+ throw new Error(`settings-schema: unhandled migration ${JSON.stringify(unhandled)}`);
160
218
  }
161
219
  }
162
220
  if (opts.check === true)
163
- return { kind: "pending", read, applied };
221
+ return { kind: "pending", read: m.read, applied: m.applied };
164
222
  const written = [];
165
223
  const removed = [];
166
- for (const [name, doc] of Object.entries(m.docs)) {
167
- if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
168
- continue;
169
- await io.writeFileAtomic(joinDirPath(dir, `${name}.json`), configDDocumentText(doc)); // the one config.d document text (writeDomainFile / writeRemoteExec write the same)
224
+ for (const name of m.changed) {
225
+ await io.writeFileAtomic(joinDirPath(dir, `${name}.json`), configDDocumentText(m.docs[name])); // the one config.d document text (writeDomainFile / writeRemoteExec write the same)
170
226
  written.push(`${name}.json`);
171
227
  }
172
- for (const name of Object.keys(docs)) {
173
- if (Object.prototype.hasOwnProperty.call(m.docs, name))
174
- continue;
228
+ for (const name of m.removed) {
175
229
  await io.removeFile(joinDirPath(dir, `${name}.json`));
176
230
  removed.push(`${name}.json`);
177
231
  }
178
- return { kind: "applied", read, applied, written, removed };
232
+ return { kind: "applied", read: m.read, applied: m.applied, written, removed };
179
233
  }
@@ -17,6 +17,7 @@
17
17
  // its entry collections. zod is this package's one runtime dependency, so this costs nothing new.
18
18
  import { z } from "zod";
19
19
  import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
20
+ import { isPlainJsonObject } from "./plain-json-object.js";
20
21
  import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private normalization (both scanners read it) + the userinfo judgement (not in the barrel)
21
22
  import { sha256Hex } from "./sha256.js";
22
23
  import { unwrapSchema } from "./zod-unwrap.js"; // package-private: the ONE reading of zod's wrapper layers (salvage + the one-time migrations share it)
@@ -731,13 +732,6 @@ export function isReservedExtensionKey(domain, parent, key) {
731
732
  const rules = EXTENSION_NAMESPACES[domain];
732
733
  return rules !== undefined && rules.some((r) => r.at.length === parent.length && r.at.every((seg, i) => (seg === ANY_INDEX ? typeof parent[i] === "number" : seg === parent[i])) && r.key.test(key));
733
734
  }
734
- /** 纯 JSON 对象(不是数组、不是类实例/带原型的东西)——只对这种做键集对比,别的形状交给 parse 判。 */
735
- function isPlainJsonObject(v) {
736
- if (v === null || typeof v !== "object" || Array.isArray(v))
737
- return false;
738
- const proto = Object.getPrototypeOf(v);
739
- return proto === Object.prototype || proto === null;
740
- }
741
735
  function collectDropped(domain, raw, parsed, prefix, depth, acc) {
742
736
  if (depth > DROP_SCAN_MAX_DEPTH)
743
737
  return;
@@ -12,22 +12,32 @@
12
12
  * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
13
  * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
14
  * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
15
- * 目录落点(`dir-create-temp` / `remove`)的 `EPERM` / `EROFS` 出路句尾加「目录可能是只读 / 不可变(chflags / chattr)」—— 改权限位修不好的那两种。
15
+ * 三个落点(`dir-create-temp` / `remove` / `rename`)的权限类出路走**同一条顺序**(`rename` 的「可写」是已证明的事实,见下)({@link permissionWayOut}):同一个 `EPERM` 既可能是目录模式不让写,也可能是不可变标志
16
+ * (macOS `chflags uchg` / Linux `chattr +i`)—— 铸点是纯函数,分不清,但调用方在 Node 一侧能读目录模式,把「模式对本进程可写」这个事实经落点的 `directoryWritable` 递进来:
17
+ * `EPERM` ∧ 可写 ⇒ chmod 已经没用,首选出路 = 清标志(`chflags nouchg` / `chattr -i`);否则(模式不可写,或调用方没读过模式 —— 注入的替身 I/O 抛的错,包看不到它背后)⇒ 先 chmod,
18
+ * 句尾提示「只读 / 不可变」的可能。`EROFS` 的出路不变(只读文件系统清不掉),`EACCES` 是纯权限位,不吃这条规则。
19
+ * `rename` 落点的「可写」不是读出来的而是已证明的(走到 rename 时临时文件刚在同一目录建成):`EPERM` 恒为清标志先行,`EACCES` 保留 14.4.0 的文件向原句("make the file replaceable — clear an immutable flag"),
20
+ * 两者句尾都是粘滞目录一句,都不说「make the directory writable」。
16
21
  *
17
22
  * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
18
23
  * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
19
24
  */
20
25
  /** 一次失败发生的地方(闭集):落点加那一步交给操作系统的真实路径(`dir-create-temp` 是要建的临时文件,点名的目录是它的父目录;`rename` 是被盖过的真文件;
21
- * `remove` / `write` 是那份文件;`read` 是读的目录或文件)。 */
26
+ * `remove` / `write` 是那份文件;`read` 是读的目录或文件)。
27
+ * `directoryWritable`(只有 `dir-create-temp` / `remove` 两个落点有)—— 失败那一刻,目录的权限位对本进程是否可写(Node 一侧读的事实,见 `./atomic-write` 的 `directoryAdmitsWrite`);
28
+ * 缺席 = 没读过(注入的替身 I/O),出路顺序按「不可写」走。`rename` 落点没有这一格:走到 rename 时临时文件刚在同一目录建成,目录可写是已证明的事实,由铸点按 {@link RENAME_DIRECTORY_WRITABLE} 给出。
29
+ */
22
30
  export type FsFailureSite = {
23
31
  readonly landing: "dir-create-temp";
24
32
  readonly path: string;
33
+ readonly directoryWritable?: boolean;
25
34
  } | {
26
35
  readonly landing: "rename";
27
36
  readonly path: string;
28
37
  } | {
29
38
  readonly landing: "remove";
30
39
  readonly path: string;
40
+ readonly directoryWritable?: boolean;
31
41
  } | {
32
42
  readonly landing: "read";
33
43
  readonly path: string;