@sema-agent/settings-schema 12.0.0 → 12.1.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 +44 -0
- package/README.md +2 -1
- package/conformance/VERSION +1 -1
- package/dist/atomic-write.js +6 -6
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +8 -9
- package/dist/config-d-file.d.ts +3 -3
- package/dist/config-d-file.js +9 -8
- package/dist/config-dir-migration.js +6 -4
- package/dist/fs-failure.d.ts +48 -0
- package/dist/fs-failure.js +83 -0
- package/dist/hooks.d.ts +30 -0
- package/dist/hooks.js +81 -0
- package/dist/local-load.js +3 -2
- package/dist/safety-merge-spec.d.ts +2 -2
- package/package.json +1 -1
- package/dist/write-failure.d.ts +0 -16
- package/dist/write-failure.js +0 -43
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,50 @@
|
|
|
6
6
|
> `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
|
|
7
7
|
> 包名是**历史记录**,如实保留不追改。
|
|
8
8
|
|
|
9
|
+
## 12.1.0 — 2026-10-12 — minor:新导出 interpolateHookEnvVars(http 钩子 header 的 $VAR / ${VAR} 展开只有一只读法,照 CC 2.1.296:名单内展开、名单外与凭据名置空并回报 blanked 清单、大写名字形、剥 CR / LF / NUL);消费方 server hook runner / cli execHttpHook
|
|
10
|
+
|
|
11
|
+
> minor:新增一只纯函数导出 `interpolateHookEnvVars(headers, allowedEnvVars, env)`(主 barrel 与 `./hooks` 两处),外加它的两个结果类型 `HookEnvInterpolation` / `HookEnvBlank`。
|
|
12
|
+
> additive:零删、零改名、零改形;导出面登记表 +3 名 × 2 条子路径。消费方:服务器的钩子执行器(引擎跑的事件)与 cli 的 `execHttpHook`(壳本端跑的事件)改调这一只,零别的改动。
|
|
13
|
+
|
|
14
|
+
### 新导出:http 钩子 header 里 `$VAR` / `${VAR}` 的展开只有一只读法 —— `interpolateHookEnvVars`
|
|
15
|
+
|
|
16
|
+
此前壳(cli 的 `execHttpHook`)与服务器的钩子执行器各自带一份展开读法,同一份钩子定义发出的字节并不相同(例:`${TOKEN}` 花括号形壳里展开、服务器里原样;
|
|
17
|
+
名单外的引用壳里置空、服务器里留字面;名字形、控制字符的处置也各不同)。现在两边调同一只函数,到桩的字节相同。读法照 CC 2.1.296 逐字节:
|
|
18
|
+
- **只读 header 值**,header 名不动;引用是 `$NAME` 或 `${NAME}`,`NAME` = `[A-Z_][A-Z0-9_]*`(只认大写;小写、数字开头、`${}`、未闭合的 `${` 都是普通文字)。
|
|
19
|
+
- **名单内**(`allowedEnvVars`)⇒ 展开成 `env` 里的值;`env` 没有这个名字 ⇒ 展开成空串(不进 `blanked`)。
|
|
20
|
+
- **名单外** ⇒ **空串**(不留字面),进返回值的 `blanked` 清单(`{ header, name, reason: "not-allowed" }`);该变量的 `env` 值不被读取。
|
|
21
|
+
- **凭据名变量**(CC 2.1.296 的闭集名字表:27 个名字 × 裸名与 `INPUT_` 前缀两种拼法,共 54 个)⇒ 即使名单点了名也是空串,进 `blanked`(`reason: "credential"`)。是名字表,不是后缀规则 —— `MY_TOKEN` 是普通变量。
|
|
22
|
+
- **结果剥 CR / LF / NUL**(防 header 注入),对每个 header 值都做,不论有没有引用。
|
|
23
|
+
- 纯函数:零 I/O、零日志、不读全局环境(`env` 是调用方给的视图)、不改入参。`blanked` 给调用方留痕(CC 对每条写一行 debug 日志),header 本身两种情形下都是定稿。
|
|
24
|
+
`blanked` 按 header 序、再按首次出现序;同一 header 里重复引用的同一个名字只列一次。
|
|
25
|
+
|
|
26
|
+
与 cli 今天的展开函数(`execHttpHook.ts` 的 `interpolateEnvVars`)逐字节同 —— 名单内 / 名单外置空 / 无值 / 名字形 / 剥除五项读数相同;唯一不同是凭据名那一臂:cli 尚无,名单点了名的凭据名它会照常展开,
|
|
27
|
+
改调本导出后收敛。CC 2.1.296 另有一族按 `env` 值与运行状态判的置空(远端汇点名、代理 / 包索引 URL 等),需要本函数不取的入参,不在这一读法里。
|
|
28
|
+
|
|
29
|
+
## 12.0.1 — 2026-10-12 — patch:文件系统失败句一只铸点(errno × 落点 × 真实路径)覆盖写 / 删 / 改名 / 迁移拒句 —— 删除失败点名目录 · 拒句用真实路径不写死 config.d/ · 不可变目录提示 · ENAMETOOLONG 一句;零型面
|
|
30
|
+
|
|
31
|
+
> patch:文件系统失败句收成一只铸点,覆盖写 / 删 / 改名 / 读与 `config.d` 文档拒句四处。零型面(零新导出、零删、零改名;导出面登记表不动),退出码、报告行、库写口抛的错的 `code` / `cause` 都不变;
|
|
32
|
+
> 变的只是 stderr / 错误 `message` 里的句子文本,逐条列在下面。消费方:零改动(按 `code` 分支的调用方不受影响;逐字匹配这些句子的脚本按新读数改匹配)。
|
|
33
|
+
|
|
34
|
+
### 行为修复:文件系统失败句只有一只铸点 —— 删除失败点名目录 · 拒句点名真实路径 · 不可变目录提示 · 路径过长一句
|
|
35
|
+
|
|
36
|
+
12.0.0 只把写失败一臂按 errno 落点分了句,同一类事实在别处还是各自手写的话(删文件、读目录 / 文件、`config.d` 文档拒句、`ENAMETOOLONG`)。
|
|
37
|
+
现在本包把文件系统错误说成人话只有一只铸点(错误码 × 落点 × 真实路径):落点是闭集(建临时文件 / `rename` / 删 / 读,外加写口整体失败这一档 —— 注入的写口抛的错,看不见是哪一步),
|
|
38
|
+
原子写器、命令行写 / 删口、目录迁移读口全从这里出句;包内门看住「铸点之外没有第二处手写句」。四处读数:
|
|
39
|
+
- **删除失败**(`migrate` 删 retired 域文件 `runtime.json` 时目录不可写):`cannot remove <文件> from directory <目录> (<errno>): make the directory writable`。
|
|
40
|
+
以前 `cannot remove <文件>: <errno>: <Node 句>`,点名的是文件,真因是目录(与 12.0.0 修掉的写失败同一形)。
|
|
41
|
+
- **文档拒句点名读的那份真实路径**:`config.d` 里空 / 纯空白 / 坏 JSON / 不是对象(域文档、retired 域文档、`remote-exec.json` 同一只文本判)的三句,
|
|
42
|
+
以前一律写死 `config.d/<名>.json` 字面 —— 对两个 scope 目录各迁一遍时,两份拒句逐字相同、认不出是哪个目录。现在是 `<目录>/remote-exec.json is empty/whitespace — …`、
|
|
43
|
+
`invalid JSON in <目录>/models.json — …`、`<路径> must be a JSON object (got <形>) — …`;`FileConfigStore` / `readBundle` / `loadRemoteExec` / 命令行各点名自己读的那一份。
|
|
44
|
+
读不了目录 / 文件的句子同样点名真实路径(以前读文件只给裸文件名)。
|
|
45
|
+
- **目录不可变 / 只读**:建临时文件与删目录项的 `EPERM` / `EROFS`,出路句尾加 `directory may be read-only or immutable (chflags / chattr)`
|
|
46
|
+
(目录带 `chflags uchg` / `chattr +i` 时,只改权限位修不好);`EACCES` / `ENOENT` 的 12.0.0 读数逐字不变。
|
|
47
|
+
- **路径过长**(`ENAMETOOLONG`):一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`。
|
|
48
|
+
以前落在建临时文件上时读成 `cannot write into directory <目录> (ENAMETOOLONG): ENAMETOOLONG: name too long, …` —— errno 两遍,而且怪到目录头上。
|
|
49
|
+
「errno 只打一遍」对所有落点生效:别的 errno 的 Node 句子去掉自带的 `<errno>: ` 前缀(例:`cannot replace <文件> (ENOSPC): no space left on device, …`)。
|
|
50
|
+
|
|
51
|
+
库写口(`writeDomainFile` / `writeRemoteExec` / 调度店写口)抛的错:`message` 是同一只铸点的句子,`code` 照抄底层 errno、原错误在 `cause`,不变。
|
|
52
|
+
|
|
9
53
|
## 12.0.0 — 2026-10-12 — 角色词随引擎收缩(`ModelRole` 8 → 6,一次性迁移 SS-M8)· 写失败按 errno 落点分句 · `config.d` 每份文档同一只文本判 · 扣名条目的可比记号 `identity.token`(major)
|
|
10
54
|
|
|
11
55
|
> 本节一处硬破坏(角色词表收缩,持久面走一次性迁移 SS-M8)外加三件:命令行写失败按落点分句、`config.d` 每份文档同一只文本判(`remote-exec.json` 非对象不再被 `migrate`
|
package/README.md
CHANGED
|
@@ -411,7 +411,8 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
|
|
|
411
411
|
`HOOK_EVENT_OWNER` (engine | shell), the matcher-field table, a per-event input schema (`HookInput`),
|
|
412
412
|
the output/decision shapes (`SyncHookOutput` / `HookSpecificOutput` / `HookPermissionRequestDecision`),
|
|
413
413
|
`HooksConfig` (the `settings.json` `hooks` shape — a CLOSED key set, so a typo is caught at the wire),
|
|
414
|
-
|
|
414
|
+
stop attribution (`StoppedBy` / `stopAttributionText`), and the one reading of `$VAR` / `${VAR}` in an `http` hook's header values
|
|
415
|
+
(`interpolateHookEnvVars` — allow-listed names expand, everything else is blanked and reported, credential variables are never released, CR / LF / NUL stripped).
|
|
415
416
|
- **Cross-session settings** (`cross-session.ts`): the two `settings.json` keys `crossSessionInbound`
|
|
416
417
|
(wake/next-turn/hold/refuse — the core 7.30.0 vocabulary, `accept` retired in 6.0.0; `default` = unset) and `dialogExpiry` (60s/5m/10m/never), each with a reader that
|
|
417
418
|
answers `ok:false` on a bad value instead of silently folding to the default.
|
package/conformance/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
12.
|
|
1
|
+
12.1.0
|
package/dist/atomic-write.js
CHANGED
|
@@ -20,15 +20,15 @@
|
|
|
20
20
|
* 「链接 ⇒ 真文件、悬空 ⇒ 响亮抛」这一只判法是 {@link resolveSymlinkTarget}:目录迁移的读口(`./config-dir-io`)读目录 / 读文件前也问它,
|
|
21
21
|
* 悬空链接在读阶段与写阶段是**同一句**({@link danglingSymlinkSentence})。
|
|
22
22
|
*
|
|
23
|
-
* ## 写失败(12.0.0)
|
|
24
|
-
* 两步各一句({@link
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
82
|
-
|
|
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(
|
|
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(
|
|
101
|
+
throw new EnvironmentFailure(fsFailureMessage({ landing: "remove", path }, err), { cause: err });
|
|
103
102
|
}
|
|
104
103
|
},
|
|
105
104
|
};
|
package/dist/config-d-file.d.ts
CHANGED
|
@@ -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
|
-
* 不是对象 ⇒ 抛,每种一句(
|
|
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
|
/**
|
package/dist/config-d-file.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
|
|
7
7
|
* (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
|
|
8
|
-
* governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)
|
|
9
|
-
*
|
|
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
|
-
* 不是对象 ⇒ 抛,每种一句(
|
|
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(
|
|
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:
|
|
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(
|
|
125
|
+
text = await io.readFile(file);
|
|
124
126
|
}
|
|
125
127
|
catch (err) {
|
|
126
|
-
return { kind: "unreadable", reason:
|
|
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
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -2283,6 +2283,36 @@ export type HookMatcherForm = "literal" | "regex" | "invalid";
|
|
|
2283
2283
|
* For a caller that must route matchers before evaluating them (run the literal ones inline, give the regex ones a deadline).
|
|
2284
2284
|
*/
|
|
2285
2285
|
export declare function hookMatcherForm(matcher: string | undefined): HookMatcherForm;
|
|
2286
|
+
/** One reference in a header value that {@link interpolateHookEnvVars} replaced with the empty string —— what the caller records. */
|
|
2287
|
+
export interface HookEnvBlank {
|
|
2288
|
+
/** The header (its name, as keyed in the input) whose value held the reference. */
|
|
2289
|
+
readonly header: string;
|
|
2290
|
+
/** The variable name as written in the reference. The value is never part of this record. */
|
|
2291
|
+
readonly name: string;
|
|
2292
|
+
/** `"not-allowed"` —— the name is not in the hook's `allowedEnvVars`. `"credential"` —— the name is a credential variable, which no list releases. */
|
|
2293
|
+
readonly reason: "not-allowed" | "credential";
|
|
2294
|
+
}
|
|
2295
|
+
/** What {@link interpolateHookEnvVars} returns: the headers as they go on the wire + the references it blanked. */
|
|
2296
|
+
export interface HookEnvInterpolation {
|
|
2297
|
+
readonly headers: Record<string, string>;
|
|
2298
|
+
/** In header order, then first-occurrence order within a header; a name that is referenced several times in one header is listed once. */
|
|
2299
|
+
readonly blanked: readonly HookEnvBlank[];
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* The one reading of `$VAR` / `${VAR}` in an http hook's header values —— the shell and the server both call this; neither keeps a copy,
|
|
2303
|
+
* so one hook definition puts the same bytes on the wire from either. The reading is CC 2.1.296's (`cli296.js:249576`), byte for byte:
|
|
2304
|
+
*
|
|
2305
|
+
* · only header **values** are read (header names are never touched);
|
|
2306
|
+
* · a reference is `$NAME` or `${NAME}` with NAME = `[A-Z_][A-Z0-9_]*`; anything else (lower case, a digit first, `${}`, an unclosed `${`) is plain text;
|
|
2307
|
+
* · a name in `allowedEnvVars` ⇒ the env value (`""` when `env` has none —— expanded to empty, not listed in `blanked`);
|
|
2308
|
+
* · a name **not** in `allowedEnvVars` ⇒ `""`, listed in `blanked` (`reason: "not-allowed"`); its env value is not read;
|
|
2309
|
+
* · a credential variable ({@link HOOK_ENV_CREDENTIAL_NAMES}'s closed set) ⇒ `""` even when the list names it, listed in `blanked` (`reason: "credential"`);
|
|
2310
|
+
* · CR / LF / NUL are removed from every resulting header value, references or not.
|
|
2311
|
+
*
|
|
2312
|
+
* The caller decides what to do with `blanked` (a debug line per entry is what CC writes); the headers are final either way.
|
|
2313
|
+
* Pure: no I/O, no logging, no global env —— `env` is the caller's view (`process.env` at the call site). Inputs are not modified.
|
|
2314
|
+
*/
|
|
2315
|
+
export declare function interpolateHookEnvVars(headers: Readonly<Record<string, string>>, allowedEnvVars: readonly string[], env: Readonly<Record<string, string | undefined>>): HookEnvInterpolation;
|
|
2286
2316
|
/**
|
|
2287
2317
|
* The hooks config record — the shape `TaskRequest.settings.hooks` carries on the wire and the shell
|
|
2288
2318
|
* reads from settings JSON: `{ [eventName]: [{ matcher?, hooks: [...] }] }`. Keys limited to the 33
|
package/dist/hooks.js
CHANGED
|
@@ -768,6 +768,87 @@ export function hookMatcherForm(matcher) {
|
|
|
768
768
|
}
|
|
769
769
|
}
|
|
770
770
|
}
|
|
771
|
+
/** `${NAME}` or `$NAME`, NAME = `[A-Z_][A-Z0-9_]*` —— upper-case names only (lower-case and digit-led spellings are not references). */
|
|
772
|
+
const HOOK_ENV_REFERENCE = /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g;
|
|
773
|
+
/** CR, LF and NUL never reach a header value (header injection through an env value or a configured template). */
|
|
774
|
+
const HOOK_HEADER_FORBIDDEN = /[\r\n\0]/g;
|
|
775
|
+
/**
|
|
776
|
+
* The credential variables an http hook header never receives, whatever `allowedEnvVars` says. The closed name set of CC 2.1.296
|
|
777
|
+
* (`cli296.js:237450` `_nn` / `ynn`, `:246731` `lGn` / `bft` / `hGn` / `vft`): 27 base names, each also under an `INPUT_` prefix
|
|
778
|
+
* (the spelling a CI action passes an input in). A name set, not a pattern —— `MY_TOKEN` is an ordinary variable.
|
|
779
|
+
* CC 296 also blanks, unless the managed policy lists the name, a second family decided from env values and live state
|
|
780
|
+
* (remote-sink names, proxy / registry URLs …); that family needs inputs this function does not take and is not part of this reading.
|
|
781
|
+
*/
|
|
782
|
+
const HOOK_ENV_CREDENTIAL_NAMES = new Set([
|
|
783
|
+
// CC 296 `_nn`: the OAuth token, the five file-descriptor spellings (`ynn`), then the bridge / device / proxy / background-session tokens
|
|
784
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
785
|
+
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
|
786
|
+
"CLAUDE_CODE_GATEWAY_TOKEN_FILE_DESCRIPTOR",
|
|
787
|
+
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
|
|
788
|
+
"CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR",
|
|
789
|
+
"CCR_AGENT_PROXY_TOKEN_FILE_DESCRIPTOR",
|
|
790
|
+
"CLAUDE_CODE_ARTIFACTS_API_TOKEN",
|
|
791
|
+
"CLAUDE_CODE_SLACK_TAG_TOKEN",
|
|
792
|
+
"CLAUDE_CODE_HFI_BEARER_TOKEN",
|
|
793
|
+
"CLAUDE_BRIDGE_OAUTH_TOKEN",
|
|
794
|
+
"CLAUDE_TRUSTED_DEVICE_TOKEN",
|
|
795
|
+
"AGENT_PROXY_AUTH_TOKEN",
|
|
796
|
+
"CLAUDE_CODE_MCP_SERVE_AUTH_TOKEN",
|
|
797
|
+
"CLAUDE_BG_AUTH_SNAPSHOT_PATH",
|
|
798
|
+
"CLAUDE_BG_SOCKET_TOKENS_PATH",
|
|
799
|
+
"CLAUDE_BG_RV_AUTH",
|
|
800
|
+
"CLAUDE_BG_PTY_AUTH",
|
|
801
|
+
"CLAUDE_BG_CLAIM_AUTH",
|
|
802
|
+
// CC 296 `lGn`: refresh / ingress / host-credential / messaging tokens, MCP client secrets, runner secrets
|
|
803
|
+
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
|
804
|
+
"CLAUDE_SESSION_INGRESS_TOKEN_FILE",
|
|
805
|
+
"CLAUDE_CODE_HOST_CREDS_FILE",
|
|
806
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
807
|
+
"MCP_CLIENT_SECRET",
|
|
808
|
+
"MCP_XAA_IDP_CLIENT_SECRET",
|
|
809
|
+
"ENVIRONMENT_SERVICE_KEY",
|
|
810
|
+
"SELF_HOSTED_RUNNER_POOL_SECRET",
|
|
811
|
+
"SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET",
|
|
812
|
+
].flatMap((name) => [name, `INPUT_${name}`]));
|
|
813
|
+
/**
|
|
814
|
+
* The one reading of `$VAR` / `${VAR}` in an http hook's header values —— the shell and the server both call this; neither keeps a copy,
|
|
815
|
+
* so one hook definition puts the same bytes on the wire from either. The reading is CC 2.1.296's (`cli296.js:249576`), byte for byte:
|
|
816
|
+
*
|
|
817
|
+
* · only header **values** are read (header names are never touched);
|
|
818
|
+
* · a reference is `$NAME` or `${NAME}` with NAME = `[A-Z_][A-Z0-9_]*`; anything else (lower case, a digit first, `${}`, an unclosed `${`) is plain text;
|
|
819
|
+
* · a name in `allowedEnvVars` ⇒ the env value (`""` when `env` has none —— expanded to empty, not listed in `blanked`);
|
|
820
|
+
* · a name **not** in `allowedEnvVars` ⇒ `""`, listed in `blanked` (`reason: "not-allowed"`); its env value is not read;
|
|
821
|
+
* · a credential variable ({@link HOOK_ENV_CREDENTIAL_NAMES}'s closed set) ⇒ `""` even when the list names it, listed in `blanked` (`reason: "credential"`);
|
|
822
|
+
* · CR / LF / NUL are removed from every resulting header value, references or not.
|
|
823
|
+
*
|
|
824
|
+
* The caller decides what to do with `blanked` (a debug line per entry is what CC writes); the headers are final either way.
|
|
825
|
+
* Pure: no I/O, no logging, no global env —— `env` is the caller's view (`process.env` at the call site). Inputs are not modified.
|
|
826
|
+
*/
|
|
827
|
+
export function interpolateHookEnvVars(headers, allowedEnvVars, env) {
|
|
828
|
+
const allowed = new Set(allowedEnvVars);
|
|
829
|
+
const blanked = [];
|
|
830
|
+
const entries = Object.entries(headers).map(([header, value]) => {
|
|
831
|
+
const listed = new Set();
|
|
832
|
+
const blank = (name, reason) => {
|
|
833
|
+
if (!listed.has(name)) {
|
|
834
|
+
listed.add(name);
|
|
835
|
+
blanked.push({ header, name, reason });
|
|
836
|
+
}
|
|
837
|
+
return "";
|
|
838
|
+
};
|
|
839
|
+
const expanded = value.replace(HOOK_ENV_REFERENCE, (reference) => {
|
|
840
|
+
const name = reference.startsWith("${") ? reference.slice(2, -1) : reference.slice(1);
|
|
841
|
+
if (!allowed.has(name))
|
|
842
|
+
return blank(name, "not-allowed");
|
|
843
|
+
if (HOOK_ENV_CREDENTIAL_NAMES.has(name))
|
|
844
|
+
return blank(name, "credential");
|
|
845
|
+
return env[name] ?? "";
|
|
846
|
+
});
|
|
847
|
+
return [header, expanded.replace(HOOK_HEADER_FORBIDDEN, "")];
|
|
848
|
+
});
|
|
849
|
+
// fromEntries defines own properties: a header keyed `__proto__` stays a header instead of replacing the prototype.
|
|
850
|
+
return { headers: Object.fromEntries(entries), blanked };
|
|
851
|
+
}
|
|
771
852
|
export const HooksConfig = z.partialRecord(HookEventName, z.array(HookMatcherEntry));
|
|
772
853
|
/**
|
|
773
854
|
* DESIGN POINT ③ (slot semantics — RESERVED, core to finalize): how `spec.hooks` composes with the
|
package/dist/local-load.js
CHANGED
|
@@ -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(
|
|
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.
|
|
3
|
+
"version": "12.1.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",
|
package/dist/write-failure.d.ts
DELETED
|
@@ -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
|
-
}
|
package/dist/write-failure.js
DELETED
|
@@ -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
|
-
}
|