@sema-agent/settings-schema 15.2.0 → 16.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +52 -46
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c16-deep-nesting-205/expected.json +2 -2
  5. package/conformance/cases/c16-deep-nesting-205/meta.json +1 -1
  6. package/conformance/cases/c50-s784-lane-host-legal/expected.json +6 -2
  7. package/conformance/cases/c50-s784-lane-host-legal/input.json +3 -1
  8. package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +6 -2
  9. package/conformance/cases/c51-s784-lane-e2b-legal/input.json +3 -1
  10. package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +6 -2
  11. package/conformance/cases/c52-s784-lane-k8s-legal/input.json +3 -1
  12. package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +6 -2
  13. package/conformance/cases/c53-s784-lane-ssh-legal/input.json +3 -1
  14. package/conformance/cases/c54-s784-lane-adb-legal/expected.json +6 -2
  15. package/conformance/cases/c54-s784-lane-adb-legal/input.json +3 -1
  16. package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +6 -2
  17. package/conformance/cases/c55-s784-lane-local-docker-legal/input.json +3 -1
  18. package/conformance/cases/c56-s784-lane-device-legal/expected.json +8 -2
  19. package/conformance/cases/c56-s784-lane-device-legal/input.json +5 -2
  20. package/conformance/cases/c57-s784-g1-enabled-no-lane/meta.json +1 -1
  21. package/conformance/cases/c58-s784-g2-multitenant-host/input.json +3 -1
  22. package/conformance/cases/c59-s784-g3-k8s-missing-image/input.json +3 -1
  23. package/conformance/cases/c60-s784-g3-e2b-missing-key/input.json +3 -1
  24. package/conformance/cases/c61-s784-g3-other-lane-key/input.json +3 -1
  25. package/conformance/cases/c62-s784-g7a-session-tidb/input.json +3 -1
  26. package/conformance/cases/c63-s784-host-two-replicas/input.json +3 -1
  27. package/conformance/cases/c64-s784-multitenant-local-store/input.json +3 -1
  28. package/conformance/cases/c65-s784-pg-without-db/input.json +3 -1
  29. package/conformance/cases/c66-s784-memory-backend-mismatch/input.json +3 -1
  30. package/conformance/cases/c66-s784-memory-backend-mismatch/meta.json +1 -1
  31. package/conformance/cases/c68-s784-device-local-store/input.json +6 -1
  32. package/conformance/cases/cli04-env-value-with-newline/expected.json +6 -2
  33. package/conformance/cases/cli04-env-value-with-newline/input.json +3 -1
  34. package/dist/bundle.d.ts +0 -18
  35. package/dist/bundle.js +1 -54
  36. package/dist/cli.d.ts +1 -1
  37. package/dist/config-d-file.d.ts +5 -5
  38. package/dist/config-d-file.js +58 -14
  39. package/dist/config-document-refusal.d.ts +9 -7
  40. package/dist/config-document-refusal.js +2 -2
  41. package/dist/device-relay.d.ts +21 -0
  42. package/dist/device-relay.js +23 -0
  43. package/dist/env-model-table.d.ts +2 -9
  44. package/dist/env-model-table.js +6 -14
  45. package/dist/execution-lane-index.d.ts +19 -7
  46. package/dist/execution-lane-index.js +26 -8
  47. package/dist/execution-lanes.d.ts +9 -4
  48. package/dist/execution-lanes.js +18 -6
  49. package/dist/file-store.d.ts +30 -3
  50. package/dist/file-store.js +74 -8
  51. package/dist/fleet.d.ts +6 -8
  52. package/dist/fleet.js +5 -7
  53. package/dist/hooks.d.ts +1 -1
  54. package/dist/index.d.ts +1 -0
  55. package/dist/index.js +2 -1
  56. package/dist/migration-receipt.d.ts +1 -1
  57. package/dist/migration-receipt.js +1 -1
  58. package/dist/one-time-migrations.d.ts +1 -1
  59. package/dist/one-time-migrations.js +166 -45
  60. package/dist/remote-exec.js +11 -4
  61. package/dist/types.d.ts +15 -11
  62. package/dist/types.js +99 -48
  63. package/dist/url-shape.d.ts +18 -0
  64. package/dist/url-shape.js +22 -0
  65. package/docs/API-SURFACE.md +12 -8
  66. package/docs/CONFORMANCE.md +1 -7
  67. package/docs/CONSUMER-INTEGRATION.md +3 -3
  68. package/llms-full.txt +24 -28
  69. package/llms.txt +9 -8
  70. package/package.json +1 -1
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "runtime": {
15
15
  "execution": {
16
- "lane": "k8s",
16
+ "lanes": [
17
+ "k8s"
18
+ ],
17
19
  "coords": {
18
20
  "K8S_NAMESPACE": "sandboxes"
19
21
  }
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "runtime": {
15
15
  "execution": {
16
- "lane": "e2b"
16
+ "lanes": [
17
+ "e2b"
18
+ ]
17
19
  },
18
20
  "store": {
19
21
  "backend": "local"
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "runtime": {
15
15
  "execution": {
16
- "lane": "k8s",
16
+ "lanes": [
17
+ "k8s"
18
+ ],
17
19
  "coords": {
18
20
  "K8S_SANDBOX_IMAGE": "img",
19
21
  "K8S_NAMESPACE": "sandboxes",
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "runtime": {
15
15
  "execution": {
16
- "lane": "host"
16
+ "lanes": [
17
+ "host"
18
+ ]
17
19
  },
18
20
  "store": {
19
21
  "backend": "local",
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "runtime": {
15
15
  "execution": {
16
- "lane": "host"
16
+ "lanes": [
17
+ "host"
18
+ ]
17
19
  },
18
20
  "store": {
19
21
  "backend": "local"
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "runtime": {
15
15
  "execution": {
16
- "lane": "k8s",
16
+ "lanes": [
17
+ "k8s"
18
+ ],
17
19
  "coords": {
18
20
  "K8S_SANDBOX_IMAGE": "img",
19
21
  "K8S_NAMESPACE": "sandboxes"
@@ -14,7 +14,9 @@
14
14
  },
15
15
  "runtime": {
16
16
  "execution": {
17
- "lane": "host"
17
+ "lanes": [
18
+ "host"
19
+ ]
18
20
  },
19
21
  "store": {
20
22
  "backend": "pg"
@@ -14,7 +14,9 @@
14
14
  },
15
15
  "runtime": {
16
16
  "execution": {
17
- "lane": "host"
17
+ "lanes": [
18
+ "host"
19
+ ]
18
20
  },
19
21
  "store": {
20
22
  "backend": "mysql",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "contributor": "contract",
3
- "claim": "store.memory.backend pg 而 store.backend mysql ⇒ 拒(pg 记忆面要 pg 店;tidb 要 mysql)。",
3
+ "claim": "store.memory.backend pg 而 store.backend mysql ⇒ 拒(pg 记忆面要 pg 店;mysql 记忆面要 mysql 店)。",
4
4
  "firstRedOn": []
5
5
  }
@@ -13,7 +13,12 @@
13
13
  },
14
14
  "runtime": {
15
15
  "execution": {
16
- "lane": "device"
16
+ "lanes": [
17
+ "device"
18
+ ],
19
+ "coords": {
20
+ "DEVICE_RELAY_URL": "none"
21
+ }
17
22
  },
18
23
  "store": {
19
24
  "backend": "local"
@@ -67,7 +67,9 @@
67
67
  "runtime": {
68
68
  "execution": {
69
69
  "coords": {},
70
- "lane": "host",
70
+ "lanes": [
71
+ "host"
72
+ ],
71
73
  "secretRefs": {}
72
74
  },
73
75
  "rateLimitRpm": 0,
@@ -112,7 +114,9 @@
112
114
  "runtime": {
113
115
  "execution": {
114
116
  "coords": {},
115
- "lane": "host",
117
+ "lanes": [
118
+ "host"
119
+ ],
116
120
  "secretRefs": {}
117
121
  },
118
122
  "rateLimitRpm": 0,
@@ -10,7 +10,9 @@
10
10
  "name": "w1",
11
11
  "runtime": {
12
12
  "execution": {
13
- "lane": "host"
13
+ "lanes": [
14
+ "host"
15
+ ]
14
16
  },
15
17
  "store": {
16
18
  "backend": "local"
package/dist/bundle.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { type PortableDomain } from "./portable-domains.js";
2
2
  import type { ConfigReader } from "./reader.js";
3
- import type { ConfigDocumentRefusal } from "./config-document-refusal.js";
4
3
  /** A bundle carries PORTABLE domains only — the non-portable ones (`NON_PORTABLE_DOMAIN_REASONS`: workers / hosts / systems / projects /
5
4
  * entitlement) never travel, and the three bundle functions are that table's other face (types narrow to {@link PortableDomain}; a
6
5
  * JS / `as` caller is refused at run time). */
@@ -20,20 +19,3 @@ export declare function writeBundle(root: string, bundle: ConfigBundle): Promise
20
19
  * `FileConfigStore`: carrying facts ⇒ `RetiredDomainError` (a bundle assembled from the remaining files would silently drop those
21
20
  * facts); absent / `{}` ⇒ left alone. */
22
21
  export declare function readBundle(root: string, domains?: readonly PortableDomain[]): Promise<ConfigBundle>;
23
- /** {@link inspectBundle} 的答案:`refusals` = 文件层拒了的每一份文档(空 ⇒ 这棵目录在文件层干净);`version` = 这一次读到的整棵目录的内容版本。 */
24
- export interface BundleInspection {
25
- /** 被拒的文档,逐份一条,次序确定:retired 域文档在前(墓碑表的声明次序),便携域按 `PORTABLE_DOMAINS` 的次序。 */
26
- readonly refusals: readonly ConfigDocumentRefusal[];
27
- /** 这一次读到的整棵目录(便携域 + retired 域的文档)的内容版本(稳定的 u53 整数):文档都干净时 = `FileConfigStore.getVersion()`;被拒的文档以「坏文档的指纹」参与
28
- * (与 `getEffective({ tolerant: true })` 对坏文件的版本哨兵同一只),所以同样的坏内容同一个数、坏内容变了换数(指纹取 trim 之后的文本:只差首尾空白的两份坏文件同数)、修好后回到内容版本,绝不与「文件缺席」同数。 */
29
- readonly version: number;
30
- }
31
- /**
32
- * 一次读完 `<root>/config.d/` 里契约读的每一份文档(全部便携域 + retired 域的墓碑文档),**不抛**文件层的拒,把它们交成类型化的拒单({@link BundleInspection})。
33
- * 与 {@link readBundle} / `FileConfigStore` 的严读读的是同一只文件读法(`./config-d-file` 的读数)—— 同一种坏形同一个词、同一句话;区别只在渲染:严读遇到第一份坏文档就抛,
34
- * 本函数把整个目录读完、逐份交出(retired 域文档带事实 ⇒ 一条 `retired_domain` 墓碑拒,带迁移编号;retired 域文档自己读不出 ⇒ 归到那一份的文件层拒)。
35
- *
36
- * 只覆盖**文件层与墓碑**:文档读得出、却过不了域的 schema(门控域判形失败)、内容哈希深度闸这类拒,不在这里 —— 它们属于严读的后半段(`getEffective` 抛的)。
37
- * 目录读口自己的错(路径不是合法的域名这类调用错)照旧抛。每份文档只读一次,答案出自这一次读到的字节(没有「先抛、再重探」的第二次读)。
38
- */
39
- export declare function inspectBundle(root: string): Promise<BundleInspection>;
package/dist/bundle.js CHANGED
@@ -10,9 +10,7 @@
10
10
  */
11
11
  import { writeDomainFile } from "./file-edit.js";
12
12
  import { PORTABLE_DOMAINS } from "./portable-domains.js";
13
- import { RETIRED_DOMAIN_NAMES, configDPath, configDVersionOf, corruptDocumentRaw, readConfigDFile, readConfigDFileReading, refuseRetiredDomainFiles } from "./config-d-file.js";
14
- import { RetiredDomainError, refuseRetiredDomain } from "./config-fns.js";
15
- import { isRetiredDomainName } from "./types.js";
13
+ import { configDPath, readConfigDFile, refuseRetiredDomainFiles } from "./config-d-file.js";
16
14
  /** The runtime guard of the same rule (a name that is not even a domain is refused first — no path traversal). */
17
15
  function assertPortable(d) {
18
16
  if (!PORTABLE_DOMAINS.includes(d)) {
@@ -68,54 +66,3 @@ export async function readBundle(root, domains = PORTABLE_DOMAINS) {
68
66
  }));
69
67
  return out;
70
68
  }
71
- /**
72
- * 一次读完 `<root>/config.d/` 里契约读的每一份文档(全部便携域 + retired 域的墓碑文档),**不抛**文件层的拒,把它们交成类型化的拒单({@link BundleInspection})。
73
- * 与 {@link readBundle} / `FileConfigStore` 的严读读的是同一只文件读法(`./config-d-file` 的读数)—— 同一种坏形同一个词、同一句话;区别只在渲染:严读遇到第一份坏文档就抛,
74
- * 本函数把整个目录读完、逐份交出(retired 域文档带事实 ⇒ 一条 `retired_domain` 墓碑拒,带迁移编号;retired 域文档自己读不出 ⇒ 归到那一份的文件层拒)。
75
- *
76
- * 只覆盖**文件层与墓碑**:文档读得出、却过不了域的 schema(门控域判形失败)、内容哈希深度闸这类拒,不在这里 —— 它们属于严读的后半段(`getEffective` 抛的)。
77
- * 目录读口自己的错(路径不是合法的域名这类调用错)照旧抛。每份文档只读一次,答案出自这一次读到的字节(没有「先抛、再重探」的第二次读)。
78
- */
79
- export async function inspectBundle(root) {
80
- const documents = [...RETIRED_DOMAIN_NAMES, ...PORTABLE_DOMAINS];
81
- const readings = await Promise.all(documents.map((d) => readConfigDFileReading(root, d)));
82
- const raws = {};
83
- const refusals = [];
84
- documents.forEach((document, i) => {
85
- const reading = readings[i];
86
- switch (reading.status) {
87
- case "absent":
88
- raws[document] = undefined;
89
- return;
90
- case "refused":
91
- raws[document] = corruptDocumentRaw(reading);
92
- refusals.push({ document, reason: reading.reason, error: reading.error });
93
- return;
94
- case "present":
95
- raws[document] = reading.value;
96
- if (isRetiredDomainName(document)) {
97
- const tombstone = tombstoneOf(document, reading.value);
98
- if (tombstone !== undefined)
99
- refusals.push(tombstone);
100
- }
101
- return;
102
- default: {
103
- const unhandled = reading;
104
- throw new Error(`inspectBundle: unhandled reading ${String(unhandled)}`);
105
- }
106
- }
107
- });
108
- return { refusals, version: configDVersionOf(raws) };
109
- }
110
- /** 墓碑判一次(与严读同一只判官 `refuseRetiredDomain`、同一句):retired 域文档带事实 ⇒ 一条 `retired_domain` 拒;缺席 / `{}` ⇒ `undefined`。 */
111
- function tombstoneOf(domain, raw) {
112
- try {
113
- refuseRetiredDomain(domain, raw);
114
- return undefined;
115
- }
116
- catch (e) {
117
- if (e instanceof RetiredDomainError)
118
- return { document: e.domain, reason: "retired_domain", error: e.message, migration: e.migration };
119
- throw e;
120
- }
121
- }
package/dist/cli.d.ts CHANGED
@@ -35,5 +35,5 @@ export interface SettingsSchemaCliIo extends ConfigDirectoryIo {
35
35
  stderr(text: string): void;
36
36
  }
37
37
  /** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
38
- export declare const COVERED_DOCUMENTS: ("models" | "rosters" | "scenarios" | "skills" | "execution" | "plugins" | "systems" | "limits" | "runtime" | "workers" | "hosts" | "projects" | "mcp" | "collab" | "governance" | "entitlement" | "prompts" | "remote-exec")[];
38
+ export declare const COVERED_DOCUMENTS: ("remote-exec" | "models" | "rosters" | "scenarios" | "skills" | "execution" | "plugins" | "systems" | "limits" | "runtime" | "workers" | "hosts" | "projects" | "mcp" | "collab" | "governance" | "entitlement" | "prompts")[];
39
39
  export declare function runSettingsSchemaCli(argv: readonly string[], io: SettingsSchemaCliIo): Promise<number>;
@@ -11,7 +11,7 @@ export declare function parseRemoteExecDocument(raw: unknown): RemoteExecSpec;
11
11
  export declare function configDPath(root: string, name: string): string;
12
12
  /** `config.d` 里能放的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`,不是配置域)。 */
13
13
  export type ConfigDDocumentName = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
14
- /** 文件层的拒因(闭集词里除墓碑外的四个)。 */
14
+ /** 文件层的拒因(闭集词里除墓碑外的五个)。 */
15
15
  export type ConfigDFileReason = Exclude<ConfigDocumentRefusalReason, "retired_domain">;
16
16
  /** 一份在场文档被拒的读数:`reason` 闭集词 · `error` 整句 · `thrown` 旧抛出口抛的那个对象(渲染成抛时原样抛它)· `fingerprint` 这份坏文档的指纹(版本哈希里的坏文件哨兵用它:
17
17
  * 文本路径取 trim 后原文的哈希 —— 不同坏内容不同数;读不出文本的 `io` 取错误文案的哈希)。 */
@@ -30,15 +30,15 @@ export type ConfigDReading = {
30
30
  readonly value: Record<string, unknown>;
31
31
  } | ConfigDRefused;
32
32
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 读数(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 在场;空 / 纯空白、坏 JSON、
33
- * 不是对象 ⇒ 被拒,每种一个词一句话(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。 */
33
+ * 不是对象、嵌得过深({@link MAX_CONFIG_D_DOCUMENT_DEPTH})⇒ 被拒,每种一个词一句话(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。 */
34
34
  export declare function judgeConfigDTextReading(name: ConfigDDocumentName, text: string, file: string): Exclude<ConfigDReading, {
35
35
  readonly status: "absent";
36
36
  }>;
37
37
  /** 抛出口:一个 `config.d/<名>.json` 在场时的**文本** → 它的值:读数是被拒 ⇒ 抛它的 `thrown`(空 / 坏 JSON / 不是对象各一句)。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
38
38
  * 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
39
39
  export declare function judgeConfigDText(name: ConfigDDocumentName, text: string, file: string): Record<string, unknown>;
40
- /** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
41
- * 非朴素对象 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
40
+ /** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个嵌得不过深的 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
41
+ * 非朴素对象、嵌得过深 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」「过深」各一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
42
42
  * `label` = 句首的称呼(文本路径给文件的真实路径,值路径给 `the <名> document`)。 */
43
43
  export declare function judgeConfigDValue(name: ConfigDDocumentName, value: unknown, label: string): Record<string, unknown>;
44
44
  /** 读一个 `config.d/<名>.json` → 读数:不存在(ENOENT)⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出(非 ENOENT 的 fs 错误)⇒ 被拒,词 `io`。 */
@@ -47,7 +47,7 @@ export declare function readConfigDFileReading(root: string, name: DomainName |
47
47
  export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
48
48
  /** 一棵 `config.d` 的原文快照:便携域与 retired 域的文档原文(缺席 ⇒ `undefined`),版本哈希的入参。 */
49
49
  export type ConfigDRaws = Partial<Record<DomainName | RetiredDomainName, unknown>>;
50
- /** 一棵 `config.d` 原文快照的内容版本(稳定的 u53 整数):同样的文档 ⇒ 同一个数。`FileConfigStore.getVersion()` 与 `inspectBundle` 读同一只。 */
50
+ /** 一棵 `config.d` 原文快照的内容版本(稳定的 u53 整数):同样的文档 ⇒ 同一个数。`FileConfigStore` 的 `getVersion()` / `getEffective()` / `read()` 读同一只。 */
51
51
  export declare function configDVersionOf(raws: ConfigDRaws): number;
52
52
  /** 一份被拒文档在版本哈希里的哨兵:`{ __corruptDomainFile: <坏文档的指纹> }` —— 绝不与「文件缺席」同字节(`stableStringify` 把 `undefined` 与 `null` 折成同样的字节,
53
53
  * 一份空 / 坏文件若读成缺席,会与「域缺席」同哈希,按版本轮询的消费方就漏掉一次变化),而且不同的坏内容换数:坏→修好、好→坏、坏内容 A→坏内容 B 都换版本。 */
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * **一次读法,两种渲染**(15.2.0):内核交**结构化读数**({@link ConfigDReading}:缺席 / 在场 / 被拒 + 闭集词 + 整句 + 抛出物 + 指纹)——
7
7
  * `judgeConfigDTextReading`(文本 → 读数)与 `readConfigDFileReading`(文件 → 读数)是判据的唯一一处;抛出口 `judgeConfigDText` / `readConfigDFile` 把被拒的读数渲染成「抛它的 `thrown`」
8
- * (原来抛的那个对象、那句话,io 是原样的 fs 错误),目录口 `inspectBundle`(`./bundle`)把整个目录的读数渲染成类型化的拒单。两种渲染读同一份判据,不会各说各话。
8
+ * (原来抛的那个对象、那句话,io 是原样的 fs 错误),单读口 `FileConfigStore.read()`(16.0.0)把整个目录的读数渲染成 effective 或类型化的拒单。两种渲染读同一份判据,不会各说各话。
9
9
  *
10
10
  * 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
11
- * (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
11
+ * (`null` / 数组 / 数 / 串 / 布尔)、嵌得比整棵目录的内容版本能算的还深(16.0.0)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
12
12
  * governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)。三种坏形每种一句,点名**调用方寻址这份文档用的路径**与形(读的人给路径,
13
13
  * 判官不拼 `config.d/` 字面 —— 多个目录各有一份时,句子认得出是哪个 scope 的哪一份;文档是符号链接时点名链接这一端:句尾「delete it」删的是这个 scope 里的这一项,不是被几个 scope 共用的目标)、
14
14
  * 句尾出路按文档类({@link documentWayOut}),从不回显文件原文。
@@ -16,8 +16,8 @@
16
16
  import { readFile } from "node:fs/promises";
17
17
  import path from "node:path";
18
18
  import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
19
- import { stableHash } from "./hash.js";
20
- import { UNINSPECTABLE, guardedSafeParse } from "./own-key-read.js";
19
+ import { MAX_HASH_DEPTH, stableHash } from "./hash.js";
20
+ import { UNINSPECTABLE, guardedSafeParse, ownElementsOf, ownEntriesOf } from "./own-key-read.js";
21
21
  import { jsonShapeOf } from "./plain-json-object.js";
22
22
  import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
23
23
  import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
@@ -47,8 +47,47 @@ function documentWayOut(name) {
47
47
  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";
48
48
  }
49
49
  const messageOf = (thrown) => (thrown instanceof Error ? thrown.message : String(thrown));
50
+ /** 一份文档在内容版本的哈希里比快照的根深一层(快照 = `{ <文档名>: 文档 }`),所以文档自己最深嵌 {@link MAX_HASH_DEPTH} − 1 层(文档的根 = 第 0 层)——
51
+ * 再深一层,整棵目录的版本就算不出(16.0.0 之前它在算版本那一步抛,把已经收集的拒一并丢掉;现在它是这一份文档的文件层拒,别的拒照列)。 */
52
+ const MAX_CONFIG_D_DOCUMENT_DEPTH = MAX_HASH_DEPTH - 1;
53
+ /** 一份文档是否嵌得比 `limit` 深(根 = 第 0 层;迭代、遇超即停 —— 多深的文档都不会把调用栈压爆)。成员经包的自有键读法取(数据属性取值,访问器调它自己的 getter 一次,不问代理陷阱);
54
+ * 读不出的容器不往下量 —— 它不是「过深」,是读不出,由之后读它的那一步响亮拒(版本哈希、迁移的快照都对它抛)。 */
55
+ function nestsDeeperThan(value, limit) {
56
+ const stack = [[value, 0]];
57
+ const descend = (members, depth) => {
58
+ if (members.ok)
59
+ for (const [, child] of members.value)
60
+ stack.push([child, depth + 1]);
61
+ };
62
+ for (let top = stack.pop(); top !== undefined; top = stack.pop()) {
63
+ const [node, depth] = top;
64
+ if (depth > limit)
65
+ return true;
66
+ // 往下量的容器与版本哈希往下走的同一组(对象 —— 朴素或不朴素,函数除外 —— 与数组);叶子与读不出的不往下。
67
+ const shape = jsonShapeOf(node);
68
+ switch (shape) {
69
+ case "plain":
70
+ case "non_plain_object":
71
+ if (typeof node !== "function")
72
+ descend(ownEntriesOf(node), depth);
73
+ break;
74
+ case "array":
75
+ descend(ownElementsOf(node), depth);
76
+ break;
77
+ case "null":
78
+ case "primitive":
79
+ case "uninspectable":
80
+ break;
81
+ default: {
82
+ const unhandled = shape;
83
+ throw new Error(`config.d: unhandled shape ${String(unhandled)}`);
84
+ }
85
+ }
86
+ }
87
+ return false;
88
+ }
50
89
  /** 一个 `config.d/<名>.json` 在场时的**文本** → 读数(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 在场;空 / 纯空白、坏 JSON、
51
- * 不是对象 ⇒ 被拒,每种一个词一句话(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。 */
90
+ * 不是对象、嵌得过深({@link MAX_CONFIG_D_DOCUMENT_DEPTH})⇒ 被拒,每种一个词一句话(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。 */
52
91
  export function judgeConfigDTextReading(name, text, file) {
53
92
  const trimmed = text.trim();
54
93
  const refuse = (reason, thrown) => ({ status: "refused", reason, error: thrown.message, thrown, fingerprint: stableHash(trimmed) });
@@ -62,7 +101,7 @@ export function judgeConfigDTextReading(name, text, file) {
62
101
  return refuse("invalid_json", new Error(`invalid JSON in ${file} — ${documentWayOut(name)}`)); // sanitized: never echo the raw file text into the error
63
102
  }
64
103
  const flaw = configDValueFlaw(name, parsed, file);
65
- return flaw === undefined ? { status: "present", value: parsed } : refuse("not_object", flaw);
104
+ return flaw === undefined ? { status: "present", value: parsed } : refuse(flaw.reason, flaw.error);
66
105
  }
67
106
  /** 抛出口:一个 `config.d/<名>.json` 在场时的**文本** → 它的值:读数是被拒 ⇒ 抛它的 `thrown`(空 / 坏 JSON / 不是对象各一句)。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
68
107
  * 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
@@ -72,20 +111,25 @@ export function judgeConfigDText(name, text, file) {
72
111
  throw reading.thrown;
73
112
  return reading.value;
74
113
  }
75
- /** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
76
- * 非朴素对象 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
114
+ /** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个嵌得不过深的 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
115
+ * 非朴素对象、嵌得过深 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」「过深」各一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
77
116
  * `label` = 句首的称呼(文本路径给文件的真实路径,值路径给 `the <名> document`)。 */
78
117
  export function judgeConfigDValue(name, value, label) {
79
118
  const flaw = configDValueFlaw(name, value, label);
80
119
  if (flaw !== undefined)
81
- throw flaw;
120
+ throw flaw.error;
82
121
  return value;
83
122
  }
84
- /** {@link judgeConfigDValue} 的判据本体:值是一个朴素 JSON 对象 ⇒ `undefined`;不是 ⇒ 要抛的那个错(交回,不抛 —— 读数与抛出口各取所需)。 */
123
+ /** {@link judgeConfigDValue} 的判据本体:值是一个嵌得不过深的朴素 JSON 对象 ⇒ `undefined`;否则 ⇒ 拒因词与要抛的那个错(交回,不抛 —— 读数与抛出口各取所需)。 */
124
+ /** 过深那一句(点名文件 / 文档与层数上限,句尾出路)。 */
125
+ function tooDeep(name, label) {
126
+ return { reason: "too_deep", error: new Error(`${label} nests deeper than ${MAX_CONFIG_D_DOCUMENT_DEPTH} levels (the config snapshot holding it could not be versioned) — ${documentWayOut(name)}`) };
127
+ }
85
128
  function configDValueFlaw(name, value, label) {
86
129
  const shape = jsonShapeOf(value);
130
+ // 「朴素」那一臂:是 JSON 对象 ⇒ 只剩深度一问(过深 = 这一份文档的文件层拒)
87
131
  if (shape === "plain")
88
- return undefined;
132
+ return nestsDeeperThan(value, MAX_CONFIG_D_DOCUMENT_DEPTH) ? tooDeep(name, label) : undefined;
89
133
  // `shape` is narrowed to the five non-plain words: an exhaustive switch (a sixth word is a compile error here)
90
134
  let kind;
91
135
  switch (shape) {
@@ -99,7 +143,7 @@ function configDValueFlaw(name, value, label) {
99
143
  kind = typeof value === "function" ? "function" : "non-plain object";
100
144
  break;
101
145
  case "uninspectable": // 读不出(撤销的 Proxy / 陷阱抛)不是「不是对象」:调用方给的对象读不出是调用方的错 ⇒ `TypeError`(v14.6.0:引擎对它抛的就是 TypeError),句子带全包唯一那一句
102
- return new TypeError(`${label} must be a JSON object (got ${UNINSPECTABLE}) — ${documentWayOut(name)}`);
146
+ return { reason: "not_object", error: new TypeError(`${label} must be a JSON object (got ${UNINSPECTABLE}) — ${documentWayOut(name)}`) };
103
147
  case "primitive":
104
148
  kind = typeof value;
105
149
  break;
@@ -108,7 +152,7 @@ function configDValueFlaw(name, value, label) {
108
152
  throw new Error(`judgeConfigDValue: unhandled shape ${String(unhandled)}`);
109
153
  }
110
154
  }
111
- return new Error(`${label} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`);
155
+ return { reason: "not_object", error: new Error(`${label} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`) };
112
156
  }
113
157
  /** 读一个 `config.d/<名>.json` → 读数:不存在(ENOENT)⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出(非 ENOENT 的 fs 错误)⇒ 被拒,词 `io`。 */
114
158
  export async function readConfigDFileReading(root, name) {
@@ -132,7 +176,7 @@ export async function readConfigDFile(root, name) {
132
176
  throw reading.thrown;
133
177
  return reading.status === "present" ? reading.value : undefined;
134
178
  }
135
- /** 一棵 `config.d` 原文快照的内容版本(稳定的 u53 整数):同样的文档 ⇒ 同一个数。`FileConfigStore.getVersion()` 与 `inspectBundle` 读同一只。 */
179
+ /** 一棵 `config.d` 原文快照的内容版本(稳定的 u53 整数):同样的文档 ⇒ 同一个数。`FileConfigStore` 的 `getVersion()` / `getEffective()` / `read()` 读同一只。 */
136
180
  export function configDVersionOf(raws) {
137
181
  return parseInt(stableHash(raws).slice(0, 13), 16); // 13 hex digits ≤ 2^52, safely within u53
138
182
  }
@@ -1,26 +1,28 @@
1
1
  /**
2
- * 契约的文件层拒一份 `config.d` 文档时交出的**类型化拒**(15.2.0)。纯数据与类型,零 node 依赖、零运行期 import —— 词表在包根,读目录的口(`inspectBundle`)在 `./node`。
2
+ * 契约的文件层拒一份 `config.d` 文档时交出的**类型化拒**(15.2.0)。纯数据与类型,零 node 依赖、零运行期 import —— 词表在包根,读目录的口(`FileConfigStore.read()`,16.0.0)在 `./node`。
3
3
  *
4
4
  * ## 为什么
5
5
  * 文件层读一份文档只有抛一句话的出口:句子不带结构化的文档名,也分不出「空 / 坏 JSON / 不是对象 / 读不出」;并发读时一次只抛第一个到达的坏文档;
6
6
  * 带事实的retired 域文档(墓碑)抛的是 `RetiredDomainError`,而retired 域文档自己读不出时谁也归不了属。消费方(服务端的本地读腿)于是在严读抛了之后,
7
7
  * 再逐域重探、自己归属、自己铸「拒的身份」—— 契约的判据在别人那里有了第二份。这里把拒的**事实**交成数据:读法只有一种(文件读法的内核交结构化读数),
8
- * 旧的抛出口是它的渲染(抛的还是原来那个对象、那句话),`inspectBundle` 是它在整个目录上的渲染。
8
+ * 旧的抛出口是它的渲染(抛的还是原来那个对象、那句话),单读口 `FileConfigStore.read()` 是它在整个目录上的渲染(16.0.0 起;15.2.0 的 `inspectBundle` 只给拒单不给 effective,消费方因此要严读一次、
9
+ * 抛了再问它一次 —— 两读之间文档被修好,第二读的拒单是空的;它随单读口退役)。
9
10
  *
10
11
  * ## 形
11
12
  * 一条拒 = `{ document, reason, error }`:
12
13
  * · `document`:被拒的文档 —— 便携域名,或 retired 域名(retired 域的文档就以域名相称,墓碑拒的「域」就是这个名字,不另设第二个键);
13
- * · `reason`:闭集词 {@link CONFIG_DOCUMENT_REFUSAL_REASONS}。文件层四个 —— `empty`(空 / 纯空白:半写、截断)· `invalid_json`(JSON 解析不过)· `not_object`(解析出来不是 JSON 对象:
14
- * `null` / 数组 / 数 / 串 / 布尔)· `io`(在场却读不出:权限、路径是目录、I/O 错误;只有 ENOENT 是缺席,不是拒);第五个 `retired_domain` 是墓碑(retired 域文档带事实);
15
- * · `error`:契约的整句,与旧出口抛出来的那一句逐字同(`empty` / `invalid_json` / `not_object` 的句子点名文件的真实路径与形,从不回显文件原文;`io` 是 fs 错误的原话 ——
14
+ * · `reason`:闭集词 {@link CONFIG_DOCUMENT_REFUSAL_REASONS}。文件层五个 —— `empty`(空 / 纯空白:半写、截断)· `invalid_json`(JSON 解析不过)· `not_object`(解析出来不是 JSON 对象:
15
+ * `null` / 数组 / 数 / 串 / 布尔)· `io`(在场却读不出:权限、路径是目录、I/O 错误;只有 ENOENT 是缺席,不是拒)· `too_deep`(16.0.0:嵌套深过整棵目录的内容版本能算的深度 ——
16
+ * 此前它在算版本那一步抛,已经收集的拒一并丢掉);第六个 `retired_domain` 是墓碑(retired 域文档带事实);
17
+ * · `error`:契约的整句,与旧出口抛出来的那一句逐字同(`empty` / `invalid_json` / `not_object` / `too_deep` 的句子点名文件的真实路径与形,从不回显文件原文;`io` 是 fs 错误的原话 ——
16
18
  * 有的带真实路径(如 EACCES 的 `open '<路径>'`)、有的不带(EISDIR),要认是哪份文档读 `document`;调用方把 `error` 送出进程前自己脱敏);
17
19
  * · 墓碑拒另带 `migration`(一次性迁移的编号,与 `RetiredDomainError.migration` 同值)。
18
20
  * 词表是闭集:消费方用 `switch (refusal.reason)` 穷举,加词 ⇒ 它们编译红(这是通知)。
19
21
  */
20
22
  import type { PortableDomain } from "./portable-domains.js";
21
23
  import type { RetiredDomainName } from "./types.js";
22
- /** 文件层拒一份文档的闭集词:四个文件层成因 + 墓碑。墓碑那个词就是 `RetiredDomainError.code`(一个词,不立第二个名)。 */
23
- export declare const CONFIG_DOCUMENT_REFUSAL_REASONS: readonly ["empty", "invalid_json", "not_object", "io", "retired_domain"];
24
+ /** 文件层拒一份文档的闭集词:五个文件层成因 + 墓碑(恒在末位)。墓碑那个词就是 `RetiredDomainError.code`(一个词,不立第二个名)。 */
25
+ export declare const CONFIG_DOCUMENT_REFUSAL_REASONS: readonly ["empty", "invalid_json", "not_object", "io", "too_deep", "retired_domain"];
24
26
  export type ConfigDocumentRefusalReason = (typeof CONFIG_DOCUMENT_REFUSAL_REASONS)[number];
25
27
  /** 能被拒的 `config.d` 文档:便携域的文档,或retired 域的文档(读它只为墓碑)。 */
26
28
  export type ConfigDocumentName = PortableDomain | RetiredDomainName;
@@ -1,2 +1,2 @@
1
- /** 文件层拒一份文档的闭集词:四个文件层成因 + 墓碑。墓碑那个词就是 `RetiredDomainError.code`(一个词,不立第二个名)。 */
2
- export const CONFIG_DOCUMENT_REFUSAL_REASONS = ["empty", "invalid_json", "not_object", "io", "retired_domain"];
1
+ /** 文件层拒一份文档的闭集词:五个文件层成因 + 墓碑(恒在末位)。墓碑那个词就是 `RetiredDomainError.code`(一个词,不立第二个名)。 */
2
+ export const CONFIG_DOCUMENT_REFUSAL_REASONS = ["empty", "invalid_json", "not_object", "io", "too_deep", "retired_domain"];
@@ -0,0 +1,21 @@
1
+ /**
2
+ * device 车道的设备入口 `DEVICE_RELAY_URL` 的**值文法** —— 唯一判官(16.0.0)。纯函数,浏览器安全;只 import 零依赖的 URL 形判官模块。
3
+ *
4
+ * ## 规则(一条)
5
+ * 设备拨哪里是部署的必填声明,合法值只有两种:
6
+ * · 一个 ws(s) 绝对 URL —— 设备经中继 / 网关拨它。不带 userinfo / query / fragment:引擎把它**逐字**发在能力面上(设备拨号的地址),
7
+ * 没有放凭据的位置(设备凭签名鉴权);
8
+ * · 字面 {@link DEVICE_RELAY_NONE} —— 没有中继,设备按引擎自己的地址拨。只认这一种拼法(`None` / `NONE` 不是它)。
9
+ * 这里不判的两件,各有自己的判官:缺席 / 空白是车道表必需栏的事(写闸对每个必需坐标同一句,`isBlankCoordinate`);值里认得出凭据形的片段由写闸对
10
+ * 每个坐标值的那一只密钥判据拒。
11
+ *
12
+ * ## 谁读
13
+ * 写闸(`WorkerSpec` 的 `runtime.execution.coords.DEVICE_RELAY_URL`)、web-admin 表单与引擎起服问同一只;引擎把 `none` 读成「没有中继」。
14
+ */
15
+ import { type WsUrlFlaw } from "./url-shape.js";
16
+ /** 「没有中继,设备拨引擎自己的地址」的那个字面值。 */
17
+ export declare const DEVICE_RELAY_NONE = "none";
18
+ /** 规则句(不含值):写闸的拒句、表单的提示读这一句。 */
19
+ export declare const DEVICE_RELAY_RULE = "must be an absolute ws:// or wss:// URL with no userinfo, query or fragment (ws(s)://host[:port][/path]) \u2014 the address devices dial, published verbatim \u2014 or the literal \"none\" (no relay: devices dial this engine's own address)";
20
+ /** 判一个**非空白**的值:合格(`none`,或合格的 ws(s) URL)⇒ `[]`;否则按判序交回拒因词(`not-ws-url` / `userinfo` / `query` / `fragment`)。 */
21
+ export declare function deviceRelayFlaws(value: string): readonly WsUrlFlaw[];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * device 车道的设备入口 `DEVICE_RELAY_URL` 的**值文法** —— 唯一判官(16.0.0)。纯函数,浏览器安全;只 import 零依赖的 URL 形判官模块。
3
+ *
4
+ * ## 规则(一条)
5
+ * 设备拨哪里是部署的必填声明,合法值只有两种:
6
+ * · 一个 ws(s) 绝对 URL —— 设备经中继 / 网关拨它。不带 userinfo / query / fragment:引擎把它**逐字**发在能力面上(设备拨号的地址),
7
+ * 没有放凭据的位置(设备凭签名鉴权);
8
+ * · 字面 {@link DEVICE_RELAY_NONE} —— 没有中继,设备按引擎自己的地址拨。只认这一种拼法(`None` / `NONE` 不是它)。
9
+ * 这里不判的两件,各有自己的判官:缺席 / 空白是车道表必需栏的事(写闸对每个必需坐标同一句,`isBlankCoordinate`);值里认得出凭据形的片段由写闸对
10
+ * 每个坐标值的那一只密钥判据拒。
11
+ *
12
+ * ## 谁读
13
+ * 写闸(`WorkerSpec` 的 `runtime.execution.coords.DEVICE_RELAY_URL`)、web-admin 表单与引擎起服问同一只;引擎把 `none` 读成「没有中继」。
14
+ */
15
+ import { DIAL_ADDRESS_URL_FORBIDDEN_PARTS, wsUrlFlaws } from "./url-shape.js";
16
+ /** 「没有中继,设备拨引擎自己的地址」的那个字面值。 */
17
+ export const DEVICE_RELAY_NONE = "none";
18
+ /** 规则句(不含值):写闸的拒句、表单的提示读这一句。 */
19
+ export const DEVICE_RELAY_RULE = `must be an absolute ws:// or wss:// URL with no userinfo, query or fragment (ws(s)://host[:port][/path]) — the address devices dial, published verbatim — or the literal "${DEVICE_RELAY_NONE}" (no relay: devices dial this engine's own address)`;
20
+ /** 判一个**非空白**的值:合格(`none`,或合格的 ws(s) URL)⇒ `[]`;否则按判序交回拒因词(`not-ws-url` / `userinfo` / `query` / `fragment`)。 */
21
+ export function deviceRelayFlaws(value) {
22
+ return value === DEVICE_RELAY_NONE ? [] : wsUrlFlaws(value, DIAL_ADDRESS_URL_FORBIDDEN_PARTS);
23
+ }
@@ -1,5 +1,4 @@
1
1
  import { type EnvModelsDecision } from "./config-root-documents.js";
2
- import { SUMMARIZE_MODEL_MISSING_CODE } from "./fleet.js";
3
2
  import type { ModelRole } from "./types.js";
4
3
  /** 一份 env 快照(`process.env` 也满足这个形)。 */
5
4
  export type EnvSnapshot = Readonly<Record<string, string | undefined>>;
@@ -142,12 +141,6 @@ export type EnvModelTableWritePlan = {
142
141
  readonly receipt: EnvModelTableReceipt;
143
142
  readonly confirmKey: string;
144
143
  readonly intent: "absent" | "stale";
145
- }
146
- /** 不可确认:先改文档(summarize 要落到一条启用条目)。 */
147
- | {
148
- readonly kind: "fix_first";
149
- readonly code: typeof SUMMARIZE_MODEL_MISSING_CODE;
150
- readonly receipt: EnvModelTableReceipt;
151
144
  };
152
145
  /**
153
146
  * 写者在写 `config.d/models.json` 之前调的**计划**(判序见文件头)。纯函数:不读环境、不读盘、不改入参。
@@ -155,9 +148,9 @@ export type EnvModelTableWritePlan = {
155
148
  */
156
149
  export declare function planEnvModelTableWrite(input: EnvModelTableWriteInput): EnvModelTableWritePlan;
157
150
  /**
158
- * 计划停下来的那两种结局(`confirm` / `fix_first`)的**一句话**(三端同一句;只用回执里的 env 键名、目录名与模型 id —— 可披露的原样,秘密形的打成记号;凭据与别的 env 值从不进句子)。
151
+ * 计划停下来的那一种结局(`confirm`)的**一句话**(三端同一句;只用回执里的 env 键名、目录名与模型 id —— 可披露的原样,秘密形的打成记号;凭据与别的 env 值从不进句子)。
159
152
  * 写类结局不是拒 ⇒ `TypeError`。
160
153
  */
161
154
  export declare function envModelTableWriteRefusal(plan: Extract<EnvModelTableWritePlan, {
162
- readonly kind: "confirm" | "fix_first";
155
+ readonly kind: "confirm";
163
156
  }>): string;