@sema-agent/settings-schema 14.5.0 → 14.6.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +15 -0
  3. package/conformance/VERSION +1 -1
  4. package/dist/atomic-write.js +10 -7
  5. package/dist/bundle.d.ts +1 -1
  6. package/dist/bundle.js +1 -1
  7. package/dist/config-dir-migration.js +2 -1
  8. package/dist/config-fns.js +2 -2
  9. package/dist/conformance/canonical.js +4 -7
  10. package/dist/conformance/corpus.js +3 -3
  11. package/dist/conformance/derive.js +6 -5
  12. package/dist/conformance/run.js +2 -1
  13. package/dist/definition-copy.d.ts +23 -0
  14. package/dist/definition-copy.js +52 -0
  15. package/dist/file-store.d.ts +1 -31
  16. package/dist/file-store.js +3 -30
  17. package/dist/fleet.js +2 -1
  18. package/dist/fs-failure.d.ts +12 -7
  19. package/dist/fs-failure.js +53 -29
  20. package/dist/hooks-config.d.ts +2 -2
  21. package/dist/hooks-config.js +112 -31
  22. package/dist/http-hook-allowlists.d.ts +7 -3
  23. package/dist/http-hook-allowlists.js +102 -16
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +2 -1
  26. package/dist/mcp-request-entry.d.ts +9 -6
  27. package/dist/mcp-request-entry.js +225 -93
  28. package/dist/plain-json-object.d.ts +28 -3
  29. package/dist/plain-json-object.js +48 -7
  30. package/dist/portable-domains.d.ts +31 -0
  31. package/dist/portable-domains.js +30 -0
  32. package/dist/safety-merge-spec.d.ts +88 -6
  33. package/dist/safety-merge-spec.js +233 -17
  34. package/dist/scheduler-store.js +3 -2
  35. package/dist/settings-units.d.ts +85 -0
  36. package/dist/settings-units.js +175 -0
  37. package/dist/types.js +57 -53
  38. package/dist/url-shape.d.ts +11 -5
  39. package/dist/url-shape.js +11 -5
  40. package/dist/workers-rows.d.ts +2 -1
  41. package/dist/workers-rows.js +5 -4
  42. package/docs/API-SURFACE.md +17 -3
  43. package/llms-full.txt +12 -2
  44. package/llms.txt +4 -2
  45. package/package.json +1 -1
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表):**定义语义的复制**。读不可信的串数组、写读出来的副本,都不许走 `[[Set]]` 与继承的成员 ——
3
+ * `Object.prototype` / `Array.prototype` 上被污染的数字键(getter / setter)、`get` / `set` 键、数组实例上被替换的 `Symbol.iterator`,
4
+ * 在下标赋值、`push`、展开、描述符对象字面量上各有一条路能把条目吞掉、替换或抛出去。本模块是这件事的唯一读法,读者只调它:
5
+ * · {@link captureStringArray}:一份串数组的捕获副本(按下标、`hasOwn`、一次读、非串即坏、`defineProperty` 写入);
6
+ * · {@link dataDescriptor}:写副本用的描述符,零原型(`{ value, writable, enumerable, configurable }` 字面量继承 `Object.prototype`,
7
+ * `Object.prototype.get` / `set` 被污染时它会被读成访问器描述符)。
8
+ * 零 import。
9
+ */
10
+ /** 数据属性描述符(可写 / 可枚举 / 可配置),**零原型**:不继承 `Object.prototype` 上被污染的 `get` / `set` 等键,也不靠对象字面量。 */
11
+ export declare function dataDescriptor(value: unknown): PropertyDescriptor;
12
+ /**
13
+ * 一份**串数组的捕获副本**;不是串数组 ⇒ `undefined`(调用方据此读成坏形 —— 名单读成 `[]` 并点名,`args` 丢键并进 `lost`)。
14
+ * **取值 + 检视 + 复制整段在一个 try 里**,`length` 与每个元素都只读一次(检视与后续使用不会读到两个答案):
15
+ * · 不是数组,或 `length` 不是安全非负整数(读成字符串的 Proxy 等)⇒ 坏形,不按 `length` 物化;
16
+ * · 逐下标先问 `Object.hasOwn` 再读 —— 洞(不是自有下标)⇒ 坏形,不顺原型链读到 `Object.prototype` / `Array.prototype` 上的数字键(数据属性或访问器);
17
+ * · 条目不是串 ⇒ 坏形;遇到第一个坏处就退出(`length` 很大的稀疏数组立即判坏形);
18
+ * · 副本用 `Object.defineProperty` + {@link dataDescriptor} 写(`push` / 下标赋值是 `[[Set]]`,会命中继承的数字键 setter、吞掉条目);
19
+ * · 不走迭代协议(展开 / `for…of` 会让数组实例上被替换的 `Symbol.iterator` 供出别的条目);
20
+ * · 读 `length` / 元素时抛(getter、Proxy 陷阱)⇒ 坏形,不抛出去。
21
+ * 交出去的是**副本**(不是调用方数组的原引用):调用方之后改原数组,副本不变。
22
+ */
23
+ export declare function captureStringArray(v: unknown): string[] | undefined;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表):**定义语义的复制**。读不可信的串数组、写读出来的副本,都不许走 `[[Set]]` 与继承的成员 ——
3
+ * `Object.prototype` / `Array.prototype` 上被污染的数字键(getter / setter)、`get` / `set` 键、数组实例上被替换的 `Symbol.iterator`,
4
+ * 在下标赋值、`push`、展开、描述符对象字面量上各有一条路能把条目吞掉、替换或抛出去。本模块是这件事的唯一读法,读者只调它:
5
+ * · {@link captureStringArray}:一份串数组的捕获副本(按下标、`hasOwn`、一次读、非串即坏、`defineProperty` 写入);
6
+ * · {@link dataDescriptor}:写副本用的描述符,零原型(`{ value, writable, enumerable, configurable }` 字面量继承 `Object.prototype`,
7
+ * `Object.prototype.get` / `set` 被污染时它会被读成访问器描述符)。
8
+ * 零 import。
9
+ */
10
+ /** 数据属性描述符(可写 / 可枚举 / 可配置),**零原型**:不继承 `Object.prototype` 上被污染的 `get` / `set` 等键,也不靠对象字面量。 */
11
+ export function dataDescriptor(value) {
12
+ const descriptor = Object.create(null);
13
+ descriptor.value = value;
14
+ descriptor.writable = true;
15
+ descriptor.enumerable = true;
16
+ descriptor.configurable = true;
17
+ return descriptor;
18
+ }
19
+ /**
20
+ * 一份**串数组的捕获副本**;不是串数组 ⇒ `undefined`(调用方据此读成坏形 —— 名单读成 `[]` 并点名,`args` 丢键并进 `lost`)。
21
+ * **取值 + 检视 + 复制整段在一个 try 里**,`length` 与每个元素都只读一次(检视与后续使用不会读到两个答案):
22
+ * · 不是数组,或 `length` 不是安全非负整数(读成字符串的 Proxy 等)⇒ 坏形,不按 `length` 物化;
23
+ * · 逐下标先问 `Object.hasOwn` 再读 —— 洞(不是自有下标)⇒ 坏形,不顺原型链读到 `Object.prototype` / `Array.prototype` 上的数字键(数据属性或访问器);
24
+ * · 条目不是串 ⇒ 坏形;遇到第一个坏处就退出(`length` 很大的稀疏数组立即判坏形);
25
+ * · 副本用 `Object.defineProperty` + {@link dataDescriptor} 写(`push` / 下标赋值是 `[[Set]]`,会命中继承的数字键 setter、吞掉条目);
26
+ * · 不走迭代协议(展开 / `for…of` 会让数组实例上被替换的 `Symbol.iterator` 供出别的条目);
27
+ * · 读 `length` / 元素时抛(getter、Proxy 陷阱)⇒ 坏形,不抛出去。
28
+ * 交出去的是**副本**(不是调用方数组的原引用):调用方之后改原数组,副本不变。
29
+ */
30
+ export function captureStringArray(v) {
31
+ try {
32
+ if (!Array.isArray(v))
33
+ return undefined;
34
+ const source = v;
35
+ const n = source.length;
36
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 0)
37
+ return undefined;
38
+ const copy = [];
39
+ for (let i = 0; i < n; i++) {
40
+ if (!Object.hasOwn(source, i))
41
+ return undefined;
42
+ const entry = source[i];
43
+ if (typeof entry !== "string")
44
+ return undefined;
45
+ Object.defineProperty(copy, i, dataDescriptor(entry));
46
+ }
47
+ return copy;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
@@ -1,22 +1,7 @@
1
1
  import { type EffectiveReadWarning } from "./config-fns.js";
2
2
  import type { ConfigReader } from "./reader.js";
3
3
  import { type DomainConfig, type DomainName, type EffectiveConfig, type RetiredDomainName } from "./types.js";
4
- /** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
5
- * **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
6
- * {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
7
- *
8
- * ⚠️ 口径诚实(对抗复审 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
9
- * / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
10
- * bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
11
- * `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
12
- *
13
- * 🔴 **开新顶层域时必须同步登记本表**(成案 = 消费方 #322 真事故):域表是**闭集**,不在这里的
14
- * 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
15
- * 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
16
- * 今日的兜底:漏登记的文件会被 {@link FileConfigStore} 的 `"unread-config-file"` 警告点名(fail-loud,
17
- * 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
18
- export declare const PORTABLE_DOMAINS: readonly ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
19
- export type PortableDomain = (typeof PORTABLE_DOMAINS)[number];
4
+ export { NON_PORTABLE_DOMAIN_REASONS, PORTABLE_DOMAINS, type PortableDomain } from "./portable-domains.js";
20
5
  /** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
21
6
  * 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
22
7
  * ——**它有自己的读取与校验路径**,所以既不是「未知文件」也不是「域文件」。
@@ -24,21 +9,6 @@ export type PortableDomain = (typeof PORTABLE_DOMAINS)[number];
24
9
  * **导出**(3.0.0):一致性门的语料派生要按同一张表给本地腿的文件分类(域文件 / 专用文件 / 忽略),
25
10
  * 手抄一份必漂 —— 漂了的那一天,门会把一个合法的 `remote-exec.json` 报成未知文件。 */
26
11
  export declare const NON_DOMAIN_CONFIG_FILES: ReadonlySet<string>;
27
- /**
28
- * 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
29
- *
30
- * `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
31
- * 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
32
- * (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;对抗复审
33
- * 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
34
- */
35
- export declare const NON_PORTABLE_DOMAIN_REASONS: {
36
- readonly workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。";
37
- readonly hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。";
38
- readonly systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。";
39
- readonly projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。";
40
- readonly entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service / web-admin 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。";
41
- };
42
12
  /** One unreadable/corrupt domain FILE surfaced by `getEffective({tolerant:true})`(0.10.12,[897]②)。
43
13
  * `error` 是 #readDomainRaw 的 sanitized 文案(带文件路径语境、绝不回显文件内容)。 */
44
14
  export type DomainReadError = {
@@ -29,25 +29,13 @@ import path from "node:path";
29
29
  import { buildEffective, DOMAIN_READ_FALLBACK, parseDomainLoud } from "./config-fns.js";
30
30
  import { configDPath, readConfigDFile, refuseRetiredDomainFiles } from "./config-d-file.js";
31
31
  import { stableHash } from "./hash.js";
32
+ import { PORTABLE_DOMAINS } from "./portable-domains.js";
32
33
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
33
34
  import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
34
35
  /** The retired domain names (`RETIRED_DOMAINS` keys) — read from `config.d/` for their tombstone only. */
35
36
  const RETIRED_DOMAIN_NAMES = Object.keys(RETIRED_DOMAINS);
36
- /** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
37
- * **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
38
- * {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
39
- *
40
- * ⚠️ 口径诚实(对抗复审 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
41
- * / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
42
- * bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
43
- * `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
44
- *
45
- * 🔴 **开新顶层域时必须同步登记本表**(成案 = 消费方 #322 真事故):域表是**闭集**,不在这里的
46
- * 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
47
- * 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
48
- * 今日的兜底:漏登记的文件会被 {@link FileConfigStore} 的 `"unread-config-file"` 警告点名(fail-loud,
49
- * 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
50
- export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
37
+ // 便携域表的定义住在纯叶 ./portable-domains.ts(14.6.0;零 node 依赖,主桶模块可 import);这里原名转出口,`./node` 子路径逐名不变。
38
+ export { NON_PORTABLE_DOMAIN_REASONS, PORTABLE_DOMAINS } from "./portable-domains.js";
51
39
  /** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
52
40
  * 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
53
41
  * ——**它有自己的读取与校验路径**,所以既不是「未知文件」也不是「域文件」。
@@ -55,21 +43,6 @@ export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins"
55
43
  * **导出**(3.0.0):一致性门的语料派生要按同一张表给本地腿的文件分类(域文件 / 专用文件 / 忽略),
56
44
  * 手抄一份必漂 —— 漂了的那一天,门会把一个合法的 `remote-exec.json` 报成未知文件。 */
57
45
  export const NON_DOMAIN_CONFIG_FILES = new Set([`${REMOTE_EXEC_DOCUMENT}.json`]);
58
- /**
59
- * 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
60
- *
61
- * `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
62
- * 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
63
- * (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;对抗复审
64
- * 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
65
- */
66
- export const NON_PORTABLE_DOMAIN_REASONS = {
67
- workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。",
68
- hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。",
69
- systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。",
70
- projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。",
71
- entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service / web-admin 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。",
72
- };
73
46
  export class FileConfigStore {
74
47
  root;
75
48
  opts;
package/dist/fleet.js CHANGED
@@ -20,6 +20,7 @@
20
20
  import { z } from "zod";
21
21
  import { INTEGER } from "./integer.js";
22
22
  import { fnv1a64 } from "./hash.js";
23
+ import { isPlainJsonObject } from "./plain-json-object.js";
23
24
  import { buildQuotedIssue } from "./quoted-issue.js";
24
25
  // ── Web-admin's fleet endpoints (paths, one spelling) ─────────────────────────────────────────────────────
25
26
  /**
@@ -173,7 +174,7 @@ export const RestartSignal = z.object({
173
174
  */
174
175
  function judgeSignalBody(body, pick, schema) {
175
176
  try {
176
- if (body === null || typeof body !== "object" || Array.isArray(body))
177
+ if (!isPlainJsonObject(body))
177
178
  return { kind: "not-an-object" };
178
179
  const parsed = schema.safeParse(pick(body));
179
180
  return parsed.success ? { kind: "read", value: parsed.data } : { kind: "invalid" };
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
7
7
  * · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
8
+ * · `chmod` —— 原子写器给临时文件设模式(已有的文件沿用它原来的模式,临时文件建成后、`rename` 之前设)。这一步失败 ⇒ 点名**设模式**这件事(文件系统不让改文件模式;目录没有问题),
9
+ * 不提目录、不提不可变标志;`path` 是被保留模式的**真文件**(chmod 作用在替换它的临时副本上,用户认得的是真文件名);
8
10
  * · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
9
11
  * · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
10
12
  * · `read` —— 读一个目录或文件;
@@ -12,18 +14,18 @@
12
14
  * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
15
  * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
16
  * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
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」。
17
+ * 三个落点(`dir-create-temp` / `remove` / `rename`)的权限类出路走**同一张表**、一只函数({@link permissionWayOut}):落点表 {@link WAY_OUT_LANDING}(这个落点认哪些 errno、清标志点名谁、动已有目录项的落点才有粘滞目录一句)×
18
+ * errno 表 {@link WAY_OUT_BY_ERRNO}(穷举;每个 errno 两格 —— 目录权限位对本进程「可写」/「不可写或没读过」)。同一个 `EPERM` 既可能是目录模式不让写,也可能是不可变标志(macOS `chflags uchg` / Linux `chattr +i`),
19
+ * 同一个 `EACCES` 在模式位已经放行时只能是 ACL 条目或安全模块策略 —— 铸点是纯函数,分不清,但调用方在 Node 一侧能读目录模式,把「模式对本进程可写」这个事实经落点的 `directoryWritable` 递进来:
20
+ * 「可写」⇒ chmod 已经没用,`EPERM` 的首选出路 = 清标志(`chflags nouchg` / `chattr -i`),动已有目录项的落点(remove / rename)再接粘滞目录一句(那条属主规则给的就是 `EPERM`),`EACCES` 的出路 = 查 ACL / 安全模块(不接粘滞句);
21
+ * 否则(模式不可写,或调用方没读过模式 —— 注入的替身 I/O 抛的错,包看不到它背后)⇒ 先让目录可写,`EPERM` / `EROFS` 句尾提示「只读 / 不可变」的可能。`EROFS` 的出路不变(只读文件系统清不掉)。
22
+ * `rename` 落点的「可写」不是读出来的而是已证明的({@link RENAME_DIRECTORY_WRITABLE}:走到 rename 时临时文件刚在同一目录建成)—— 一个来源,`EPERM` 与 `EACCES` 同走「可写」那一列,所以 rename 句从不说「make the directory writable」。
21
23
  *
22
24
  * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
23
25
  * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
24
26
  */
25
27
  /** 一次失败发生的地方(闭集):落点加那一步交给操作系统的真实路径(`dir-create-temp` 是要建的临时文件,点名的目录是它的父目录;`rename` 是被盖过的真文件;
26
- * `remove` / `write` 是那份文件;`read` 是读的目录或文件)。
28
+ * `remove` / `write` 是那份文件;`read` 是读的目录或文件;`chmod` 是被保留模式的真文件〔这一格不是「交给操作系统的路径」口径:chmod 的对象是替换它的临时副本〕)。
27
29
  * `directoryWritable`(只有 `dir-create-temp` / `remove` 两个落点有)—— 失败那一刻,目录的权限位对本进程是否可写(Node 一侧读的事实,见 `./atomic-write` 的 `directoryAdmitsWrite`);
28
30
  * 缺席 = 没读过(注入的替身 I/O),出路顺序按「不可写」走。`rename` 落点没有这一格:走到 rename 时临时文件刚在同一目录建成,目录可写是已证明的事实,由铸点按 {@link RENAME_DIRECTORY_WRITABLE} 给出。
29
31
  */
@@ -31,6 +33,9 @@ export type FsFailureSite = {
31
33
  readonly landing: "dir-create-temp";
32
34
  readonly path: string;
33
35
  readonly directoryWritable?: boolean;
36
+ } | {
37
+ readonly landing: "chmod";
38
+ readonly path: string;
34
39
  } | {
35
40
  readonly landing: "rename";
36
41
  readonly path: string;
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
7
7
  * · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
8
+ * · `chmod` —— 原子写器给临时文件设模式(已有的文件沿用它原来的模式,临时文件建成后、`rename` 之前设)。这一步失败 ⇒ 点名**设模式**这件事(文件系统不让改文件模式;目录没有问题),
9
+ * 不提目录、不提不可变标志;`path` 是被保留模式的**真文件**(chmod 作用在替换它的临时副本上,用户认得的是真文件名);
8
10
  * · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
9
11
  * · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
10
12
  * · `read` —— 读一个目录或文件;
@@ -12,36 +14,58 @@
12
14
  * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
15
  * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
16
  * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
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」。
17
+ * 三个落点(`dir-create-temp` / `remove` / `rename`)的权限类出路走**同一张表**、一只函数({@link permissionWayOut}):落点表 {@link WAY_OUT_LANDING}(这个落点认哪些 errno、清标志点名谁、动已有目录项的落点才有粘滞目录一句)×
18
+ * errno 表 {@link WAY_OUT_BY_ERRNO}(穷举;每个 errno 两格 —— 目录权限位对本进程「可写」/「不可写或没读过」)。同一个 `EPERM` 既可能是目录模式不让写,也可能是不可变标志(macOS `chflags uchg` / Linux `chattr +i`),
19
+ * 同一个 `EACCES` 在模式位已经放行时只能是 ACL 条目或安全模块策略 —— 铸点是纯函数,分不清,但调用方在 Node 一侧能读目录模式,把「模式对本进程可写」这个事实经落点的 `directoryWritable` 递进来:
20
+ * 「可写」⇒ chmod 已经没用,`EPERM` 的首选出路 = 清标志(`chflags nouchg` / `chattr -i`),动已有目录项的落点(remove / rename)再接粘滞目录一句(那条属主规则给的就是 `EPERM`),`EACCES` 的出路 = 查 ACL / 安全模块(不接粘滞句);
21
+ * 否则(模式不可写,或调用方没读过模式 —— 注入的替身 I/O 抛的错,包看不到它背后)⇒ 先让目录可写,`EPERM` / `EROFS` 句尾提示「只读 / 不可变」的可能。`EROFS` 的出路不变(只读文件系统清不掉)。
22
+ * `rename` 落点的「可写」不是读出来的而是已证明的({@link RENAME_DIRECTORY_WRITABLE}:走到 rename 时临时文件刚在同一目录建成)—— 一个来源,`EPERM` 与 `EACCES` 同走「可写」那一列,所以 rename 句从不说「make the directory writable」。
21
23
  *
22
24
  * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
23
25
  * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
24
26
  */
25
- /** `dir-create-temp` 里「目录本身不让建文件」的 errno:权限不够、只读文件系统、目录不在。出路 = 让目录可写。 */
26
- const DIRECTORY_NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS", "ENOENT"]);
27
- /** `remove` 里「目录不让删目录项」的 errno(同一个因:目录不可写)。目录不在 / 文件已不在(ENOENT)不是这个因,给 Node 自己的句子。 */
28
- const DIRECTORY_ENTRY_NOT_REMOVABLE = new Set(["EACCES", "EPERM", "EROFS"]);
29
- /** `rename` 里「目录收新文件、却换不掉这一份」的 errno:不可变标志、粘滞目录里别人的文件。出路 = 文件向(见 {@link renameWayOut}),句尾加粘滞目录一句。 */
30
- const FILE_NOT_REPLACEABLE = new Set(["EACCES", "EPERM"]);
31
27
  /** `rename` 落点上「目录对本进程可写」的事实:走到 rename 时临时文件刚在**同一目录**建成,这是已证明的,不是读出来的 —— 写器不对 rename 落点读模式位(一个来源)。 */
32
28
  const RENAME_DIRECTORY_WRITABLE = true;
33
- /** 权限类出路的**唯一一条顺序**(三个落点共用;`rename` 以「可写」为已证明的事实代入):`EPERM` ∧ 目录模式对本进程可写 ⇒ 真因只能是不可变标志(或粘滞位),chmod 无效 ⇒ 首选出路 = 清标志,点名清谁(`flagSubject`)与两个平台的命令;
34
- * 其余 ⇒ 先让目录可写,`EPERM` / `EROFS`(靠改权限位修不好的两种)的句尾提示「只读 / 不可变」;`EACCES` / `ENOENT` 是纯权限位 / 目录不在,出路就是让目录可写。 */
35
- const permissionWayOut = (code, directoryWritable, flagSubject) => code === "EPERM" && directoryWritable === true
36
- ? `clear the immutable flag on ${flagSubject} (macOS: chflags nouchg; Linux: chattr -i) — the directory's permission bits already admit this process, so chmod will not help`
37
- : code === "EPERM" || code === "EROFS"
38
- ? "make the directory writable; directory may be read-only or immutable (chflags / chattr)"
39
- : "make the directory writable";
40
- /** `rename` 落点的出路(文件向):`EPERM` 走 {@link permissionWayOut} 的顺序(目录可写已证明 ⇒ 清标志先行);`EACCES` 是 14.4.0 的原句逐字 ——
41
- * 无论哪个 errno,都不说「make the directory writable」(与已证明的事实自相矛盾)。 */
42
- const renameWayOut = (code) => code === "EPERM" ? permissionWayOut(code, RENAME_DIRECTORY_WRITABLE, "the file or its directory") : "make the file replaceable — clear an immutable flag";
43
- /** `rename` 句尾的粘滞目录一句(与权限类出路并列的另一个因:粘滞目录里别人的文件只有属主能换)。 */
44
- const STICKY_DIRECTORY_NOTE = "in a sticky directory only the file's owner may replace it";
29
+ const MODE_BITS_ADMIT = "the directory's permission bits already admit this process, so chmod will not help";
30
+ const MAKE_DIRECTORY_WRITABLE = "make the directory writable";
31
+ const READ_ONLY_OR_IMMUTABLE = `${MAKE_DIRECTORY_WRITABLE}; directory may be read-only or immutable (chflags / chattr)`;
32
+ /** errno 表:每个权限类 errno 在目录权限位「可写」(`admits`:chmod 已无用,说还剩什么真因)与「不可写或没读过」(`otherwise`)两格的出路,以及「可写」时是否接粘滞目录一句(`stickyWhenAdmitted`)。
33
+ * `EROFS` / `ENOENT` 的两格同一句 —— 模式位可不可写不改变它们的出路(只读文件系统清不掉;目录不在,先让它在)。 */
34
+ const WAY_OUT_BY_ERRNO = {
35
+ EPERM: {
36
+ admits: (flagSubject) => `clear the immutable flag on ${flagSubject} (macOS: chflags nouchg; Linux: chattr -i) — ${MODE_BITS_ADMIT}`,
37
+ otherwise: READ_ONLY_OR_IMMUTABLE,
38
+ stickyWhenAdmitted: true,
39
+ },
40
+ EACCES: {
41
+ admits: () => `${MODE_BITS_ADMIT}; an ACL entry may still deny this process (macOS: ls -le; Linux: getfacl); so may a security-module policy (SELinux / AppArmor: the audit log names the rule)`,
42
+ otherwise: MAKE_DIRECTORY_WRITABLE,
43
+ stickyWhenAdmitted: false, // 粘滞目录的属主规则返回的是 EPERM(macOS `man 2 unlink` / `rename`;Linux 同,推断),EACCES 的句尾不指向一个产生不了它的因
44
+ },
45
+ EROFS: { admits: () => READ_ONLY_OR_IMMUTABLE, otherwise: READ_ONLY_OR_IMMUTABLE, stickyWhenAdmitted: false },
46
+ ENOENT: { admits: () => MAKE_DIRECTORY_WRITABLE, otherwise: MAKE_DIRECTORY_WRITABLE, stickyWhenAdmitted: false },
47
+ };
48
+ const WAY_OUT_LANDING = {
49
+ "dir-create-temp": { errnos: ["EACCES", "EPERM", "EROFS", "ENOENT"], flagSubject: "the directory" },
50
+ remove: { errnos: ["EACCES", "EPERM", "EROFS"], flagSubject: "the file or its directory", stickyVerb: "remove" },
51
+ rename: { errnos: ["EACCES", "EPERM"], flagSubject: "the file or its directory", stickyVerb: "replace" },
52
+ };
53
+ /** 权限类出路的**唯一一只函数**(三个落点共用,表驱动):`code` 不是这个落点认的权限类 errno ⇒ `undefined`(调用方给 Node 自己的句子)。
54
+ * 组合律一条:errno 表的格(按 `directoryWritable === true` 选「可写」列或「不可写或没读过」列);「可写」∧ 该 errno 带粘滞 ∧ 这个落点有 `stickyVerb` ⇒ 句尾接 `in a sticky directory only the file's owner may <verb> it`。 */
55
+ const permissionWayOut = (landing, code, directoryWritable) => {
56
+ const spec = WAY_OUT_LANDING[landing];
57
+ const errno = spec.errnos.find((e) => e === code);
58
+ if (errno === undefined)
59
+ return undefined;
60
+ const row = WAY_OUT_BY_ERRNO[errno];
61
+ if (directoryWritable !== true)
62
+ return row.otherwise;
63
+ const way = row.admits(spec.flagSubject);
64
+ return row.stickyWhenAdmitted && spec.stickyVerb !== undefined ? `${way}; in a sticky directory only the file's owner may ${spec.stickyVerb} it` : way;
65
+ };
66
+ /** `chmod` 落点认的 errno:`EPERM` —— 创建者对自己刚建的临时文件 chmod 被拒,只能是文件系统 / 挂载不支持改模式。别的 errno 给 Node 自己的句子。 */
67
+ const CHMOD_REFUSED = new Set(["EPERM"]);
68
+ const CHMOD_WAY_OUT = "the filesystem does not allow changing file modes — keep this file on a filesystem that supports POSIX permissions";
45
69
  const errnoOf = (e) => {
46
70
  const code = e?.code;
47
71
  return typeof code === "string" ? code : undefined;
@@ -65,13 +89,13 @@ export function fsFailureSentence(site, cause) {
65
89
  const tag = code === undefined ? "" : ` (${code})`;
66
90
  switch (site.landing) {
67
91
  case "dir-create-temp":
68
- return `cannot write into directory ${parentDirectory(site.path)}${tag}: ${code !== undefined && DIRECTORY_NOT_WRITABLE.has(code) ? permissionWayOut(code, site.directoryWritable, "the directory") : nodeSentenceOf(cause, code)}`;
92
+ return `cannot write into directory ${parentDirectory(site.path)}${tag}: ${permissionWayOut("dir-create-temp", code, site.directoryWritable) ?? nodeSentenceOf(cause, code)}`;
93
+ case "chmod":
94
+ return `cannot set the mode of ${site.path}${tag}: ${code !== undefined && CHMOD_REFUSED.has(code) ? CHMOD_WAY_OUT : nodeSentenceOf(cause, code)}`;
69
95
  case "rename":
70
- return code !== undefined && FILE_NOT_REPLACEABLE.has(code)
71
- ? `cannot replace ${site.path}${tag}: ${renameWayOut(code)}; ${STICKY_DIRECTORY_NOTE}`
72
- : `cannot replace ${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
96
+ return `cannot replace ${site.path}${tag}: ${permissionWayOut("rename", code, RENAME_DIRECTORY_WRITABLE) ?? nodeSentenceOf(cause, code)}`;
73
97
  case "remove":
74
- return `cannot remove ${site.path} from directory ${parentDirectory(site.path)}${tag}: ${code !== undefined && DIRECTORY_ENTRY_NOT_REMOVABLE.has(code) ? permissionWayOut(code, site.directoryWritable, "the file or its directory") : nodeSentenceOf(cause, code)}`;
98
+ return `cannot remove ${site.path} from directory ${parentDirectory(site.path)}${tag}: ${permissionWayOut("remove", code, site.directoryWritable) ?? nodeSentenceOf(cause, code)}`;
75
99
  case "read":
76
100
  return `cannot read ${site.of === "directory" ? "directory " : ""}${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
77
101
  case "write":
@@ -167,8 +167,8 @@ export declare const HOOKS_DROPPED_PIECE_CODES: readonly ["hooks-not-object", "u
167
167
  export type HooksDroppedPieceCode = (typeof HOOKS_DROPPED_PIECE_CODES)[number];
168
168
  /**
169
169
  * Why the document was REJECTED (closed; each is "a guard may be in this piece"):
170
- * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped);
171
- * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry;
170
+ * · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped), or the document could not be read at all (path `[]`);
171
+ * · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry, or an object that is not a readable block (a class instance, a value that cannot be inspected) holding — or possibly holding — a guard sign;
172
172
  * · `guard-under-unknown-event` — an unknown event name holding a guard sign;
173
173
  * · `guard-event-not-array` — a guard event whose value is not an array (and not null), or a known event whose non-array value holds a sign;
174
174
  * · `guard-matcher-invalid` — a bad matcher in a guard event, or a matcher holding a sign (in any event);