@sema-agent/settings-schema 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +728 -0
- package/LICENSE +103 -0
- package/README.md +129 -0
- package/dist/api/auth-bridge.d.ts +331 -0
- package/dist/api/auth-bridge.js +210 -0
- package/dist/api/auth.d.ts +216 -0
- package/dist/api/auth.js +138 -0
- package/dist/api/scopes.d.ts +344 -0
- package/dist/api/scopes.js +222 -0
- package/dist/api/wire.d.ts +60 -0
- package/dist/api/wire.js +89 -0
- package/dist/bundle.d.ts +13 -0
- package/dist/bundle.js +67 -0
- package/dist/config-fns.d.ts +318 -0
- package/dist/config-fns.js +472 -0
- package/dist/cross-domain.d.ts +34 -0
- package/dist/cross-domain.js +118 -0
- package/dist/file-edit.d.ts +36 -0
- package/dist/file-edit.js +125 -0
- package/dist/file-store.d.ts +89 -0
- package/dist/file-store.js +238 -0
- package/dist/fleet.d.ts +498 -0
- package/dist/fleet.js +317 -0
- package/dist/hash.d.ts +32 -0
- package/dist/hash.js +59 -0
- package/dist/hooks.d.ts +5477 -0
- package/dist/hooks.js +627 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +26 -0
- package/dist/local-load.d.ts +42 -0
- package/dist/local-load.js +172 -0
- package/dist/migrate.d.ts +255 -0
- package/dist/migrate.js +542 -0
- package/dist/node.d.ts +11 -0
- package/dist/node.js +11 -0
- package/dist/reader.d.ts +18 -0
- package/dist/reader.js +1 -0
- package/dist/remote-exec.d.ts +274 -0
- package/dist/remote-exec.js +182 -0
- package/dist/resolve-roster.d.ts +28 -0
- package/dist/resolve-roster.js +108 -0
- package/dist/safety-merge-spec.d.ts +327 -0
- package/dist/safety-merge-spec.js +70 -0
- package/dist/scheduler-store-node.d.ts +72 -0
- package/dist/scheduler-store-node.js +119 -0
- package/dist/scheduler-store.d.ts +67 -0
- package/dist/scheduler-store.js +89 -0
- package/dist/secret-refs.d.ts +33 -0
- package/dist/secret-refs.js +48 -0
- package/dist/sha256.d.ts +16 -0
- package/dist/sha256.js +114 -0
- package/dist/skills-manifest.d.ts +12 -0
- package/dist/skills-manifest.js +54 -0
- package/dist/types.d.ts +13560 -0
- package/dist/types.js +2118 -0
- package/package.json +138 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, InfraCostRates, LimitsConfig, SkillsManifestSchema, containsSecretToken } from "./types.js";
|
|
2
|
+
/** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
|
|
3
|
+
* `received 'sk-live-…'`), so a secret pasted into the WRONG field would leak via the error string. Redacts
|
|
4
|
+
* space-spanning auth credentials (`Bearer <token>`) FIRST (the token pass can't see across the space), then
|
|
5
|
+
* any secret-shaped run (`%` kept in the run so a percent-encoded secret stays whole for the percent-aware
|
|
6
|
+
* containsSecretToken). invariant ①: a secret VALUE never rides an error. R13/R14. */
|
|
7
|
+
export function redactSecrets(text) {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/\b(bearer|basic)(\s+)\S+/gi, "$1$2[redacted-secret]")
|
|
10
|
+
.replace(/[A-Za-z0-9_./:+=%-]{6,}/g, (tok) => (containsSecretToken(tok) ? "[redacted-secret]" : tok));
|
|
11
|
+
}
|
|
12
|
+
/** Sanitize a ZodError IN PLACE so no surfaced error (message / `received` / nested `unionErrors`) echoes a
|
|
13
|
+
* pasted secret. RECURSES into `unionErrors` (a `z.union` failure nests a ZodError per member — e.g. RoleTarget
|
|
14
|
+
* `{model}|{select}`) and redacts string path segments too. The ONE sanitizer every validation entry point
|
|
15
|
+
* uses (parseDomain / validateDomain / validateRemoteExec / write*). R14. */
|
|
16
|
+
export function sanitizeZodError(err) {
|
|
17
|
+
for (const iss of err.issues) {
|
|
18
|
+
const rec = iss;
|
|
19
|
+
for (const k of Object.keys(rec)) {
|
|
20
|
+
const v = rec[k];
|
|
21
|
+
if (typeof v === "string")
|
|
22
|
+
rec[k] = redactSecrets(v);
|
|
23
|
+
else if (Array.isArray(v)) {
|
|
24
|
+
rec[k] = v.map((el) => {
|
|
25
|
+
if (typeof el === "string")
|
|
26
|
+
return redactSecrets(el);
|
|
27
|
+
if (el && typeof el === "object" && Array.isArray(el.issues))
|
|
28
|
+
return sanitizeZodError(el); // nested unionErrors
|
|
29
|
+
return el;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return err;
|
|
35
|
+
}
|
|
36
|
+
export const ROLE_RANK = { viewer: 0, editor: 1, publisher: 2, admin: 3 };
|
|
37
|
+
export const USER_ROLES = ["viewer", "editor", "publisher", "admin"];
|
|
38
|
+
/** True if `role` is at least `min` in the hierarchy (e.g. publisher satisfies editor). */
|
|
39
|
+
export function roleAtLeast(role, min) {
|
|
40
|
+
return ROLE_RANK[role] >= ROLE_RANK[min];
|
|
41
|
+
}
|
|
42
|
+
export function isRole(v) {
|
|
43
|
+
return typeof v === "string" && USER_ROLES.includes(v);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Minimum role to WRITE a domain. Default = `editor`; `skills` and `mcp` are raised to `publisher`
|
|
47
|
+
* because their content goes straight into the agent's system prompt / tool surface — one edit retargets
|
|
48
|
+
* the prompt of every worker × scenario that loads it (docs/MCP-SKILLS.md §3.3, decision §6 Q3 从严).
|
|
49
|
+
* Looked up by the [domain] PUT route. Any domain absent here uses {@link DEFAULT_DOMAIN_WRITE_ROLE}.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_DOMAIN_WRITE_ROLE = "editor";
|
|
52
|
+
export const DOMAIN_WRITE_ROLE = {
|
|
53
|
+
skills: "publisher",
|
|
54
|
+
mcp: "publisher",
|
|
55
|
+
// plugins are an EXECUTABLE surface (commands/hooks/MCP ride in via the materialized plugin) — the same
|
|
56
|
+
// raised gate as skills/mcp (C2, 产品拍板: 可执行面同档).
|
|
57
|
+
plugins: "publisher",
|
|
58
|
+
// scenarios carry SYSTEM-PROMPT-level text + can SHADOW built-in scenario names (产品拍板 ①②) — a stronger
|
|
59
|
+
// injection surface than skills, so at least the same gate.
|
|
60
|
+
scenarios: "publisher",
|
|
61
|
+
// systems drive CREDENTIAL minting/delivery/rotation (S2/S3) — the credential lifecycle is ≥ the skills tier.
|
|
62
|
+
systems: "publisher",
|
|
63
|
+
// access: DELETED in 0.6.0 (域整删,EXPERT-REDESIGN §9)。
|
|
64
|
+
// governance (治理三件,0.6.0 从 runtime 拆出): editor(EXPERT-REDESIGN §5 domainWriteRole 同步——与
|
|
65
|
+
// runtime 残余同档,default 即 editor,故不列条目;此注释即该裁定的落点)。
|
|
66
|
+
// entitlement governs who-can-USE-what (models/skills/mcp/budget/runtime caps) per principal — a governance +
|
|
67
|
+
// security surface (budget ceilings, bypass kill-switch) on par with systems. Same raised gate.
|
|
68
|
+
entitlement: "publisher",
|
|
69
|
+
// prompts (0.10.19, board [1069]③/[1050]②d): declaration text goes STRAIGHT into the system prompt of
|
|
70
|
+
// every worker×scenario the binding covers — the same injection surface (and rationale) as skills/mcp,
|
|
71
|
+
// so the same raised gate. Center had pre-raised this locally per governance precedent; this row is the
|
|
72
|
+
// contract-side source of truth so every consumer of the shared write gate gets publisher, not editor.
|
|
73
|
+
prompts: "publisher",
|
|
74
|
+
// execution (0.8.0): editor — same档 as governance (治理旋钮同类;真安全边界在 service gate,
|
|
75
|
+
// client-advisory 面不值 publisher 档)。default 即 editor,故不列条目;此注释即该裁定的落点。
|
|
76
|
+
// projects (0.10.0, 142-S3): editor — 登记簿是身份索引不是注入面(无 prompt/凭据/可执行内容);
|
|
77
|
+
// mint/claim 动作端点与编辑面同档(草案 §3 "该 scope editor+")。default 即 editor,故不列条目。
|
|
78
|
+
// limits (0.19.0): editor —— 与 runtime(它的限额残余前身)同档,刻意不抬 publisher:本域是纯数值
|
|
79
|
+
// 旋钮,无 prompt 注入面、无凭据、无可执行内容;真正的执法点在消费方的限额闸,发布闸(PUBLISH_MODE /
|
|
80
|
+
// 审批流)才是"要不要多一道人"的属主。default 即 editor,故不列条目;此注释即该裁定的落点。
|
|
81
|
+
};
|
|
82
|
+
/** The minimum role required to write `domain` (the raised gate for skills/mcp, else editor). */
|
|
83
|
+
export function domainWriteRole(domain) {
|
|
84
|
+
return DOMAIN_WRITE_ROLE[domain] ?? DEFAULT_DOMAIN_WRITE_ROLE;
|
|
85
|
+
}
|
|
86
|
+
/** The empty/defaults effective config at version 0 — what publish mode serves before the first publish.
|
|
87
|
+
* Seeds the epoch (`new Date(0)`) so it's deterministic (the wall-clock default was the original store.ts
|
|
88
|
+
* non-determinism — see the file header). */
|
|
89
|
+
export function emptyEffective() {
|
|
90
|
+
return buildEffective(0, () => undefined, new Date(0).toISOString());
|
|
91
|
+
}
|
|
92
|
+
/** Validate + fill defaults for a domain payload (or the schema's empty default when absent/invalid). */
|
|
93
|
+
export function parseDomain(domain, raw) {
|
|
94
|
+
// ONLY true absence (undefined) defaults; an explicit `null` (e.g. a `config.d/<d>.json` containing JSON
|
|
95
|
+
// `null`) is corrupt input → let zod reject it ("expected object, received null"), not silently default. R4.
|
|
96
|
+
let r;
|
|
97
|
+
try {
|
|
98
|
+
r = DOMAIN_SCHEMAS[domain].safeParse(raw === undefined ? {} : raw);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// a hostile getter/Proxy trap threw during traversal — its message may carry a secret; never surface it. R13.
|
|
102
|
+
throw new Error(`invalid "${domain}" config (a value could not be read during validation)`);
|
|
103
|
+
}
|
|
104
|
+
if (r.success)
|
|
105
|
+
return r.data;
|
|
106
|
+
throw sanitizeZodError(r.error); // recursive redaction (message/received/unionErrors) — no secret echo. R13/R14.
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Shared GC predicate for worker-status rows (F10) — keeps memory/sqlite/tidb identical. Given the rows'
|
|
110
|
+
* `{name, updatedAt}` and the GC options, returns the names to prune (A: not in `keepNames`; B: older than
|
|
111
|
+
* `ttlMs`). Pass neither selector → prunes nothing (guards against a wipe-all). See `ConfigStore.gcWorkerStatuses`.
|
|
112
|
+
*/
|
|
113
|
+
export function statusRowsToPrune(rows, opts) {
|
|
114
|
+
if (opts.keepNames === undefined && opts.ttlMs === undefined)
|
|
115
|
+
return [];
|
|
116
|
+
const keep = opts.keepNames ? new Set(opts.keepNames) : undefined;
|
|
117
|
+
const cutoff = opts.ttlMs !== undefined ? (opts.now ?? new Date()).getTime() - opts.ttlMs : undefined;
|
|
118
|
+
return rows
|
|
119
|
+
.filter((r) => (keep !== undefined && !keep.has(r.name)) || (cutoff !== undefined && Date.parse(r.updatedAt) < cutoff))
|
|
120
|
+
.map((r) => r.name);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Merge every domain into the effective config a consumer pulls. Shared by all store impls.
|
|
124
|
+
* `updatedAt` is REQUIRED (the determinism refactor — the original store.ts defaulted it to the wall clock,
|
|
125
|
+
* which made `emptyEffective`/the memory store non-deterministic). Every caller now supplies it explicitly.
|
|
126
|
+
*/
|
|
127
|
+
/** Lift the governance trio out of a LEGACY runtime doc (pre-0.6 stored runtime carried autonomy/commandPolicy/
|
|
128
|
+
* approvalRequire). Used as the governance domain's read fallback when governance was NEVER set — so a 0.5
|
|
129
|
+
* store upgrades with zero data migration and zero gate loss. An explicitly-set governance domain WINS
|
|
130
|
+
* (its absence-of-a-key then means "center not managing", never "fall back to legacy runtime"). */
|
|
131
|
+
export function legacyGovernanceFromRuntime(rawRuntime) {
|
|
132
|
+
if (rawRuntime === null || typeof rawRuntime !== "object")
|
|
133
|
+
return undefined;
|
|
134
|
+
const r = rawRuntime;
|
|
135
|
+
const lifted = {};
|
|
136
|
+
for (const k of GOVERNANCE_MIRROR_KEYS)
|
|
137
|
+
if (r[k] !== undefined)
|
|
138
|
+
lifted[k] = r[k];
|
|
139
|
+
return Object.keys(lifted).length ? lifted : undefined;
|
|
140
|
+
}
|
|
141
|
+
/** 扫描深度上限(嵌套再深的配置文档不存在;有上限才不会被恶意深链拖住)。 */
|
|
142
|
+
const DROP_SCAN_MAX_DEPTH = 8;
|
|
143
|
+
/** 一条警告最多点名多少个键(告警载荷有界;截断即说明,见 {@link droppedKeys})。 */
|
|
144
|
+
const DROP_SCAN_MAX_KEYS = 64;
|
|
145
|
+
/** 纯 JSON 对象(不是数组、不是类实例/带原型的东西)——只对这种做键集对比,别的形状交给 parse 判。 */
|
|
146
|
+
function isPlainJsonObject(v) {
|
|
147
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
148
|
+
return false;
|
|
149
|
+
const proto = Object.getPrototypeOf(v);
|
|
150
|
+
return proto === Object.prototype || proto === null;
|
|
151
|
+
}
|
|
152
|
+
function collectDropped(raw, parsed, prefix, depth, spared, out) {
|
|
153
|
+
if (depth > DROP_SCAN_MAX_DEPTH || out.length >= DROP_SCAN_MAX_KEYS)
|
|
154
|
+
return;
|
|
155
|
+
if (Array.isArray(raw) && Array.isArray(parsed)) {
|
|
156
|
+
// 长度不同 = 整条元素被丢/被裁(hosts 的 grandfather clamp 有自己的警告),不在本判据内,别造重复噪声。
|
|
157
|
+
if (raw.length !== parsed.length)
|
|
158
|
+
return;
|
|
159
|
+
for (let i = 0; i < raw.length; i++)
|
|
160
|
+
collectDropped(raw[i], parsed[i], `${prefix}${i}.`, depth + 1, [], out);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!isPlainJsonObject(raw) || !isPlainJsonObject(parsed))
|
|
164
|
+
return;
|
|
165
|
+
for (const k of Object.keys(raw)) {
|
|
166
|
+
if (out.length >= DROP_SCAN_MAX_KEYS)
|
|
167
|
+
return;
|
|
168
|
+
if (depth === 0 && spared.includes(k))
|
|
169
|
+
continue;
|
|
170
|
+
if (!Object.prototype.hasOwnProperty.call(parsed, k)) {
|
|
171
|
+
out.push(prefix + k);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
collectDropped(raw[k], parsed[k], `${prefix}${k}.`, depth + 1, spared, out);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`),点号路径形。
|
|
179
|
+
*
|
|
180
|
+
* 成案(sema-server #322 真事故):往 `runtime` 文档里加一个不属于该域的键,两条腿都会把它剥掉,
|
|
181
|
+
* `domainErrors` 空、解析"成功"——配置写了、值没到、零告警。剥除行为本身是**刻意的前向兼容**
|
|
182
|
+
* (新 center 的新键不该炸老消费方),所以修法不是收紧成 `.strict()`,而是让它**响亮**:
|
|
183
|
+
* {@link buildEffective} 每域解析成功后调用本函数,非空即发 `"unknown-keys-dropped"` 警告。
|
|
184
|
+
*
|
|
185
|
+
* **嵌套也扫**(codex 复审 F2 真 finding):`{infraCostRates:{toolCalMicroUsd:10}}` 这种**拼错的嵌套
|
|
186
|
+
* 键**在顶层看不见(`infraCostRates` 键还在),只看顶层等于对计费面的错键完全失明。故按 raw↔parsed
|
|
187
|
+
* 的键集逐层对比(对象逐键、数组同长时逐下标),不依赖任何 zod 内省。
|
|
188
|
+
*
|
|
189
|
+
* `runtime` 域的治理三件({@link GOVERNANCE_MIRROR_KEYS})**不算被丢**:0.5 期 runtime 文档携带它们是
|
|
190
|
+
* 成文的抬升位(本文件的 governance 抬升函数会把它们抬进 governance 域),报出来是噪声。豁免只在顶层。
|
|
191
|
+
*
|
|
192
|
+
* 边界(如实登记):① 深度上限 {@link DROP_SCAN_MAX_DEPTH}、键数上限 {@link DROP_SCAN_MAX_KEYS}(告警
|
|
193
|
+
* 载荷有界,超出即少报);② 数组长度不同就整段跳过(元素级裁剪由各自的 clamp 告警负责);③ 只比对纯
|
|
194
|
+
* JSON 对象——raw 若是类实例/Proxy 这类形状不扫;④ 读 raw 的值可能触发访问器,整段包 try/catch 且**绝不**
|
|
195
|
+
* 让其错误外泄(错误文案可能带 secret,与 parseDomain 的 R13 同纪律),扫描失败即当作"没看见",不影响解析。
|
|
196
|
+
*/
|
|
197
|
+
export function droppedKeys(domain, raw, parsed) {
|
|
198
|
+
const out = [];
|
|
199
|
+
try {
|
|
200
|
+
collectDropped(raw, parsed, "", 0, domain === "runtime" ? GOVERNANCE_MIRROR_KEYS : [], out);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return []; // 反射/访问器抛错:告警是尽力而为的审计线,绝不能把解析拖下水,更不能回显其错误文案
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* open-world 域(`.passthrough()`)的**已知键表**:路径 → 该层已知键(`""` = 顶层)。键集从 schema 的
|
|
209
|
+
* `shape` **单源派生**(手抄一份必然漂),路径表本身是显式的——嵌套只在真有 open-world 子对象的地方登记。
|
|
210
|
+
*
|
|
211
|
+
* 为什么需要它(codex 复审 round2 F2):承运解决了「值到不了」,但没解决「键名拼错了没人说」——
|
|
212
|
+
* `toolResultsTtlSec`(多一个 s)会完好穿过本包,而消费方只遍历自己的键表,于是那条天花板悄悄没生效。
|
|
213
|
+
* 承运 + 点名两件一起做:值照送(消费方有权认新键),名照报(拼错的有人说)。
|
|
214
|
+
*
|
|
215
|
+
* 新开 open-world 域必须来这里登记 —— `test/silent-drop-gate-0.19.test.ts` 有一条门:任何
|
|
216
|
+
* `unknownKeys === "passthrough"` 的域 schema 都必须在本表里有行(缺行 = 该域的未知键无人点名)。
|
|
217
|
+
*/
|
|
218
|
+
const OPEN_WORLD_KNOWN_KEYS = {
|
|
219
|
+
limits: { "": Object.keys(LimitsConfig.shape), infraCostRates: Object.keys(InfraCostRates.shape) },
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* 一份已解析的 open-world 域文档里,**本包不认识但已承运**的键(点号路径)。非 open-world 域恒空
|
|
223
|
+
* (它们的未知键走 {@link droppedKeys} 那条更重的「被丢了」告警)。
|
|
224
|
+
*
|
|
225
|
+
* 边界(如实):只看 {@link OPEN_WORLD_KNOWN_KEYS} 登记了的层级 —— 未登记层级(如某个未知键自己的
|
|
226
|
+
* 子树)不再下探,那本就是消费方自定义的内容,逐层猜没有意义。
|
|
227
|
+
*/
|
|
228
|
+
export function carriedUnknownKeys(domain, parsed) {
|
|
229
|
+
const table = OPEN_WORLD_KNOWN_KEYS[domain];
|
|
230
|
+
if (!table || !isPlainJsonObject(parsed))
|
|
231
|
+
return [];
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const [path, known] of Object.entries(table)) {
|
|
234
|
+
const node = path === "" ? parsed : parsed[path];
|
|
235
|
+
if (!isPlainJsonObject(node))
|
|
236
|
+
continue;
|
|
237
|
+
const prefix = path === "" ? "" : `${path}.`;
|
|
238
|
+
for (const k of Object.keys(node)) {
|
|
239
|
+
if (out.length >= DROP_SCAN_MAX_KEYS)
|
|
240
|
+
return out;
|
|
241
|
+
if (!known.includes(k))
|
|
242
|
+
out.push(prefix + k);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 一域的 parse + **静默剥键/静默承运两条告警**(0.19.0)。`buildEffective` 与 `FileConfigStore.getDomain`
|
|
249
|
+
* 共用这一个判据 —— 此前只有 effective 那条路径有门,而 `getDomain` 是 `exportBundle` 的读法
|
|
250
|
+
* (codex 复审 round2 F3:导出一份被剥了键的域再导入 = 持久的配置丢失,而且全程无声)。
|
|
251
|
+
*/
|
|
252
|
+
export function parseDomainLoud(domain, raw, onWarning) {
|
|
253
|
+
const parsed = parseDomain(domain, raw);
|
|
254
|
+
if (onWarning) {
|
|
255
|
+
const dropped = droppedKeys(domain, raw, parsed);
|
|
256
|
+
if (dropped.length > 0)
|
|
257
|
+
onWarning({ domain, kind: "unknown-keys-dropped", keys: dropped });
|
|
258
|
+
const carried = carriedUnknownKeys(domain, parsed);
|
|
259
|
+
if (carried.length > 0)
|
|
260
|
+
onWarning({ domain, kind: "unknown-keys-carried", keys: carried });
|
|
261
|
+
}
|
|
262
|
+
return parsed;
|
|
263
|
+
}
|
|
264
|
+
/** Per-domain READ-corruption policy (0.8.1) — what buildEffective does when a stored domain no longer parses.
|
|
265
|
+
* `"default"` = degrade to the schema default + warn (safe: the default only REMOVES capability, fail-closed).
|
|
266
|
+
* `"throw"` = keep fail-loud (defaulting would LOOSEN gates or destructively apply — worse than an outage,
|
|
267
|
+
* consumers fail-static on last-good). A `satisfies Record<DomainName,…>` forces every FUTURE domain to make
|
|
268
|
+
* this call explicitly (compile error until it's classified). Per-domain rationale:
|
|
269
|
+
* - models/skills/mcp/plugins/scenarios/systems/collab: additive capability catalogs — empty = the
|
|
270
|
+
* capability disappears (fail-closed), no gate is lost.
|
|
271
|
+
* - hosts: empty registry = no auto-placement targets (availability-only); self-registration re-lands
|
|
272
|
+
* entries as approved:false = 待批 (fail-closed). Plus the precise grandfather clamp runs FIRST.
|
|
273
|
+
* - rosters: THROW — a lost roster makes resolveEffectiveForWorker fall back to the FULL models catalog
|
|
274
|
+
* ("global(fallback)"), silently WIDENING every restricted worker's model access. 放权,不许.
|
|
275
|
+
* - workers: THROW — an empty fleet spec would make the reconciler RETIRE every managed worker
|
|
276
|
+
* (destructive apply — far worse than a read outage).
|
|
277
|
+
* - runtime: THROW — defaulting drops centrally-published rate/cost ceilings = unbounded spend (fail-open
|
|
278
|
+
* on the budget axis).
|
|
279
|
+
* - governance: THROW — defaulting drops autonomy/commandPolicy/approvalRequire = the consumer falls back
|
|
280
|
+
* to env/defaults with the operator's guardrails silently gone. 放权,不许.
|
|
281
|
+
* - entitlement: THROW — defaulting drops the org kill-switch/budget ceilings/tier restrictions for every
|
|
282
|
+
* principal. 放权,不许.
|
|
283
|
+
* - execution: THROW — defaulting flips required:true (sandbox mandate) back to false. 放权,不许.
|
|
284
|
+
* (0.10.1: sessionMirror rides the SAME stance — defaulting would evaporate an org's required:true
|
|
285
|
+
* mirror mandate = 审计面静默放权; the shared rationale is exactly WHY it was收编 here, not a new domain.)
|
|
286
|
+
* - projects: THROW — defaulting evaporates the whole identity ledger: once the S4 worker gate ships,
|
|
287
|
+
* every `proj:*` derivation fails-fast (全租户记忆面停摆 — consumers fail-static on last-good is
|
|
288
|
+
* strictly better); WORSE, the claim write face is read-modify-write — reading a defaulted-empty
|
|
289
|
+
* ledger and writing it back would CLOBBER existing registrations down to one entry (data loss,
|
|
290
|
+
* one layer beyond the rosters "放权" rationale). */
|
|
291
|
+
export const DOMAIN_READ_FALLBACK = {
|
|
292
|
+
models: "default",
|
|
293
|
+
rosters: "throw",
|
|
294
|
+
skills: "default",
|
|
295
|
+
mcp: "default",
|
|
296
|
+
plugins: "default",
|
|
297
|
+
scenarios: "default",
|
|
298
|
+
systems: "default",
|
|
299
|
+
collab: "default",
|
|
300
|
+
workers: "throw",
|
|
301
|
+
hosts: "default",
|
|
302
|
+
runtime: "throw",
|
|
303
|
+
governance: "throw",
|
|
304
|
+
entitlement: "throw",
|
|
305
|
+
execution: "throw",
|
|
306
|
+
projects: "throw",
|
|
307
|
+
// prompts:内容域(scenarios 同类)——坏形→空默认(=「无 prompts 管理」当年语义,web [1064]②
|
|
308
|
+
// backfill-empty-default 同口径);fail-closed 由 PUBLISH_MODE 发布闸承担,default 不扩权
|
|
309
|
+
// (空 bindings=不下发任何内容)。
|
|
310
|
+
prompts: "default",
|
|
311
|
+
// limits(0.19.0):THROW —— 与 runtime 逐字同判据(本域就是 runtime 限额残余的继任发布位)。坏形
|
|
312
|
+
// 降级成空域 = 中央发布的成本/限流天花板整片蒸发,消费方回自己的 env 底(可能宽得多)= 预算轴放权。
|
|
313
|
+
// 消费方 fail-static 在上一份好配置严格更好。
|
|
314
|
+
limits: "throw",
|
|
315
|
+
};
|
|
316
|
+
/** READ-path grandfather clamp for the 0.8.0 hosts tightening (0.8.1, S3 M-1). Clamps ONLY the two bounds
|
|
317
|
+
* 0.8.0 added (name > 63 → drop the entry; array > 1024 → slice) so 0.7-era legal stored data keeps reading;
|
|
318
|
+
* everything else (regex, types, duplicates) is left for parseDomain — this is a targeted grandfather, not a
|
|
319
|
+
* lenient parser. PURE + idempotent; returns the ORIGINAL reference when nothing exceeds the bounds. */
|
|
320
|
+
export function grandfatherHostsRead(raw) {
|
|
321
|
+
if (raw === null || typeof raw !== "object" || !Array.isArray(raw.hosts)) {
|
|
322
|
+
return { raw, droppedNames: [] }; // not the expected shape — let parseDomain rule on it
|
|
323
|
+
}
|
|
324
|
+
const original = raw.hosts;
|
|
325
|
+
const droppedNames = [];
|
|
326
|
+
let hosts = original.filter((h) => {
|
|
327
|
+
const name = h !== null && typeof h === "object" ? h.name : undefined;
|
|
328
|
+
if (typeof name === "string" && name.length > HOST_NAME_MAX) {
|
|
329
|
+
droppedNames.push(name.slice(0, HOST_NAME_MAX) + "…"); // audit label — clipped so the warning itself stays bounded
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
});
|
|
334
|
+
let truncatedFrom;
|
|
335
|
+
if (hosts.length > HOSTS_ARRAY_MAX) {
|
|
336
|
+
truncatedFrom = hosts.length;
|
|
337
|
+
hosts = hosts.slice(0, HOSTS_ARRAY_MAX);
|
|
338
|
+
}
|
|
339
|
+
if (droppedNames.length === 0 && truncatedFrom === undefined)
|
|
340
|
+
return { raw, droppedNames }; // identity — byte-stable
|
|
341
|
+
return { raw: { ...raw, hosts }, droppedNames, truncatedFrom };
|
|
342
|
+
}
|
|
343
|
+
export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
344
|
+
// 0.19.0 静默剥键/静默承运门:解析**成功**的域,把 schema 丢掉的键、以及 open-world 域里承运但本包
|
|
345
|
+
// 不认识的键(都含嵌套,点号路径)点名(行为不变,只是不再无声)。判据与 `getDomain` 同源
|
|
346
|
+
// ({@link parseDomainLoud})。只在成功路径上报——降级路径已经有 domain-defaulted 那条更重的警告。
|
|
347
|
+
const parseLoud = (domain, raw) => parseDomainLoud(domain, raw, onWarning);
|
|
348
|
+
// Generic READ guardrail (0.8.1): a domain that no longer parses degrades to its schema default + a warning
|
|
349
|
+
// — but ONLY where DOMAIN_READ_FALLBACK says the default is safe; gate-bearing domains keep throwing (决策
|
|
350
|
+
// 表与逐域理由见 DOMAIN_READ_FALLBACK). The success path is UNCHANGED (same parseDomain, same bytes).
|
|
351
|
+
const parseOr = (domain, raw) => {
|
|
352
|
+
try {
|
|
353
|
+
return parseLoud(domain, raw);
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
if (DOMAIN_READ_FALLBACK[domain] === "throw")
|
|
357
|
+
throw e;
|
|
358
|
+
onWarning?.({ domain, kind: "domain-defaulted", error: e });
|
|
359
|
+
return parseDomain(domain, undefined); // schema default — always valid
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
// governance TRUTH: the domain itself; if it was never set, lift the trio from a legacy runtime doc
|
|
363
|
+
// (0.5→0.6 upgrade path — a2/local stores keep their gates without a data migration).
|
|
364
|
+
const governanceRaw = getRaw("governance");
|
|
365
|
+
const governance = parseLoud("governance", governanceRaw === undefined ? legacyGovernanceFromRuntime(getRaw("runtime")) : governanceRaw);
|
|
366
|
+
// runtime slot = limit residue ONLY. 0.7.1(BREAKING,service 回执收账):E1 的 governance→runtime
|
|
367
|
+
// 六闸双写镜像已撤——service 1.150.0 已切优先读 eff.governance 并回帖确认(撤双写以其确认为门槛,
|
|
368
|
+
// ask⑤ 顺序契约兑现)。READ 方向的 legacy lift(governance 域从未设时从旧 runtime 文档抬升三闸,
|
|
369
|
+
// 见 legacyGovernanceFromRuntime)保留——那是 0.5→0.6 升级路径,与写出镜像无关。
|
|
370
|
+
const runtime = parseLoud("runtime", getRaw("runtime"));
|
|
371
|
+
// hosts: the PRECISE grandfather for the 0.8.0 tightening runs BEFORE parse (0.7-era legal data over the
|
|
372
|
+
// new bounds reads on, clamped + warned); any residual corruption falls to the generic guardrail above.
|
|
373
|
+
const hostsGf = grandfatherHostsRead(getRaw("hosts"));
|
|
374
|
+
if (hostsGf.droppedNames.length > 0 || hostsGf.truncatedFrom !== undefined) {
|
|
375
|
+
onWarning?.({ domain: "hosts", kind: "hosts-grandfathered", droppedNames: hostsGf.droppedNames, truncatedFrom: hostsGf.truncatedFrom });
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
version,
|
|
379
|
+
updatedAt,
|
|
380
|
+
models: parseOr("models", getRaw("models")),
|
|
381
|
+
rosters: parseLoud("rosters", getRaw("rosters")),
|
|
382
|
+
skills: parseOr("skills", getRaw("skills")),
|
|
383
|
+
mcp: parseOr("mcp", getRaw("mcp")),
|
|
384
|
+
plugins: parseOr("plugins", getRaw("plugins")),
|
|
385
|
+
scenarios: parseOr("scenarios", getRaw("scenarios")),
|
|
386
|
+
systems: parseOr("systems", getRaw("systems")),
|
|
387
|
+
collab: parseOr("collab", getRaw("collab")),
|
|
388
|
+
workers: parseLoud("workers", getRaw("workers")),
|
|
389
|
+
hosts: parseOr("hosts", hostsGf.raw),
|
|
390
|
+
runtime,
|
|
391
|
+
governance,
|
|
392
|
+
entitlement: parseLoud("entitlement", getRaw("entitlement")),
|
|
393
|
+
// execution (0.8.0, S3): plain scope-level parse — MIRRORS governance (no overlay/merge invented here).
|
|
394
|
+
execution: parseLoud("execution", getRaw("execution")),
|
|
395
|
+
// projects (0.10.0, 142-S3): THROW policy → parseLoud(≡ parseDomain + 剥键告警), never parseOr
|
|
396
|
+
// (defaulting would evaporate the identity ledger AND expose the claim read-modify-write face to
|
|
397
|
+
// clobbering stored registrations).
|
|
398
|
+
projects: parseLoud("projects", getRaw("projects")),
|
|
399
|
+
// prompts (0.10.18, [1064]②): content domain → parseOr(坏形回空默认=「无 prompts 管理」语义;
|
|
400
|
+
// fail-closed 由 PUBLISH_MODE 发布闸承担,空 bindings 不下发内容,default 不扩权)。
|
|
401
|
+
prompts: parseOr("prompts", getRaw("prompts")),
|
|
402
|
+
// limits (0.19.0, sema-server #322 批1): THROW 档 → parseLoud(坏形绝不静默降级成「没有天花板」)。
|
|
403
|
+
limits: parseLoud("limits", getRaw("limits")),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/** READ-side tolerant judgment for a GET /effective **wire payload**(the {@link EffectiveWire} contract)at
|
|
407
|
+
* its CONSUMPTION boundary — a service pulling from a remote center passes the parsed JSON here instead of
|
|
408
|
+
* type-asserting it(0.12.0,[2281] 裁B:「两个契约共用一个拼写不是一个被检查的契约」——拼写副本要在
|
|
409
|
+
* 消费端被真校验)。ONE judgment, two legs: every known domain goes through the SAME `parseDomain` +
|
|
410
|
+
* {@link DOMAIN_READ_FALLBACK} policy as {@link buildEffective}(catalog 域坏形 → schema default + warning;
|
|
411
|
+
* gate 域坏形 → throw,caller 的整包 catch 决定回落),including the governance legacy-lift and the hosts
|
|
412
|
+
* read-grandfather — DELEGATED to buildEffective, not re-implemented(单判据双腿消费,§M4)。
|
|
413
|
+
*
|
|
414
|
+
* `skills` is the ONE wire divergence: on the wire it is a MANIFEST(content → contentHash,
|
|
415
|
+
* {@link SkillsManifest}),so the full skills-domain schema would reject every LEGAL manifest and silently
|
|
416
|
+
* default the domain(蒸发整张 skill 清单)— it validates against {@link SkillsManifestSchema} instead,
|
|
417
|
+
* same "default" fallback tier as the skills domain itself.
|
|
418
|
+
*
|
|
419
|
+
* OPEN-WORLD on unknown top-level keys: they ride through VERBATIM(never validated, never dropped)— a
|
|
420
|
+
* newer center's new domain reaches an older service untouched(§M2 能力面语义:读不到即走老路,不该炸),
|
|
421
|
+
* and the legacy `teams` key(not a domain since 0.7, services still consume it for backward compat)keeps
|
|
422
|
+
* flowing. Throws ONLY on garbage no caller can partially trust: a non-object payload, or a missing/
|
|
423
|
+
* non-finite `version` — that is not "a bad domain", it is not an effective config at all. */
|
|
424
|
+
export function readEffectiveWire(raw, onWarning) {
|
|
425
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
426
|
+
throw new Error("effective wire payload is not a JSON object");
|
|
427
|
+
const r = raw;
|
|
428
|
+
if (typeof r.version !== "number" || !Number.isFinite(r.version))
|
|
429
|
+
throw new Error("effective wire payload has no numeric version");
|
|
430
|
+
const updatedAt = typeof r.updatedAt === "string" ? r.updatedAt : "";
|
|
431
|
+
// skills redirected to undefined: buildEffective must never see the manifest(it would fail the full-domain
|
|
432
|
+
// schema and "default" it with a FALSE domain-defaulted warning);the manifest is judged separately below.
|
|
433
|
+
// prompts likewise redirected(#100):on the wire it is the center-assembled RESOLVED form(worker lane
|
|
434
|
+
// strips the bindings storage table — types.ts PromptsConfig 注 [1057]⑤),consumer-validated additive JSON;
|
|
435
|
+
// the storage-domain schema would swallow it to {bindings:[]} with ZERO warning(zod non-strict 剥键后
|
|
436
|
+
// "成功")— 3.24.0 起 server 侧 promptsGate 恒败的根因。verbatim,by presence(见 return)。
|
|
437
|
+
const built = buildEffective(r.version, (d) => (d === "skills" || d === "prompts" ? undefined : r[d]), updatedAt, onWarning);
|
|
438
|
+
let skills;
|
|
439
|
+
const manifest = SkillsManifestSchema.safeParse(r.skills ?? {});
|
|
440
|
+
if (manifest.success) {
|
|
441
|
+
skills = manifest.data;
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
onWarning?.({ domain: "skills", kind: "domain-defaulted", error: sanitizeZodError(manifest.error) });
|
|
445
|
+
skills = { skills: [] };
|
|
446
|
+
}
|
|
447
|
+
// Open-world passthrough: keys that are neither validated domains nor the envelope fields ride verbatim.
|
|
448
|
+
const passthrough = {};
|
|
449
|
+
for (const [k, v] of Object.entries(r)) {
|
|
450
|
+
if (!(k in built) && k !== "skills")
|
|
451
|
+
passthrough[k] = v;
|
|
452
|
+
}
|
|
453
|
+
// prompts:剥掉 buildEffective 恒物化的 {bindings:[]} 底座,按 wire 在场性 verbatim 挂回(#100:
|
|
454
|
+
// 缺席进缺席出——center worker-lane 闸关是常态形,物化会让消费端「缺席」分支永不可达)。
|
|
455
|
+
const { prompts: _promptsMaterialized, ...builtSansPrompts } = built;
|
|
456
|
+
return { ...passthrough, ...builtSansPrompts, skills, ...("prompts" in r ? { prompts: r.prompts } : {}) };
|
|
457
|
+
}
|
|
458
|
+
/** 档位组→引擎单表的唯一解析点(0.9.0)。返回 ACTIVE 档位组的绑定表(剔除未绑档),即 core
|
|
459
|
+
* `RunnerDeps.tiers` 要吃的那张单表——组切换=center 换这张表下发,零新引擎机制。无 active 组/未命中/
|
|
460
|
+
* 组内一档未绑 → `undefined`(引擎档位层恒惰性,与「未配 tiers」逐字同义)。消费方(center /effective
|
|
461
|
+
* 投影、service applyEffective 接线)都走这一个函数,不要各自解析。 */
|
|
462
|
+
export function resolveActiveTiers(models) {
|
|
463
|
+
if (!models.activeTierGroup)
|
|
464
|
+
return undefined;
|
|
465
|
+
const group = (models.tierGroups ?? []).find((g) => g.name === models.activeTierGroup);
|
|
466
|
+
if (!group)
|
|
467
|
+
return undefined; // schema 已 fail-loud 悬空 active;这里兜底旧数据,静默=INERT 与缺表同义
|
|
468
|
+
const bound = Object.entries(group.tiers).filter((e) => typeof e[1] === "string" && e[1].length > 0);
|
|
469
|
+
if (bound.length === 0)
|
|
470
|
+
return undefined;
|
|
471
|
+
return Object.fromEntries(bound);
|
|
472
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-domain referential integrity — the PURE half, shared by the single-domain PUT and the batch write so
|
|
3
|
+
* BOTH enforce the same rules (docs/MULTI-ROSTER.md §6). The FLEET-coupled `applyAutoPlacement` (which imports
|
|
4
|
+
* `../fleet/placement`) STAYS in sema-registry — it's not pure and not portable. This package carries only
|
|
5
|
+
* `refIntegrityIssues` + `siblingResolver` + the ref-issue types.
|
|
6
|
+
*
|
|
7
|
+
* BATCH-AWARE: a sibling domain (the models a roster references, the rosters a worker references) may be
|
|
8
|
+
* changing in the SAME batch. So checks resolve siblings through a {@link SiblingResolver} that prefers a
|
|
9
|
+
* value being written NOW over the stored one — otherwise "add a model AND a roster that uses it" in one
|
|
10
|
+
* batch would wrongly fail. The single-domain PUT passes no pending writes, so its resolver is just the
|
|
11
|
+
* reader (behaviour unchanged).
|
|
12
|
+
*/
|
|
13
|
+
import type { ConfigReader } from "./reader.js";
|
|
14
|
+
import type { DomainConfig } from "./types.js";
|
|
15
|
+
export interface RefIssue {
|
|
16
|
+
path: (string | number)[];
|
|
17
|
+
message: string;
|
|
18
|
+
code: string;
|
|
19
|
+
}
|
|
20
|
+
/** Resolve a sibling domain's effective value for integrity checks. */
|
|
21
|
+
export type SiblingResolver = <K extends keyof DomainConfig>(domain: K) => Promise<DomainConfig[K] | undefined>;
|
|
22
|
+
/** A resolver over the reader, optionally shadowed by pending (same-operation) writes keyed by domain.
|
|
23
|
+
* Widened from the fat ConfigStore to the narrow {@link ConfigReader} (it only calls `getDomain`) so a TOC
|
|
24
|
+
* file source can use it without the full store. */
|
|
25
|
+
export declare function siblingResolver(store: ConfigReader, pending?: Partial<{
|
|
26
|
+
[K in keyof DomainConfig]: DomainConfig[K];
|
|
27
|
+
}>): SiblingResolver;
|
|
28
|
+
/** The domains whose writes carry cross-domain model/roster references we validate at save time. */
|
|
29
|
+
export type RefCheckedDomain = "rosters" | "workers" | "models" | "collab" | "systems" | "entitlement";
|
|
30
|
+
/** Cross-domain ref integrity for a rosters/workers/models/collab/systems value. Paths start with the domain
|
|
31
|
+
* key (e.g. `["rosters", i, "models", j]`) so a caller can prefix a batch index. Empty = ok. Runtime
|
|
32
|
+
* resolution stays tolerant (falls back); this is purely a save-time guard — a precise 400 at save time is
|
|
33
|
+
* friendlier than a silently-wrong roster. */
|
|
34
|
+
export declare function refIntegrityIssues(domain: RefCheckedDomain, value: unknown, resolve: SiblingResolver): Promise<RefIssue[]>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** A resolver over the reader, optionally shadowed by pending (same-operation) writes keyed by domain.
|
|
2
|
+
* Widened from the fat ConfigStore to the narrow {@link ConfigReader} (it only calls `getDomain`) so a TOC
|
|
3
|
+
* file source can use it without the full store. */
|
|
4
|
+
export function siblingResolver(store, pending) {
|
|
5
|
+
return async (domain) => {
|
|
6
|
+
if (pending && Object.prototype.hasOwnProperty.call(pending, domain))
|
|
7
|
+
return pending[domain];
|
|
8
|
+
return store.getDomain(domain);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Cross-domain ref integrity for a rosters/workers/models/collab/systems value. Paths start with the domain
|
|
12
|
+
* key (e.g. `["rosters", i, "models", j]`) so a caller can prefix a batch index. Empty = ok. Runtime
|
|
13
|
+
* resolution stays tolerant (falls back); this is purely a save-time guard — a precise 400 at save time is
|
|
14
|
+
* friendlier than a silently-wrong roster. */
|
|
15
|
+
export async function refIntegrityIssues(domain, value, resolve) {
|
|
16
|
+
const issues = [];
|
|
17
|
+
if (domain === "rosters") {
|
|
18
|
+
const catalog = new Set(((await resolve("models"))?.models ?? []).map((m) => m.name));
|
|
19
|
+
value.rosters.forEach((r, i) => {
|
|
20
|
+
r.models.forEach((name, j) => {
|
|
21
|
+
if (!catalog.has(name))
|
|
22
|
+
issues.push({ path: ["rosters", i, "models", j], message: `roster "${r.name}" references unknown model "${name}" (not in the models catalog)`, code: "custom" });
|
|
23
|
+
});
|
|
24
|
+
for (const [role, target] of Object.entries(r.roles ?? {})) {
|
|
25
|
+
if (target && "model" in target && !catalog.has(target.model))
|
|
26
|
+
issues.push({ path: ["rosters", i, "roles", role], message: `roster "${r.name}" role.${role} → unknown model "${target.model}"`, code: "custom" });
|
|
27
|
+
}
|
|
28
|
+
// 0.6.3 markers (primaryModel/cheapModel) reference the catalog like a {model}-form role does — same
|
|
29
|
+
// save-time guard (a dangling marker would silently derive nothing at resolution).
|
|
30
|
+
if (r.primaryModel && !catalog.has(r.primaryModel))
|
|
31
|
+
issues.push({ path: ["rosters", i, "primaryModel"], message: `roster "${r.name}" primaryModel → unknown model "${r.primaryModel}" (not in the models catalog)`, code: "custom" });
|
|
32
|
+
if (r.cheapModel && !catalog.has(r.cheapModel))
|
|
33
|
+
issues.push({ path: ["rosters", i, "cheapModel"], message: `roster "${r.name}" cheapModel → unknown model "${r.cheapModel}" (not in the models catalog)`, code: "custom" });
|
|
34
|
+
(r.atModelAllowlist ?? []).forEach((name, j) => {
|
|
35
|
+
if (!catalog.has(name))
|
|
36
|
+
issues.push({ path: ["rosters", i, "atModelAllowlist", j], message: `roster "${r.name}" atModelAllowlist "${name}" not in the models catalog`, code: "custom" });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else if (domain === "workers") {
|
|
41
|
+
const rosterNames = new Set(((await resolve("rosters"))?.rosters ?? []).map((r) => r.name));
|
|
42
|
+
value.workers.forEach((w, i) => {
|
|
43
|
+
if (w.roster && !rosterNames.has(w.roster))
|
|
44
|
+
issues.push({ path: ["workers", i, "roster"], message: `worker "${w.name}" references unknown roster "${w.roster}"`, code: "custom" });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
else if (domain === "models") {
|
|
48
|
+
// The roles map + @-allowlist reference models in THIS payload's OWN catalog (they ship together in the
|
|
49
|
+
// models domain), so validate against `value`, not the stored/sibling models.
|
|
50
|
+
const m = value;
|
|
51
|
+
const catalog = new Set(m.models.map((x) => x.name));
|
|
52
|
+
for (const [role, target] of Object.entries(m.roles ?? {})) {
|
|
53
|
+
if (target && "model" in target && !catalog.has(target.model))
|
|
54
|
+
issues.push({ path: ["models", "roles", role], message: `models.roles.${role} → unknown model "${target.model}" (not in the catalog)`, code: "custom" });
|
|
55
|
+
}
|
|
56
|
+
(m.atModelAllowlist ?? []).forEach((name, j) => {
|
|
57
|
+
if (!catalog.has(name))
|
|
58
|
+
issues.push({ path: ["models", "atModelAllowlist", j], message: `atModelAllowlist "${name}" not in the models catalog`, code: "custom" });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (domain === "collab") {
|
|
62
|
+
// collab (0.7.0, replaces teams): a slot member's `modelRef` is a models-CATALOG name (T1 — never a
|
|
63
|
+
// roster, never a gateway id) and must resolve against the catalog (sibling — batch-aware). ABSENT
|
|
64
|
+
// modelRef is the legal "跟随用户当前模型" state, not a dangle. This is the SAVE-time guard; a model
|
|
65
|
+
// deleted/renamed LATER still dangles → the web renders the 悬空 destructive badge (§11-6), same as
|
|
66
|
+
// groupBindings.
|
|
67
|
+
const catalog = new Set(((await resolve("models"))?.models ?? []).map((x) => x.name));
|
|
68
|
+
value.templates.forEach((t, i) => {
|
|
69
|
+
t.slots.forEach((s, j) => {
|
|
70
|
+
s.members.forEach((mem, k) => {
|
|
71
|
+
if (mem.modelRef && !catalog.has(mem.modelRef))
|
|
72
|
+
issues.push({ path: ["templates", i, "slots", j, "members", k, "modelRef"], message: `collab template "${t.id}" slot "${s.id}" → unknown model "${mem.modelRef}" (not in the catalog)`, code: "custom" });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else if (domain === "systems") {
|
|
78
|
+
// systems.callsWorkers → workers domain (a dangling name would mint a credential routed at nobody;
|
|
79
|
+
// resolution stays tolerant — this is the save-time guard, same as the other ref-checked domains).
|
|
80
|
+
const workers = new Set((await resolve("workers"))?.workers.map((w) => w.name) ?? []);
|
|
81
|
+
value.systems.forEach((sys, i) => {
|
|
82
|
+
sys.callsWorkers.forEach((name, j) => {
|
|
83
|
+
if (!workers.has(name))
|
|
84
|
+
issues.push({ path: ["systems", i, "callsWorkers", j], message: `system "${sys.name}" references unknown worker "${name}"`, code: "custom" });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else if (domain === "entitlement") {
|
|
89
|
+
// A tier references FIVE sibling domains by name (roster→rosters, models[]→models catalog, visibleSkills→
|
|
90
|
+
// skills, visibleMcp→mcp, visiblePlugins→plugins). A dangling ref otherwise resolves silently (e.g. a typo'd roster → resolve-
|
|
91
|
+
// entitlement yields an empty set → the principal is silently denied) — a precise 400 at save time is
|
|
92
|
+
// friendlier, matching the rosters/workers guards.
|
|
93
|
+
const rosterNames = new Set(((await resolve("rosters"))?.rosters ?? []).map((r) => r.name));
|
|
94
|
+
const modelCatalog = new Set(((await resolve("models"))?.models ?? []).map((m) => m.name));
|
|
95
|
+
const skillNames = new Set((await resolve("skills"))?.skills.map((s) => s.name) ?? []);
|
|
96
|
+
const mcpNames = new Set((await resolve("mcp"))?.servers.map((s) => s.name) ?? []);
|
|
97
|
+
const pluginNames = new Set((await resolve("plugins"))?.plugins.map((p) => p.name) ?? []);
|
|
98
|
+
value.tiers.forEach((t, i) => {
|
|
99
|
+
if (t.roster && !rosterNames.has(t.roster))
|
|
100
|
+
issues.push({ path: ["tiers", i, "roster"], message: `tier "${t.id}" references unknown roster "${t.roster}"`, code: "custom" });
|
|
101
|
+
(t.models ?? []).forEach((name, j) => { if (!modelCatalog.has(name))
|
|
102
|
+
issues.push({ path: ["tiers", i, "models", j], message: `tier "${t.id}" model "${name}" not in the models catalog`, code: "custom" }); });
|
|
103
|
+
(t.visibleSkills ?? []).forEach((name, j) => { if (!skillNames.has(name))
|
|
104
|
+
issues.push({ path: ["tiers", i, "visibleSkills", j], message: `tier "${t.id}" visibleSkill "${name}" not in skills`, code: "custom" }); });
|
|
105
|
+
(t.visibleMcp ?? []).forEach((name, j) => { if (!mcpNames.has(name))
|
|
106
|
+
issues.push({ path: ["tiers", i, "visibleMcp", j], message: `tier "${t.id}" visibleMcp "${name}" not in mcp`, code: "custom" }); });
|
|
107
|
+
(t.visiblePlugins ?? []).forEach((name, j) => { if (!pluginNames.has(name))
|
|
108
|
+
issues.push({ path: ["tiers", i, "visiblePlugins", j], message: `tier "${t.id}" visiblePlugin "${name}" not in plugins`, code: "custom" }); });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Exhaustiveness guard: a new RefCheckedDomain MUST add its own branch above, not silently inherit another
|
|
113
|
+
// domain's validation. `never` makes that a compile error; the throw is the runtime backstop.
|
|
114
|
+
const unhandled = domain;
|
|
115
|
+
throw new Error(`refIntegrityIssues: unhandled domain "${String(unhandled)}"`);
|
|
116
|
+
}
|
|
117
|
+
return issues;
|
|
118
|
+
}
|