@sema-agent/settings-schema 11.1.0 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +14 -1
  3. package/bin/settings-schema.mjs +5 -9
  4. package/conformance/VERSION +1 -1
  5. package/conformance/cases/c73-projects-extended-registration-reads/expected.json +150 -0
  6. package/conformance/cases/c73-projects-extended-registration-reads/input.json +42 -0
  7. package/conformance/cases/c73-projects-extended-registration-reads/meta.json +6 -0
  8. package/conformance/cases/c74-projects-over-bound-refused/expected.json +89 -0
  9. package/conformance/cases/c74-projects-over-bound-refused/input.json +29 -0
  10. package/conformance/cases/c74-projects-over-bound-refused/meta.json +5 -0
  11. package/conformance/cases/c75-projects-patch-remote-uniqueness/expected.json +89 -0
  12. package/conformance/cases/c75-projects-patch-remote-uniqueness/input.json +35 -0
  13. package/conformance/cases/c75-projects-patch-remote-uniqueness/meta.json +5 -0
  14. package/dist/api/feedback.js +2 -3
  15. package/dist/api/scopes.js +2 -1
  16. package/dist/atomic-write.d.ts +7 -1
  17. package/dist/atomic-write.js +43 -5
  18. package/dist/cli.d.ts +34 -6
  19. package/dist/cli.js +80 -130
  20. package/dist/config-d-file.d.ts +11 -0
  21. package/dist/config-d-file.js +35 -1
  22. package/dist/config-dir-io.d.ts +2 -0
  23. package/dist/config-dir-io.js +14 -0
  24. package/dist/config-dir-migration.d.ts +67 -0
  25. package/dist/config-dir-migration.js +181 -0
  26. package/dist/cross-domain.d.ts +8 -3
  27. package/dist/cross-domain.js +23 -13
  28. package/dist/file-edit.js +7 -5
  29. package/dist/file-store.js +2 -1
  30. package/dist/fleet.js +3 -4
  31. package/dist/hooks.d.ts +14 -1
  32. package/dist/hooks.js +61 -16
  33. package/dist/host-path.d.ts +17 -0
  34. package/dist/host-path.js +22 -0
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. package/dist/integer.js +2 -1
  38. package/dist/local-load.d.ts +1 -1
  39. package/dist/local-load.js +9 -31
  40. package/dist/migration-receipt.d.ts +3 -2
  41. package/dist/node.d.ts +2 -0
  42. package/dist/node.js +3 -0
  43. package/dist/one-time-migrations.d.ts +5 -3
  44. package/dist/one-time-migrations.js +21 -7
  45. package/dist/previous-web-admin-env.d.ts +15 -0
  46. package/dist/previous-web-admin-env.js +20 -0
  47. package/dist/quoted-issue.d.ts +41 -0
  48. package/dist/quoted-issue.js +35 -0
  49. package/dist/remote-exec.d.ts +3 -0
  50. package/dist/remote-exec.js +12 -7
  51. package/dist/retired-envs.js +13 -2
  52. package/dist/retired-form.d.ts +6 -1
  53. package/dist/retired-form.js +10 -2
  54. package/dist/rfc3339.d.ts +10 -0
  55. package/dist/rfc3339.js +12 -0
  56. package/dist/runtime-lift-targets.d.ts +7 -0
  57. package/dist/runtime-lift-targets.js +7 -0
  58. package/dist/shell-quote.d.ts +1 -0
  59. package/dist/shell-quote.js +14 -0
  60. package/dist/types.d.ts +140 -20
  61. package/dist/types.js +224 -114
  62. package/dist/web-admin-credential.d.ts +2 -2
  63. package/dist/web-admin-credential.js +5 -10
  64. package/dist/web-admin-env.d.ts +2 -1
  65. package/dist/web-admin-env.js +2 -1
  66. package/dist/web-admin-session-url.d.ts +17 -0
  67. package/dist/web-admin-session-url.js +64 -0
  68. package/package.json +1 -1
@@ -9,8 +9,42 @@
9
9
  */
10
10
  import { readFile } from "node:fs/promises";
11
11
  import path from "node:path";
12
- import { refuseRetiredDomain } from "./config-fns.js";
12
+ import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
13
+ import { RemoteExecSpec } from "./remote-exec.js";
13
14
  import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
15
+ /** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
16
+ * 都用这一只(此前三处各写一遍同一行),于是迁移写回的文件与编辑器写出的同一份文件逐字节同形。 */
17
+ export function configDDocumentText(value) {
18
+ return `${JSON.stringify(value, null, 2)}\n`;
19
+ }
20
+ /** `config.d/remote-exec.json` 在场时的**文本** → 它的 JSON 值(纯判,零 I/O)—— `loadRemoteExec` 与目录迁移读同一只、同一句(从 `loadRemoteExec` 里搬出)。
21
+ * 空 / 纯空白(半写、截断 —— 不是「用默认」:默认车道要的是文件**缺席**)、坏 JSON ⇒ 抛,句子点名 `filePath`、不回显原文。值的形由
22
+ * {@link parseRemoteExecDocument} 判(不是对象也归它拒)。 */
23
+ export function judgeRemoteExecText(filePath, text) {
24
+ const trimmed = text.trim();
25
+ if (!trimmed)
26
+ throw new Error(`${filePath} is empty/corrupt — delete it for the host default, or write a valid remoteExec spec`);
27
+ try {
28
+ return JSON.parse(trimmed); // sanitized: don't let a SyntaxError echo the raw (possibly secret-bearing) file text
29
+ }
30
+ catch {
31
+ throw new Error(`${filePath} — invalid JSON (delete it for the host default, or write a valid remoteExec spec)`);
32
+ }
33
+ }
34
+ /** 判一份 remote-exec 文档的值(`RemoteExecSpec`),错误**脱敏**(zod 会回显出错的值 —— 粘错位置的密钥不许随抛出的错走);反射访问抛(恶意 getter /
35
+ * Proxy)⇒ 定句。`loadRemoteExec` / `writeRemoteExec` 与目录迁移的整文档终判调同一只。 */
36
+ export function parseRemoteExecDocument(raw) {
37
+ let r;
38
+ try {
39
+ r = RemoteExecSpec.safeParse(raw);
40
+ }
41
+ catch {
42
+ throw new Error("invalid remoteExec (a value could not be read during validation)");
43
+ }
44
+ if (r.success)
45
+ return r.data;
46
+ throw sanitizeZodError(r.error);
47
+ }
14
48
  /** `<root>/config.d/<名>.json` 的路径 —— 名字的判也在这里(**一处**):不是配置域也不是 retired 域 ⇒ 拒(不拿用户给的名拼路径,防 `../` 穿越)。 */
15
49
  export function configDPath(root, name) {
16
50
  if (!isDomainName(name) && !isRetiredDomainName(name))
@@ -0,0 +1,2 @@
1
+ import type { ConfigDirectoryIo } from "./config-dir-migration.js";
2
+ export declare const nodeConfigDirectoryIo: ConfigDirectoryIo;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `migrateConfigDirectory`(`./config-dir-migration`)的**真 I/O 绑定**(Node 专用):读目录 / 读文件 / 删文件走 `node:fs/promises`,写盘走本包唯一的原子写器
3
+ * `./atomic-write`(同目录临时文件 → rename;`config-document` 类的模式规则:新建 0o644 经 umask、已有的沿用原模式;目标是符号链接则写到它指向的真文件)。
4
+ * 读目录 / 读文件先过写器同一只链接判法 `resolveSymlinkTarget`:链接读它指向的真文件 / 真目录,**悬空链接**抛与写器同一句(改前读阶段先撞 ENOENT,句子不提链接)。
5
+ * 命令行入口与引擎起服的目录迁移注入同一份,写法与文件模式因此只在原子写器那一处定。
6
+ */
7
+ import { readdir, readFile, unlink } from "node:fs/promises";
8
+ import { resolveSymlinkTarget, writeFileAtomic } from "./atomic-write.js";
9
+ export const nodeConfigDirectoryIo = {
10
+ readDir: async (path) => readdir(resolveSymlinkTarget(path)),
11
+ readFile: async (path) => readFile(resolveSymlinkTarget(path), "utf8"),
12
+ writeFileAtomic: async (path, text) => writeFileAtomic(path, text, "config-document"), // the files this migration writes are config.d domain documents
13
+ removeFile: (path) => unlink(path),
14
+ };
@@ -0,0 +1,67 @@
1
+ import { type MigrationReceipt } from "./one-time-migrations.js";
2
+ import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
3
+ import { type DomainName, type RetiredDomainName } from "./types.js";
4
+ /** 注入的 I/O(本模块不碰 `node:fs`)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
5
+ export interface ConfigDirectoryIo {
6
+ readDir(path: string): Promise<string[]>;
7
+ readFile(path: string): Promise<string>;
8
+ writeFileAtomic(path: string, text: string): Promise<void>;
9
+ removeFile(path: string): Promise<void>;
10
+ }
11
+ /** 一条拒绝 —— 迁移步的拒(收据 `refused`)与整域终判的拒**同一套形**:`migration`(整域终判拒为 `null`)/ `document` / `path`(相对该域文档,出门形)/ `appliedAs: "refused"` / `reason`。
12
+ * 句子来自本包唯一的脱敏器,坐标段与句子都是出门形、不带值。 */
13
+ export type ConfigDirectoryRefusal = {
14
+ readonly migration: string | null;
15
+ readonly document: string;
16
+ readonly path: string;
17
+ readonly appliedAs: "refused";
18
+ readonly reason: string;
19
+ };
20
+ export type ConfigDirectoryMigration = {
21
+ readonly kind: "unreadable";
22
+ readonly reason: string;
23
+ } | {
24
+ readonly kind: "no-documents";
25
+ readonly entries: readonly string[];
26
+ } | {
27
+ readonly kind: "refused";
28
+ readonly read: readonly string[];
29
+ readonly applied: readonly MigrationReceipt[];
30
+ readonly refused: readonly ConfigDirectoryRefusal[];
31
+ readonly why: string;
32
+ } | {
33
+ readonly kind: "noop";
34
+ readonly read: readonly string[];
35
+ } | {
36
+ readonly kind: "pending";
37
+ readonly read: readonly string[];
38
+ readonly applied: readonly MigrationReceipt[];
39
+ } | {
40
+ readonly kind: "applied";
41
+ readonly read: readonly string[];
42
+ readonly applied: readonly MigrationReceipt[];
43
+ readonly written: readonly string[];
44
+ readonly removed: readonly string[];
45
+ };
46
+ export interface MigrateConfigDirectoryOptions {
47
+ /** 覆盖面:调用方读的文档名集(配置域、retired 域,或 `remote-exec`)。拼错的名字 ⇒ 抛 `TypeError`,不让它悄悄变成「什么都没覆盖」。 */
48
+ readonly documents: readonly ConfigDirectoryDocument[];
49
+ readonly io: ConfigDirectoryIo;
50
+ /** 只判、不写:有改动且整域判得过 ⇒ `pending`。默认 `false`。 */
51
+ readonly check?: boolean;
52
+ }
53
+ /** 目录迁移能读写的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`)。 */
54
+ export type ConfigDirectoryDocument = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
55
+ /** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
56
+ export declare const joinDirPath: (dir: string, name: string) => string;
57
+ type RefusedReceipt = Extract<MigrationReceipt, {
58
+ readonly appliedAs: "refused";
59
+ }>;
60
+ /** 一张收据的出门形(坐标只点名、不带值):`refused` 带 `reason`(= {@link ConfigDirectoryRefusal} 的形),`moved` 带 `to`。包内私有 —— 命令行报告用它渲染 `applied`。 */
61
+ export declare function reportReceipt(r: RefusedReceipt): ConfigDirectoryRefusal;
62
+ export declare function reportReceipt(r: MigrationReceipt): Record<string, unknown>;
63
+ /**
64
+ * 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
65
+ */
66
+ export declare function migrateConfigDirectory(dir: string, opts: MigrateConfigDirectoryOptions): Promise<ConfigDirectoryMigration>;
67
+ export {};
@@ -0,0 +1,181 @@
1
+ /**
2
+ * **一个 `config.d` 目录的一次性迁移** —— 「读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件」这一条语义的唯一实现。命令行(`migrate <config.d 目录> [--check]`,
3
+ * 见 `./cli`)是它的渲染器(退出码 + 报告行);引擎起服时对本地 `config.d` 的迁移调同一只函数,不按同语义自写一份。
4
+ *
5
+ * **覆盖面 = 调用方读的文档名集**({@link MigrateConfigDirectoryOptions.documents}:配置域、retired 域,还可以是 `remote-exec` ——
6
+ * `config.d/remote-exec.json`,一次性迁移 SS-M7 的座位走查也读它;它与域文档同一条路:读 → 迁 → 改过的整文档终判 → 同一只写器写回):覆盖面内没有的域文件就是从未设过(一次性迁移的 `coverage: "scope"`);
7
+ * 覆盖面外的文件不读、不判、不写、不删 —— 本调用方没有资格改写它从不消费的数据,它们是坏文件也挡不住覆盖面内的迁移。命令行的覆盖面是全部配置域加 retired 域;
8
+ * 引擎的覆盖面是它本地读腿会读的那几份。**迁移不许越过覆盖面去写**:retired 的 `runtime` 域迁出时要写 `governance` 与 `limits`(`RUNTIME_LIFT_TARGETS`),
9
+ * 覆盖面没把这两个域一并包进来时,目录算不上「一个 scope 的全集」—— 迁移按 `coverage: "partial"` 走(`runtime` 在那一支上以收据 `refused` 拒迁、一字不写),
10
+ * 不会把覆盖面外已有的 `governance.json` / `limits.json` 当作「从未设过」覆盖掉;日后若别的迁移步新建覆盖面外的文档,函数响亮抛(守卫),不静默写。
11
+ *
12
+ * 本模块不碰 `node:fs`:读写由注入的 {@link ConfigDirectoryIo} 做(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用 `./atomic-write` 那一只原子写器与它的模式表),
13
+ * 所以测试可以直接喂内存实现。
14
+ *
15
+ * ## 结果是一个闭集({@link ConfigDirectoryMigration}),调用方穷举
16
+ * · `unreadable` —— 读不了目录 / 某份文件,或某份文档是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象,与 `FileConfigStore` 同一只判、同一句)。一字不写。
17
+ * · `no-documents`—— 覆盖面内一份文档都没找到;带回目录清单 `entries`(调用方自己判这是「填错了目录」还是「真空的新目录」)。一字不写。
18
+ * · `refused` —— 迁移步拒(收据 `refused`:要猜才改得动的形)**或**要写回的文档整域读不进(整域终判:名字 / 路由唯一这类跨行规则只有整域判得出)。一字不写。
19
+ * · `noop` —— 读到了文档、没有要改的。
20
+ * · `pending` —— `check: true` 且有改动、整域判得过:一字不写。
21
+ * · `applied` —— 已写回:先写改过 / 新建的文档,最后删被移除的 retired 域文件 —— 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
22
+ * 写盘阶段的 I/O 错误**原样抛出**(不吞、不改写):已写完的文档留在新位上,再跑一次幂等收尾。
23
+ */
24
+ import { z } from "zod";
25
+ import { formatConfigPath, parseDomain } from "./config-fns.js";
26
+ import { configDDocumentText, judgeConfigDText, judgeRemoteExecText, parseRemoteExecDocument } from "./config-d-file.js";
27
+ import { migrateStoredDocuments } from "./one-time-migrations.js";
28
+ import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
29
+ import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
30
+ import { isDomainName, isRetiredDomainName } from "./types.js";
31
+ const isConfigDirectoryDocument = (name) => isDomainName(name) || isRetiredDomainName(name) || name === REMOTE_EXEC_DOCUMENT;
32
+ /** 一份文档在场时的文本 → 值:域 / retired 域过 `judgeConfigDText`(与 `FileConfigStore` 同一只),`remote-exec` 过 `judgeRemoteExecText`(与 `loadRemoteExec` 同一只)。 */
33
+ function judgeDocumentText(dir, name, text) {
34
+ return name === REMOTE_EXEC_DOCUMENT ? judgeRemoteExecText(joinDirPath(dir, `${name}.json`), text) : judgeConfigDText(name, text);
35
+ }
36
+ /** 改过的文档的整文档终判:域过读侧那一只 `parseDomain`,`remote-exec` 过 `loadRemoteExec` 那一只 `parseRemoteExecDocument`;retired 域不终判(它只会被删)。 */
37
+ function judgeWholeDocument(name, doc) {
38
+ if (isDomainName(name))
39
+ parseDomain(name, doc);
40
+ else if (name === REMOTE_EXEC_DOCUMENT)
41
+ parseRemoteExecDocument(doc);
42
+ }
43
+ /** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
44
+ export const joinDirPath = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
45
+ export function reportReceipt(r) {
46
+ const base = { migration: r.migration, document: r.document, path: formatConfigPath(r.path), appliedAs: r.appliedAs };
47
+ switch (r.appliedAs) {
48
+ case "moved":
49
+ return { ...base, to: `${r.to.document}:${formatConfigPath(r.to.path)}` };
50
+ case "refused":
51
+ return { ...base, reason: r.reason };
52
+ case "rewritten":
53
+ case "dropped-shadowed":
54
+ case "dropped-inert":
55
+ return base;
56
+ default: {
57
+ const unhandled = r;
58
+ throw new Error(`settings-schema: unhandled receipt ${JSON.stringify(unhandled)}`);
59
+ }
60
+ }
61
+ }
62
+ /** 整域终判的一条拒(issue 来自 `parseDomain`,已过本包唯一的脱敏器 —— 坐标段与句子都是出门形)。 */
63
+ function wholeDomainRefusal(document, path, reason) {
64
+ return { migration: null, document, path: formatConfigPath(path), appliedAs: "refused", reason };
65
+ }
66
+ /**
67
+ * 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
68
+ * `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `check` 之前:否则同一个目录 `check` 报 pending、
69
+ * 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
70
+ */
71
+ function verdictOf(input, migrated, receipts) {
72
+ const refusedReceipts = receipts.filter((r) => r.appliedAs === "refused");
73
+ if (refusedReceipts.length > 0) {
74
+ return { kind: "refused", refused: refusedReceipts.map((r) => reportReceipt(r)), why: `${refusedReceipts.length} coordinate(s) cannot be migrated (see "refused") — fix them by hand and re-run` };
75
+ }
76
+ if (receipts.length === 0)
77
+ return { kind: "noop" };
78
+ const refused = [];
79
+ for (const [name, doc] of Object.entries(migrated)) {
80
+ if (Object.prototype.hasOwnProperty.call(input, name) && input[name] === doc)
81
+ continue;
82
+ try {
83
+ judgeWholeDocument(name, doc);
84
+ }
85
+ catch (err) {
86
+ if (err instanceof z.ZodError)
87
+ for (const i of err.issues)
88
+ refused.push(wholeDomainRefusal(name, i.path, i.message));
89
+ else
90
+ refused.push(wholeDomainRefusal(name, [], err instanceof Error ? err.message : String(err)));
91
+ }
92
+ }
93
+ if (refused.length > 0) {
94
+ const documents = [...new Set(refused.map((r) => r.document))];
95
+ return { kind: "refused", refused, why: `the migrated ${documents.join(" / ")} document(s) do not read as a whole domain (see "refused") — fix them by hand and re-run` };
96
+ }
97
+ return { kind: "changes" };
98
+ }
99
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
100
+ /**
101
+ * 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
102
+ */
103
+ export async function migrateConfigDirectory(dir, opts) {
104
+ const covered = new Set();
105
+ for (const name of opts.documents) {
106
+ if (!isConfigDirectoryDocument(name))
107
+ throw new TypeError(`migrateConfigDirectory: "${String(name)}" is not a config domain, a retired domain or remote-exec — the coverage names the documents the caller reads`);
108
+ covered.add(name);
109
+ }
110
+ const { io } = opts;
111
+ let names;
112
+ try {
113
+ names = await io.readDir(dir);
114
+ }
115
+ catch (err) {
116
+ return { kind: "unreadable", reason: `cannot read directory ${dir}: ${messageOf(err)}` };
117
+ }
118
+ const docs = {};
119
+ for (const name of [...names].sort()) {
120
+ if (name.startsWith(".") || !name.endsWith(".json"))
121
+ continue;
122
+ const base = name.slice(0, -".json".length);
123
+ if (!covered.has(base) || !isConfigDirectoryDocument(base))
124
+ continue;
125
+ let text;
126
+ try {
127
+ text = await io.readFile(joinDirPath(dir, name));
128
+ }
129
+ catch (err) {
130
+ return { kind: "unreadable", reason: `cannot read ${name}: ${messageOf(err)}` };
131
+ }
132
+ try {
133
+ docs[base] = judgeDocumentText(dir, base, text); // the one judge per document kind: a domain file's is FileConfigStore's, remote-exec.json's is loadRemoteExec's
134
+ }
135
+ catch (err) {
136
+ return { kind: "unreadable", reason: messageOf(err) };
137
+ }
138
+ }
139
+ const read = Object.keys(docs);
140
+ if (read.length === 0)
141
+ return { kind: "no-documents", entries: names };
142
+ // 目录是不是「一个 scope 的全集」只看一件事:SS-M1 要写的目标域是不是都在覆盖面里(见文件头);不是 ⇒ partial,runtime 的迁移被它自己拒掉。
143
+ const coverage = RUNTIME_LIFT_TARGETS.every((target) => covered.has(target)) ? "scope" : "partial";
144
+ const m = migrateStoredDocuments(docs, { coverage });
145
+ const outside = Object.keys(m.docs).filter((name) => !Object.prototype.hasOwnProperty.call(docs, name) && !covered.has(name));
146
+ if (outside.length > 0) {
147
+ // 守卫:迁移新建了覆盖面外的文档。那份文件调用方没读过,「从未设过」只对覆盖面内成立 —— 写下去会覆盖它。今天不会发生(见文件头);发生了就是迁移步的写入域变了,这里先红。
148
+ throw new Error(`migrateConfigDirectory: the migration produced document(s) outside the coverage (${outside.join(", ")}) — refusing to write files the caller did not read`);
149
+ }
150
+ const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
151
+ const verdict = verdictOf(docs, m.docs, m.receipts);
152
+ switch (verdict.kind) {
153
+ case "refused":
154
+ return { kind: "refused", read, applied, refused: verdict.refused, why: verdict.why };
155
+ case "noop":
156
+ return { kind: "noop", read };
157
+ case "changes":
158
+ break;
159
+ default: {
160
+ const unhandled = verdict;
161
+ throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
162
+ }
163
+ }
164
+ if (opts.check === true)
165
+ return { kind: "pending", read, applied };
166
+ const written = [];
167
+ const removed = [];
168
+ for (const [name, doc] of Object.entries(m.docs)) {
169
+ if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
170
+ continue;
171
+ await io.writeFileAtomic(joinDirPath(dir, `${name}.json`), configDDocumentText(doc)); // the one config.d document text (writeDomainFile / writeRemoteExec write the same)
172
+ written.push(`${name}.json`);
173
+ }
174
+ for (const name of Object.keys(docs)) {
175
+ if (Object.prototype.hasOwnProperty.call(m.docs, name))
176
+ continue;
177
+ await io.removeFile(joinDirPath(dir, `${name}.json`));
178
+ removed.push(`${name}.json`);
179
+ }
180
+ return { kind: "applied", read, applied, written, removed };
181
+ }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { ConfigReader } from "./reader.js";
14
14
  import type { DomainConfig } from "./types.js";
15
+ import { type QuotedIssueParams } from "./quoted-issue.js";
15
16
  /** What a dangling reference failed to find —— the closed set (S-1075, 11.0.0): one word per referenced domain. */
16
17
  export declare const REF_ISSUE_KINDS: readonly ["unknown_model", "unknown_roster", "unknown_worker", "unknown_skill", "unknown_mcp", "unknown_plugin"];
17
18
  export type RefIssueKind = (typeof REF_ISSUE_KINDS)[number];
@@ -37,7 +38,9 @@ export interface RefReferrer {
37
38
  * holds the reference), `target` (the name that was not found), `path` (where the reference sits; `path[0]` = the referrer's
38
39
  * domain key). `message` is {@link renderRefIssue} of them — a reader that must treat the user-written values differently from
39
40
  * the sentence's fixed words (a log boundary fingerprinting each value) re-renders with its own `show` instead of parsing the
40
- * sentence. Before 11.0.0 the values were only inside `message`. */
41
+ * sentence. Before 11.0.0 the values were only inside `message`. `params` is the same sentence in the form every custom
42
+ * issue of this package carries (`QuotedIssueParams`: fixed words + the two values as operands) —— a reader that reads custom issues one
43
+ * way reads this one the same way. */
41
44
  export interface RefIssue {
42
45
  path: (string | number)[];
43
46
  code: "custom";
@@ -45,10 +48,12 @@ export interface RefIssue {
45
48
  referrer: RefReferrer;
46
49
  target: string;
47
50
  message: string;
51
+ params: QuotedIssueParams;
48
52
  }
49
53
  /** The ONE rendering of a {@link RefIssue}'s sentence: `<referrer> → unknown <noun> "<target>" (at <path>)`. `show` maps each
50
- * user-written value (the referrer's name, the target); the default shows them as written — that is how `message` is minted. */
51
- export declare function renderRefIssue(issue: Omit<RefIssue, "message">, show?: (value: string) => string): string;
54
+ * user-written value (the referrer's name, the target); the default shows them as written — that is how `message` is minted.
55
+ * It renders {@link refIssueSentence}'s params through `renderQuotedSentence` (the one renderer of every custom issue's sentence). */
56
+ export declare function renderRefIssue(issue: Omit<RefIssue, "message" | "params">, show?: (value: string) => string): string;
52
57
  /** Resolve a sibling domain's effective value for integrity checks. */
53
58
  export type SiblingResolver = <K extends keyof DomainConfig>(domain: K) => Promise<DomainConfig[K] | undefined>;
54
59
  /** A resolver over the reader, optionally shadowed by pending (same-operation) writes keyed by domain.
@@ -1,4 +1,5 @@
1
1
  import { formatConfigPath } from "./config-fns.js";
2
+ import { buildQuotedIssue, fixed, renderQuotedSentence } from "./quoted-issue.js";
2
3
  /** What a dangling reference failed to find —— the closed set (S-1075, 11.0.0): one word per referenced domain. */
3
4
  export const REF_ISSUE_KINDS = ["unknown_model", "unknown_roster", "unknown_worker", "unknown_skill", "unknown_mcp", "unknown_plugin"];
4
5
  /** The domain a {@link RefIssueKind}'s target lives in. */
@@ -29,33 +30,42 @@ function refTargetNoun(kind) {
29
30
  return "plugin";
30
31
  }
31
32
  }
32
- /** How a referrer is named in the sentence (its name through `show`). */
33
- function refReferrerLabel(r, show) {
34
- const named = (noun) => (r.name === undefined ? noun : `${noun} "${show(r.name)}"`);
33
+ /** How a referrer is named in the sentence: the noun for its domain (its own name, when it has one, follows as an operand). */
34
+ function refReferrerNoun(r) {
35
35
  switch (r.domain) {
36
36
  case "rosters":
37
- return named("roster");
37
+ return "roster";
38
38
  case "workers":
39
- return named("worker");
39
+ return "worker";
40
40
  case "models":
41
- return named("models");
41
+ return "models";
42
42
  case "collab":
43
- return named("collab template");
43
+ return "collab template";
44
44
  case "systems":
45
- return named("system");
45
+ return "system";
46
46
  case "entitlement":
47
- return named("tier");
47
+ return "tier";
48
48
  }
49
49
  }
50
+ /** A {@link RefIssue}'s sentence —— THE one sentence builder (`<referrer> → unknown <noun> "<target>" (at <path>)`): the referrer's name and
51
+ * the target are operands, everything else (nouns, the coordinate) is fixed text. `message`, `params` and {@link renderRefIssue} all come from it. */
52
+ function refIssueSentence(issue) {
53
+ const noun = refReferrerNoun(issue.referrer);
54
+ const at = formatConfigPath(issue.path);
55
+ return issue.referrer.name === undefined
56
+ ? buildQuotedIssue `${fixed(noun)} → unknown ${fixed(refTargetNoun(issue.kind))} "${issue.target}" (at ${fixed(at)})`
57
+ : buildQuotedIssue `${fixed(noun)} "${issue.referrer.name}" → unknown ${fixed(refTargetNoun(issue.kind))} "${issue.target}" (at ${fixed(at)})`;
58
+ }
50
59
  /** The ONE rendering of a {@link RefIssue}'s sentence: `<referrer> → unknown <noun> "<target>" (at <path>)`. `show` maps each
51
- * user-written value (the referrer's name, the target); the default shows them as written — that is how `message` is minted. */
60
+ * user-written value (the referrer's name, the target); the default shows them as written — that is how `message` is minted.
61
+ * It renders {@link refIssueSentence}'s params through `renderQuotedSentence` (the one renderer of every custom issue's sentence). */
52
62
  export function renderRefIssue(issue, show = (v) => v) {
53
- return `${refReferrerLabel(issue.referrer, show)} → unknown ${refTargetNoun(issue.kind)} "${show(issue.target)}" (at ${formatConfigPath(issue.path)})`;
63
+ return renderQuotedSentence(refIssueSentence(issue).params, (operand) => show(String(operand)));
54
64
  }
55
65
  /** The one minting point of a {@link RefIssue}. */
56
66
  function danglingRef(path, kind, referrer, target) {
57
- const operands = { path, code: "custom", kind, referrer, target };
58
- return { ...operands, message: renderRefIssue(operands) };
67
+ const sentence = refIssueSentence({ path, kind, referrer, target });
68
+ return { path, code: "custom", kind, referrer, target, ...sentence };
59
69
  }
60
70
  /** A resolver over the reader, optionally shadowed by pending (same-operation) writes keyed by domain.
61
71
  * Widened from the fat ConfigStore to the narrow {@link ConfigReader} (it only calls `getDomain`) so a TOC
package/dist/file-edit.js CHANGED
@@ -9,7 +9,9 @@ import { mkdir } from "node:fs/promises";
9
9
  import path from "node:path";
10
10
  import { writeFileAtomic } from "./atomic-write.js";
11
11
  import { collectEnvRefs } from "./secret-refs.js";
12
- import { RemoteExecSpec } from "./remote-exec.js";
12
+ import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
13
+ import { configDDocumentText } from "./config-d-file.js";
14
+ import { buildQuotedIssue, fixed } from "./quoted-issue.js";
13
15
  import { RetiredDomainError, parseDomain, sanitizeZodError } from "./config-fns.js";
14
16
  import { DOMAIN_SCHEMAS, isDomainName, isRetiredDomainName } from "./types.js";
15
17
  /** Throw a CLEAR error for a non-domain key instead of the cryptic `undefined.parse(...)` you'd get from
@@ -21,7 +23,7 @@ function assertDomainName(domain) {
21
23
  if (isRetiredDomainName(domain))
22
24
  throw new RetiredDomainError(domain);
23
25
  if (!isDomainName(domain)) {
24
- const hint = domain === "remote-exec" ? " — the exec backend is NOT a domain; use writeRemoteExec (config.d/remote-exec.json)" : "";
26
+ const hint = domain === REMOTE_EXEC_DOCUMENT ? " — the exec backend is NOT a domain; use writeRemoteExec (config.d/remote-exec.json)" : "";
25
27
  throw new Error(`"${domain}" is not a config domain (expected one of: ${DOMAINS_LIST})${hint}`);
26
28
  }
27
29
  }
@@ -37,7 +39,7 @@ export function validateDomain(domain, raw) {
37
39
  }
38
40
  catch {
39
41
  // a hostile getter/Proxy trap threw during traversal — never surface its (secret-bearing) message. R13.
40
- return { ok: false, issues: [{ code: "custom", message: `invalid "${domain}" config (a value could not be read during validation)`, path: [] }] };
42
+ return { ok: false, issues: [{ code: "custom", ...buildQuotedIssue `invalid "${fixed(domain)}" config (a value could not be read during validation)`, path: [] }] };
41
43
  }
42
44
  if (r.success)
43
45
  return { ok: true, value: r.data };
@@ -50,7 +52,7 @@ export function validateRemoteExec(raw) {
50
52
  r = RemoteExecSpec.safeParse(raw);
51
53
  }
52
54
  catch {
53
- return { ok: false, issues: [{ code: "custom", message: "invalid remoteExec (a value could not be read during validation)", path: [] }] };
55
+ return { ok: false, issues: [{ code: "custom", ...buildQuotedIssue `invalid remoteExec (a value could not be read during validation)`, path: [] }] };
54
56
  }
55
57
  return r.success ? { ok: true, value: r.data } : { ok: false, issues: sanitizeZodError(r.error).issues }; // no secret echo (R14)
56
58
  }
@@ -68,7 +70,7 @@ export async function writeDomainFile(root, domain, value) {
68
70
  const parsed = parseDomain(domain, value); // routes through the SANITIZED parser (explicit-null reject R4 + no secret echo R14)
69
71
  const dir = path.join(root, "config.d");
70
72
  await mkdir(dir, { recursive: true });
71
- writeFileAtomic(path.join(dir, `${domain}.json`), JSON.stringify(parsed, null, 2) + "\n", "config-document");
73
+ writeFileAtomic(path.join(dir, `${domain}.json`), configDDocumentText(parsed), "config-document");
72
74
  return parsed;
73
75
  }
74
76
  /** LIST every env-NAME the current local config requires (across models/workers/mcp + the
@@ -29,6 +29,7 @@ import path from "node:path";
29
29
  import { buildEffective, DOMAIN_READ_FALLBACK, parseDomainLoud } from "./config-fns.js";
30
30
  import { configDPath, readConfigDFile, refuseRetiredDomainFiles } from "./config-d-file.js";
31
31
  import { stableHash } from "./hash.js";
32
+ import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
32
33
  import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
33
34
  /** The retired domain names (`RETIRED_DOMAINS` keys) — read from `config.d/` for their tombstone only. */
34
35
  const RETIRED_DOMAIN_NAMES = Object.keys(RETIRED_DOMAINS);
@@ -53,7 +54,7 @@ export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins"
53
54
  *
54
55
  * **导出**(3.0.0):一致性门的语料派生要按同一张表给本地腿的文件分类(域文件 / 专用文件 / 忽略),
55
56
  * 手抄一份必漂 —— 漂了的那一天,门会把一个合法的 `remote-exec.json` 报成未知文件。 */
56
- export const NON_DOMAIN_CONFIG_FILES = new Set(["remote-exec.json"]);
57
+ export const NON_DOMAIN_CONFIG_FILES = new Set([`${REMOTE_EXEC_DOCUMENT}.json`]);
57
58
  /**
58
59
  * 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
59
60
  *
package/dist/fleet.js CHANGED
@@ -20,6 +20,7 @@
20
20
  import { z } from "zod";
21
21
  import { INTEGER } from "./integer.js";
22
22
  import { fnv1a64 } from "./hash.js";
23
+ import { buildQuotedIssue } from "./quoted-issue.js";
23
24
  // ── Web-admin's fleet endpoints (paths, one spelling) ─────────────────────────────────────────────────────
24
25
  /**
25
26
  * Web-admin's `/api/fleet/*` endpoints that are addressed from outside web-admin, relative to its origin. ONE spelling: whoever
@@ -115,7 +116,7 @@ export function compareGeneration(a, b) {
115
116
  return a === b ? 0 : a < b ? -1 : 1;
116
117
  }
117
118
  /** The generation field of every wire shape that carries one — a string judged by {@link isCanonicalGeneration}. */
118
- const Generation = z.string().refine(isCanonicalGeneration, { message: "a generation is a canonical non-negative decimal string (no sign, no leading zero)" });
119
+ const Generation = z.string().refine(isCanonicalGeneration, buildQuotedIssue `a generation is a canonical non-negative decimal string (no sign, no leading zero)`);
119
120
  /**
120
121
  * The serving lease as the ENGINE holds it — a CLOSED set of four words, owned here (the engine's vocabulary, word for word;
121
122
  * a reader that switches on it exhaustively is told by the compiler when a word is added):
@@ -271,9 +272,7 @@ export const ModelRefusalsWire = z
271
272
  entries: z.record(z.string().min(1), z.string().min(1)),
272
273
  plane: z.string().min(1).optional(),
273
274
  })
274
- .refine((s) => Object.keys(s.entries).length > 0 || s.plane !== undefined, {
275
- message: "an empty model-refusals signal is not a signal — omit the key instead (absent ⇔ no reportable refusals)",
276
- });
275
+ .refine((s) => Object.keys(s.entries).length > 0 || s.plane !== undefined, buildQuotedIssue `an empty model-refusals signal is not a signal — omit the key instead (absent ⇔ no reportable refusals)`);
277
276
  const isModelEntryRefusalCode = (c) => MODEL_ENTRY_REFUSAL_CODES.includes(c);
278
277
  const isModelPlaneRefusalCode = (c) => MODEL_PLANE_REFUSAL_CODES.includes(c);
279
278
  /**
package/dist/hooks.d.ts CHANGED
@@ -2267,9 +2267,22 @@ export type HookMatcherEntry = z.infer<typeof HookMatcherEntry>;
2267
2267
  * for the bad matcher itself.
2268
2268
  * Before 11.0.0 the server anchored the regex form (`^(?:…)$`) while the shell did not, so one regex matcher selected different
2269
2269
  * tools in the two places. A deployment whose regex matchers relied on that anchoring selects MORE tools now — add `^…$`.
2270
- * Pure: no budget / timeout — a caller evaluating untrusted regex matchers under a deadline does so around this call.
2270
+ * Pure: no budget / timeout — a caller evaluating untrusted regex matchers under a deadline does so around this call
2271
+ * ({@link hookMatcherForm} tells it beforehand which matchers run a regex at all).
2271
2272
  */
2272
2273
  export declare function hookMatcherSelects(matcher: string | undefined, name: string): boolean | "invalid";
2274
+ /** The form of a hooks matcher under {@link hookMatcherSelects}' reading (closed set). */
2275
+ export type HookMatcherForm = "literal" | "regex" | "invalid";
2276
+ /**
2277
+ * Which form `matcher` takes under the one reading —— the same parse step as {@link hookMatcherSelects}, so for every
2278
+ * matcher `hookMatcherForm(m) === "invalid"` ⇔ `hookMatcherSelects(m, name) === "invalid"` (for any `name`):
2279
+ * · `"literal"` —— no regex is compiled or run: absent / `""` / `"*"` (selects everything) and the NAME-LIST form (only name
2280
+ * characters, `|`, `,` and spaces — membership after splitting);
2281
+ * · `"regex"` —— anything else that compiles as a JS regex (tested unanchored);
2282
+ * · `"invalid"` —— a regex that does not compile (the caller does not run the hook).
2283
+ * For a caller that must route matchers before evaluating them (run the literal ones inline, give the regex ones a deadline).
2284
+ */
2285
+ export declare function hookMatcherForm(matcher: string | undefined): HookMatcherForm;
2273
2286
  /**
2274
2287
  * The hooks config record — the shape `TaskRequest.settings.hooks` carries on the wire and the shell
2275
2288
  * reads from settings JSON: `{ [eventName]: [{ matcher?, hooks: [...] }] }`. Keys limited to the 33
package/dist/hooks.js CHANGED
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import { z } from "zod";
32
32
  import { INTEGER } from "./integer.js";
33
+ import { buildQuotedIssue } from "./quoted-issue.js";
33
34
  // ── event set ──────────────────────────────────────────────────────────────────────────────────────────
34
35
  // The two halves below are where each word came from (CC 2.1.198's own table, then the later additions) — provenance for the
35
36
  // reader of this file, not a contract: they are module-private, and the ONE exported closed set is {@link HOOK_EVENTS}.
@@ -623,7 +624,7 @@ export const HOOK_INJECTED_ENV = [
623
624
  /** Default per-hook timeout when an entry does not set one (seconds; CC default). */
624
625
  export const DEFAULT_HOOK_TIMEOUT_SECONDS = 60;
625
626
  /** A non-empty string with no NUL byte (spawn()/JSON-safe). */
626
- const hookStr = z.string().min(1).refine((s) => !s.includes("\0"), "must not contain a NUL byte");
627
+ const hookStr = z.string().min(1).refine((s) => !s.includes("\0"), buildQuotedIssue `must not contain a NUL byte`);
627
628
  /**
628
629
  * `if` condition — permission-rule syntax (e.g. "Bash(git *)") evaluated against tool_name/tool_input
629
630
  * BEFORE spawning the hook (only meaningful on tool events). Shell superset field, CC-compatible.
@@ -684,6 +685,25 @@ export const HookMatcherEntry = z.object({
684
685
  });
685
686
  /** The characters a matcher may consist of to be read as a NAME LIST rather than a regex. */
686
687
  const HOOK_MATCHER_NAME_LIST = /^[a-zA-Z0-9_|, -]+$/;
688
+ function readHookMatcher(matcher) {
689
+ if (matcher === undefined || matcher === "" || matcher === "*")
690
+ return { form: "all" };
691
+ if (HOOK_MATCHER_NAME_LIST.test(matcher)) {
692
+ return {
693
+ form: "names",
694
+ names: matcher
695
+ .split(/[|,]/)
696
+ .map((part) => part.trim())
697
+ .filter((part) => part !== ""),
698
+ };
699
+ }
700
+ try {
701
+ return { form: "regex", pattern: new RegExp(matcher) };
702
+ }
703
+ catch {
704
+ return { form: "invalid" };
705
+ }
706
+ }
687
707
  /**
688
708
  * Does `matcher` select `name`? —— **the one reading** of a hooks matcher (11.0.0). The shell and the server both ask this
689
709
  * function; neither keeps a copy.
@@ -703,25 +723,50 @@ const HOOK_MATCHER_NAME_LIST = /^[a-zA-Z0-9_|, -]+$/;
703
723
  * for the bad matcher itself.
704
724
  * Before 11.0.0 the server anchored the regex form (`^(?:…)$`) while the shell did not, so one regex matcher selected different
705
725
  * tools in the two places. A deployment whose regex matchers relied on that anchoring selects MORE tools now — add `^…$`.
706
- * Pure: no budget / timeout — a caller evaluating untrusted regex matchers under a deadline does so around this call.
726
+ * Pure: no budget / timeout — a caller evaluating untrusted regex matchers under a deadline does so around this call
727
+ * ({@link hookMatcherForm} tells it beforehand which matchers run a regex at all).
707
728
  */
708
729
  export function hookMatcherSelects(matcher, name) {
709
- if (matcher === undefined || matcher === "" || matcher === "*")
710
- return true;
711
- if (HOOK_MATCHER_NAME_LIST.test(matcher)) {
712
- return matcher
713
- .split(/[|,]/)
714
- .map((part) => part.trim())
715
- .some((part) => part !== "" && part === name);
716
- }
717
- let pattern;
718
- try {
719
- pattern = new RegExp(matcher);
730
+ const r = readHookMatcher(matcher);
731
+ switch (r.form) {
732
+ case "all":
733
+ return true;
734
+ case "names":
735
+ return r.names.includes(name);
736
+ case "regex":
737
+ return r.pattern.test(name);
738
+ case "invalid":
739
+ return "invalid";
740
+ default: {
741
+ const unhandled = r;
742
+ throw new Error(`hookMatcherSelects: unhandled matcher reading ${JSON.stringify(unhandled)}`);
743
+ }
720
744
  }
721
- catch {
722
- return "invalid";
745
+ }
746
+ /**
747
+ * Which form `matcher` takes under the one reading —— the same parse step as {@link hookMatcherSelects}, so for every
748
+ * matcher `hookMatcherForm(m) === "invalid"` ⇔ `hookMatcherSelects(m, name) === "invalid"` (for any `name`):
749
+ * · `"literal"` —— no regex is compiled or run: absent / `""` / `"*"` (selects everything) and the NAME-LIST form (only name
750
+ * characters, `|`, `,` and spaces — membership after splitting);
751
+ * · `"regex"` —— anything else that compiles as a JS regex (tested unanchored);
752
+ * · `"invalid"` —— a regex that does not compile (the caller does not run the hook).
753
+ * For a caller that must route matchers before evaluating them (run the literal ones inline, give the regex ones a deadline).
754
+ */
755
+ export function hookMatcherForm(matcher) {
756
+ const r = readHookMatcher(matcher);
757
+ switch (r.form) {
758
+ case "all":
759
+ case "names":
760
+ return "literal";
761
+ case "regex":
762
+ return "regex";
763
+ case "invalid":
764
+ return "invalid";
765
+ default: {
766
+ const unhandled = r;
767
+ throw new Error(`hookMatcherForm: unhandled matcher reading ${JSON.stringify(unhandled)}`);
768
+ }
723
769
  }
724
- return pattern.test(name);
725
770
  }
726
771
  export const HooksConfig = z.partialRecord(HookEventName, z.array(HookMatcherEntry));
727
772
  /**