@sema-agent/settings-schema 12.0.0 → 12.0.1

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
@@ -6,6 +6,30 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 12.0.1 — 2026-10-12 — patch:文件系统失败句一只铸点(errno × 落点 × 真实路径)覆盖写 / 删 / 改名 / 迁移拒句 —— 删除失败点名目录 · 拒句用真实路径不写死 config.d/ · 不可变目录提示 · ENAMETOOLONG 一句;零型面
10
+
11
+ > patch:文件系统失败句收成一只铸点,覆盖写 / 删 / 改名 / 读与 `config.d` 文档拒句四处。零型面(零新导出、零删、零改名;导出面登记表不动),退出码、报告行、库写口抛的错的 `code` / `cause` 都不变;
12
+ > 变的只是 stderr / 错误 `message` 里的句子文本,逐条列在下面。消费方:零改动(按 `code` 分支的调用方不受影响;逐字匹配这些句子的脚本按新读数改匹配)。
13
+
14
+ ### 行为修复:文件系统失败句只有一只铸点 —— 删除失败点名目录 · 拒句点名真实路径 · 不可变目录提示 · 路径过长一句
15
+
16
+ 12.0.0 只把写失败一臂按 errno 落点分了句,同一类事实在别处还是各自手写的话(删文件、读目录 / 文件、`config.d` 文档拒句、`ENAMETOOLONG`)。
17
+ 现在本包把文件系统错误说成人话只有一只铸点(错误码 × 落点 × 真实路径):落点是闭集(建临时文件 / `rename` / 删 / 读,外加写口整体失败这一档 —— 注入的写口抛的错,看不见是哪一步),
18
+ 原子写器、命令行写 / 删口、目录迁移读口全从这里出句;包内门看住「铸点之外没有第二处手写句」。四处读数:
19
+ - **删除失败**(`migrate` 删 retired 域文件 `runtime.json` 时目录不可写):`cannot remove <文件> from directory <目录> (<errno>): make the directory writable`。
20
+ 以前 `cannot remove <文件>: <errno>: <Node 句>`,点名的是文件,真因是目录(与 12.0.0 修掉的写失败同一形)。
21
+ - **文档拒句点名读的那份真实路径**:`config.d` 里空 / 纯空白 / 坏 JSON / 不是对象(域文档、retired 域文档、`remote-exec.json` 同一只文本判)的三句,
22
+ 以前一律写死 `config.d/<名>.json` 字面 —— 对两个 scope 目录各迁一遍时,两份拒句逐字相同、认不出是哪个目录。现在是 `<目录>/remote-exec.json is empty/whitespace — …`、
23
+ `invalid JSON in <目录>/models.json — …`、`<路径> must be a JSON object (got <形>) — …`;`FileConfigStore` / `readBundle` / `loadRemoteExec` / 命令行各点名自己读的那一份。
24
+ 读不了目录 / 文件的句子同样点名真实路径(以前读文件只给裸文件名)。
25
+ - **目录不可变 / 只读**:建临时文件与删目录项的 `EPERM` / `EROFS`,出路句尾加 `directory may be read-only or immutable (chflags / chattr)`
26
+ (目录带 `chflags uchg` / `chattr +i` 时,只改权限位修不好);`EACCES` / `ENOENT` 的 12.0.0 读数逐字不变。
27
+ - **路径过长**(`ENAMETOOLONG`):一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`。
28
+ 以前落在建临时文件上时读成 `cannot write into directory <目录> (ENAMETOOLONG): ENAMETOOLONG: name too long, …` —— errno 两遍,而且怪到目录头上。
29
+ 「errno 只打一遍」对所有落点生效:别的 errno 的 Node 句子去掉自带的 `<errno>: ` 前缀(例:`cannot replace <文件> (ENOSPC): no space left on device, …`)。
30
+
31
+ 库写口(`writeDomainFile` / `writeRemoteExec` / 调度店写口)抛的错:`message` 是同一只铸点的句子,`code` 照抄底层 errno、原错误在 `cause`,不变。
32
+
9
33
  ## 12.0.0 — 2026-10-12 — 角色词随引擎收缩(`ModelRole` 8 → 6,一次性迁移 SS-M8)· 写失败按 errno 落点分句 · `config.d` 每份文档同一只文本判 · 扣名条目的可比记号 `identity.token`(major)
10
34
 
11
35
  > 本节一处硬破坏(角色词表收缩,持久面走一次性迁移 SS-M8)外加三件:命令行写失败按落点分句、`config.d` 每份文档同一只文本判(`remote-exec.json` 非对象不再被 `migrate`
@@ -1 +1 @@
1
- 12.0.0
1
+ 12.0.1
@@ -20,15 +20,15 @@
20
20
  * 「链接 ⇒ 真文件、悬空 ⇒ 响亮抛」这一只判法是 {@link resolveSymlinkTarget}:目录迁移的读口(`./config-dir-io`)读目录 / 读文件前也问它,
21
21
  * 悬空链接在读阶段与写阶段是**同一句**({@link danglingSymlinkSentence})。
22
22
  *
23
- * ## 写失败(12.0.0)
24
- * 两步各一句({@link AtomicWriteFailure},句子在 `./write-failure`):临时文件建不出 ⇒ 「cannot write into directory <dir> (<errno>): …」(点名目录 —— 目录不可写时
23
+ * ## 写失败(12.0.0;句子归文件系统失败句的唯一铸点)
24
+ * 两步各一句({@link FsFailure},句子在 `./fs-failure`):临时文件建不出 ⇒ 「cannot write into directory <dir> (<errno>): …」(点名目录 —— 目录不可写时
25
25
  * 文件本身没错);`rename` 失败 ⇒ 「cannot replace <file> (<errno>): …」(点名真文件)。认得的 errno 给对应出路,别的给 Node 自己的句子;`code` / `cause` 留着。
26
26
  * 可写目录里的只读文件照常写得进(`rename` 换的是目录项)。
27
27
  */
28
28
  import { randomBytes } from "node:crypto";
29
29
  import { chmodSync, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
30
30
  import path from "node:path";
31
- import { AtomicWriteFailure } from "./write-failure.js";
31
+ import { FsFailure } from "./fs-failure.js";
32
32
  /** 按文档类的模式表(闭集)。 */
33
33
  export const ATOMIC_FILE_CLASSES = {
34
34
  "config-document": { newMode: 0o644, existing: "keep" },
@@ -87,12 +87,12 @@ export function writeFileAtomic(target, text, kind) {
87
87
  }
88
88
  const dir = path.dirname(real);
89
89
  const tmp = path.join(dir, `.${path.basename(real)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
90
- let step = "create";
90
+ let site = { landing: "dir-create-temp", path: tmp };
91
91
  try {
92
92
  writeFileSync(tmp, text, { encoding: "utf8", mode: kept ?? spec.newMode });
93
93
  if (kept !== undefined)
94
94
  chmodSync(tmp, kept); // a kept mode is restored exactly (the creation mode above went through the umask)
95
- step = "replace";
95
+ site = { landing: "rename", path: real };
96
96
  renameSync(tmp, real);
97
97
  }
98
98
  catch (e) {
@@ -102,6 +102,6 @@ export function writeFileAtomic(target, text, kind) {
102
102
  catch {
103
103
  /* 临时文件没建成:没什么可清 */
104
104
  }
105
- throw new AtomicWriteFailure(step, dir, real, e); // 12.0.0: the sentence names the step that failed and its way out (`./write-failure`)
105
+ throw new FsFailure(site, e); // the sentence names the step that failed and its way out (`./fs-failure`, the one mint)
106
106
  }
107
107
  }
package/dist/cli.d.ts CHANGED
@@ -15,8 +15,8 @@
15
15
  * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
16
16
  *
17
17
  * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
18
- * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;写盘失败按落点分句 ——
19
- * 目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路(原子写器的句子,`./write-failure`)——
18
+ * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;句子按落点分句、
19
+ * 一律出自文件系统失败句的唯一铸点 `./fs-failure` —— 例:目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路)——
20
20
  * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或覆盖面内某份文档(域文档或 `remote-exec.json`)是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` / `loadRemoteExec` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
21
21
  * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
22
22
  * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
package/dist/cli.js CHANGED
@@ -15,8 +15,8 @@
15
15
  * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
16
16
  *
17
17
  * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
18
- * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;写盘失败按落点分句 ——
19
- * 目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路(原子写器的句子,`./write-failure`)——
18
+ * 2 = 用法错,或**环境错**(读目录 / 读文件 / 写盘 / 删文件的 I/O 错:权限、磁盘、悬空符号链接 …;stdout 空、stderr 一行点名路径与 errno,不打栈;句子按落点分句、
19
+ * 一律出自文件系统失败句的唯一铸点 `./fs-failure` —— 例:目录建不出文件 ⇒ 点名目录、目标换不掉 ⇒ 点名文件,句尾是对应出路)——
20
20
  * rc 1 是「拒迁」的约定码,不许被一个权限错冒充),或覆盖面内某份文档(域文档或 `remote-exec.json`)是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` / `loadRemoteExec` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
21
21
  * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
22
22
  * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
@@ -31,7 +31,7 @@
31
31
  import { reportReceipt, joinDirPath, migrateConfigDirectory } from "./config-dir-migration.js";
32
32
  import { MIGRATE_COMMAND_TEMPLATE, migrateCommandFor } from "./retired-form.js";
33
33
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
34
- import { AtomicWriteFailure } from "./write-failure.js";
34
+ import { fsFailureMessage } from "./fs-failure.js";
35
35
  import { DOMAINS, RETIRED_DOMAINS } from "./types.js";
36
36
  /** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
37
37
  export const COVERED_DOCUMENTS = [...DOMAINS, ...Object.keys(RETIRED_DOMAINS), REMOTE_EXEC_DOCUMENT];
@@ -78,10 +78,9 @@ function reportOf(outcome, dir, applied, refused) {
78
78
  class EnvironmentFailure extends Error {
79
79
  }
80
80
  const oneLine = (text) => text.replace(/\s*\n\s*/g, " ");
81
- const messageOf = (err) => oneLine(err instanceof Error ? err.message : String(err));
82
- /** 把注入的写口包一层:写盘 / 删文件抛 ⇒ {@link EnvironmentFailure}。本包的原子写器抛的是按失败那一步分好的句子({@link AtomicWriteFailure}:目录建不出文件 ⇒
83
- * 点名目录,目标换不掉 ⇒ 点名文件,句尾是对应出路)—— 原样用;别的写口(注入的替身)抛的错 ⇒ 点名**要写的那份文件**与原错误句。
84
- * 读口不包:读目录 / 读文件错由 `migrateConfigDirectory` 自己收成 `unreadable`(同为 rc 2)。 */
81
+ /** 把注入的写口包一层:写盘 / 删文件抛 ⇒ {@link EnvironmentFailure},句子由铸点出(`fsFailureMessage`):本包的原子写器抛的是按失败那一步分好的句子
82
+ * (目录建不出文件 ⇒ 点名目录,目标换不掉 ⇒ 点名文件,句尾是对应出路)—— 原样用;别的写口(注入的替身)抛的错步骤看不见 ⇒ 点名**要写的那份文件**与原错误句。
83
+ * 读口不包:读目录 / 读文件错由 `migrateConfigDirectory` 自己收成 `unreadable`(同为 rc 2,句子同出铸点)。 */
85
84
  function withEnvironmentArm(io) {
86
85
  return {
87
86
  readDir: (path) => io.readDir(path),
@@ -91,7 +90,7 @@ function withEnvironmentArm(io) {
91
90
  await io.writeFileAtomic(path, text);
92
91
  }
93
92
  catch (err) {
94
- throw new EnvironmentFailure(err instanceof AtomicWriteFailure ? err.message : `cannot write ${path}: ${messageOf(err)}`, { cause: err });
93
+ throw new EnvironmentFailure(fsFailureMessage({ landing: "write", path }, err), { cause: err });
95
94
  }
96
95
  },
97
96
  removeFile: async (path) => {
@@ -99,7 +98,7 @@ function withEnvironmentArm(io) {
99
98
  await io.removeFile(path);
100
99
  }
101
100
  catch (err) {
102
- throw new EnvironmentFailure(`cannot remove ${path}: ${messageOf(err)}`, { cause: err });
101
+ throw new EnvironmentFailure(fsFailureMessage({ landing: "remove", path }, err), { cause: err });
103
102
  }
104
103
  },
105
104
  };
@@ -11,9 +11,9 @@ export declare function configDPath(root: string, name: string): string;
11
11
  /** `config.d` 里能放的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`,不是配置域)。 */
12
12
  export type ConfigDDocumentName = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
13
13
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
14
- * 不是对象 ⇒ 抛,每种一句(点名文件与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)与自己读文本的命令行(`migrate`)
15
- * 都过它 —— 一种坏形一句话,不论是哪份文档。 */
16
- export declare function judgeConfigDText(name: ConfigDDocumentName, text: string): Record<string, unknown>;
14
+ * 不是对象 ⇒ 抛,每种一句(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
15
+ * 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
16
+ export declare function judgeConfigDText(name: ConfigDDocumentName, text: string, file: string): Record<string, unknown>;
17
17
  /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
18
18
  export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
19
19
  /**
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
7
7
  * (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
8
- * governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)。三种坏形每种一句,点名文件与形、句尾出路按文档类
9
- * ({@link documentWayOut}),从不回显文件原文。
8
+ * governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)。三种坏形每种一句,点名**调用方寻址这份文档用的路径**与形(读的人给路径,
9
+ * 判官不拼 `config.d/` 字面 —— 多个目录各有一份时,句子认得出是哪个 scope 的哪一份;文档是符号链接时点名链接这一端:句尾「delete it」删的是这个 scope 里的这一项,不是被几个 scope 共用的目标)、
10
+ * 句尾出路按文档类({@link documentWayOut}),从不回显文件原文。
10
11
  */
11
12
  import { readFile } from "node:fs/promises";
12
13
  import path from "node:path";
@@ -43,10 +44,9 @@ function documentWayOut(name) {
43
44
  return name === REMOTE_EXEC_DOCUMENT ? "delete it for the host default, or write a valid remoteExec spec" : "delete it for the domain default, or write a valid object";
44
45
  }
45
46
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 它的值(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 它;空 / 纯空白、坏 JSON、
46
- * 不是对象 ⇒ 抛,每种一句(点名文件与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)与自己读文本的命令行(`migrate`)
47
- * 都过它 —— 一种坏形一句话,不论是哪份文档。 */
48
- export function judgeConfigDText(name, text) {
49
- const file = `config.d/${name}.json`;
47
+ * 不是对象 ⇒ 抛,每种一句(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
48
+ * 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
49
+ export function judgeConfigDText(name, text, file) {
50
50
  const trimmed = text.trim();
51
51
  if (!trimmed)
52
52
  throw new Error(`${file} is empty/whitespace — ${documentWayOut(name)}`);
@@ -65,16 +65,17 @@ export function judgeConfigDText(name, text) {
65
65
  }
66
66
  /** 读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ {@link judgeConfigDText}。 */
67
67
  export async function readConfigDFile(root, name) {
68
+ const file = configDPath(root, name);
68
69
  let text;
69
70
  try {
70
- text = await readFile(configDPath(root, name), "utf8");
71
+ text = await readFile(file, "utf8");
71
72
  }
72
73
  catch (e) {
73
74
  if (e.code === "ENOENT")
74
75
  return undefined;
75
76
  throw e;
76
77
  }
77
- return judgeConfigDText(name, text);
78
+ return judgeConfigDText(name, text, file);
78
79
  }
79
80
  /**
80
81
  * `config.d/` 里每个 **retired 域**的文件过一次墓碑(`refuseRetiredDomain`,与 `buildEffective` / `parseDomain` 同一只判官、同一句):带事实 ⇒
@@ -24,6 +24,7 @@
24
24
  import { z } from "zod";
25
25
  import { formatConfigPath, parseDomain } from "./config-fns.js";
26
26
  import { configDDocumentText, judgeConfigDText, parseRemoteExecDocument } from "./config-d-file.js";
27
+ import { fsFailureMessage } from "./fs-failure.js";
27
28
  import { migrateStoredDocuments } from "./one-time-migrations.js";
28
29
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
29
30
  import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
@@ -109,7 +110,7 @@ export async function migrateConfigDirectory(dir, opts) {
109
110
  names = await io.readDir(dir);
110
111
  }
111
112
  catch (err) {
112
- return { kind: "unreadable", reason: `cannot read directory ${dir}: ${messageOf(err)}` };
113
+ return { kind: "unreadable", reason: fsFailureMessage({ landing: "read", path: dir, of: "directory" }, err) };
113
114
  }
114
115
  const docs = {};
115
116
  for (const name of [...names].sort()) {
@@ -119,14 +120,15 @@ export async function migrateConfigDirectory(dir, opts) {
119
120
  if (!covered.has(base) || !isConfigDirectoryDocument(base))
120
121
  continue;
121
122
  let text;
123
+ const file = joinDirPath(dir, name);
122
124
  try {
123
- text = await io.readFile(joinDirPath(dir, name));
125
+ text = await io.readFile(file);
124
126
  }
125
127
  catch (err) {
126
- return { kind: "unreadable", reason: `cannot read ${name}: ${messageOf(err)}` };
128
+ return { kind: "unreadable", reason: fsFailureMessage({ landing: "read", path: file, of: "file" }, err) };
127
129
  }
128
130
  try {
129
- docs[base] = judgeConfigDText(base, text); // the one config.d text judge (FileConfigStore / loadRemoteExec read with it): empty / bad JSON / not an object ⇒ unreadable
131
+ docs[base] = judgeConfigDText(base, text, file); // the one config.d text judge (FileConfigStore / loadRemoteExec read with it): empty / bad JSON / not an object ⇒ unreadable
130
132
  }
131
133
  catch (err) {
132
134
  return { kind: "unreadable", reason: messageOf(err) };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表;零 `node:` 依赖):**文件系统失败句的唯一铸点** —— `(errno, 落点, 真实路径)` → 一句。
3
+ * 本包凡是把文件系统错误说成人话的地方(原子写器、命令行的写 / 删口、目录迁移的读口)都调 {@link fsFailureSentence},没有第二处手写的 `cannot …` 句子
4
+ * (`test/` 下的 `fs-failure-single-mint-gate` 门扫 `src/` 里的字符串字面量,铸点之外出现即红)。
5
+ *
6
+ * 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
7
+ * · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
8
+ * · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
9
+ * · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
10
+ * · `read` —— 读一个目录或文件;
11
+ * · `write` —— 写口整体失败、步骤看不见(注入的写口抛的错,不是本包原子写器抛的):只点名要写的文件。
12
+ * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
+ * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
+ * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
15
+ * 目录落点(`dir-create-temp` / `remove`)的 `EPERM` / `EROFS` 出路句尾加「目录可能是只读 / 不可变(chflags / chattr)」—— 改权限位修不好的那两种。
16
+ *
17
+ * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
18
+ * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
19
+ */
20
+ /** 一次失败发生的地方(闭集):落点加那一步交给操作系统的真实路径(`dir-create-temp` 是要建的临时文件,点名的目录是它的父目录;`rename` 是被盖过的真文件;
21
+ * `remove` / `write` 是那份文件;`read` 是读的目录或文件)。 */
22
+ export type FsFailureSite = {
23
+ readonly landing: "dir-create-temp";
24
+ readonly path: string;
25
+ } | {
26
+ readonly landing: "rename";
27
+ readonly path: string;
28
+ } | {
29
+ readonly landing: "remove";
30
+ readonly path: string;
31
+ } | {
32
+ readonly landing: "read";
33
+ readonly path: string;
34
+ readonly of: "directory" | "file";
35
+ } | {
36
+ readonly landing: "write";
37
+ readonly path: string;
38
+ };
39
+ /** 一次文件系统失败的句子(纯函数)。 */
40
+ export declare function fsFailureSentence(site: FsFailureSite, cause: unknown): string;
41
+ /** 原子写器抛的错:句子见 {@link fsFailureSentence};`code` 照抄底层 errno(按 `code` 分支的调用方不受影响),原错误在 `cause`。 */
42
+ export declare class FsFailure extends Error {
43
+ readonly landing: FsFailureSite["landing"];
44
+ readonly code: string | undefined;
45
+ constructor(site: FsFailureSite, cause: unknown);
46
+ }
47
+ /** 取一次失败的句子:{@link FsFailure}(已在更细的落点铸过)原样用,别的错按调用方所在的口 `site` 铸。 */
48
+ export declare function fsFailureMessage(site: FsFailureSite, err: unknown): string;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表;零 `node:` 依赖):**文件系统失败句的唯一铸点** —— `(errno, 落点, 真实路径)` → 一句。
3
+ * 本包凡是把文件系统错误说成人话的地方(原子写器、命令行的写 / 删口、目录迁移的读口)都调 {@link fsFailureSentence},没有第二处手写的 `cannot …` 句子
4
+ * (`test/` 下的 `fs-failure-single-mint-gate` 门扫 `src/` 里的字符串字面量,铸点之外出现即红)。
5
+ *
6
+ * 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
7
+ * · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
8
+ * · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
9
+ * · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
10
+ * · `read` —— 读一个目录或文件;
11
+ * · `write` —— 写口整体失败、步骤看不见(注入的写口抛的错,不是本包原子写器抛的):只点名要写的文件。
12
+ * 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
13
+ * · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
14
+ * · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
15
+ * 目录落点(`dir-create-temp` / `remove`)的 `EPERM` / `EROFS` 出路句尾加「目录可能是只读 / 不可变(chflags / chattr)」—— 改权限位修不好的那两种。
16
+ *
17
+ * 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
18
+ * 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
19
+ */
20
+ /** `dir-create-temp` 里「目录本身不让建文件」的 errno:权限不够、只读文件系统、目录不在。出路 = 让目录可写。 */
21
+ const DIRECTORY_NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS", "ENOENT"]);
22
+ /** `remove` 里「目录不让删目录项」的 errno(同一个因:目录不可写)。目录不在 / 文件已不在(ENOENT)不是这个因,给 Node 自己的句子。 */
23
+ const DIRECTORY_ENTRY_NOT_REMOVABLE = new Set(["EACCES", "EPERM", "EROFS"]);
24
+ /** `rename` 里「目录收新文件、却换不掉这一份」的 errno:不可变标志、粘滞目录里别人的文件。出路 = 让文件可被替换。 */
25
+ const FILE_NOT_REPLACEABLE = new Set(["EACCES", "EPERM"]);
26
+ /** 目录落点(`dir-create-temp` / `remove`)的出路。`EPERM`(目录带不可变标志:macOS `chflags uchg`、Linux `chattr +i`)与 `EROFS`(只读文件系统)靠改权限位修不好,
27
+ * 出路句尾提示这一种可能;`EACCES` / `ENOENT` 是纯权限位 / 目录不在,出路就是让目录可写。 */
28
+ const directoryWayOut = (code) => (code === "EPERM" || code === "EROFS" ? "make the directory writable; directory may be read-only or immutable (chflags / chattr)" : "make the directory writable");
29
+ const errnoOf = (e) => {
30
+ const code = e?.code;
31
+ return typeof code === "string" ? code : undefined;
32
+ };
33
+ /** Node 自己的句子,折成一行;开头自带的 `<errno>: ` 去掉(errno 由句首的 `(<errno>)` 打一遍,不打两遍)。 */
34
+ const nodeSentenceOf = (e, code) => {
35
+ const line = (e instanceof Error ? e.message : String(e)).replace(/\s*\n\s*/g, " ");
36
+ return code !== undefined && line.startsWith(`${code}: `) ? line.slice(code.length + 2) : line;
37
+ };
38
+ /** 路径的父目录(字符串切法,零 `node:` 依赖;两种分隔符都认)。根下的文件 ⇒ 根;没有分隔符 ⇒ 当前目录。 */
39
+ const parentDirectory = (p) => {
40
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
41
+ return i < 0 ? "." : i === 0 ? p.slice(0, 1) : p.slice(0, i);
42
+ };
43
+ /** 一次文件系统失败的句子(纯函数)。 */
44
+ export function fsFailureSentence(site, cause) {
45
+ const code = errnoOf(cause);
46
+ // 路径本身不能用(某一节超过 NAME_MAX、整条超过 PATH_MAX):与落点无关,既不是目录的错也不是文件的错 —— 一句点名被操作系统拒的路径。
47
+ if (code === "ENAMETOOLONG")
48
+ return `path too long: ${site.path} (${code})`;
49
+ const tag = code === undefined ? "" : ` (${code})`;
50
+ switch (site.landing) {
51
+ case "dir-create-temp":
52
+ return `cannot write into directory ${parentDirectory(site.path)}${tag}: ${code !== undefined && DIRECTORY_NOT_WRITABLE.has(code) ? directoryWayOut(code) : nodeSentenceOf(cause, code)}`;
53
+ case "rename":
54
+ return code !== undefined && FILE_NOT_REPLACEABLE.has(code)
55
+ ? `cannot replace ${site.path}${tag}: make the file replaceable — clear an immutable flag; in a sticky directory only the file's owner may replace it`
56
+ : `cannot replace ${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
57
+ case "remove":
58
+ return `cannot remove ${site.path} from directory ${parentDirectory(site.path)}${tag}: ${code !== undefined && DIRECTORY_ENTRY_NOT_REMOVABLE.has(code) ? directoryWayOut(code) : nodeSentenceOf(cause, code)}`;
59
+ case "read":
60
+ return `cannot read ${site.of === "directory" ? "directory " : ""}${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
61
+ case "write":
62
+ return `cannot write ${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
63
+ default: {
64
+ const unhandled = site;
65
+ throw new Error(`fsFailureSentence: unhandled landing ${JSON.stringify(unhandled)}`);
66
+ }
67
+ }
68
+ }
69
+ /** 原子写器抛的错:句子见 {@link fsFailureSentence};`code` 照抄底层 errno(按 `code` 分支的调用方不受影响),原错误在 `cause`。 */
70
+ export class FsFailure extends Error {
71
+ landing;
72
+ code;
73
+ constructor(site, cause) {
74
+ super(fsFailureSentence(site, cause), { cause });
75
+ this.name = "FsFailure";
76
+ this.landing = site.landing;
77
+ this.code = errnoOf(cause);
78
+ }
79
+ }
80
+ /** 取一次失败的句子:{@link FsFailure}(已在更细的落点铸过)原样用,别的错按调用方所在的口 `site` 铸。 */
81
+ export function fsFailureMessage(site, err) {
82
+ return err instanceof FsFailure ? err.message : fsFailureSentence(site, err);
83
+ }
@@ -23,9 +23,10 @@ export function remoteExecPath(root) {
23
23
  /** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if the file is absent
24
24
  * (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file. */
25
25
  export async function loadRemoteExec(root) {
26
+ const file = remoteExecPath(root);
26
27
  let text;
27
28
  try {
28
- text = await readFile(remoteExecPath(root), "utf8");
29
+ text = await readFile(file, "utf8");
29
30
  }
30
31
  catch (e) {
31
32
  if (e.code === "ENOENT")
@@ -36,7 +37,7 @@ export async function loadRemoteExec(root) {
36
37
  // silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker).
37
38
  // To get the host default, the file must be ABSENT, not empty. The text judge is THE config.d one (empty / bad JSON / not an object, one sentence
38
39
  // each — the same judge FileConfigStore and the directory migration read with); the sanitized value judge is the migration's too (`./config-d-file`).
39
- return parseRemoteExecDocument(judgeConfigDText(REMOTE_EXEC_DOCUMENT, text));
40
+ return parseRemoteExecDocument(judgeConfigDText(REMOTE_EXEC_DOCUMENT, text, file));
40
41
  }
41
42
  /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
42
43
  * client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
@@ -53,8 +53,8 @@
53
53
  import { z } from "zod";
54
54
  /** How values from different layers/principals combine for a category. */
55
55
  export declare const MergeShape: z.ZodEnum<{
56
- replace: "replace";
57
56
  concat: "concat";
57
+ replace: "replace";
58
58
  denyFirst: "denyFirst";
59
59
  runAll: "runAll";
60
60
  overrideByName: "overrideByName";
@@ -185,8 +185,8 @@ export declare const TightenRule: z.ZodDiscriminatedUnion<[z.ZodObject<{
185
185
  export type TightenRule = z.infer<typeof TightenRule>;
186
186
  export declare const MergeCategorySpec: z.ZodObject<{
187
187
  mergeShape: z.ZodEnum<{
188
- replace: "replace";
189
188
  concat: "concat";
189
+ replace: "replace";
190
190
  denyFirst: "denyFirst";
191
191
  runAll: "runAll";
192
192
  overrideByName: "overrideByName";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "12.0.0",
3
+ "version": "12.0.1",
4
4
  "description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-web-admin, sema-server, and the TOC desktop/CLI. One source of truth. (Renamed at 1.0.0 from @sema-agent/registry-core — name and docs only, no behavior change; note the range moves 0.19.0 -> 1.0.0, so a dependency must become ^1.0.0. The old names @sema-agent/registry-core <=0.19.0 and @sema-ai/registry-core remain published, frozen, for migration.)",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -1,16 +0,0 @@
1
- /**
2
- * 包内私有(不进主桶、不进 exports 表;零 `node:` 依赖):原子写器(`./atomic-write`)写失败的**唯一一句** —— 按失败落在哪一步、errno 是什么分句,
3
- * 句尾给那一步对应的出路。写器有两步:在目标所在目录里建临时文件(`create`,连同恢复沿用的模式)、`rename` 盖过目标(`replace`)。
4
- * 改前一句话包打天下(「cannot write <文件>: <Node 的错误句> … fix the cause」):目录不可写时点名的却是文件(Node 句子里是临时文件的路径),
5
- * 而可写目录里的只读文件照常写得进 —— 句子把人指向了错的那一个。命令行(`./cli`)认这只错误类型、原样印它的句子。
6
- */
7
- /** 原子写的两步(闭集)。 */
8
- export type AtomicWriteStep = "create" | "replace";
9
- /** 一次写失败的句子(纯函数)。认得的 errno 给对应出路;别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。 */
10
- export declare function atomicWriteFailureSentence(step: AtomicWriteStep, dir: string, file: string, cause: unknown): string;
11
- /** 原子写器抛的错:句子见 {@link atomicWriteFailureSentence};`code` 照抄底层 errno(按 `code` 分支的调用方不受影响),原错误在 `cause`。 */
12
- export declare class AtomicWriteFailure extends Error {
13
- readonly step: AtomicWriteStep;
14
- readonly code: string | undefined;
15
- constructor(step: AtomicWriteStep, dir: string, file: string, cause: unknown);
16
- }
@@ -1,43 +0,0 @@
1
- /**
2
- * 包内私有(不进主桶、不进 exports 表;零 `node:` 依赖):原子写器(`./atomic-write`)写失败的**唯一一句** —— 按失败落在哪一步、errno 是什么分句,
3
- * 句尾给那一步对应的出路。写器有两步:在目标所在目录里建临时文件(`create`,连同恢复沿用的模式)、`rename` 盖过目标(`replace`)。
4
- * 改前一句话包打天下(「cannot write <文件>: <Node 的错误句> … fix the cause」):目录不可写时点名的却是文件(Node 句子里是临时文件的路径),
5
- * 而可写目录里的只读文件照常写得进 —— 句子把人指向了错的那一个。命令行(`./cli`)认这只错误类型、原样印它的句子。
6
- */
7
- /** `create` 步里「目录本身不让建文件」的 errno:权限不够、只读文件系统、目录不在。出路 = 让目录可写。 */
8
- const DIRECTORY_NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS", "ENOENT"]);
9
- /** `replace` 步里「目录收新文件、却换不掉这一份」的 errno:不可变标志、粘滞目录里别人的文件。出路 = 让文件可被替换。 */
10
- const FILE_NOT_REPLACEABLE = new Set(["EACCES", "EPERM"]);
11
- const errnoOf = (e) => {
12
- const code = e?.code;
13
- return typeof code === "string" ? code : undefined;
14
- };
15
- const messageOf = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s*\n\s*/g, " ");
16
- /** 一次写失败的句子(纯函数)。认得的 errno 给对应出路;别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。 */
17
- export function atomicWriteFailureSentence(step, dir, file, cause) {
18
- const code = errnoOf(cause);
19
- const tag = code === undefined ? "" : ` (${code})`;
20
- switch (step) {
21
- case "create":
22
- return code !== undefined && DIRECTORY_NOT_WRITABLE.has(code) ? `cannot write into directory ${dir}${tag}: make the directory writable` : `cannot write into directory ${dir}${tag}: ${messageOf(cause)}`;
23
- case "replace":
24
- return code !== undefined && FILE_NOT_REPLACEABLE.has(code)
25
- ? `cannot replace ${file}${tag}: make the file replaceable — clear an immutable flag; in a sticky directory only the file's owner may replace it`
26
- : `cannot replace ${file}${tag}: ${messageOf(cause)}`;
27
- default: {
28
- const unhandled = step;
29
- throw new Error(`atomicWriteFailureSentence: unhandled step ${String(unhandled)}`);
30
- }
31
- }
32
- }
33
- /** 原子写器抛的错:句子见 {@link atomicWriteFailureSentence};`code` 照抄底层 errno(按 `code` 分支的调用方不受影响),原错误在 `cause`。 */
34
- export class AtomicWriteFailure extends Error {
35
- step;
36
- code;
37
- constructor(step, dir, file, cause) {
38
- super(atomicWriteFailureSentence(step, dir, file, cause), { cause });
39
- this.name = "AtomicWriteFailure";
40
- this.step = step;
41
- this.code = errnoOf(cause);
42
- }
43
- }