@sema-agent/settings-schema 16.0.0 → 17.0.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 +46 -0
- package/README.md +19 -0
- package/conformance/VERSION +1 -1
- package/dist/atomic-write.d.ts +4 -0
- package/dist/atomic-write.js +20 -0
- package/dist/config-d-file.d.ts +8 -7
- package/dist/config-d-file.js +14 -13
- package/dist/config-document-refusal.d.ts +1 -1
- package/dist/file-store.d.ts +2 -2
- package/dist/file-store.js +6 -5
- package/dist/local-load.d.ts +12 -4
- package/dist/local-load.js +20 -31
- package/llms-full.txt +3 -3
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,52 @@
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 17.0.0 — 2026-11-01 — major:`config.d` 文档与 `.env` 的缺席律只有一种 —— 悬空的符号链接是「在场却读不出」,读它拒,不再读成缺席
|
|
12
|
+
|
|
13
|
+
> 档位(按发版纪律):**major** —— 一处行为硬破坏(B1,三问在下):此前被静默受理的输入(悬空的符号链接)改拒,能让按上一版运行的部署在**运行期**变红;先例 16.0.0 的纯空白字段改拒(「校验收紧」)。
|
|
14
|
+
> 律:**缺席 = 路径上没有任何条目。** 三只各自在 `ENOENT` 上返回「缺席」的读法(`config.d` 域文档读法、`loadRemoteExec` 的私有读、`loadEnvFile`)并成一只 —— 规则变少,不是变多;没有新词、没有新分支、没有特例。
|
|
15
|
+
> 导出面:零变化(没有导出增删,签名不变);行为随 B1 变的是读这些文件的出口(`loadRemoteExec` / `loadEnvFile` / `loadLocalConfig` / `FileConfigStore` 的各读口 / `readBundle`;以 `FileConfigStore` 为读者的 `exportBundle` 随它)。零别名、零双读、零过渡读法;不需要一次性迁移。
|
|
16
|
+
|
|
17
|
+
### BREAKING 清单(三问 + 下游动作)
|
|
18
|
+
|
|
19
|
+
**B1 · `config.d` 文档与 `.env` 是悬空的符号链接 ⇒ 读不出(拒),不再读成缺席。**
|
|
20
|
+
律:**缺席 = 路径上没有任何条目。** `readFile` 跟着符号链接走:链接在而目标不在(挂载点没挂上、目标被改名 / 回滚删掉)时,内核同样答 `ENOENT` —— 那是**在场却读不出**,不是缺席。本包的目录迁移读口、根级文档读者(`readEnvModelsDecision`)与原子写器早就这么读;
|
|
21
|
+
`config.d` 的文档读法(`FileConfigStore` / `readBundle` / `loadRemoteExec`)与 `.env` 此前在 `ENOENT` 上各自答「缺席」,同一个文件两个答案 —— 对 `remote-exec.json` 这是一次隔离降级:spec 写着 `local-docker`、链接悬空、引擎静默落宿主车道。
|
|
22
|
+
形:`config.d` 文档读法、`loadRemoteExec` 与 `loadEnvFile` 三处共一个缺席臂(包内私有的 `readTextIfPresent`):读不出且是 `ENOENT` ⇒ 在缺席臂里对「文件本身」与「`config.d` 目录」(`.env` 只问它自己)各问一次目录迁移读口问的**同一只**链接判官,悬空 ⇒ 抛那一句
|
|
23
|
+
(`<链接> is a dangling symbolic link (it points at <目标>, which does not exist) — nothing is read or written through it; repair or remove the link`,与迁移读口、原子写器逐字同一句,错上不带 errno 码);都不是 ⇒ 真缺席。判在缺席臂里,不在读前:读得到的路径零变化(不多一次系统调用);读之后目标恰好出现仍答缺席 —— 读的那一刻它确实不在,与旧读法同一边界,不是放宽。
|
|
24
|
+
`loadRemoteExec` 不再有私有的读:走 `config.d` 文档的那一只读法(`remote-exec` 与域文档同一条路径构造器、同一只文本判官);`remoteExecPath` 答的是同一条路径。
|
|
25
|
+
读数:`FileConfigStore.read()` 的拒单多出 `{ document, reason: "io", error: <悬空句> }`(`config.d` 目录本身悬空 ⇒ 每份文档各一条,同一句);严读 `getEffective()` / `getDomain()` / `getVersion()` 与 `readBundle` 抛这一句;宽容读 `getEffective({ tolerant: true })` 按坏文件同一张处置表
|
|
26
|
+
(「缺省」档的域落缺省并单列进 `domainErrors`,「抛」档的域抛);`loadRemoteExec` / `loadEnvFile` / `loadLocalConfig` 抛。词表不变(`io` 早在闭集里)。
|
|
27
|
+
不在本律内(读法不变):① 配置根自己或更上层是悬空的符号链接 —— `loadRemoteExec` / 域文档读法 / `loadEnvFile` / `readEnvModelsDecision` 答缺席,目录迁移读口答 `unreadable`(`ENOENT`);
|
|
28
|
+
② 一次性迁移 SS-M11 对 `config.d/models.json` 的那一读(经根级文档的 I/O 座)只判文件自己,不问 `config.d` 目录座:`config.d` 目录悬空时它读成「不管模型面」。
|
|
29
|
+
**谁需要**:`CONFIG_PROVIDER=local` 的部署用符号链接把 `config.d` 或其下的文档(`remote-exec.json` / 域文档)、`.env` 指到别处管理的路径(卷 / NFS / 部署工具链)—— 要的是「链接坏了 ⇒ 起服拒、句子点名链接」,而不是静默跑缺省;
|
|
30
|
+
`remote-exec.json` 尤其:隔离车道的声明不能因为一条断链悄悄变成宿主车道。
|
|
31
|
+
**谁受伤**:今天带着悬空链接(`config.d/remote-exec.json`、`config.d/<域>.json`、`config.d` 目录本身、`.env`)静默跑缺省的部署,升级后:`loadRemoteExec` / `loadEnvFile` / `loadLocalConfig` 抛(以 `loadRemoteExec` 起服的引擎拒启);
|
|
32
|
+
`FileConfigStore` 的域文档读数由「缺席 ⇒ 域缺省」变成拒(单读口交拒单,严读抛,宽容读按域档处置)。读 `FileConfigStore.read()` 的消费方照自己对 `refused` 的路径走(拒单里多出 `io` 条目);按 `ENOENT` 码认缺席的读者读不到它(悬空句的错不带 `code`)。
|
|
33
|
+
**补偿**:拒句自带出路 —— 修好链接(挂上卷 / 恢复目标)或删掉它(删掉 = 回到缺席时的缺省)。没有迁移:这不是旧形,是布局坏了,无从替运维猜。
|
|
34
|
+
|
|
35
|
+
### 文档
|
|
36
|
+
|
|
37
|
+
README 升级指引(16.0.x → 17.0.0)与各消费方的读点表;`config.d` 文档读法、`FileConfigStore`、`loadRemoteExec` / `loadEnvFile` 与文件层拒因 `io` 的注释把「只有 `ENOENT` 是缺席」改成「缺席 = 路径上没有任何条目」;`llms-full.txt` 的 T3 与 `io` 词的说明同改。
|
|
38
|
+
|
|
39
|
+
### 钉
|
|
40
|
+
|
|
41
|
+
`test/remote-exec-dangling-symlink.test.ts`(24 格):对照格 14(缺席 / `config.d` 不存在 / 链接指向合法文件 / 成环 / 目录 / 权限三档 / 空文件 / 坏 JSON / 祖先座 / `config.d` 是指向真目录的链接 / `.env` 缺席与链接 / 根级文档悬空),
|
|
42
|
+
翻面格(悬空的 `remote-exec.json`、悬空的 `config.d`、`loadLocalConfig`、域文档与 `.env`),类格(严读 / 宽容读 / `getDomain` / `readBundle` / 单读口对同一形给同一句 · 悬空 `config.d` 令整棵目录被拒),结构钉(三处读法共一个缺席臂 · `remoteExecPath` 取自 `configDPath`)。
|
|
43
|
+
|
|
44
|
+
**自 v16.0.0 起运行期代码有改动的公开导出(机器重算;上界 —— 可达 ≠ 一定变,不可达 ⇒ 一定不变;本版新增的导出不入表)**
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
FileConfigStore [./node]
|
|
48
|
+
loadEnvFile [./node]
|
|
49
|
+
loadLocalConfig [./node]
|
|
50
|
+
loadRemoteExec [./node]
|
|
51
|
+
migrateEnvModelsDecision [./node]
|
|
52
|
+
readBundle [./node]
|
|
53
|
+
remoteExecPath [./node]
|
|
54
|
+
writeRemoteExec [./node]
|
|
55
|
+
```
|
|
56
|
+
|
|
11
57
|
## 16.0.0 — 2026-10-30 — major:worker 的执行车道由单值改为有序车道集 `runtime.execution.lanes` · device 车道的设备入口 `DEVICE_RELAY_URL` 必需且有值文法 · 记忆持久面方言词 `tidb` → `mysql` · 退役模型面组拒码 `config.summarize_model_missing` · `HostStatus.ts` 判 RFC 3339(加可选 `heartbeatMs`)· 本地配置单读口 `FileConfigStore.read()`(过深的文档是文件层的拒)· 本地文件契约 `RemoteExecSpec` 的非空串字段:只含空白 = 空 ⇒ 拒 · 一次性迁移 SS-M12
|
|
12
58
|
|
|
13
59
|
> 档位(按发版纪律):**major** —— 七处硬破坏(B1–B7,逐条三问在下),加性若干。发版纪律按消费方在**运行期**与**编译期**的可见性判档。
|
package/README.md
CHANGED
|
@@ -47,6 +47,25 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
|
|
|
47
47
|
> zod 3, nested inside every zod-4 host). The move to zod 4 did not change what any schema accepts or produces —
|
|
48
48
|
> the conformance corpus reads byte-identically on both — only the wording of validation errors (see CHANGELOG 11.0.0).
|
|
49
49
|
|
|
50
|
+
## 升级指引:16.0.x → 17.0.0(BREAKING:`config.d` 文档与 `.env` 的悬空符号链接读不出 ⇒ 拒,不再读成缺席)
|
|
51
|
+
|
|
52
|
+
- **改了什么**(三问见 CHANGELOG 17.0.0 B1):缺席 = 路径上没有任何条目。`config.d/<名>.json`(含 `remote-exec.json`)、`config.d` 目录本身或 `.env` 是**悬空的符号链接**(链接在、目标不在)时,
|
|
53
|
+
读它们的出口 —— `loadRemoteExec` / `loadEnvFile` / `loadLocalConfig`、`FileConfigStore` 的各读口、`readBundle` —— 不再答「缺席」(引擎落缺省车道 / 域缺省 / 空 env),而是拒,句子点名链接、目标与出路
|
|
54
|
+
(`<链接> is a dangling symbolic link (it points at <目标>, which does not exist) — … repair or remove the link`,与目录迁移的读口、原子写器同一句)。三只各自在 `ENOENT` 上答缺席的读法并成一只。
|
|
55
|
+
`FileConfigStore.read()` 的拒单里这一形是 `reason: "io"`(`config.d` 目录悬空 ⇒ 每份文档各一条)。导出面零变化。配置根本身(或更上层)是悬空链接不在本律内,读法不变。
|
|
56
|
+
- **存下来的数据**:没有迁移 —— 这不是旧形,是布局坏了,无从替运维猜。
|
|
57
|
+
- **部署环境(先查、后升级)**:升级前在每个配置根上跑 `find -L <配置根> -maxdepth 2 -type l`(列出悬空的符号链接;空输出 = 无事可做)。命中的:修好链接(挂上卷 / 恢复目标),或删掉它(删掉 = 回到缺席时的缺省:`remote-exec.json` 缺席 ⇒ 宿主车道缺省)。
|
|
58
|
+
今天带着悬空链接静默跑缺省的部署,升级后起服拒 —— 这正是本版要的:声明了 `local-docker` 的 `remote-exec.json` 不会因为一条断链悄悄变成宿主车道。
|
|
59
|
+
- **各消费方的读点**:
|
|
60
|
+
|
|
61
|
+
| 消费方 | 读点 | 动作 |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| server | 起服读 `config.d/remote-exec.json`:`loadRemoteExec` 外的 `catch → 抛` | 零改;「缺席」只剩真缺席,悬空链接落进这个既有的 `catch`(起服拒、拒句点名链接) |
|
|
64
|
+
| server | 每拍本地读腿 `FileConfigStore.read()` | 零改;悬空的域文档 / `config.d` 目录链接现在是拒单里的 `io` 条目(此前读成域缺省),走服务端对 `refused` 的既有路径 |
|
|
65
|
+
| server | 目录迁移 warn 的说明「悬空符号链接 —— 读腿接受,这一行是唯一的信号」;目录信任判官「走不到 = 读者也读不到(读者自己对后者响亮抛)」的前提 | 前者的注随改(读腿现在拒);后者的前提对悬空链接才成立(此前读者对它答缺席) |
|
|
66
|
+
| cli / client-core / sdk / web-admin | 产品码调用 `loadRemoteExec` / `loadEnvFile` / `loadLocalConfig` / `readBundle` / `FileConfigStore`(`config.d` 的文件读口) | 无读点,无动作 |
|
|
67
|
+
| 部署 | `config.d`、`config.d/*.json`、`.env` 用符号链接指向卷 / NFS / 部署工具链管理的路径 | 升级前跑上面的 `find`;链接坏了 ⇒ 起服拒,不再静默跑缺省 |
|
|
68
|
+
|
|
50
69
|
## 升级指引:15.2.x → 16.0.0(BREAKING:执行车道集 · 设备入口必需且有文法 · 记忆方言词 mysql · 退役一只模型面组拒码 · 宿主状态时刻判形 · 本地配置单读口 · 文件契约的纯空白字段)
|
|
51
70
|
|
|
52
71
|
- **改了什么**(逐条三问见 CHANGELOG 16.0.0):
|
package/conformance/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
17.0.0
|
package/dist/atomic-write.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export declare function danglingSymlinkSentence(link: string, pointsAt: string):
|
|
|
16
16
|
/** 读写的真目标 —— **一只判法**(写器与目录迁移的读口共用):`target` 是符号链接 ⇒ 解析到它指向的真文件(悬空 ⇒ 抛 {@link danglingSymlinkSentence});
|
|
17
17
|
* 不是链接或不存在 ⇒ 原样(不存在的由调用方的读 / 写照常报)。 */
|
|
18
18
|
export declare function resolveSymlinkTarget(target: string): string;
|
|
19
|
+
/** 读一份**可以缺席**的 UTF-8 文本 —— `config.d` 文档与 `.env` 的缺席律在这里:读得到 ⇒ 文本;`ENOENT` ⇒ 先在缺席臂里对 `file` 自己与 `dirSeats`(这份文件所在、包把它当布局座的目录,如 `config.d`)各问一次
|
|
20
|
+
* {@link resolveSymlinkTarget}(与目录迁移的读口同一只判官):任一座是悬空链接 ⇒ 抛 {@link danglingSymlinkSentence} 那一句(在场却读不出,不是缺席);都不是 ⇒ `undefined`(路径上真的没有条目)。
|
|
21
|
+
* 别的读不出(`EACCES` / `EISDIR` / `ELOOP` …)原样抛。配置根自己是悬空链接(祖先座)不在问的范围内:`dirSeats` 只列文件所在的那一级布局座。 */
|
|
22
|
+
export declare function readTextIfPresent(file: string, dirSeats?: readonly string[]): Promise<string | undefined>;
|
|
19
23
|
/** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
|
|
20
24
|
export declare function writeFileAtomic(target: string, text: string, kind: AtomicFileClass): void;
|
|
21
25
|
/**
|
package/dist/atomic-write.js
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* 「链接 ⇒ 真文件、悬空 ⇒ 响亮抛」这一只判法是 {@link resolveSymlinkTarget}:目录迁移的读口(`./config-dir-io`)读目录 / 读文件前也问它,
|
|
21
21
|
* 悬空链接在读阶段与写阶段是**同一句**({@link danglingSymlinkSentence})。
|
|
22
22
|
*
|
|
23
|
+
* ## `config.d` 文档与 `.env` 的缺席只有一种读法({@link readTextIfPresent})
|
|
24
|
+
* **缺席 = 路径上没有任何条目。** `readFile` 跟着符号链接走,链接在而目标不在时内核同样答 `ENOENT`:那是在场却读不出,不是缺席。读一份可以缺席的文本(`config.d` 的文档、配置根下的 `.env`)
|
|
25
|
+
* 因此都过同一只读者 —— 读不出且是 `ENOENT` ⇒ 在**缺席臂里**问一次链接判法再答「缺席」,悬空 ⇒ 抛那一句(同一句,不带 errno 码)。判在缺席臂而不在读前:读得到的路径零变化(不多一次系统调用);读之后目标恰好出现仍答缺席 —— 读的那一刻它确实不在,与旧读法同一边界,不是放宽。
|
|
26
|
+
*
|
|
23
27
|
* ## 不存在才建({@link createFileExclusive})
|
|
24
28
|
* 另一种写法只有一处不同:最后一步不是 `rename` 盖过目标,而是把(已 fsync 的)临时文件 `link` 成目标名 —— 目标名已在(任何东西:文件、目录、哪怕是悬空的符号链接)⇒
|
|
25
29
|
* 内核拒(`EEXIST`),答 `present`、一字不动。读到「缺席」与写入之间别人写下的东西因此不会被吞(一次性迁移铸配置根的决定文档时用它)。
|
|
@@ -38,6 +42,7 @@
|
|
|
38
42
|
* 写器在建临时文件那一步读了经落点递给铸点(`EPERM` ∧ 可写 ⇒ 真因是不可变标志,首选出路是清标志;`EACCES` ∧ 可写 ⇒ 真因是 ACL / 安全模块;不可写 ⇒ 先让目录可写);`chmod` / `rename` 那两步不读 —— 临时文件刚在同目录建成,可写是已证明的事实,铸点自己给。
|
|
39
43
|
*/
|
|
40
44
|
import { randomBytes } from "node:crypto";
|
|
45
|
+
import { readFile } from "node:fs/promises";
|
|
41
46
|
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
42
47
|
import path from "node:path";
|
|
43
48
|
import { FsFailure } from "./fs-failure.js";
|
|
@@ -75,6 +80,21 @@ export function resolveSymlinkTarget(target) {
|
|
|
75
80
|
throw e;
|
|
76
81
|
}
|
|
77
82
|
}
|
|
83
|
+
/** 读一份**可以缺席**的 UTF-8 文本 —— `config.d` 文档与 `.env` 的缺席律在这里:读得到 ⇒ 文本;`ENOENT` ⇒ 先在缺席臂里对 `file` 自己与 `dirSeats`(这份文件所在、包把它当布局座的目录,如 `config.d`)各问一次
|
|
84
|
+
* {@link resolveSymlinkTarget}(与目录迁移的读口同一只判官):任一座是悬空链接 ⇒ 抛 {@link danglingSymlinkSentence} 那一句(在场却读不出,不是缺席);都不是 ⇒ `undefined`(路径上真的没有条目)。
|
|
85
|
+
* 别的读不出(`EACCES` / `EISDIR` / `ELOOP` …)原样抛。配置根自己是悬空链接(祖先座)不在问的范围内:`dirSeats` 只列文件所在的那一级布局座。 */
|
|
86
|
+
export async function readTextIfPresent(file, dirSeats = []) {
|
|
87
|
+
try {
|
|
88
|
+
return await readFile(file, "utf8");
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (e.code !== "ENOENT")
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
for (const seat of [file, ...dirSeats])
|
|
95
|
+
resolveSymlinkTarget(seat);
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
78
98
|
/** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
|
|
79
99
|
export function writeFileAtomic(target, text, kind) {
|
|
80
100
|
const real = resolveSymlinkTarget(target);
|
package/dist/config-d-file.d.ts
CHANGED
|
@@ -7,10 +7,10 @@ export declare function configDDocumentText(value: unknown): string;
|
|
|
7
7
|
/** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
|
|
8
8
|
* Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
|
|
9
9
|
export declare function parseRemoteExecDocument(raw: unknown): RemoteExecSpec;
|
|
10
|
-
/** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
|
|
11
|
-
export declare function configDPath(root: string, name: string): string;
|
|
12
10
|
/** `config.d` 里能放的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`,不是配置域)。 */
|
|
13
11
|
export type ConfigDDocumentName = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
|
|
12
|
+
/** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是 `config.d` 里能放的文档名(配置域、retired 域、`remote-exec`)⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
|
|
13
|
+
export declare function configDPath(root: string, name: string): string;
|
|
14
14
|
/** 文件层的拒因(闭集词里除墓碑外的五个)。 */
|
|
15
15
|
export type ConfigDFileReason = Exclude<ConfigDocumentRefusalReason, "retired_domain">;
|
|
16
16
|
/** 一份在场文档被拒的读数:`reason` 闭集词 · `error` 整句 · `thrown` 旧抛出口抛的那个对象(渲染成抛时原样抛它)· `fingerprint` 这份坏文档的指纹(版本哈希里的坏文件哨兵用它:
|
|
@@ -34,17 +34,18 @@ export type ConfigDReading = {
|
|
|
34
34
|
export declare function judgeConfigDTextReading(name: ConfigDDocumentName, text: string, file: string): Exclude<ConfigDReading, {
|
|
35
35
|
readonly status: "absent";
|
|
36
36
|
}>;
|
|
37
|
-
/** 抛出口:一个 `config.d/<名>.json` 在场时的**文本** → 它的值:读数是被拒 ⇒ 抛它的 `thrown`(空 / 坏 JSON / 不是对象各一句)。读盘的入口(`readConfigDFile
|
|
37
|
+
/** 抛出口:一个 `config.d/<名>.json` 在场时的**文本** → 它的值:读数是被拒 ⇒ 抛它的 `thrown`(空 / 坏 JSON / 不是对象各一句)。读盘的入口(`readConfigDFile`)
|
|
38
38
|
* 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
|
|
39
39
|
export declare function judgeConfigDText(name: ConfigDDocumentName, text: string, file: string): Record<string, unknown>;
|
|
40
40
|
/** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个嵌得不过深的 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
|
|
41
41
|
* 非朴素对象、嵌得过深 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」「过深」各一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
|
|
42
42
|
* `label` = 句首的称呼(文本路径给文件的真实路径,值路径给 `the <名> document`)。 */
|
|
43
43
|
export declare function judgeConfigDValue(name: ConfigDDocumentName, value: unknown, label: string): Record<string, unknown>;
|
|
44
|
-
/** 读一个 `config.d/<名>.json` →
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
/** 读一个 `config.d/<名>.json` → 读数:路径上没有任何条目 ⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出 ⇒ 被拒,词 `io` —— 非 ENOENT 的 fs 错误,以及悬空的符号链接
|
|
45
|
+
* (文档本身或 `config.d` 目录:内核跟链接读到 ENOENT,但条目在场;句子是 `danglingSymlinkSentence`(`./atomic-write`)那一句,与目录迁移的读口同一句)。缺席与悬空的分辨在 `readTextIfPresent`。 */
|
|
46
|
+
export declare function readConfigDFileReading(root: string, name: ConfigDDocumentName): Promise<ConfigDReading>;
|
|
47
|
+
/** 抛出口:读一个 `config.d/<名>.json`:路径上没有任何条目 ⇒ `undefined`;在场 ⇒ 它的值;被拒 ⇒ 抛读数的 `thrown`(见 {@link readConfigDFileReading})。 */
|
|
48
|
+
export declare function readConfigDFile(root: string, name: ConfigDDocumentName): Promise<Record<string, unknown> | undefined>;
|
|
48
49
|
/** 一棵 `config.d` 的原文快照:便携域与 retired 域的文档原文(缺席 ⇒ `undefined`),版本哈希的入参。 */
|
|
49
50
|
export type ConfigDRaws = Partial<Record<DomainName | RetiredDomainName, unknown>>;
|
|
50
51
|
/** 一棵 `config.d` 原文快照的内容版本(稳定的 u53 整数):同样的文档 ⇒ 同一个数。`FileConfigStore` 的 `getVersion()` / `getEffective()` / `read()` 读同一只。 */
|
package/dist/config-d-file.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 包内私有(不进主桶、不进 exports 表):**一只** `config.d/<名>.json` 文件读法 —— `FileConfigStore`、`readBundle`、`loadRemoteExec` 与命令行 `migrate` 读同一个目录,
|
|
3
|
-
* 同一种文件形同一个答案、同一句话(12.0.0 起 `remote-exec.json` 也是这一只;此前它有自己的一只文本判,只拒空与坏 JSON
|
|
4
|
-
*
|
|
3
|
+
* 同一种文件形同一个答案、同一句话(12.0.0 起 `remote-exec.json` 也是这一只;此前它有自己的一只文本判,只拒空与坏 JSON,非对象在命令行上被当成「无事可做」;17.0.0 起连**读盘**也是这一只 ——
|
|
4
|
+
* `loadRemoteExec` 不再有私有的 `readFile`,走 `readConfigDFile(root, "remote-exec")`)。
|
|
5
|
+
* 分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行自己读文本,只调它)与读盘的 `readConfigDFile`(调它);路径与名字的判只在 `configDPath`(`remote-exec` 的路径也出自它)。
|
|
5
6
|
*
|
|
6
7
|
* **一次读法,两种渲染**(15.2.0):内核交**结构化读数**({@link ConfigDReading}:缺席 / 在场 / 被拒 + 闭集词 + 整句 + 抛出物 + 指纹)——
|
|
7
8
|
* `judgeConfigDTextReading`(文本 → 读数)与 `readConfigDFileReading`(文件 → 读数)是判据的唯一一处;抛出口 `judgeConfigDText` / `readConfigDFile` 把被拒的读数渲染成「抛它的 `thrown`」
|
|
8
9
|
* (原来抛的那个对象、那句话,io 是原样的 fs 错误),单读口 `FileConfigStore.read()`(16.0.0)把整个目录的读数渲染成 effective 或类型化的拒单。两种渲染读同一份判据,不会各说各话。
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
+
* 律:**缺席 = 路径上没有任何条目**(`undefined`):`ENOENT`,且文档本身与 `config.d` 目录都不是悬空的符号链接(链接在而目标不在是**在场却读不出**,读数是 `io` 拒,句子点名链接与出路 ——
|
|
12
|
+
* 本文件的缺席臂就是 `readTextIfPresent`(`./atomic-write`));在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
|
|
11
13
|
* (`null` / 数组 / 数 / 串 / 布尔)、嵌得比整棵目录的内容版本能算的还深(16.0.0)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
|
|
12
14
|
* governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)。三种坏形每种一句,点名**调用方寻址这份文档用的路径**与形(读的人给路径,
|
|
13
15
|
* 判官不拼 `config.d/` 字面 —— 多个目录各有一份时,句子认得出是哪个 scope 的哪一份;文档是符号链接时点名链接这一端:句尾「delete it」删的是这个 scope 里的这一项,不是被几个 scope 共用的目标)、
|
|
14
16
|
* 句尾出路按文档类({@link documentWayOut}),从不回显文件原文。
|
|
15
17
|
*/
|
|
16
|
-
import { readFile } from "node:fs/promises";
|
|
17
18
|
import path from "node:path";
|
|
19
|
+
import { readTextIfPresent } from "./atomic-write.js";
|
|
18
20
|
import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
|
|
19
21
|
import { MAX_HASH_DEPTH, stableHash } from "./hash.js";
|
|
20
22
|
import { UNINSPECTABLE, guardedSafeParse, ownElementsOf, ownEntriesOf } from "./own-key-read.js";
|
|
@@ -36,9 +38,9 @@ export function parseRemoteExecDocument(raw) {
|
|
|
36
38
|
return judged.value.data;
|
|
37
39
|
throw judged.value.error;
|
|
38
40
|
}
|
|
39
|
-
/** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**)
|
|
41
|
+
/** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是 `config.d` 里能放的文档名(配置域、retired 域、`remote-exec`)⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
|
|
40
42
|
export function configDPath(root, name) {
|
|
41
|
-
if (!isDomainName(name) && !isRetiredDomainName(name))
|
|
43
|
+
if (!isDomainName(name) && !isRetiredDomainName(name) && name !== REMOTE_EXEC_DOCUMENT)
|
|
42
44
|
throw new Error(`"${name}" is not a config domain — refusing to build a config.d path from it (no traversal)`);
|
|
43
45
|
return path.join(root, "config.d", `${name}.json`);
|
|
44
46
|
}
|
|
@@ -103,7 +105,7 @@ export function judgeConfigDTextReading(name, text, file) {
|
|
|
103
105
|
const flaw = configDValueFlaw(name, parsed, file);
|
|
104
106
|
return flaw === undefined ? { status: "present", value: parsed } : refuse(flaw.reason, flaw.error);
|
|
105
107
|
}
|
|
106
|
-
/** 抛出口:一个 `config.d/<名>.json` 在场时的**文本** → 它的值:读数是被拒 ⇒ 抛它的 `thrown`(空 / 坏 JSON / 不是对象各一句)。读盘的入口(`readConfigDFile
|
|
108
|
+
/** 抛出口:一个 `config.d/<名>.json` 在场时的**文本** → 它的值:读数是被拒 ⇒ 抛它的 `thrown`(空 / 坏 JSON / 不是对象各一句)。读盘的入口(`readConfigDFile`)
|
|
107
109
|
* 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
|
|
108
110
|
export function judgeConfigDText(name, text, file) {
|
|
109
111
|
const reading = judgeConfigDTextReading(name, text, file);
|
|
@@ -154,22 +156,21 @@ function configDValueFlaw(name, value, label) {
|
|
|
154
156
|
}
|
|
155
157
|
return { reason: "not_object", error: new Error(`${label} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`) };
|
|
156
158
|
}
|
|
157
|
-
/** 读一个 `config.d/<名>.json` →
|
|
159
|
+
/** 读一个 `config.d/<名>.json` → 读数:路径上没有任何条目 ⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出 ⇒ 被拒,词 `io` —— 非 ENOENT 的 fs 错误,以及悬空的符号链接
|
|
160
|
+
* (文档本身或 `config.d` 目录:内核跟链接读到 ENOENT,但条目在场;句子是 `danglingSymlinkSentence`(`./atomic-write`)那一句,与目录迁移的读口同一句)。缺席与悬空的分辨在 `readTextIfPresent`。 */
|
|
158
161
|
export async function readConfigDFileReading(root, name) {
|
|
159
162
|
const file = configDPath(root, name);
|
|
160
163
|
let text;
|
|
161
164
|
try {
|
|
162
|
-
text = await
|
|
165
|
+
text = await readTextIfPresent(file, [path.dirname(file)]);
|
|
163
166
|
}
|
|
164
167
|
catch (e) {
|
|
165
|
-
if (e.code === "ENOENT")
|
|
166
|
-
return { status: "absent" };
|
|
167
168
|
const error = messageOf(e);
|
|
168
169
|
return { status: "refused", reason: "io", error, thrown: e, fingerprint: stableHash(error) };
|
|
169
170
|
}
|
|
170
|
-
return judgeConfigDTextReading(name, text, file);
|
|
171
|
+
return text === undefined ? { status: "absent" } : judgeConfigDTextReading(name, text, file);
|
|
171
172
|
}
|
|
172
|
-
/** 抛出口:读一个 `config.d/<名>.json
|
|
173
|
+
/** 抛出口:读一个 `config.d/<名>.json`:路径上没有任何条目 ⇒ `undefined`;在场 ⇒ 它的值;被拒 ⇒ 抛读数的 `thrown`(见 {@link readConfigDFileReading})。 */
|
|
173
174
|
export async function readConfigDFile(root, name) {
|
|
174
175
|
const reading = await readConfigDFileReading(root, name);
|
|
175
176
|
if (reading.status === "refused")
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 一条拒 = `{ document, reason, error }`:
|
|
13
13
|
* · `document`:被拒的文档 —— 便携域名,或 retired 域名(retired 域的文档就以域名相称,墓碑拒的「域」就是这个名字,不另设第二个键);
|
|
14
14
|
* · `reason`:闭集词 {@link CONFIG_DOCUMENT_REFUSAL_REASONS}。文件层五个 —— `empty`(空 / 纯空白:半写、截断)· `invalid_json`(JSON 解析不过)· `not_object`(解析出来不是 JSON 对象:
|
|
15
|
-
* `null` / 数组 / 数 / 串 / 布尔)· `io`(在场却读不出:权限、路径是目录、I/O
|
|
15
|
+
* `null` / 数组 / 数 / 串 / 布尔)· `io`(在场却读不出:权限、路径是目录、I/O 错误、悬空的符号链接〔文档本身或 `config.d` 目录:链接在、目标不在,17.0.0 起;句子点名链接与出路〕;缺席 = 路径上没有任何条目,不是拒)· `too_deep`(16.0.0:嵌套深过整棵目录的内容版本能算的深度 ——
|
|
16
16
|
* 此前它在算版本那一步抛,已经收集的拒一并丢掉);第六个 `retired_domain` 是墓碑(retired 域文档带事实);
|
|
17
17
|
* · `error`:契约的整句,与旧出口抛出来的那一句逐字同(`empty` / `invalid_json` / `not_object` / `too_deep` 的句子点名文件的真实路径与形,从不回显文件原文;`io` 是 fs 错误的原话 ——
|
|
18
18
|
* 有的带真实路径(如 EACCES 的 `open '<路径>'`)、有的不带(EISDIR),要认是哪份文档读 `document`;调用方把 `error` 送出进程前自己脱敏);
|
package/dist/file-store.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { type DomainConfig, type DomainName, type EffectiveConfig, type RetiredD
|
|
|
5
5
|
export { NON_PORTABLE_DOMAIN_REASONS, PORTABLE_DOMAINS, type PortableDomain } from "./portable-domains.js";
|
|
6
6
|
/** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
|
|
7
7
|
* 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
|
|
8
|
-
*
|
|
8
|
+
* ——**它有自己的校验路径**(读盘与域文档同一只 `config.d` 文档读法,值判是 `RemoteExecSpec`),所以既不是「未知文件」也不是「域文件」。
|
|
9
9
|
*
|
|
10
10
|
* **导出**(3.0.0):一致性门的语料派生要按同一张表给本地腿的文件分类(域文件 / 专用文件 / 忽略),
|
|
11
11
|
* 手抄一份必漂 —— 漂了的那一天,门会把一个合法的 `remote-exec.json` 报成未知文件。 */
|
|
@@ -55,7 +55,7 @@ export declare class FileConfigStore implements ConfigReader {
|
|
|
55
55
|
domainPath(domain: DomainName | RetiredDomainName): string;
|
|
56
56
|
/** Read+parse one domain (validated, defaults filled), or `undefined` if the domain is ABSENT — matching
|
|
57
57
|
* the {@link ConfigReader} contract (reader.ts: absent domain → undefined) and the server stores'
|
|
58
|
-
* `has(domain) ? parse : undefined`. Only
|
|
58
|
+
* `has(domain) ? parse : undefined`. Only an ABSENT file (ENOENT with nothing at the path; a dangling symlink is not absence) is absence → undefined. A present `{}` IS a set
|
|
59
59
|
* domain → parsed with defaults. A present file that is empty or whitespace-only (a half-write) throws as a corrupt file,
|
|
60
60
|
* as do bad JSON, a non-object, an unreadable file and one nested too deep (the same reading as getEffective / readBundle).
|
|
61
61
|
* (getEffective still fills defaults for absent domains; getDomain is the per-domain "was this ever set?" probe.) */
|
package/dist/file-store.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* <root>/.env secret VALUES (KEY=value) — the ONLY file holding secrets; gitignored, never read here.
|
|
12
12
|
*
|
|
13
13
|
* Rules:
|
|
14
|
-
* -
|
|
14
|
+
* - An ABSENT file (nothing at the path: ENOENT, and neither the file nor `config.d` is a dangling symlink) → the domain is absent → schema defaults
|
|
15
|
+
* fill in (parseDomain tolerates undefined). A DANGLING symlink is present-but-unreadable (a refusal, `io`), never absence.
|
|
15
16
|
* A present-but-EMPTY/whitespace file is a half-write → THROW (fail-closed, like remote-exec); to mean
|
|
16
17
|
* "absent/defaults" the file must be missing, not empty. A valid `{}` object IS a set domain (→ defaults).
|
|
17
18
|
* - `read()` (16.0.0) is the ONE read that answers both ways: the effective config + its read warnings as data when every
|
|
@@ -37,7 +38,7 @@ import { isDomainName, isRetiredDomainName } from "./types.js";
|
|
|
37
38
|
export { NON_PORTABLE_DOMAIN_REASONS, PORTABLE_DOMAINS } from "./portable-domains.js";
|
|
38
39
|
/** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
|
|
39
40
|
* 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
|
|
40
|
-
*
|
|
41
|
+
* ——**它有自己的校验路径**(读盘与域文档同一只 `config.d` 文档读法,值判是 `RemoteExecSpec`),所以既不是「未知文件」也不是「域文件」。
|
|
41
42
|
*
|
|
42
43
|
* **导出**(3.0.0):一致性门的语料派生要按同一张表给本地腿的文件分类(域文件 / 专用文件 / 忽略),
|
|
43
44
|
* 手抄一份必漂 —— 漂了的那一天,门会把一个合法的 `remote-exec.json` 报成未知文件。 */
|
|
@@ -59,7 +60,7 @@ export class FileConfigStore {
|
|
|
59
60
|
return configDPath(this.root, domain); // the one name guard (no traversal) lives there
|
|
60
61
|
}
|
|
61
62
|
/** Read + JSON.parse one domain file —— the ONE `config.d/` file reading (`./config-d-file`, shared with `readBundle`): a MISSING
|
|
62
|
-
* file (ENOENT) → undefined = the domain is ABSENT (the ONLY way to mean "use defaults"); a present file that is EMPTY/whitespace-only,
|
|
63
|
+
* file (nothing at the path — ENOENT and not a dangling symlink) → undefined = the domain is ABSENT (the ONLY way to mean "use defaults"); a present file that is EMPTY/whitespace-only,
|
|
63
64
|
* or parses to anything but a JSON OBJECT, is CORRUPT → throw, FAIL CLOSED (an empty present file is a half-write/truncation, NOT
|
|
64
65
|
* "absent": treating it as absent would silently DROP a published domain — e.g. a truncated governance.json drops its
|
|
65
66
|
* commandPolicy/autonomy guardrails). This also keeps getVersion in lock-step with getEffective/getDomain: stableStringify collapses
|
|
@@ -142,7 +143,7 @@ export class FileConfigStore {
|
|
|
142
143
|
}
|
|
143
144
|
/** Read+parse one domain (validated, defaults filled), or `undefined` if the domain is ABSENT — matching
|
|
144
145
|
* the {@link ConfigReader} contract (reader.ts: absent domain → undefined) and the server stores'
|
|
145
|
-
* `has(domain) ? parse : undefined`. Only
|
|
146
|
+
* `has(domain) ? parse : undefined`. Only an ABSENT file (ENOENT with nothing at the path; a dangling symlink is not absence) is absence → undefined. A present `{}` IS a set
|
|
146
147
|
* domain → parsed with defaults. A present file that is empty or whitespace-only (a half-write) throws as a corrupt file,
|
|
147
148
|
* as do bad JSON, a non-object, an unreadable file and one nested too deep (the same reading as getEffective / readBundle).
|
|
148
149
|
* (getEffective still fills defaults for absent domains; getDomain is the per-domain "was this ever set?" probe.) */
|
|
@@ -150,7 +151,7 @@ export class FileConfigStore {
|
|
|
150
151
|
await refuseRetiredDomainFiles(this.root); // every read entry asks the tombstone first (exportBundle reads through here)
|
|
151
152
|
const raw = await this.#readDomainRaw(domain);
|
|
152
153
|
if (raw === undefined)
|
|
153
|
-
return undefined; //
|
|
154
|
+
return undefined; // absent file (nothing at the path) → domain absent (not schema defaults); a corrupt or unreadable file (empty, bad JSON, dangling symlink …) threw above
|
|
154
155
|
// 0.19.0(对抗复审 复审 round2 F3):与 buildEffective **同一条** parse-and-warn 判据。此前本方法直接
|
|
155
156
|
// parseDomain,于是 `exportBundle`(它就是逐域走 getDomain 读的)会把一份携带未知键的域**静默剥**
|
|
156
157
|
// 成一份"干净"的 bundle —— 再 import 回去就是一次持久的配置丢失,全程零告警。
|
package/dist/local-load.d.ts
CHANGED
|
@@ -2,17 +2,25 @@ import { type RequiredEnvName } from "./file-edit.js";
|
|
|
2
2
|
import { type EffectiveReadWarning } from "./config-fns.js";
|
|
3
3
|
import { type RemoteExecSpec } from "./remote-exec.js";
|
|
4
4
|
import type { EffectiveConfig } from "./types.js";
|
|
5
|
-
/** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
|
|
5
|
+
/** The remoteExec file path under a config root (under config.d/, beside the per-domain files) — the ONE config.d path builder (`configDPath`) answers it. */
|
|
6
6
|
export declare function remoteExecPath(root: string): string;
|
|
7
|
-
/** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if
|
|
8
|
-
* (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file.
|
|
7
|
+
/** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if there is NOTHING at that path
|
|
8
|
+
* (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file.
|
|
9
|
+
*
|
|
10
|
+
* ABSENT means no entry on the path — nothing else. A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
|
|
11
|
+
* silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker); so is a DANGLING symbolic link, at the file or at
|
|
12
|
+
* `config.d` itself (the kernel follows the link and answers ENOENT, but the entry is there: present and unreadable — it throws the same one sentence the directory
|
|
13
|
+
* migration prints, with its way out: repair or remove the link). To get the host default, the file must be ABSENT, not empty and not a broken link.
|
|
14
|
+
* This function has no read of its own: it is THE `config.d` document reading (`readConfigDFile`, one absence rule, one text judge — empty / bad JSON / not an object, one sentence
|
|
15
|
+
* each — shared with FileConfigStore, readBundle and the directory migration); the sanitized value judge is the migration's too (`./config-d-file`). */
|
|
9
16
|
export declare function loadRemoteExec(root: string): Promise<RemoteExecSpec | undefined>;
|
|
10
17
|
/** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
|
|
11
18
|
* client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
|
|
12
19
|
export declare function writeRemoteExec(root: string, spec: unknown): Promise<RemoteExecSpec>;
|
|
13
20
|
/** Parse `<root>/.env` (KEY=VALUE, `#` comments, optional surrounding quotes) MERGED OVER process.env (the
|
|
14
21
|
* file wins). The ONLY place secret VALUES live — the contract files carry env-NAMEs only. A `KEY=` with an
|
|
15
|
-
* empty value IS present (the caller may treat empty as a weak warning). Absent .env → just process.env
|
|
22
|
+
* empty value IS present (the caller may treat empty as a weak warning). Absent .env → just process.env — absent
|
|
23
|
+
* means no entry on the path (the one absence rule, `readTextIfPresent`): a DANGLING symbolic link at `.env` is present and unreadable, so it throws.
|
|
16
24
|
* A `.env` value of `file:<path>` resolves to that file's trimmed content (file-NAME ref — key stays out of
|
|
17
25
|
* env, contract stays env-NAME-only). Schema still rejects pasted plaintext (the contract field is a NAME). */
|
|
18
26
|
export declare function loadEnvFile(root: string): Promise<Record<string, string>>;
|
package/dist/local-load.js
CHANGED
|
@@ -10,34 +10,28 @@
|
|
|
10
10
|
import { mkdir, readFile } from "node:fs/promises";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import { writeFileAtomic } from "./atomic-write.js";
|
|
13
|
+
import { readTextIfPresent, writeFileAtomic } from "./atomic-write.js";
|
|
14
14
|
import { FileConfigStore } from "./file-store.js";
|
|
15
15
|
import { listRequiredEnvNames } from "./file-edit.js";
|
|
16
16
|
import {} from "./config-fns.js";
|
|
17
|
-
import { configDDocumentText,
|
|
17
|
+
import { configDDocumentText, configDPath, parseRemoteExecDocument, readConfigDFile } from "./config-d-file.js";
|
|
18
18
|
import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
|
|
19
|
-
/** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
|
|
19
|
+
/** The remoteExec file path under a config root (under config.d/, beside the per-domain files) — the ONE config.d path builder (`configDPath`) answers it. */
|
|
20
20
|
export function remoteExecPath(root) {
|
|
21
|
-
return
|
|
21
|
+
return configDPath(root, REMOTE_EXEC_DOCUMENT);
|
|
22
22
|
}
|
|
23
|
-
/** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if
|
|
24
|
-
* (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file.
|
|
23
|
+
/** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if there is NOTHING at that path
|
|
24
|
+
* (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file.
|
|
25
|
+
*
|
|
26
|
+
* ABSENT means no entry on the path — nothing else. A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
|
|
27
|
+
* silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker); so is a DANGLING symbolic link, at the file or at
|
|
28
|
+
* `config.d` itself (the kernel follows the link and answers ENOENT, but the entry is there: present and unreadable — it throws the same one sentence the directory
|
|
29
|
+
* migration prints, with its way out: repair or remove the link). To get the host default, the file must be ABSENT, not empty and not a broken link.
|
|
30
|
+
* This function has no read of its own: it is THE `config.d` document reading (`readConfigDFile`, one absence rule, one text judge — empty / bad JSON / not an object, one sentence
|
|
31
|
+
* each — shared with FileConfigStore, readBundle and the directory migration); the sanitized value judge is the migration's too (`./config-d-file`). */
|
|
25
32
|
export async function loadRemoteExec(root) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
text = await readFile(file, "utf8");
|
|
30
|
-
}
|
|
31
|
-
catch (e) {
|
|
32
|
-
if (e.code === "ENOENT")
|
|
33
|
-
return undefined;
|
|
34
|
-
throw e;
|
|
35
|
-
}
|
|
36
|
-
// A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
|
|
37
|
-
// silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker).
|
|
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
|
|
39
|
-
// each — the same judge FileConfigStore and the directory migration read with); the sanitized value judge is the migration's too (`./config-d-file`).
|
|
40
|
-
return parseRemoteExecDocument(judgeConfigDText(REMOTE_EXEC_DOCUMENT, text, file));
|
|
33
|
+
const doc = await readConfigDFile(root, REMOTE_EXEC_DOCUMENT);
|
|
34
|
+
return doc === undefined ? undefined : parseRemoteExecDocument(doc);
|
|
41
35
|
}
|
|
42
36
|
/** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
|
|
43
37
|
* client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
|
|
@@ -77,7 +71,8 @@ async function resolveFileRef(spec, root) {
|
|
|
77
71
|
}
|
|
78
72
|
/** Parse `<root>/.env` (KEY=VALUE, `#` comments, optional surrounding quotes) MERGED OVER process.env (the
|
|
79
73
|
* file wins). The ONLY place secret VALUES live — the contract files carry env-NAMEs only. A `KEY=` with an
|
|
80
|
-
* empty value IS present (the caller may treat empty as a weak warning). Absent .env → just process.env
|
|
74
|
+
* empty value IS present (the caller may treat empty as a weak warning). Absent .env → just process.env — absent
|
|
75
|
+
* means no entry on the path (the one absence rule, `readTextIfPresent`): a DANGLING symbolic link at `.env` is present and unreadable, so it throws.
|
|
81
76
|
* A `.env` value of `file:<path>` resolves to that file's trimmed content (file-NAME ref — key stays out of
|
|
82
77
|
* env, contract stays env-NAME-only). Schema still rejects pasted plaintext (the contract field is a NAME). */
|
|
83
78
|
export async function loadEnvFile(root) {
|
|
@@ -85,15 +80,9 @@ export async function loadEnvFile(root) {
|
|
|
85
80
|
for (const [k, v] of Object.entries(process.env))
|
|
86
81
|
if (v !== undefined)
|
|
87
82
|
merged[k] = v;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
catch (e) {
|
|
93
|
-
if (e.code === "ENOENT")
|
|
94
|
-
return merged;
|
|
95
|
-
throw e;
|
|
96
|
-
}
|
|
83
|
+
const text = await readTextIfPresent(path.join(root, ".env"));
|
|
84
|
+
if (text === undefined)
|
|
85
|
+
return merged;
|
|
97
86
|
for (const raw of text.split("\n")) {
|
|
98
87
|
const line = raw.trim();
|
|
99
88
|
if (!line || line.startsWith("#"))
|
package/llms-full.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
llms-full.txt — 自包含全文 · 消费方(使用者)视角 · @sema-agent/settings-schema
|
|
3
|
-
重写日期: 2026-
|
|
3
|
+
重写日期: 2026-11-01 | 包版本: 17.0.0
|
|
4
4
|
生成方式: 无生成脚本——按当前树 src/*.ts 导出面 + package.json + README/CHANGELOG 手写事实版。
|
|
5
5
|
证据约定: 每条"它怎么工作"的断言附 (文件: 符号) 坐标,刻意不用行号(行号逐版腐烂;符号名由
|
|
6
6
|
typecheck 与 test/package-surface.test.ts、test/docs-hygiene.test.ts 等常驻门钉住)。无证据推断显式标【推断】。
|
|
@@ -532,7 +532,7 @@ entitlement/execution/limits = `none`;`projects` = `salvage`,整域档位仍 `"t
|
|
|
532
532
|
- `FileConfigStore.read()` → `FileConfigRead`(16.0.0,`./node`;单读口,取代 15.2.0 的 `inspectBundle`):一次读完 `config.d` 里契约读的每一份文档(全部便携域 + retired 域的墓碑文档,每份只读一次),
|
|
533
533
|
交一个结局 —— 文件层都读得进 ⇒ `{ kind: "effective", effective, warnings }`(effective 与严读 `getEffective()` 逐位同;读侧告警交成数据,不经 `onWarning`);
|
|
534
534
|
有文档被拒 ⇒ `{ kind: "refused", refusals, version, warnings }`。每条拒 `ConfigDocumentRefusal`(包根的类型,按 `reason` 判别):`{ document, reason, error }`,`reason` ∈ 闭集词 `CONFIG_DOCUMENT_REFUSAL_REASONS`
|
|
535
|
-
(`empty` 空 / 纯空白〔半写〕· `invalid_json` · `not_object`〔`null` / 数组 / 数 / 串 / 布尔〕· `io
|
|
535
|
+
(`empty` 空 / 纯空白〔半写〕· `invalid_json` · `not_object`〔`null` / 数组 / 数 / 串 / 布尔〕· `io`〔在场却读不出:权限 / 目录 / I/O 错误 / 悬空的符号链接(17.0.0);缺席 = 路径上没有任何条目〕· `too_deep`〔16.0.0:嵌得比整棵目录的内容版本能算的深〕·
|
|
536
536
|
`retired_domain`〔retired 域文档带事实;另带 `migration`〕),`error` 与严读抛的是同一句。次序确定:retired 域文档在前,便携域按 `PORTABLE_DOMAINS`。`version` 与 `FileConfigStore.getVersion()` 同一只函数
|
|
537
537
|
(被拒的文档以「坏文档的指纹」参与,与宽容读的坏文件版本哨兵同一只)。读得出却过不了门控域 schema 的拒不在拒单里,照严读抛。
|
|
538
538
|
旧出口(`readBundle` / `FileConfigStore` 各读口 / `loadRemoteExec` / 命令行 `migrate`)与新口读同一份判据(`config-d-file` 的读数),抛的还是原来那个对象、那句话。(src/bundle.ts;src/config-d-file.ts;src/config-document-refusal.ts)
|
|
@@ -638,7 +638,7 @@ T1. **EffectiveConfig ≠ EffectiveWire**:本地是全文(skills.content 内联)
|
|
|
638
638
|
entitlement 剥离。别假设本地结果是 wire 形状。(§3.2)
|
|
639
639
|
T2. **`./node` 污染浏览器 bundle**:纯契约只 import `.`。
|
|
640
640
|
T3. **三态文件**(remote-exec.json / config.d/*.json):缺失→默认;存在→赢;空或损坏→抛错(fail-closed)。
|
|
641
|
-
"想要默认就删文件,不是留空文件"。
|
|
641
|
+
"想要默认就删文件,不是留空文件"。「缺失」= 路径上没有任何条目;**悬空的符号链接(文件或 `config.d` 目录;`.env` 同)是存在而读不出 → 抛错**(17.0.0;出路:修好链接或删掉它)。
|
|
642
642
|
T4. **`null` ≠ absent**:`parseDomain(d, null)` = 损坏抛错,只有 `undefined`(文件缺失)才填默认。
|
|
643
643
|
T5. **`governance.commandPolicy` 的 command 是精确 argv[0] 名**,glob/path/带参数会被 fail-loud 拒收
|
|
644
644
|
(消费端引擎是精确 Set 匹配,glob 永不匹配 = 静默放行 fail-open,所以 schema 层就拒)。DENY-WINS。
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
llms.txt — 消费方(使用者)视角索引 · @sema-agent/settings-schema
|
|
3
|
-
重写日期: 2026-
|
|
3
|
+
重写日期: 2026-11-01 | 包版本: 17.0.0
|
|
4
4
|
生成方式: 无生成脚本——按当前树的 src/*.ts 导出面 + package.json + README/CHANGELOG 手写事实版;
|
|
5
5
|
每条断言附证据坐标(文件:符号),刻意不用行号(行号随每版腐烂,符号名由 typecheck/test 钉住)。
|
|
6
6
|
刷新触发: package.json 版号变更(test/docs-hygiene.test.ts D 组钉「包版本:」与 package.json 相等、
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/settings-schema",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.0",
|
|
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",
|