@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
package/dist/types.js
ADDED
|
@@ -0,0 +1,2118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config schema — the single source of truth for what the config center manages.
|
|
3
|
+
*
|
|
4
|
+
* Organized into DOMAINS (one top-level menu each). The UI, the store, and the service-facing API all
|
|
5
|
+
* speak these types. Use zod both to validate UI input AND to derive the TS types (one definition).
|
|
6
|
+
*
|
|
7
|
+
* Detail level is deliberate (the user asked for "详略设计"): `models` is the MVP — fully specified —
|
|
8
|
+
* while `scenarios` / `runtime` carry a real-but-skeletal schema to be fleshed out in later phases
|
|
9
|
+
* (see docs/ROADMAP.md; `integrations`/`access` were deleted in 0.6.0, EXPERT-REDESIGN §9). Adding a
|
|
10
|
+
* field is: extend the zod schema here → the form + the effective-config API pick it up.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ SECURITY BOUNDARY (see docs/SERVICE-INTEGRATION.md): the config center stores **non-secret**
|
|
13
|
+
* config only. A model's API key / a backend token is NEVER stored here as a value — we store the NAME
|
|
14
|
+
* of the env var that holds it (`apiKeyEnv`, `tokenEnv`), and the agent-service reads the real secret
|
|
15
|
+
* from its own environment. So a leak of this DB never leaks a credential. This is **enforced** by the
|
|
16
|
+
* `ENV_NAME` schema (regex) on every `*Env` field — a pasted secret value is rejected, not just discouraged.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { MAX_EXTRA_BODY_DEPTH } from "./hash.js"; // reconcile the extraBody depth cap with the hash cap (R18)
|
|
20
|
+
import { DispatchPolicy } from "./fleet.js"; // WorkerSpec.fleet.dispatch (scheduling framework D3)
|
|
21
|
+
// COARSE, best-effort literal-secret SHAPE detector (NOT exhaustive — the real boundary is the env-NAME schema
|
|
22
|
+
// on structured fields). Rejected in free-form value fields (worker `env`, model `extraBody`), URL components,
|
|
23
|
+
// AND as an env-NAME value (an AWS key id is a valid env-NAME charset yet is a secret VALUE, not a NAME). Case-
|
|
24
|
+
// INSENSITIVE (HTTP auth schemes are). sk-/GitHub/Slack/JWT/AWS AKIA/Google AIza/PEM/Basic. Round 2-5.
|
|
25
|
+
// `-----BEGIN[A-Z0-9 ]*PRIVATE KEY` matches ONLY private-key PEMs (BEGIN [RSA/EC/OPENSSH/ENCRYPTED ]PRIVATE KEY)
|
|
26
|
+
// — a PUBLIC KEY / CERTIFICATE is NOT a secret and must NOT be flagged (PRINCIPAL_JWT_PUBKEYS lives in env).
|
|
27
|
+
// 0.1.32: this is the root fix for the public-key false-positive — replaces the 0.1.31 name-based exemption,
|
|
28
|
+
// which review flagged as a bypass (a private key misnamed `*_PUBKEY` skipped scanning entirely).
|
|
29
|
+
export const SECRET_LITERAL_RE = /^(sk-|gh[opus]_|github_pat_|xox[baprse]-|xapp-|bearer\s|basic\s|eyJ[A-Za-z0-9_-]{10,}\.|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|-----BEGIN[A-Z0-9 ]*PRIVATE KEY)/i;
|
|
30
|
+
// 所有 *Env / *tokenEnv 字段的值是宿主机 env 变量的 NAME(不是值)。合法 env 变量名 + 拒绝 secret 形状的值(AKIA…
|
|
31
|
+
// 这类是合法 env-NAME 字符集却是密钥)—— "只存 env-NAME、绝不存 secret 值" 由 SCHEMA 保证。Round-5。
|
|
32
|
+
export const ENV_NAME = z
|
|
33
|
+
.string()
|
|
34
|
+
.regex(/^[A-Z_][A-Z0-9_]*$/, "must be an ENV var NAME (A-Z, 0-9, _; not a value)")
|
|
35
|
+
// Most SECRET_LITERAL_RE shapes (sk-/bearer/eyJ./AIza/-----BEGIN) can't be env-NAMEs anyway (dash/space/dot/
|
|
36
|
+
// lowercase — the charset above rejects them). The ONE secret shape that IS a valid all-caps env-NAME charset
|
|
37
|
+
// is an AWS access-key id (AKIA…) — block exactly that, WITHOUT false-rejecting legit names like
|
|
38
|
+
// GITHUB_PAT_TOKEN / GHU_TOKEN (which the full SECRET_LITERAL_RE would). Round-5.
|
|
39
|
+
.refine((s) => !/^AKIA[0-9A-Z]{12,}$/.test(s), "looks like an AWS access-key id, not an env-NAME — put the NAME of the env var here (the value lives in .env)");
|
|
40
|
+
function scanForSecret(s) {
|
|
41
|
+
if (SECRET_LITERAL_RE.test(s))
|
|
42
|
+
return true;
|
|
43
|
+
// HTTP auth scheme + an opaque token, at a token boundary. SECRET_LITERAL_RE's `bearer\s`/`basic\s` only fire
|
|
44
|
+
// at the STRING start; the tokenizer below splits the scheme from its token (the space is a delimiter), so a
|
|
45
|
+
// mid-string `Authorization: Bearer <opaque-OAuth-token>` (no sk-/eyJ prefix) slipped through. Bounded
|
|
46
|
+
// {8,4096} → ReDoS-safe. The space is the last delimiter-spanning shape (sk-/gh_/AKIA keep their `-`/`_` in
|
|
47
|
+
// the token; the JWT dot is handled above). R12. */
|
|
48
|
+
if (/(?:^|[^A-Za-z0-9_-])(?:bearer|basic)[ \t]+\S{8,4096}/i.test(s))
|
|
49
|
+
return true;
|
|
50
|
+
// a JWT (3 dot-joined base64url segments, header starts `eyJ`) searched anywhere — so a mid-string one
|
|
51
|
+
// survives the tokenizer's dot-split. ReDoS-SAFE: a token-boundary prefix `(?:^|[^A-Za-z0-9_-])` (one start
|
|
52
|
+
// per word, so `"eyJ".repeat(n)` is a single anchored start = linear, not the quadratic an unanchored scan
|
|
53
|
+
// gave) + BOUNDED {n,2048} quantifiers. Requiring TWO dots (3 segments) avoids the false positive a 1-dot
|
|
54
|
+
// `eyJ…json` (base64 + a file extension) caused. Round-8/9 (fixed the ReDoS + the false positive).
|
|
55
|
+
if (/(?:^|[^A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,2048}\.[A-Za-z0-9_-]{8,2048}\.[A-Za-z0-9_-]{4,2048}/i.test(s))
|
|
56
|
+
return true;
|
|
57
|
+
// Tokenize on ANY non-[alnum_-] char (incl `.` `"` `,` `;` `[](){}|` …) so a secret embedded in JSON/quoted/
|
|
58
|
+
// dotted/comma punctuation (`{"apiKey":"sk-…"}`, `host.sk-…`, `a,sk-…`) is isolated as its OWN token, then
|
|
59
|
+
// anchored-test each (keeping `-` in the token set so `sk-…` stays whole; a benign `task-key` stays one token
|
|
60
|
+
// that doesn't start with a secret prefix → no false positive). Round-8. prior set missed `."',;[](){}`.
|
|
61
|
+
return s.split(/[^A-Za-z0-9_-]+/).some((tok) => tok !== "" && SECRET_LITERAL_RE.test(tok));
|
|
62
|
+
}
|
|
63
|
+
/** A NUL byte in a string the consumer feeds to spawn()/fs is a FAIL-OPEN config: it validates+persists here
|
|
64
|
+
* but Node throws at RUNTIME — `ERR_INVALID_ARG_VALUE` for argv, "must be a string without null bytes" for an
|
|
65
|
+
* fs path — so the worker dies at exec/boot, not at config edit. A NUL is never legitimate in a command / path
|
|
66
|
+
* / host / image, so reject it on every exec-bound string field (mcp stdio + the whole remoteExec contract). R20. */
|
|
67
|
+
export const hasNul = (s) => s.includes("\0");
|
|
68
|
+
/** Does `s` contain a secret-shaped token? Scans the raw string AND a percent-decoded form (a secret can hide
|
|
69
|
+
* as `%73%6B-…`) — so mcp args / dockerHost / URL components all get uniform percent-decode coverage. Tokenized
|
|
70
|
+
* + JWT-aware (see scanForSecret). Shared by NON_SECRET_URL + dockerHost + mcp stdio. Round-4/5/11. */
|
|
71
|
+
export function containsSecretToken(s) {
|
|
72
|
+
// scan the raw string AND up to 3 percent-decoded layers (handles a multi-encoded `%2573%256B…` → `%73%6B…`
|
|
73
|
+
// → `sk-…`). TOLERANT decode: each valid %HH is decoded, a malformed escape (`%ZZ`, `%` at end) is left as-is
|
|
74
|
+
// so one bad escape elsewhere can't abort the whole decode (R13's all-or-nothing decodeURIComponent did). We
|
|
75
|
+
// do NOT fail-closed on a bare `%` — that would false-reject legit values like `100%` / `%s`. A secret glued
|
|
76
|
+
// to a malformed escape (`%ZZsk-`) is an accepted coarse-heuristic gap (no consumer reconstructs it; the real
|
|
77
|
+
// guarantee is the env-NAME boundary on structured fields). Bounded → deterministic. R11/R13/R14. */
|
|
78
|
+
let cur = s;
|
|
79
|
+
for (let i = 0; i < 4; i++) {
|
|
80
|
+
if (scanForSecret(cur))
|
|
81
|
+
return true;
|
|
82
|
+
const next = cur.replace(/%[0-9A-Fa-f]{2}/g, (m) => {
|
|
83
|
+
try {
|
|
84
|
+
return decodeURIComponent(m);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return m;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
if (next === cur)
|
|
91
|
+
break;
|
|
92
|
+
cur = next;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
/** Scan a URL's PATH + query + fragment (KEYS and values, decoded, tokenized) for a secret-shaped literal.
|
|
97
|
+
* Userinfo is handled separately (NON_SECRET_URL bans it; dockerHost scans the ssh user). Round-4/5. */
|
|
98
|
+
export function urlQueryFragmentHasSecret(u) {
|
|
99
|
+
let pathname = u.pathname;
|
|
100
|
+
try {
|
|
101
|
+
pathname = decodeURIComponent(u.pathname);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* malformed %-escape → use raw */
|
|
105
|
+
}
|
|
106
|
+
if (containsSecretToken(pathname))
|
|
107
|
+
return true; // a secret can sit in a path segment (`/sk-live-…/route`)
|
|
108
|
+
for (const [k, v] of u.searchParams.entries())
|
|
109
|
+
if (containsSecretToken(k) || containsSecretToken(v))
|
|
110
|
+
return true;
|
|
111
|
+
const frag = u.hash.startsWith("#") ? u.hash.slice(1) : u.hash;
|
|
112
|
+
if (frag) {
|
|
113
|
+
let decoded = frag;
|
|
114
|
+
try {
|
|
115
|
+
decoded = decodeURIComponent(frag.replace(/\+/g, " "));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* malformed %-escape → test the raw form */
|
|
119
|
+
}
|
|
120
|
+
if (containsSecretToken(decoded))
|
|
121
|
+
return true;
|
|
122
|
+
for (const [k, v] of new URLSearchParams(frag).entries())
|
|
123
|
+
if (containsSecretToken(k) || containsSecretToken(v))
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
// A URL that must NOT embed credentials in userinfo (`https://user:pass@host`) or smuggle a secret-shaped value
|
|
129
|
+
// into the query/fragment — those would store a secret VALUE in the config + ship it on /effective (defeating
|
|
130
|
+
// the env-NAME boundary). Must be an http(s) URL with a host (the scheme check rejects file://host/… /
|
|
131
|
+
// javascript://host/… that DO carry a host). Round 2-5 hardening.
|
|
132
|
+
export const NON_SECRET_URL = z
|
|
133
|
+
.string()
|
|
134
|
+
.url()
|
|
135
|
+
.refine((s) => {
|
|
136
|
+
try {
|
|
137
|
+
const u = new URL(s);
|
|
138
|
+
if (!(u.protocol === "http:" || u.protocol === "https:") || !u.host || u.username || u.password)
|
|
139
|
+
return false;
|
|
140
|
+
return !urlQueryFragmentHasSecret(u);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return true; // already passed .url(); unreachable in practice
|
|
144
|
+
}
|
|
145
|
+
}, "must be an http(s) URL with a host and NO credentials in the userinfo/query/fragment — secrets go in the *Env (env-NAME) fields");
|
|
146
|
+
/** Validate a free-form `extraBody`: returns true = UNSAFE (reject). A GETTER-FREE, descriptor-based, bounded
|
|
147
|
+
* walk that rejects — WITHOUT ever invoking a getter (a malicious getter could throw a secret-bearing message,
|
|
148
|
+
* or return different values across the validate vs persist JSON.stringify calls) — anything that isn't plain
|
|
149
|
+
* JSON-safe + secret-free: a literal-secret KEY or string value, a non-finite number, undefined/function/
|
|
150
|
+
* symbol/bigint, a non-plain container (Map/Set/Date/class / a `toJSON`-bearing object — proto ≠ Object.prototype),
|
|
151
|
+
* a sparse array, an ACCESSOR property, a cycle, or a pathologically deep/large value. Because a passing value
|
|
152
|
+
* is proven plain JSON (no toJSON/getters), the stored original == what JSON.stringify persists — closing the
|
|
153
|
+
* non-idempotent-toJSON / accessor-leak / non-plain-container bypasses. Round 2-5 hardening. */
|
|
154
|
+
export function extraBodyUnsafe(v) {
|
|
155
|
+
const stack = [[v, 0]];
|
|
156
|
+
const seen = new WeakSet();
|
|
157
|
+
let budget = 100_000;
|
|
158
|
+
while (stack.length) {
|
|
159
|
+
if (budget-- <= 0)
|
|
160
|
+
return true; // too large to vet → fail-closed
|
|
161
|
+
const [cur, depth] = stack.pop();
|
|
162
|
+
if (depth > MAX_EXTRA_BODY_DEPTH)
|
|
163
|
+
return true; // too deep to hash later — cap = hash cap MINUS the wrapper reserve, since extraBody is always hashed NESTED (R18: a value passing at the raw hash cap would still overflow once wrapped → write fail-open + read hard-DoS)
|
|
164
|
+
if (cur === null)
|
|
165
|
+
continue;
|
|
166
|
+
const t = typeof cur;
|
|
167
|
+
if (t === "string") {
|
|
168
|
+
if (containsSecretToken(cur))
|
|
169
|
+
return true; // tokenized (mid-string secret too) — sibling of URL/dockerHost/mcp. R8
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (t === "number") {
|
|
173
|
+
if (!Number.isFinite(cur))
|
|
174
|
+
return true; // Infinity/NaN → JSON null on persist
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (t === "boolean")
|
|
178
|
+
continue;
|
|
179
|
+
if (t !== "object")
|
|
180
|
+
return true; // undefined / function / symbol / bigint
|
|
181
|
+
if (seen.has(cur))
|
|
182
|
+
return true; // cycle → reject (would also break stableStringify)
|
|
183
|
+
seen.add(cur);
|
|
184
|
+
if (Array.isArray(cur)) {
|
|
185
|
+
if (cur.length !== Object.keys(cur).length)
|
|
186
|
+
return true; // sparse array (holes → null on persist)
|
|
187
|
+
// descriptor walk for arrays too (NOT `for…of`/index access — that would INVOKE an accessor index's
|
|
188
|
+
// getter, which could throw a secret-bearing message). Reject accessor elements. R11.
|
|
189
|
+
for (const [k, desc] of Object.entries(Object.getOwnPropertyDescriptors(cur))) {
|
|
190
|
+
if (k === "length")
|
|
191
|
+
continue; // the array's own `length` data prop — not an element
|
|
192
|
+
if (!("value" in desc))
|
|
193
|
+
return true; // accessor element — do NOT invoke
|
|
194
|
+
stack.push([desc.value, depth + 1]);
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const proto = Object.getPrototypeOf(cur);
|
|
199
|
+
if (proto !== Object.prototype && proto !== null)
|
|
200
|
+
return true; // Map/Set/Date/class/toJSON-bearing → not plain
|
|
201
|
+
// descriptor walk: never read a value via [] (would invoke a getter); reject accessors outright.
|
|
202
|
+
for (const [k, desc] of Object.entries(Object.getOwnPropertyDescriptors(cur))) {
|
|
203
|
+
if (!("value" in desc))
|
|
204
|
+
return true; // accessor (get/set) — do NOT invoke
|
|
205
|
+
if (containsSecretToken(k))
|
|
206
|
+
return true; // secret-shaped KEY (tokenized). R8
|
|
207
|
+
stack.push([desc.value, depth + 1]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
/** A k8s resource quantity ("250m", "1", "0.5", "512Mi", "1Gi", "128M") — catches garbage at config-submit
|
|
213
|
+
* rather than surfacing as a reconcile ApplyError at the apiserver (consistent with ENV_NAME/DURATION guards). */
|
|
214
|
+
export const K8S_QUANTITY = z.string().regex(/^\d+(\.\d+)?(m|[kKMGTPE]i?)?$/, 'must be a k8s quantity like "250m", "512Mi", "1Gi"');
|
|
215
|
+
/** A TCP port: 1–65535. ONE shared schema so every port field (workers.coords.tidbPort, ssh.port, …) bounds
|
|
216
|
+
* the SAME way — a port of 65536/0/-1 is config corruption, reject it at validation, not at connect time. R16. */
|
|
217
|
+
export const TCP_PORT = z.number().int().positive().max(65535);
|
|
218
|
+
/** Per-1M-token price ($/Mtok). Maps to the service's MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE
|
|
219
|
+
* (budget.ts buildPricing → per-principal cost gate). Lives on the models catalog (ModelEntry.cost) — the
|
|
220
|
+
* single pricing source (worker.model, the old second consumer, was deleted in 0.7.0). A free model declares
|
|
221
|
+
* 0s (core 1.26: missing cost ≠ cheapest). */
|
|
222
|
+
// `.finite()` on every $/Mtok field: `z.number().nonnegative()` accepts Infinity (Infinity >= 0), which then
|
|
223
|
+
// JSON.stringify's to `null` on write → a later read fails "expected number, received null" (fail-closed at the
|
|
224
|
+
// WRONG time). `.int()` fields are already Infinity-safe (Infinity isn't an integer). Round-4.
|
|
225
|
+
export const ModelCost = z.object({
|
|
226
|
+
input: z.number().finite().nonnegative(),
|
|
227
|
+
output: z.number().finite().nonnegative(),
|
|
228
|
+
cacheRead: z.number().finite().nonnegative().default(0),
|
|
229
|
+
cacheWrite: z.number().finite().nonnegative().default(0),
|
|
230
|
+
});
|
|
231
|
+
// ── Domain: models (MVP, fully specified) ─────────────────────────────────────
|
|
232
|
+
// Maps 1:1 onto sema-server's config.models / config.roles / @-model allowlist (core 1.24 role
|
|
233
|
+
// map). `name` is the catalog key AND the `@name` handle; `id` is the gateway's model id.
|
|
234
|
+
// ── sealed-box 密钥托管(0.10.5,产品拍板铁律修订:「不存 secret 值」精化为「不存**可解明文**」)──
|
|
235
|
+
// center 可存"不可解密的密文":浏览器端用执行域公钥 libsodium sealed-box(crypto_box_seal,X25519+
|
|
236
|
+
// XSalsa20-Poly1305)加密后提交,center 无私钥永远解不开;caps/config 原样下发,执行侧私钥解密注入 env。
|
|
237
|
+
// 明文 key 绝不出浏览器 = center 服务端代码路径里绝不出现明文。
|
|
238
|
+
/** sealed-box 算法标识(v1 唯一取值)。z.literal 钉死:换算法=换值=显式契约演进,不许静默漂移。 */
|
|
239
|
+
export const SEALED_BOX_ALG = "libsodium-sealedbox-x25519";
|
|
240
|
+
/** base64 形状底线(标准字母表,可带 padding)。**只校验形状不校验内容**——center 是托管方不是消费方,
|
|
241
|
+
* 解密/解不开是执行侧运行时的事;这里只挡"根本不是 base64"的写入事故。 */
|
|
242
|
+
export const BASE64_SHAPE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
243
|
+
/** 接入模型的 sealed-box 托管密文(默认模式「直接贴 key」的存储形态)。
|
|
244
|
+
* - `ciphertext`:crypto_box_seal(plaintextKey, executionPublicKey) 的 base64。sealed box 开销 48 字节,
|
|
245
|
+
* 真实 API key ≤ 几百字节;`.max(8192)` 只防 blob 滥用,不是格式承诺。
|
|
246
|
+
* - `publicKeyId`:加密时用的执行域公钥 id(指向 execution.publicKeys[].publicKeyId)——执行侧凭它选私钥;
|
|
247
|
+
* 轮换后旧密文仍带旧 id,可解性由执行侧留存的旧私钥决定。
|
|
248
|
+
* - **write-only 纪律(消费方约定)**:center 的 GET 面永不回 ciphertext 本体(只回 publicKeyId+设置
|
|
249
|
+
* 时间);密文字段绝不进任何日志。schema 层不执法读面,这是 API 层的契约义务。
|
|
250
|
+
* - **与 `apiKeyEnv` 的互斥语义**:见 ModelEntry.sealedApiKey JSDoc(两者都设 = sealed 优先)。 */
|
|
251
|
+
export const SealedApiKey = z.object({
|
|
252
|
+
/** sealed-box 密文,base64(形状校验 only——内容对 center 永远不透明)。 */
|
|
253
|
+
ciphertext: z.string().min(1).max(8192).regex(BASE64_SHAPE, "ciphertext must be base64"),
|
|
254
|
+
/** 加密所用执行域公钥的 id(execution.publicKeys 登记表的键)。 */
|
|
255
|
+
publicKeyId: z.string().min(1).max(128),
|
|
256
|
+
/** 算法标识,v1 恒为 {@link SEALED_BOX_ALG}。 */
|
|
257
|
+
alg: z.literal(SEALED_BOX_ALG),
|
|
258
|
+
/** 密文设置时刻(ISO)。center 写面 server 端盖章(projects.registeredAt 同款纪律);write-only
|
|
259
|
+
* 的 masked GET 面靠它+publicKeyId 展示「已于 X 设置」——密文本体永不回读。optional:执行侧
|
|
260
|
+
* 解密不依赖它,非 center 写面(本地 TOC 手写配置)缺省合法。 */
|
|
261
|
+
setAt: z.string().min(1).optional(),
|
|
262
|
+
});
|
|
263
|
+
export const ModelEntry = z.object({
|
|
264
|
+
/** Catalog key + `@name` mention handle (stable, url/word-safe), e.g. "model-a". */
|
|
265
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
266
|
+
/** The gateway's model id, e.g. "deepseek-v4-pro" (what actually goes on the wire). */
|
|
267
|
+
id: z.string().min(1),
|
|
268
|
+
/** Routes the brain in the service (createRoutingBrain by provider): "gateway"/"vllm" → OpenAI brain,
|
|
269
|
+
* "anthropic" → Anthropic brain. */
|
|
270
|
+
provider: z.string().min(1).default("gateway"),
|
|
271
|
+
/** API family — must match the serving brain or cache accounting mis-counts (service design/18).
|
|
272
|
+
* "openai-completions" for vLLM/OpenAI gateways, "anthropic-messages" for the Anthropic route. */
|
|
273
|
+
api: z.enum(["openai-completions", "anthropic-messages"]).default("openai-completions"),
|
|
274
|
+
/** Gateway base URL (non-secret). Empty → the service's default gateway for that provider. */
|
|
275
|
+
baseUrl: NON_SECRET_URL.optional().or(z.literal("")),
|
|
276
|
+
/** NAME of the env var (in the SERVICE's env) holding this model's API key — never the key itself.
|
|
277
|
+
* 专家模式(env-NAME 引用,web 面折叠进「高级」);默认模式见 {@link ModelEntry}.sealedApiKey。 */
|
|
278
|
+
apiKeyEnv: ENV_NAME.optional(),
|
|
279
|
+
/** sealed-box 托管密文(0.10.5 默认模式「直接贴 key」;形状见 {@link SealedApiKey})。
|
|
280
|
+
* **互斥语义(两者都设 = sealed 优先)**:`sealedApiKey` 与 `apiKeyEnv` 语义互斥——执行侧注入 env 时
|
|
281
|
+
* sealed 解密值**压过** apiKeyEnv 引用(用户从专家模式切回默认贴 key 后,残留的 env-NAME 不得抢线)。
|
|
282
|
+
* 刻意不 schema 层拒双设(拒了=切模式必须先清旧字段,UI/迁移两头找病;优先级裁决比互斥禁令便宜)。
|
|
283
|
+
* ABSENT = 未托管(纯 env-NAME 现状),零迁移。 */
|
|
284
|
+
sealedApiKey: SealedApiKey.optional(),
|
|
285
|
+
/** Price/capability tier — fuels core 1.25 `selectModel({ tier })` role resolution. */
|
|
286
|
+
tier: z.enum(["cheap", "standard", "premium"]).default("standard"),
|
|
287
|
+
reasoning: z.boolean().default(false),
|
|
288
|
+
vision: z.boolean().default(false),
|
|
289
|
+
contextWindow: z.number().int().positive().optional(),
|
|
290
|
+
maxTokens: z.number().int().positive().optional(),
|
|
291
|
+
/** Auto-compaction trigger threshold (tokens) for this model — center 显式下发时本地 FileConfigStore
|
|
292
|
+
* lane 必须原样保真(0.10.10:strip 导致 parse 期即丢)。消费方=壳/引擎的 compaction
|
|
293
|
+
* 配置(safety-merge autoCompaction seam);ABSENT = 消费方自身默认,零迁移。 */
|
|
294
|
+
autoCompactTokens: z.number().int().positive().optional(),
|
|
295
|
+
/** Chars-per-token estimate for local token estimation (may be fractional, e.g. 3.5;CJK-heavy 模型
|
|
296
|
+
* 比英文低)。与 autoCompactTokens 同班车(0.10.10 [830]④a);ABSENT = 消费方自身默认,零迁移。 */
|
|
297
|
+
charsPerToken: z.number().finite().positive().optional(),
|
|
298
|
+
/** Per-model prompt-guidance lines(装配 behavior 槽的 per-model 缓解句)。形对齐 core 消费面
|
|
299
|
+
* `model.promptGuidance?: string[]`(engine/llm types;prompt-assembly 战役起装配位固定于
|
|
300
|
+
* volatile 尾前)。web/center models 域先行小切片(不等 pack 大架构)。Non-secret model
|
|
301
|
+
* metadata(cost/tier 同类);ABSENT = 无 per-model 句,零迁移。 */
|
|
302
|
+
promptGuidance: z.array(z.string()).optional(),
|
|
303
|
+
/** $/Mtok. A genuinely-free model still declares 0s (core 1.26: missing cost ≠ cheapest). */
|
|
304
|
+
cost: ModelCost.optional(),
|
|
305
|
+
/** Extra OpenAI/Anthropic request-body params merged into every call to this model (core passes them
|
|
306
|
+
* through), e.g. {frequency_penalty:0.5, presence_penalty:0.3}. Non-secret model metadata — same class
|
|
307
|
+
* as cost/tier. Shipped on /effective.models.models[].extraBody; the service merges it into the request. */
|
|
308
|
+
// z.unknown() (NOT z.record — that iterates + reads top-level getters during parse, firing a malicious getter
|
|
309
|
+
// before our check). The getter-free descriptor walk (extraBodyUnsafe) validates the RAW value without
|
|
310
|
+
// invoking any getter; a Proxy that fakes its descriptors is caught by re-scanning the JSON clone; and the
|
|
311
|
+
// .transform persists that CLONE (concrete plain JSON) so a non-idempotent toJSON can't differ at persist.
|
|
312
|
+
// Round 2-5 hardening.
|
|
313
|
+
extraBody: z
|
|
314
|
+
.unknown()
|
|
315
|
+
.optional()
|
|
316
|
+
// ONE transform that validates AND canonicalizes with a SINGLE JSON.stringify — the scanned clone IS the
|
|
317
|
+
// persisted value, so a non-idempotent getter/Proxy can't validate one serialization and persist another
|
|
318
|
+
// (the prior superRefine+transform stringified TWICE — review R10). getter-free descriptor walk bounds size
|
|
319
|
+
// BEFORE the stringify; the clone is re-scanned so a Proxy that fakes its descriptors still can't ship a secret.
|
|
320
|
+
.transform((v, ctx) => {
|
|
321
|
+
if (v === undefined)
|
|
322
|
+
return undefined;
|
|
323
|
+
const proto = typeof v === "object" && v !== null ? Object.getPrototypeOf(v) : undefined;
|
|
324
|
+
if (typeof v !== "object" || v === null || Array.isArray(v) || (proto !== Object.prototype && proto !== null)) {
|
|
325
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody must be a plain JSON object" });
|
|
326
|
+
return z.NEVER;
|
|
327
|
+
}
|
|
328
|
+
// wrap in try/catch: a hostile Proxy's getOwnPropertyDescriptor/ownKeys trap could THROW a secret-bearing
|
|
329
|
+
// message — never surface it; treat any reflective failure as invalid with a FIXED message. R11.
|
|
330
|
+
let rawUnsafe;
|
|
331
|
+
try {
|
|
332
|
+
rawUnsafe = extraBodyUnsafe(v);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody could not be validated (reflective access threw)" });
|
|
336
|
+
return z.NEVER;
|
|
337
|
+
}
|
|
338
|
+
if (rawUnsafe) {
|
|
339
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody: a key/value is a secret shape, non-JSON (Infinity/function/Map), an accessor, cyclic, or too deep" });
|
|
340
|
+
return z.NEVER;
|
|
341
|
+
}
|
|
342
|
+
let clone;
|
|
343
|
+
try {
|
|
344
|
+
clone = JSON.parse(JSON.stringify(v)); // the ONLY serialization — its result is what we scan AND persist
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody is not JSON-serializable" });
|
|
348
|
+
return z.NEVER;
|
|
349
|
+
}
|
|
350
|
+
if (extraBodyUnsafe(clone)) {
|
|
351
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody: its serialized form carries a secret" });
|
|
352
|
+
return z.NEVER;
|
|
353
|
+
}
|
|
354
|
+
return clone;
|
|
355
|
+
}),
|
|
356
|
+
/** 配额倍率(EXPERT-REDESIGN §4-1):该模型烧 token 双窗额度的倍率,默认 1。折算时点=weight-at-burn
|
|
357
|
+
* (记账时固化到 UsageEntry.quotaWeightAtUse/weightedTokens):改倍率只影响此后记账,不回算窗口内历史。 */
|
|
358
|
+
quotaWeight: z.number().finite().nonnegative().default(1),
|
|
359
|
+
enabled: z.boolean().default(true),
|
|
360
|
+
notes: z.string().optional(),
|
|
361
|
+
}).superRefine((m, ctx) => {
|
|
362
|
+
// provider↔api pairing (service createRoutingBrain / design/18): the Anthropic brain needs `anthropic-messages`
|
|
363
|
+
// and that api needs the Anthropic provider — a mismatch mis-counts cache/cost at runtime. The console FORM
|
|
364
|
+
// suggested this; enforce it in the SCHEMA so a direct PUT / local TOC config can't ship a mis-paired model.
|
|
365
|
+
// (provider:"anthropic" must set api explicitly; gateway/vllm keep the openai-completions default.) Round-4.
|
|
366
|
+
if ((m.provider === "anthropic") !== (m.api === "anthropic-messages"))
|
|
367
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `provider "${m.provider}" + api "${m.api}" mismatch — provider:"anthropic" ⟺ api:"anthropic-messages"`, path: ["api"] });
|
|
368
|
+
});
|
|
369
|
+
// `verifier` (2026-06-11, SERVICE-RESPONSE-…-PHASE2): real consumer = core's verification gate
|
|
370
|
+
// (runWithVerification/resumeWithVerification resolve the verifier sub-task model via this role). Fallback
|
|
371
|
+
// semantics per core: a roster that doesn't set `verifier` uses `default`. core also has `advisor` (teacher
|
|
372
|
+
// mode) — deliberately NOT added: Service AI evaluated teacher mode and won't consume it (no consumer, no key).
|
|
373
|
+
export const ModelRole = z.enum(["default", "summarize", "subagent", "team", "synthesize", "verifier"]);
|
|
374
|
+
/** A role resolves either to a model NAME, or by capability/price (core 1.25 `{ select }`). */
|
|
375
|
+
export const RoleTarget = z.union([
|
|
376
|
+
z.object({ model: z.string().min(1) }),
|
|
377
|
+
z.object({ select: z.object({ tier: z.enum(["cheap", "standard", "premium"]).optional(), needs: z.array(z.enum(["vision", "reasoning"])).optional(), prefer: z.enum(["cheapest", "premium"]).optional() }) }),
|
|
378
|
+
]);
|
|
379
|
+
// ── sema 档位(tiers)与档位组(tier groups)──(center 半场, core 1.265 档位车) ──────
|
|
380
|
+
// core 侧接缝 = `RunnerDeps.tiers`(档位词→模型绑定的**单表**, catalog 单点增广 expandTiers);center 侧
|
|
381
|
+
// 把「多套命名预配置」存成 tierGroups,下发时只解析 ACTIVE 那一套成单表(组切换=center 换表下发,零新
|
|
382
|
+
// 引擎机制)。与 `ModelEntry.tier`(cheap/standard/premium 价格分级, core 1.25 selectModel)是两回事——
|
|
383
|
+
// 那是能力/价格元数据,这是用户面的模型预配置绑定;词表也不同(max/pro/flash/lite + ultra 预留)。
|
|
384
|
+
/** 档位词→catalog 模型 `name` 的绑定。值=目录键(string 引用,不 enum——开放性照 execution
|
|
385
|
+
* allowedLanes 先例);`ultra` 预留在 max 之上,绑定即生效零码改。未绑档引擎侧沿链降档
|
|
386
|
+
* fail-open;整表未配=引擎恒惰性(INERT)。 */
|
|
387
|
+
export const TierBindings = z.object({
|
|
388
|
+
ultra: z.string().min(1).optional(),
|
|
389
|
+
max: z.string().min(1).optional(),
|
|
390
|
+
pro: z.string().min(1).optional(),
|
|
391
|
+
flash: z.string().min(1).optional(),
|
|
392
|
+
lite: z.string().min(1).optional(),
|
|
393
|
+
});
|
|
394
|
+
/** 「档位组」= 一套命名的档位绑定表预配置,如「日常组=v4-pro/v4-flash」vs「攻坚组=fable-5/v4-pro」。 */
|
|
395
|
+
export const TierGroup = z.object({
|
|
396
|
+
/** Group key (same charset as catalog names), e.g. "daily" / "heavy". */
|
|
397
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
398
|
+
tiers: TierBindings.default({}),
|
|
399
|
+
notes: z.string().optional(),
|
|
400
|
+
});
|
|
401
|
+
export const ModelsConfig = z
|
|
402
|
+
.object({
|
|
403
|
+
models: z.array(ModelEntry).default([]),
|
|
404
|
+
/** 结构级显式默认模型 = catalog `name`(目录键)。与 `roles.default`(角色词)是两回事——消费方
|
|
405
|
+
* (server resolveDefaultModelName)以本键为第一优先级,先于 roles/档位绑定;
|
|
406
|
+
* ABSENT = 沿既有 roles/档位 fallback 链,存量 doc 零迁移。悬空 ref 写入时 fail-loud(见下
|
|
407
|
+
* superRefine);未经 schema 的本地/存量 lane 由消费方 warn 降级(与 activeTierGroup 双层姿势一致)。 */
|
|
408
|
+
default: z.string().min(1).optional(),
|
|
409
|
+
/** role → target. Unset roles fall back to `default` (core fallback chain). */
|
|
410
|
+
roles: z.record(ModelRole, RoleTarget).default({}),
|
|
411
|
+
/** Which model names an end user may `@`-mention. Empty/undefined → all enabled models. */
|
|
412
|
+
atModelAllowlist: z.array(z.string()).optional(),
|
|
413
|
+
/** 命名档位组预配置。只有 ACTIVE 组的绑定表会下发为引擎的 tiers 单表。 */
|
|
414
|
+
tierGroups: z.array(TierGroup).default([]),
|
|
415
|
+
/** 当前生效的档位组 name。未设(或无组)→ 不下发 tiers,引擎档位层恒惰性。 */
|
|
416
|
+
activeTierGroup: z.string().optional(),
|
|
417
|
+
})
|
|
418
|
+
// `name` is the catalog key AND the @-handle AND the cross-domain ref target (rosters/collab/roles/allowlist
|
|
419
|
+
// all reference it by name). EVERY other name-keyed domain dedups (rosters/scenarios/systems/...); models —
|
|
420
|
+
// the most-referenced — was the lone gap. A duplicate name ships two conflicting defs (id/apiKeyEnv/cost/
|
|
421
|
+
// enabled) on the wire and makes every ref ambiguous (the `new Set(names)` existence checks collapse them).
|
|
422
|
+
// Round-3 fix (reproduced).
|
|
423
|
+
.superRefine((cfg, ctx) => {
|
|
424
|
+
const seen = new Set();
|
|
425
|
+
cfg.models.forEach((m, i) => {
|
|
426
|
+
if (seen.has(m.name))
|
|
427
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate model "${m.name}" — the catalog key (and @-handle) must be unique`, path: ["models", i, "name"] });
|
|
428
|
+
seen.add(m.name);
|
|
429
|
+
});
|
|
430
|
+
// Catalog self-references must resolve to a real model — a dangling global `roles.{role}.model` or an
|
|
431
|
+
// `atModelAllowlist` name not in `models` ships a broken wire (the global /effective path uses eff.models
|
|
432
|
+
// directly, with no roster filter). This lived ONLY in refIntegrityIssues (sema-registry) — but the schema
|
|
433
|
+
// is the only guard for a local TOC config / a direct PUT. Round-4 (reproduced).
|
|
434
|
+
const names = new Set(cfg.models.map((m) => m.name));
|
|
435
|
+
for (const [role, t] of Object.entries(cfg.roles ?? {})) {
|
|
436
|
+
if (t && "model" in t && !names.has(t.model))
|
|
437
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `role "${role}" → model "${t.model}" is not in the catalog`, path: ["roles", role, "model"] });
|
|
438
|
+
}
|
|
439
|
+
(cfg.atModelAllowlist ?? []).forEach((n, i) => {
|
|
440
|
+
if (!names.has(n))
|
|
441
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `atModelAllowlist["${n}"] is not in the catalog`, path: ["atModelAllowlist", i] });
|
|
442
|
+
});
|
|
443
|
+
// 结构级 default 悬空检测(照上面 roles/allowlist 同款,写入时 fail-loud;消费方对未经 schema 的
|
|
444
|
+
// 存量/本地 lane 仍 warn 降级——与 activeTierGroup 的「写面 fail-loud + 读面 graceful」双层姿势一致)。
|
|
445
|
+
if (cfg.default !== undefined && !names.has(cfg.default)) {
|
|
446
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `default "${cfg.default}" is not in the catalog`, path: ["default"] });
|
|
447
|
+
}
|
|
448
|
+
// 档位组 ref 完整性(照上面 roles/allowlist 悬空检测同款):组名去重 + 绑定必须命中 catalog +
|
|
449
|
+
// activeTierGroup 必须命中某组(悬空 active = 下发面从「换表」静默滑成「无表」,写入时 fail-loud)。
|
|
450
|
+
const groupSeen = new Set();
|
|
451
|
+
cfg.tierGroups.forEach((g, i) => {
|
|
452
|
+
if (groupSeen.has(g.name))
|
|
453
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate tier group "${g.name}"`, path: ["tierGroups", i, "name"] });
|
|
454
|
+
groupSeen.add(g.name);
|
|
455
|
+
for (const [tier, model] of Object.entries(g.tiers)) {
|
|
456
|
+
if (typeof model === "string" && !names.has(model))
|
|
457
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `tier group "${g.name}" ${tier} → model "${model}" is not in the catalog`, path: ["tierGroups", i, "tiers", tier] });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
if (cfg.activeTierGroup !== undefined && !groupSeen.has(cfg.activeTierGroup)) {
|
|
461
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `activeTierGroup "${cfg.activeTierGroup}" is not a tier group name`, path: ["activeTierGroup"] });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
// ── Domain: rosters (per-worker model roster — docs/MULTI-ROSTER.md) ───────────
|
|
465
|
+
// A named VIEW over the global `models` catalog: which models a worker exposes + its role map + @-allowlist.
|
|
466
|
+
// A worker references one by name (WorkerSpec.roster); GET /effective?worker=<name> resolves it. Models stay
|
|
467
|
+
// single-sourced in the `models` domain — a roster only references them by name (cross-domain ref checked at
|
|
468
|
+
// resolution/PUT, see docs/MULTI-ROSTER.md §6). This is what lets one sema-registry serve heterogeneous
|
|
469
|
+
// instances (OA vs review 各持异构模型) instead of a single global roster.
|
|
470
|
+
export const RosterEntry = z.object({
|
|
471
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
472
|
+
/** Catalog model names this roster exposes (must exist in models.models[].name). Empty = the full catalog. */
|
|
473
|
+
models: z.array(z.string().min(1)).default([]),
|
|
474
|
+
/**
|
|
475
|
+
* 主模型角色标记(0.6.3, E3)——catalog model NAME。worker.model 废除后 service 从 roster
|
|
476
|
+
* 解析 boot 主模型的归宿位:解析语义(resolve-roster)= primary 喂 `default`/`team`/`synthesize` 三角色、
|
|
477
|
+
* cheap 喂 `subagent`/`summarize`(service 现有 ModelRoles 口径;`verifier` 不派生——unset→default 兜底链)。
|
|
478
|
+
* 显式 `roles` **逐角色**优先于标记派生;标记优先于全局 models.roles。additive:不设=行为与 0.6.2 全等。
|
|
479
|
+
*/
|
|
480
|
+
primaryModel: z.string().min(1).optional(),
|
|
481
|
+
/** cheap 模型角色标记(同上;worker.model.cheapId 的迁移归宿,§3②)。 */
|
|
482
|
+
cheapModel: z.string().min(1).optional(),
|
|
483
|
+
/** Role map override; unset roles fall back to marker-derived roles, then the global models.roles. */
|
|
484
|
+
roles: z.record(ModelRole, RoleTarget).optional(),
|
|
485
|
+
/** @-mention allowlist override; unset → inherits the global models.atModelAllowlist. */
|
|
486
|
+
atModelAllowlist: z.array(z.string()).optional(),
|
|
487
|
+
notes: z.string().optional(),
|
|
488
|
+
});
|
|
489
|
+
export const RostersConfig = z
|
|
490
|
+
.object({ rosters: z.array(RosterEntry).default([]) })
|
|
491
|
+
.superRefine((cfg, ctx) => {
|
|
492
|
+
const seen = new Set();
|
|
493
|
+
cfg.rosters.forEach((r, i) => {
|
|
494
|
+
if (seen.has(r.name))
|
|
495
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate roster "${r.name}"`, path: ["rosters", i, "name"] });
|
|
496
|
+
seen.add(r.name);
|
|
497
|
+
// intra-roster consistency: a role target / an @-allowlist entry pinned to a model NAME must be one
|
|
498
|
+
// this roster exposes (when it's a subset). Cross-domain (the name exists in the catalog) is checked at
|
|
499
|
+
// PUT time — see MULTI-ROSTER.md. (Both layers matter: this catches subset-internal inconsistency even
|
|
500
|
+
// when models is the full catalog is left to the PUT check.)
|
|
501
|
+
if (r.models.length) {
|
|
502
|
+
const allowed = new Set(r.models);
|
|
503
|
+
// 0.6.3 markers: same subset-consistency line as roles/@-allowlist — a marker naming a model this
|
|
504
|
+
// roster doesn't expose would derive a dangling role (dropped at resolution, but reject loudly at PUT).
|
|
505
|
+
if (r.primaryModel && !allowed.has(r.primaryModel)) {
|
|
506
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" primaryModel → "${r.primaryModel}" not in roster.models`, path: ["rosters", i, "primaryModel"] });
|
|
507
|
+
}
|
|
508
|
+
if (r.cheapModel && !allowed.has(r.cheapModel)) {
|
|
509
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" cheapModel → "${r.cheapModel}" not in roster.models`, path: ["rosters", i, "cheapModel"] });
|
|
510
|
+
}
|
|
511
|
+
for (const [role, target] of Object.entries(r.roles ?? {})) {
|
|
512
|
+
if (target && "model" in target && !allowed.has(target.model)) {
|
|
513
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" role.${role} → "${target.model}" not in roster.models`, path: ["rosters", i, "roles", role] });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
(r.atModelAllowlist ?? []).forEach((name, j) => {
|
|
517
|
+
if (!allowed.has(name))
|
|
518
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" atModelAllowlist "${name}" not in roster.models`, path: ["rosters", i, "atModelAllowlist", j] });
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
// ── Domain: scenarios (场景组合配置化 — ASK-CENTER-2026-06-11-SCENARIOS, v1) ───────────────────────────
|
|
524
|
+
// "组合即配置、能力钉死在部署": the center COMPOSES scenarios from the toolsets the deployment registers —
|
|
525
|
+
// it can never mint a new capability (a new toolset = a service code change, deliberately expensive). The
|
|
526
|
+
// service overlays these onto its built-in scenario table at boot; SAME NAME OVERRIDES the built-in (产品拍板
|
|
527
|
+
// ①, the worker logs `sema_registry_scenarios.shadowsBuiltin` as the audit hook). Invalid entries are
|
|
528
|
+
// skipped+warned worker-side, never fail boot. restart-to-apply like skills/mcp (产品拍板 ③).
|
|
529
|
+
// The old skeletal roles/tools fields are gone (never stored anywhere, zero consumers, no migration).
|
|
530
|
+
export const ScenarioEntry = z.object({
|
|
531
|
+
/** Scenario name — may equal a built-in name (then the center definition SHADOWS it). */
|
|
532
|
+
name: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/, "lowercase slug, 2-32 chars (a-z, 0-9, -)"),
|
|
533
|
+
/** Toolset key from the SERVICE's registry (v1: "none" | "repo-readonly"). Deliberately a shaped string,
|
|
534
|
+
* not an enum: the worker registry is the authority (unknown → skip+warn there), so the center never needs
|
|
535
|
+
* a release just to NAME a toolset a newer service ships. */
|
|
536
|
+
toolset: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/, "toolset key (lowercase slug)"),
|
|
537
|
+
// ScenarioEntry.prompt 已废弃(clay 亲裁 2026-07-17,board [1051]③a;第二评审独立同判「second
|
|
538
|
+
// registry field would become an ambiguous precedence channel」):配置域 prompt 单入口=center
|
|
539
|
+
// prompts binding(scenario 维度);引擎 API 层 TaskSpec.systemPrompt 保留(不同层,勿混)。
|
|
540
|
+
// 现网无用户直删;z.object 非 strict ⇒ 存量数据带 prompt 被 strip,parse 不炸。
|
|
541
|
+
enabled: z.boolean().default(true),
|
|
542
|
+
notes: z.string().optional(),
|
|
543
|
+
});
|
|
544
|
+
export const ScenarioConfig = z
|
|
545
|
+
.object({ scenarios: z.array(ScenarioEntry).default([]) })
|
|
546
|
+
.superRefine((cfg, ctx) => {
|
|
547
|
+
const seen = new Set();
|
|
548
|
+
cfg.scenarios.forEach((sc, i) => {
|
|
549
|
+
if (seen.has(sc.name))
|
|
550
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate scenario "${sc.name}"`, path: ["scenarios", i, "name"] });
|
|
551
|
+
seen.add(sc.name);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
// ── Domain: systems (系统接入/凭证生命周期 — SERVICE-PROPOSAL-SYSTEMS-DOMAIN, S1) ─────────────────────────
|
|
555
|
+
// Declarative SYSTEM onboarding: which external systems (OA / CC-MCP facade / portal …) may call which
|
|
556
|
+
// workers, with the credential LIFECYCLE (mint/deliver/rotate/revoke) owned by the orchestrator (S2/S3).
|
|
557
|
+
// `name` doubles as the worker-derived run.source (the worker attributes a run to whichever credential
|
|
558
|
+
// authenticated — never self-reported). This domain holds METADATA + nonces ONLY — credential VALUES live
|
|
559
|
+
// exclusively in the secret store (I1: audit/UI/API see fingerprints, never values).
|
|
560
|
+
/** Platform credential surfaces a system may NEVER deliver into (红线1 — would let a publish overwrite the
|
|
561
|
+
* platform's own secrets). Runtime delivery (S3) re-checks; this is the save-time half. */
|
|
562
|
+
export const DELIVER_DENYLIST = new Set([
|
|
563
|
+
"a2-platform-secrets",
|
|
564
|
+
"tidb-creds",
|
|
565
|
+
"gitea-notify",
|
|
566
|
+
"worker-secrets",
|
|
567
|
+
// the orchestrator's OWN minted-token store (S2) — a system must never deliver into its credential source.
|
|
568
|
+
"systems-credentials",
|
|
569
|
+
]);
|
|
570
|
+
export const SystemEntry = z.object({
|
|
571
|
+
/** System id = run.source value. Same shape the worker enforces for attribution. */
|
|
572
|
+
name: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/, "lowercase slug, 2-32 chars (a-z, 0-9, -)"),
|
|
573
|
+
/** Workers this system may call — its token lands in each one's SERVICE_AUTH_TOKENS CSV (S2).
|
|
574
|
+
* Cross-domain ref-checked against the workers domain. */
|
|
575
|
+
callsWorkers: z.array(z.string().min(1)).default([]),
|
|
576
|
+
/** Where the orchestrator delivers this system's credential (the CALLER's secret). v1: a BARE secret name
|
|
577
|
+
* in the orchestrator's OWN namespace (no "ns/name" — cross-namespace = an RBAC expansion, deferred until
|
|
578
|
+
* a real need). Optional: a system without deliverTo gets a minted token visible only via out-of-band ops. */
|
|
579
|
+
deliverTo: z
|
|
580
|
+
.object({
|
|
581
|
+
k8sSecret: z
|
|
582
|
+
.string()
|
|
583
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'bare secret NAME in the orchestrator namespace (no "ns/" prefix — v1 is same-namespace only)')
|
|
584
|
+
.max(253)
|
|
585
|
+
// The `<worker>-env` suffix is the orchestrator's worker-credential projection pattern. A target
|
|
586
|
+
// worker that isn't provisioned yet has no Secret to label-check, so a name-level reservation is
|
|
587
|
+
// the only thing closing the "squat on a future worker's credential surface" timing hole (S3 设计
|
|
588
|
+
// §4.4, council DESIGN-6/13). Cost: callers can't use a secret that happens to end in -env.
|
|
589
|
+
.refine((n) => !n.endsWith("-env"), { message: 'secret names ending in "-env" are reserved for orchestrator worker-credential projections (红线1)' }),
|
|
590
|
+
key: z.string().regex(/^[-._a-zA-Z0-9]+$/, "k8s secret data key").max(253),
|
|
591
|
+
})
|
|
592
|
+
.optional(),
|
|
593
|
+
/** Bump to ROTATE this system's credential (S3 choreography: mint new → dual-token CSV → deliver → grace →
|
|
594
|
+
* retire old). Same idiom as WorkerSpec.recreateNonce. */
|
|
595
|
+
rotateNonce: z.string().max(64).optional(),
|
|
596
|
+
/** Overlap window (seconds) during rotation before the OLD token is retired. Absent → orchestrator default
|
|
597
|
+
* (service AI to confirm; S3). */
|
|
598
|
+
rotateGraceSec: z.number().int().positive().optional(),
|
|
599
|
+
enabled: z.boolean().default(true),
|
|
600
|
+
notes: z.string().optional(),
|
|
601
|
+
});
|
|
602
|
+
export const SystemsConfig = z
|
|
603
|
+
.object({ systems: z.array(SystemEntry).default([]) })
|
|
604
|
+
.superRefine((cfg, ctx) => {
|
|
605
|
+
const seen = new Set();
|
|
606
|
+
const targets = new Map(); // "secret\0key" -> index of first ENABLED claimant
|
|
607
|
+
cfg.systems.forEach((sys, i) => {
|
|
608
|
+
if (seen.has(sys.name))
|
|
609
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate system "${sys.name}"`, path: ["systems", i, "name"] });
|
|
610
|
+
seen.add(sys.name);
|
|
611
|
+
if (sys.deliverTo && DELIVER_DENYLIST.has(sys.deliverTo.k8sSecret)) {
|
|
612
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `deliverTo.k8sSecret "${sys.deliverTo.k8sSecret}" is a PLATFORM credential surface — refused (红线1)`, path: ["systems", i, "deliverTo", "k8sSecret"] });
|
|
613
|
+
}
|
|
614
|
+
// Cross-system deliverTo collision (S3 设计 §4.5, council DESIGN-4): two enabled systems writing the
|
|
615
|
+
// same {secret,key} would silently overwrite each other (same SSA fieldManager). Save-time half; the
|
|
616
|
+
// orchestrator re-checks at delivery time.
|
|
617
|
+
if (sys.deliverTo && sys.enabled) {
|
|
618
|
+
const t = `${sys.deliverTo.k8sSecret}\0${sys.deliverTo.key}`;
|
|
619
|
+
const prior = targets.get(t);
|
|
620
|
+
if (prior !== undefined) {
|
|
621
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `deliverTo target "${sys.deliverTo.k8sSecret}/${sys.deliverTo.key}" already claimed by system "${cfg.systems[prior].name}" — one credential per delivery slot`, path: ["systems", i, "deliverTo"] });
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
targets.set(t, i);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
/** One system's credential-lifecycle OBSERVATION (S3 设计 §7) — written by the orchestrator each cycle via
|
|
630
|
+
* `PUT /api/config/systems/status` (batch), read by the S4 UI. Carries FINGERPRINTS only, never values (I1). */
|
|
631
|
+
export const SystemStatusEntry = z.object({
|
|
632
|
+
name: z.string().min(1),
|
|
633
|
+
/** lkg-hold = the systems domain went empty/absent and the orchestrator is holding the last-known-good
|
|
634
|
+
* injection (S3 设计 §5); refused = a deliverTo runtime red-line check failed (see `reason`). */
|
|
635
|
+
phase: z.enum(["bootstrap", "steady", "rotating", "grace", "refused", "lkg-hold"]),
|
|
636
|
+
/** sha256/8 of the CURRENT token. */
|
|
637
|
+
fp: z.string().regex(/^[0-9a-f]{8}$/),
|
|
638
|
+
/** sha256/8 of the retiring token while a rotation grace window is open. */
|
|
639
|
+
prevFp: z.string().regex(/^[0-9a-f]{8}$/).optional(),
|
|
640
|
+
mintedAt: z.string().optional(),
|
|
641
|
+
/** When the retiring token will be dropped (set at delivery; absent while awaiting convergence). */
|
|
642
|
+
retireAt: z.string().optional(),
|
|
643
|
+
/** sha256/8 of the token last successfully written to deliverTo. */
|
|
644
|
+
deliveredFp: z.string().regex(/^[0-9a-f]{8}$/).optional(),
|
|
645
|
+
/** Which check refused delivery / why the orchestrator is holding (operator-facing, never a value). */
|
|
646
|
+
reason: z.string().max(500).optional(),
|
|
647
|
+
updatedAt: z.string(),
|
|
648
|
+
});
|
|
649
|
+
// ── Domain: runtime (限额残余 — API/CLI-only, EXPERT-REDESIGN §5) ─────────────────
|
|
650
|
+
// 0.6.0 (runtime 域废除拆三刀): the governance trio (autonomy/commandPolicy/approvalRequire) moved OUT into
|
|
651
|
+
// the new `governance` domain; the legacy-skeletal `approvalDeny`/`runStaleSec` (service never read them) are
|
|
652
|
+
// DELETED outright. What remains = the fleet cost/rate LIMIT residue — kept API/CLI-only (the web renders NO
|
|
653
|
+
// runtime surface; the "runtime" UI concept is abolished).
|
|
654
|
+
// WIRE TRANSITION — **DONE, 0.7.1**: the 0.6.0-era 双写 (buildEffective mirroring the governance keys into the
|
|
655
|
+
// runtime slot until the service switched) was WITHDRAWN once service 1.150.0 confirmed it reads `eff.governance`
|
|
656
|
+
// (§5 收账). Today the runtime slot carries the limit residue ONLY, and `governance` is the sole wire position of
|
|
657
|
+
// the 6 gates. The transitional `RuntimeWire` alias was deleted in #282 件3 (三消费仓 grep -w 零命中) — the READ
|
|
658
|
+
// direction的 legacy lift (never-set governance domain ← old runtime doc, {@link GOVERNANCE_MIRROR_KEYS}) is a
|
|
659
|
+
// different mechanism and stays.
|
|
660
|
+
export const RuntimeConfig = z.object({
|
|
661
|
+
// `.optional()` on every gate (settled discipline): ABSENT = "the center isn't managing this — keep your
|
|
662
|
+
// env value"; present — INCLUDING an explicit 0 — overrides (publishable "gate OFF"). A default here would
|
|
663
|
+
// silently override a deployment's env.
|
|
664
|
+
rateLimitPerMin: z.number().int().nonnegative().optional(),
|
|
665
|
+
// Fleet-level cost governance (SERVICE-RESPONSE-2026-06-11-PHASE2). All restart-to-apply, none secret.
|
|
666
|
+
// Per-worker WorkerSpec.runtime.maxPrincipalCostUsd (injected env) outranks this fleet default — precedence
|
|
667
|
+
// is the consumer's, documented in SERVICE-INTEGRATION.
|
|
668
|
+
maxTaskCostUsd: z.number().finite().nonnegative().optional(),
|
|
669
|
+
maxTaskTokens: z.number().int().nonnegative().optional(),
|
|
670
|
+
maxPrincipalCostUsd: z.number().finite().nonnegative().optional(),
|
|
671
|
+
costQuotaWindowSec: z.number().int().positive().optional(),
|
|
672
|
+
});
|
|
673
|
+
// ── Domain: limits (cost/限流族的热更新发布位 — 0.19.0, sema-server #322 批1) ──────
|
|
674
|
+
// 为什么是**新顶层域**而不是往 `runtime` 里加键(消费方亲验,不是推测):`RuntimeConfig` 是 zod 默认的
|
|
675
|
+
// `"strip"` 对象,`runtime` 文档里的未知键在两条腿上都被剥掉(实测 `{rateLimitPerMin:7,
|
|
676
|
+
// toolResultTtlSec:999}` → `{rateLimitPerMin:7}`);而未知**顶层域**在 wire 腿是 open-world verbatim
|
|
677
|
+
// 透传的。于是 server 把 14 键的限额面发成顶层 `limits`——远端腿即刻可用,**本地腿(FileConfigStore
|
|
678
|
+
// 域表是闭集)读不到**:`config.d/limits.json` 根本不进 effective,且零告警。本域 = 那个缺口的源头修复
|
|
679
|
+
// (CLI/桌面单机形正是热更新战役的主诉求形)。同族先例:governance 当年也是从 runtime 拆成独立顶层域。
|
|
680
|
+
//
|
|
681
|
+
// 与 `runtime` 的关系(刻意不合并,也刻意不删旧位):五个限额键(rateLimitPerMin / maxTaskCostUsd /
|
|
682
|
+
// maxTaskTokens / maxPrincipalCostUsd / costQuotaWindowSec)两个位都可能出现。**优先级归消费方**并已
|
|
683
|
+
// 成文(server:新位 `limits` 赢,旧位是过渡读法);本包两位都如实解析,不在契约层替消费方折叠——
|
|
684
|
+
// 折叠会让「旧位有值、新位撤键」这类过渡态在两个消费者之间产生两种答案。
|
|
685
|
+
//
|
|
686
|
+
// 判据分工(**宽进严出**,与既有域一致):本域只判**结构**——类型、有限数、数组/记录的元素类型。
|
|
687
|
+
// 业务域值(下限、`0` 关断哨兵、跨键不变量如「窗长必 > 0」「runStaleSec 必严格大于心跳」)的判据属主
|
|
688
|
+
// 是消费方(server `stage-limits.ts`:坏值 ⇒ **整批响亮拒 + 旧值继续服务**)。本包把那些阈值抄一份的
|
|
689
|
+
// 代价是双属主漂移,而且处置更糟:本域是 `"throw"` 档,契约层拒 = **整份 local effective 抛**(全域
|
|
690
|
+
// 连坐),比消费方的整批拒重得多。所以:结构不合法(类型错/NaN/Infinity)在此 fail-loud;业务值一律放行。
|
|
691
|
+
/** 基建计价(micro-USD/单位)。三键各自 optional:缺席 = 该项不改。**嵌套层同样 open-world**
|
|
692
|
+
* (`.passthrough()`,与 {@link LimitsConfig} 顶层同判据):默认 strip 会把「消费方新增了一个计价项、
|
|
693
|
+
* 契约层版号还没跟上」变成那一项静默不生效;而拼错的费率名同样该由**持有键表的消费方**响亮拒,不是
|
|
694
|
+
* 在这里被无声抹掉(抹掉之后消费方连拒的原料都没有了)。承运不等于沉默:未知成员由本包的
|
|
695
|
+
* `"unknown-keys-carried"` 警告点名(config-fns)。 */
|
|
696
|
+
export const InfraCostRates = z
|
|
697
|
+
.object({
|
|
698
|
+
toolCallMicroUsd: z.number().finite().optional(),
|
|
699
|
+
sandboxSecMicroUsd: z.number().finite().optional(),
|
|
700
|
+
egressGbMicroUsd: z.number().finite().optional(),
|
|
701
|
+
})
|
|
702
|
+
.passthrough();
|
|
703
|
+
export const LimitsConfig = z.object({
|
|
704
|
+
// `.optional()` 无默认,与 RuntimeConfig 同纪律:ABSENT = 「center 不管这一键」⇒ 消费方回自己的 env 底;
|
|
705
|
+
// 显式值(**包括 0**)= 发布了一个真值(0 多半是「关断哨兵」,语义归消费方)。给默认值 = 静默覆盖部署。
|
|
706
|
+
/** 每 principal 每分钟请求上限。 */
|
|
707
|
+
rateLimitPerMin: z.number().finite().optional(),
|
|
708
|
+
/** 每 principal 滚动窗累计花费上限(USD)。 */
|
|
709
|
+
maxPrincipalCostUsd: z.number().finite().optional(),
|
|
710
|
+
/** 上面那条配额的窗长(秒)。 */
|
|
711
|
+
costQuotaWindowSec: z.number().finite().optional(),
|
|
712
|
+
/** 单任务成本天花板(USD)。 */
|
|
713
|
+
maxTaskCostUsd: z.number().finite().optional(),
|
|
714
|
+
/** 单任务 token 天花板。 */
|
|
715
|
+
maxTaskTokens: z.number().finite().optional(),
|
|
716
|
+
/** 基建计价(micro-USD/单位)。见 {@link InfraCostRates}。 */
|
|
717
|
+
infraCostRates: InfraCostRates.optional(),
|
|
718
|
+
/** 附件孤儿对象 GC 宽限(ms)。 */
|
|
719
|
+
attachmentOrphanGraceMs: z.number().finite().optional(),
|
|
720
|
+
/** workspace 单文件读上限(字节)。 */
|
|
721
|
+
workspaceFileMaxBytes: z.number().finite().optional(),
|
|
722
|
+
/** 附件单文件上限(字节)。 */
|
|
723
|
+
attachmentMaxBytes: z.number().finite().optional(),
|
|
724
|
+
/** 附件 mime 白名单。**空数组 = 显式「一个都不许」**,与缺席(本键不管)不是一回事——元素只判
|
|
725
|
+
* 「是字符串」,空串/大小写/别名归消费方(它会响亮拒,见域头注的判据分工)。 */
|
|
726
|
+
attachmentMimeAllowlist: z.array(z.string()).optional(),
|
|
727
|
+
/** 未绑定附件 TTL(ms)。 */
|
|
728
|
+
attachmentUnboundTtlMs: z.number().finite().optional(),
|
|
729
|
+
/** SIGTERM drain 窗(ms)。 */
|
|
730
|
+
drainGraceMs: z.number().finite().optional(),
|
|
731
|
+
/** run 心跳陈旧窗(秒)。 */
|
|
732
|
+
runStaleSec: z.number().finite().optional(),
|
|
733
|
+
/** 卸载工具结果的 TTL(秒)。 */
|
|
734
|
+
toolResultTtlSec: z.number().finite().optional(),
|
|
735
|
+
})
|
|
736
|
+
// 🔴 **开放世界(`.passthrough()`)** —— 与 {@link PrincipalCapsWire} 的 `runtimeCaps`/`budget` 逐字
|
|
737
|
+
// 同判据:本域的键集**按消费方的发布节奏生长**(限额面是分批接线的),而消费方的 registry-core 依赖是
|
|
738
|
+
// `^0.x` 段(0.x 的脱字号不跨 minor)。默认的 strip 会让「消费方新增了一个限额键、契约层版号还没跟上」
|
|
739
|
+
// 变成**键被静默剥掉 ⇒ 那条天花板悄悄回落到部署 env 底** —— 正是本域要根治的那种病换了个位置复发。
|
|
740
|
+
// 已知键照旧严判结构(坏值 ⇒ 本域 throw 档 ⇒ 消费方 fail-static 在上一份好配置);未知键原样承运,
|
|
741
|
+
// 由消费方(键表在它手里)决定认或不认。附带收益:老 TOC 编辑新 center 的 limits 文档时不会把自己
|
|
742
|
+
// 不认识的键写没了(strip 语义下 writeDomainFile 会真的丢键)。
|
|
743
|
+
// **承运 ≠ 沉默**(codex 复审 round2 F2):未知成员会被 `"unknown-keys-carried"` 警告逐个点名
|
|
744
|
+
// (config-fns 的已知键表由本 schema 的 shape 单源派生)——拼错的键名于是有人说,而值仍然到得了
|
|
745
|
+
// 消费方手里,两头都不吃亏。
|
|
746
|
+
.passthrough();
|
|
747
|
+
// ── Domain: governance (治理三件 — split out of runtime, EXPERT-REDESIGN §5) ──────
|
|
748
|
+
// The governance trio an operator tunes via publish: autonomy ladder + command guardrails + approval-required
|
|
749
|
+
// tools. NOT下沉 — gets its own admin tab (E5 form; E1 ships a transitional generic editor). Same `.optional()`
|
|
750
|
+
// no-default discipline as the six gates: ABSENT = center 不管这项(keep env / 消费者默认),present 才生效。
|
|
751
|
+
export const GovernanceConfig = z.object({
|
|
752
|
+
// autonomy:一个跨 knob 的安全总开关 / 放权梯度(普通用户敢用 + 专业用户放权)。
|
|
753
|
+
// read-only(只读规划,不落改)| ask(每个放权动作问)| plan(plan-review 门)| auto(放开)。
|
|
754
|
+
// ⚠️ 消费侧(service)把它**展开**到 core 已有三原语(handsReadOnly / shellGate+onAsk / plan_review),
|
|
755
|
+
// 经 core 的收紧-only `tightenTaskSpec` 合并。core 裁决:不在 core 加并行 autonomy 枚举(双实现陷阱),
|
|
756
|
+
// 不静默覆盖调用者已设安全字段(auto 模式尤其不能静默清门控)。详见 sema-registry docs/CONFIG-RESEARCH §10。
|
|
757
|
+
autonomy: z.enum(["read-only", "ask", "plan", "auto"]).optional(),
|
|
758
|
+
// commandPolicy:命令级 allow/ask/deny 护栏(remoteExec 管"在哪跑",这管"能跑什么命令")。每条 = 一个
|
|
759
|
+
// **精确 argv[0] 命令名** → 决策;**DENY-WINS**(冲突 deny 赢,与 core `combinePolicies` 一致,NOT last-match-wins)。
|
|
760
|
+
// ⚠️ EXACT,不是 glob(service 裁决①,2026-06-22):core 1.114.0 shipped 的 `createCoarseCommandNamePolicy`
|
|
761
|
+
// 是 **精确 Set 匹配**(§10 把 `createCommandGlobPolicy` 降级时 glob 能力即移除——glob-on-argv[0] 正是评审
|
|
762
|
+
// 要避的 bypass 面)。所以 `git*` 这类 glob 在此**被 fail-loud 拒绝**(不是静默永不匹配=放行的 fail-open 洞);
|
|
763
|
+
// 要扩面靠 allowlist/blocklist 模式(消费侧),不靠通配。
|
|
764
|
+
// ⚠️ COARSE / 非沙箱:只匹配 leading simple-command 名(经 core `parseLeadingCommandName` fail-closed 解析器);
|
|
765
|
+
// `$(...)` / `sh -c` / `env` / `xargs` / `find -exec` / `python -c` / 子命令都绕得过——是 egress/irreversibility
|
|
766
|
+
// 安全轴之下的**纵深防御,绝非唯一隔离**(真隔离靠 executionEnv sandbox)。消费侧(service)必须经
|
|
767
|
+
// `combinePolicies(部署默认, 本策略)` 编译进 `TaskSpec.toolPolicy`,绝不裸覆盖清掉部署级护栏;`shellGate:
|
|
768
|
+
// "classify"` 下普通 ask 会被 irreversibility 提升为不可预算的 irreversible_ask。core 建
|
|
769
|
+
// `createCoarseCommandNamePolicy` 消费它(config-research §10)。
|
|
770
|
+
commandPolicy: z
|
|
771
|
+
.array(z.object({
|
|
772
|
+
// 精确 argv[0] 命令名(如 "rm" / "git" / "docker-compose" / "python3.11")—— NOT a glob、NOT a path、NOT a
|
|
773
|
+
// shell 片段。拒 `*`/`?`(glob)、`/`(路径)、空格/shell 算符:运行时只拿 argv[0] 的名做精确匹配,任何这些
|
|
774
|
+
// 字符都永不匹配 = 规则静默失效(fail-open)→ 在此 fail-loud 拒收,逼操作员写成能真正生效的精确名。
|
|
775
|
+
command: z
|
|
776
|
+
.string()
|
|
777
|
+
.min(1)
|
|
778
|
+
.max(128)
|
|
779
|
+
.regex(/^[A-Za-z0-9._+-]+$/, "exact argv[0] command NAME — letters/digits/._+- only; NO glob (*,?), path (/), spaces or shell fragments — e.g. rm, git, docker-compose"),
|
|
780
|
+
decision: z.enum(["allow", "ask", "deny"]),
|
|
781
|
+
}))
|
|
782
|
+
.optional(),
|
|
783
|
+
/** Tool names needing operator approval (F4). Moved verbatim from runtime (same ABSENT/explicit-[] semantics). */
|
|
784
|
+
approvalRequire: z.array(z.string()).optional(),
|
|
785
|
+
});
|
|
786
|
+
/** The governance trio keys. 0.7.1:写出方向的 runtime 双写镜像已撤(service 1.150.0 已切新位,
|
|
787
|
+
* 回执确认)——此常量今日唯一职责=READ 方向的 legacy lift(governance 域从未设时从旧 runtime 文档抬升,
|
|
788
|
+
* config-fns legacyGovernanceFromRuntime),单一键源防逐键漂移。 */
|
|
789
|
+
export const GOVERNANCE_MIRROR_KEYS = ["autonomy", "commandPolicy", "approvalRequire"];
|
|
790
|
+
// ── integrations domain: DELETED in 0.6.0 (EXPERT-REDESIGN §9 — 零消费者实锤:service 明确排除,OA/Gitea
|
|
791
|
+
// 实走 worker.env)。“按 scope connect GitHub” 是 backlog 的全新设计,不复用此形状。
|
|
792
|
+
// ── access domain: DELETED in 0.6.0 (EXPERT-REDESIGN §9 — 彻底删除不留兼容;唯一消费者 agent-client portal
|
|
793
|
+
// 已迁 env 名单)。人员自助申请 access_request 与 git-identities 是不同前缀,保留,与此无关。
|
|
794
|
+
// ── Domain: entitlement (per-PRINCIPAL governance — the "管人" axis, ⊥ console RBAC) ───────────────────────
|
|
795
|
+
// L2 of the control plane (design CENTER-CONTROL-PLANE §3): who-can-USE-what (models / skills / mcp / budget /
|
|
796
|
+
// runtime caps), keyed by the runtime PRINCIPAL — orthogonal to the api-token RBAC (who-can-EDIT-config); mixing
|
|
797
|
+
// the two is a security incident. Stored in full here; the worker `/effective` EMPTIES it (privacy — never leak
|
|
798
|
+
// every principal's tier/budget to a worker), and `/effective?principal=` resolves the per-principal view
|
|
799
|
+
// (effective = worker-base ∩ tier⊕roles ∩ time-bounded overrides; intersection = either side's "deny" wins).
|
|
800
|
+
// Minimal records — never raw usage numbers (those live in the data plane / telemetry).
|
|
801
|
+
/** Token budget — LiteLLM-interop vocab + the center-specific DEGRADE mode + a per-RUN admission gate. */
|
|
802
|
+
export const EntitlementBudget = z.object({
|
|
803
|
+
/** soft = alert only · hard = pre-call block · degrade = fall back to a cheaper model (center-specific). */
|
|
804
|
+
mode: z.enum(["soft", "hard", "degrade"]).optional(),
|
|
805
|
+
// .finite() on every $/USD float: z.number().nonnegative() accepts Infinity, which JSON.stringify's to null on
|
|
806
|
+
// persist → a later strict reparse throws (fail-open on write, fail-hard on read). Same discipline as ModelCost.
|
|
807
|
+
maxBudgetUsd: z.number().finite().nonnegative().optional(),
|
|
808
|
+
budgetDuration: z.string().optional(), // e.g. "30d"
|
|
809
|
+
tpmLimit: z.number().int().nonnegative().optional(),
|
|
810
|
+
rpmLimit: z.number().int().nonnegative().optional(),
|
|
811
|
+
maxParallelRequests: z.number().int().nonnegative().optional(),
|
|
812
|
+
perModel: z.record(z.string(), z.object({ maxBudgetUsd: z.number().finite().nonnegative().optional(), tpmLimit: z.number().int().nonnegative().optional(), rpmLimit: z.number().int().nonnegative().optional() })).optional(),
|
|
813
|
+
/** Per-RUN admission gate (agentic-runaway guard, §3.3 🔴) — period budgets can't stop ONE runaway run. */
|
|
814
|
+
maxRunCostUsd: z.number().finite().nonnegative().optional(),
|
|
815
|
+
maxIterations: z.number().int().positive().optional(),
|
|
816
|
+
/** WHERE this budget is enforced (scheduling framework D4, 产品拍板 2026-07-04): `local` = worker-only
|
|
817
|
+
* (per-instance or shared-DB — today's posture) · `lease` = AP pre-authorized QuotaLease from center +
|
|
818
|
+
* after-the-fact reconciliation (default for sema-managed fleets) · `central` = CP pre-call check against
|
|
819
|
+
* center (strict tenants; slow but exact). ABSENT = local. Orthogonal to `mode` (what happens on breach). */
|
|
820
|
+
enforcement: z.enum(["local", "lease", "central"]).optional(),
|
|
821
|
+
});
|
|
822
|
+
/** Token 双窗额度(配额轴, EXPERT-REDESIGN §4 — Claude 式 5h 滚动窗 + 自然周窗),单位=加权 tokens
|
|
823
|
+
* (ModelEntry.quotaWeight 折算,weight-at-burn 记账时固化)。挂载点**只三处**:defaults(全局默认)/
|
|
824
|
+
* subject(用户)/groupBinding(组) —— **绝不挂 tier**(B1 违宪修复,EntitlementTier fail-loud 拒收)。
|
|
825
|
+
* 取值极性(与 budget 的 min-wins 不同,明定):最具体一层生效(用户 > 组 > 全局默认),同层多组命中取
|
|
826
|
+
* 更严;tokenQuota 不参与 budget 的 min-of-budgets 归并。任一窗耗尽即拒(CC 式拒绝+恢复时间,E4)。 */
|
|
827
|
+
export const TokenQuota = z.object({
|
|
828
|
+
/** 5h 滚动窗额度(加权 tokens)。ABSENT = 此窗不设限。 */
|
|
829
|
+
fiveHourTokens: z.number().int().nonnegative().optional(),
|
|
830
|
+
/** 自然周窗额度(加权 tokens)。ABSENT = 此窗不设限。 */
|
|
831
|
+
weeklyTokens: z.number().int().nonnegative().optional(),
|
|
832
|
+
});
|
|
833
|
+
/** 预算组(共享钱池,§4-1)——与个人 budget 双轨并行:一笔花费计入个人轨 + 所有所属池;多池并存取最先
|
|
834
|
+
* 耗尽;tokens 配额轴(TokenQuota)与它是两轨,先到顶者生效。成员条目带 `memberSince`:池只聚合入组时刻
|
|
835
|
+
* 之后的用量(入组不带入已烧量、退组不回落)——契约随 E1 冻结。 */
|
|
836
|
+
export const BudgetGroupMember = z.object({
|
|
837
|
+
/** 池成员生效时刻(ISO)——用量聚合的下边界。 */
|
|
838
|
+
memberSince: z.string().min(1),
|
|
839
|
+
});
|
|
840
|
+
export const BudgetGroup = z.object({
|
|
841
|
+
id: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "budget group id: letters/digits/._- , no spaces"),
|
|
842
|
+
name: z.string().min(1),
|
|
843
|
+
maxBudgetUsd: z.number().finite().nonnegative(),
|
|
844
|
+
/** 池预算周期,e.g. "30d"(同 EntitlementBudget.budgetDuration 词汇)。ABSENT = 不按周期重置。 */
|
|
845
|
+
budgetDuration: z.string().optional(),
|
|
846
|
+
members: z
|
|
847
|
+
.object({
|
|
848
|
+
/** instance-level group ids(registry `groups` 表——store 对象,zod 看不见,存在性同 groupBindings
|
|
849
|
+
* 先例不做 schema 校验,dangling 由 UI 标红)。 */
|
|
850
|
+
groups: z.array(BudgetGroupMember.extend({ group: z.string().min(1) })).default([]),
|
|
851
|
+
principals: z.array(BudgetGroupMember.extend({ principal: z.string().min(1) })).default([]),
|
|
852
|
+
})
|
|
853
|
+
.default({ groups: [], principals: [] }),
|
|
854
|
+
});
|
|
855
|
+
/** 生效场景分配(§7,与配额同一挂载心智):全局默认 + 按组 + 按用户三层;解析优先级=用户 > 组 > 全局默认
|
|
856
|
+
* > worker 级 runtime.scenario 兜底(引擎兜底行为待 ask② 确认)。`scenario` 是场景 NAME——可指 center
|
|
857
|
+
* scenarios 域条目,也可指 service 内建场景名(内建不在域里,故**不做** cross-domain 存在性硬校验,
|
|
858
|
+
* dangling 由 UI 标注)。 */
|
|
859
|
+
export const ScenarioAssignments = z.object({
|
|
860
|
+
/** 全局默认场景名。ABSENT = 不设全局默认(落到 worker 级 runtime.scenario / 引擎内建)。 */
|
|
861
|
+
default: z.string().min(1).optional(),
|
|
862
|
+
groups: z.array(z.object({ group: z.string().min(1), scenario: z.string().min(1) })).default([]),
|
|
863
|
+
principals: z.array(z.object({ principal: z.string().min(1), scenario: z.string().min(1) })).default([]),
|
|
864
|
+
});
|
|
865
|
+
/** Org-forced-off runtime ceilings (gate②). ABSENT = no ceiling (local controls); `false` = org-disabled
|
|
866
|
+
* fleet-wide. Only ever pushes the "off" ceiling — never force-enables (monotone tighten-only). */
|
|
867
|
+
export const EntitlementRuntimeCaps = z.object({
|
|
868
|
+
allowBypassPermissions: z.boolean().optional(),
|
|
869
|
+
allowAutoMode: z.boolean().optional(),
|
|
870
|
+
/** Per-principal governance of the workflow/ultracode capability (K-2, 产品拍板 2026-06-28). center treats this as
|
|
871
|
+
* the "准不准" axis; the engine separately advertises "能不能" (Capabilities.workflows). The shell enables
|
|
872
|
+
* ultracode/workflows iff engine-capable ∧ principal-entitled. Like the others: ABSENT = no ceiling,
|
|
873
|
+
* `false` = org-disabled fleet-wide (tighten-only false-wins; never force-enables). Full server-side
|
|
874
|
+
* enforcement awaits core adding a per-principal authz axis that consumes this; until then it gates the shell UI. */
|
|
875
|
+
allowWorkflows: z.boolean().optional(),
|
|
876
|
+
/** Per-principal governance of the Fork (fork-session) tool — center's "准不准" axis, mirroring allowWorkflows.
|
|
877
|
+
* The engine advertises "能不能" separately; a Fork is admitted iff engine-capable ∧ principal-entitled. Like the
|
|
878
|
+
* others: ABSENT = no ceiling (Fork allowed), `false` = org-disabled fleet-wide (tighten-only false-wins; never
|
|
879
|
+
* force-enables). core (RuntimeCaps.allowFork, prepare-task) consumes this per-principal: `allowFork === false`
|
|
880
|
+
* → Fork DENIED, else allowed. */
|
|
881
|
+
allowFork: z.boolean().optional(),
|
|
882
|
+
/** Per-principal governance of observer agents (core 1.270.0/1.273.0 OBS-2) —
|
|
883
|
+
* center's "准不准" axis for the AgentDefinition.observer pairing surface. ⚠️ core's consumption polarity is
|
|
884
|
+
* INVERTED vs the other allow* caps: the engine is `=== true` explicit opt-in (default OFF — an undefined cap
|
|
885
|
+
* ships dark), so this key is the ENABLE line, not just a ceiling. Three-state semantics (ABSENT ≠ false):
|
|
886
|
+
* ABSENT = center says nothing → key NOT emitted on the wire (core stays the SOLE default source; service's
|
|
887
|
+
* toCoreRuntimeCaps must not backfill a missing key into an explicit verdict — that would poison the
|
|
888
|
+
* env-baseline compose order); `true` = explicitly enable observer agents for this principal; `false` =
|
|
889
|
+
* explicit org deny — overrides the node-level env baseline EXPERIMENTAL_OBSERVER_AGENTS (center speaks →
|
|
890
|
+
* center wins). On compose across bundles, `false` (deny) still wins — see RUNTIME_CAP_STRICT; core's
|
|
891
|
+
* `=== true` opt-in check is orthogonal to that merge polarity. */
|
|
892
|
+
allowObservers: z.boolean().optional(),
|
|
893
|
+
/** Per-principal ARM switch for the auto-mode CLASSIFIER (core 1.276 批2 seam `RuntimeCaps.autoMode`, 板
|
|
894
|
+
* A surviving permission `ask` is routed to the small-model security classifier before any
|
|
895
|
+
* human/durable resolution). ⚠️ Same INVERTED consumption polarity as `allowObservers`: core is `=== true`
|
|
896
|
+
* explicit opt-in, default OFF — this key is the ENABLE line, not just a ceiling. Three-state semantics
|
|
897
|
+
* (ABSENT ≠ false): ABSENT = center says nothing → key NOT emitted on the wire (core stays the SOLE default
|
|
898
|
+
* source — never backfill absence into an explicit verdict); `true` = explicitly arm the classifier for this
|
|
899
|
+
* principal (a no-op on a deployment without a wired classify leg — the capability gate lives in core);
|
|
900
|
+
* `false` = explicit org deny. On compose across bundles `false` (deny) wins — see RUNTIME_CAP_STRICT;
|
|
901
|
+
* core's `=== true` opt-in check is orthogonal to that merge polarity. DIVISION OF LABOR vs
|
|
902
|
+
* {@link allowAutoMode}: `allowAutoMode` is the 准入 may-I ceiling on the USER-facing auto permission mode
|
|
903
|
+
* (shell/settings face, tighten-only); `autoMode` is center's 部署 arm decision for the server-side
|
|
904
|
+
* classifier leg itself. */
|
|
905
|
+
autoMode: z.boolean().optional(),
|
|
906
|
+
allowUltracode: z.boolean().optional(),
|
|
907
|
+
/** Force this principal's run onto durable-approval (per-tool gate) — center's fleet-wide handle for CC-faithful
|
|
908
|
+
* per-tool interception (K-3, core confirmed it consumes this). Polarity is INVERTED vs the allow* caps:
|
|
909
|
+
* here `true` is the STRICT/tightening value (forcing the gate ON), so it wins on compose — see RUNTIME_CAP_STRICT.
|
|
910
|
+
* ABSENT = no fleet override (local/default resolution decides). Server-side enforcement = core's per-principal axis. */
|
|
911
|
+
forceDurableGate: z.boolean().optional(),
|
|
912
|
+
});
|
|
913
|
+
/** The STRICT (tightening) value per RuntimeCap — the value that WINS on compose (monotone tighten-only). The
|
|
914
|
+
* `allow*` ceilings tighten toward `false` (disabled wins); `forceDurableGate` tightens toward `true` (forced
|
|
915
|
+
* wins). Single source for the merge polarity so resolvers don't hardcode it. `Record<keyof …>` = a new cap
|
|
916
|
+
* without an entry is a COMPILE error (no silent wrong-direction merge). */
|
|
917
|
+
export const RUNTIME_CAP_STRICT = {
|
|
918
|
+
allowBypassPermissions: false,
|
|
919
|
+
allowAutoMode: false,
|
|
920
|
+
allowWorkflows: false,
|
|
921
|
+
allowFork: false,
|
|
922
|
+
allowObservers: false,
|
|
923
|
+
autoMode: false,
|
|
924
|
+
allowUltracode: false,
|
|
925
|
+
forceDurableGate: true,
|
|
926
|
+
};
|
|
927
|
+
/** A named entitlement bundle (a `tier`, or a composable `role`). Bind a `roster` to inherit its model/skill/mcp
|
|
928
|
+
* visibility for free; explicit allowlists intersect on top. Edit a tier once → re-prices everyone on it. */
|
|
929
|
+
export const EntitlementTier = z.object({
|
|
930
|
+
id: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "tier id: letters/digits/._- , no spaces"),
|
|
931
|
+
roster: z.string().optional(),
|
|
932
|
+
models: z.array(z.string()).optional(),
|
|
933
|
+
visibleSkills: z.array(z.string()).optional(),
|
|
934
|
+
visibleMcp: z.array(z.string()).optional(),
|
|
935
|
+
/** Plugin allowlist (plugins domain NAMEs) — plugins are an executable surface, so the same null/absent =
|
|
936
|
+
* "no constraint on this axis" semantics as visibleMcp (deny-wins on intersection; C2, additive 0.5.0). */
|
|
937
|
+
visiblePlugins: z.array(z.string()).optional(),
|
|
938
|
+
budget: EntitlementBudget.optional(),
|
|
939
|
+
runtimeCaps: EntitlementRuntimeCaps.optional(),
|
|
940
|
+
/** design/170 件A(additive 0.15.0):org 记忆授权束——`org:` scope 键 → 授予(`write` 缺席=只读)。
|
|
941
|
+
* 挂 bundle 与 visibleSkills 同轴:tier ⊕ roles 跨束**并集**(write:true 在并集中胜出——多束=更多
|
|
942
|
+
* 访问,「展开」轴)。刻意**不挂 defaults**:全员默认 org 授权恰是准入面要防的形;要全员授,建显式
|
|
943
|
+
* tier。键形状门=`org:` 前缀(严校验单一真源在 core 的 v2 scope 契约,此处只拦挂错轴的类别错误)。 */
|
|
944
|
+
orgMemory: z.record(z.string().regex(/^org:\S+$/, "orgMemory key must be an `org:` scope string"), z.object({ write: z.boolean().optional() }).strict()).optional(),
|
|
945
|
+
/** 🔴 B1 违宪修复(EXPERT-REDESIGN §4-1):tokenQuota **不挂 tier** —— 只挂 defaults/subject/groupBinding。
|
|
946
|
+
* fail-loud 而非 zod 默认的静默剥除(admin 挂上去以为生效才是最糟的)。 */
|
|
947
|
+
tokenQuota: z
|
|
948
|
+
.custom(() => false, { message: "tokenQuota does not mount on a tier — mount it on defaults / subjects[] / groupBindings[] (拍板:配额挂用户/组,非访问级别)" })
|
|
949
|
+
.optional(),
|
|
950
|
+
});
|
|
951
|
+
/** A time-bounded, audited per-principal override (escape hatch above the tier/role defaults). */
|
|
952
|
+
export const EntitlementOverride = z
|
|
953
|
+
.object({
|
|
954
|
+
principal: z.string().min(1),
|
|
955
|
+
key: z.string().min(1),
|
|
956
|
+
value: z.unknown(),
|
|
957
|
+
effectiveFrom: z.string().optional(),
|
|
958
|
+
effectiveUntil: z.string().optional(),
|
|
959
|
+
approvedBy: z.string().min(1),
|
|
960
|
+
reason: z.string().optional(),
|
|
961
|
+
})
|
|
962
|
+
.superRefine((o, ctx) => {
|
|
963
|
+
// value is persisted in the entitlement domain + frozen into PublishedSnapshot + audited — a pasted secret
|
|
964
|
+
// would ride along. Reject any secret-looking token (string or anywhere in a JSON value). Reference secrets
|
|
965
|
+
// by env-NAME, never inline. (Budget overrides are numbers/enums, so this never fires on the v1 use.)
|
|
966
|
+
const s = typeof o.value === "string" ? o.value : o.value == null ? "" : JSON.stringify(o.value);
|
|
967
|
+
if (s && containsSecretToken(s))
|
|
968
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "override.value appears to contain a secret token — overrides are persisted + audited; reference secrets by env-NAME, never inline a value", path: ["value"] });
|
|
969
|
+
});
|
|
970
|
+
export const EntitlementSubject = z.object({
|
|
971
|
+
principal: z.string().min(1),
|
|
972
|
+
displayName: z.string().optional(),
|
|
973
|
+
tierId: z.string().optional(),
|
|
974
|
+
/** Additional tier ids composed on top of `tierId` (union of allows, min of budgets) — the "role combo". */
|
|
975
|
+
roles: z.array(z.string()).default([]),
|
|
976
|
+
overrides: z.array(EntitlementOverride).default([]),
|
|
977
|
+
/** 用户层 token 双窗额度(最具体层,压过组与全局默认——极性见 {@link TokenQuota})。 */
|
|
978
|
+
tokenQuota: TokenQuota.optional(),
|
|
979
|
+
});
|
|
980
|
+
/** A GROUP → tier/roles binding (W4-Q01 已拍: 组绑定住在 entitlement 域内, per-scope 天然). `group` is the
|
|
981
|
+
* INSTANCE-LEVEL group id (registry `groups` table — a store object, invisible to zod, so group EXISTENCE
|
|
982
|
+
* cannot be schema-checked; a dangling group binding silently matches nobody and the UI red-flags it).
|
|
983
|
+
* Merge semantics (W4-Q02 已拍, 并列合并): a member of the group gets the binding's tier ⊕ roles bundles
|
|
984
|
+
* UNIONED with their personal subject bundles on the allow axes; budget/runtimeCaps stay tighten-only
|
|
985
|
+
* (更严者胜) — a group binding can NARROW a personal budget, never widen it (top invariant). Overrides
|
|
986
|
+
* remain personal-only (subject.overrides) — no group-wide escape hatch. */
|
|
987
|
+
export const EntitlementGroupBinding = z.object({
|
|
988
|
+
group: z.string().min(1),
|
|
989
|
+
tierId: z.string().optional(),
|
|
990
|
+
/** Additional tier ids composed on top of `tierId` — same "role combo" shape as EntitlementSubject. */
|
|
991
|
+
roles: z.array(z.string()).default([]),
|
|
992
|
+
/** 组层 token 双窗额度(同层多组命中取更严;被用户层压过——极性见 {@link TokenQuota})。 */
|
|
993
|
+
tokenQuota: TokenQuota.optional(),
|
|
994
|
+
});
|
|
995
|
+
export const EntitlementConfig = z
|
|
996
|
+
.object({
|
|
997
|
+
/** FLEET-WIDE baseline applied to EVERY principal — including those with NO subject record. This is how an
|
|
998
|
+
* org kill-switch (e.g. runtimeCaps.allowBypassPermissions:false) reaches unconfigured principals: a tier
|
|
999
|
+
* can only TIGHTEN further (monotone), never re-loosen the default. Leave unset for per-tier-only control. */
|
|
1000
|
+
defaults: z
|
|
1001
|
+
.object({
|
|
1002
|
+
runtimeCaps: EntitlementRuntimeCaps.optional(),
|
|
1003
|
+
budget: EntitlementBudget.optional(),
|
|
1004
|
+
/** 全局默认 token 双窗额度(最不具体层——被组/用户层压过)。 */
|
|
1005
|
+
tokenQuota: TokenQuota.optional(),
|
|
1006
|
+
})
|
|
1007
|
+
.optional(),
|
|
1008
|
+
tiers: z.array(EntitlementTier).default([]),
|
|
1009
|
+
subjects: z.array(EntitlementSubject).default([]),
|
|
1010
|
+
/** Group bindings (W4, additive 0.4.0) — bind an instance-level GROUP to a tier ⊕ roles combo. */
|
|
1011
|
+
groupBindings: z.array(EntitlementGroupBinding).default([]),
|
|
1012
|
+
/** 预算组(共享钱池,0.6.0 §4)——见 {@link BudgetGroup}。 */
|
|
1013
|
+
budgetGroups: z.array(BudgetGroup).default([]),
|
|
1014
|
+
/** 生效场景分配(0.6.0 §7)——见 {@link ScenarioAssignments}。 */
|
|
1015
|
+
scenarioAssignments: ScenarioAssignments.optional(),
|
|
1016
|
+
})
|
|
1017
|
+
.superRefine((cfg, ctx) => {
|
|
1018
|
+
const tierIds = new Set();
|
|
1019
|
+
cfg.tiers.forEach((t, i) => {
|
|
1020
|
+
if (tierIds.has(t.id))
|
|
1021
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate tier id "${t.id}"`, path: ["tiers", i, "id"] });
|
|
1022
|
+
tierIds.add(t.id);
|
|
1023
|
+
});
|
|
1024
|
+
const seenPools = new Set();
|
|
1025
|
+
cfg.budgetGroups.forEach((b, i) => {
|
|
1026
|
+
if (seenPools.has(b.id))
|
|
1027
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate budget group id "${b.id}"`, path: ["budgetGroups", i, "id"] });
|
|
1028
|
+
seenPools.add(b.id);
|
|
1029
|
+
});
|
|
1030
|
+
const seenGroups = new Set();
|
|
1031
|
+
cfg.groupBindings.forEach((g, i) => {
|
|
1032
|
+
if (seenGroups.has(g.group))
|
|
1033
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate group binding "${g.group}"`, path: ["groupBindings", i, "group"] });
|
|
1034
|
+
seenGroups.add(g.group);
|
|
1035
|
+
if (g.tierId && !tierIds.has(g.tierId))
|
|
1036
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `group binding "${g.group}" → unknown tier "${g.tierId}"`, path: ["groupBindings", i, "tierId"] });
|
|
1037
|
+
g.roles.forEach((r, j) => { if (!tierIds.has(r))
|
|
1038
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `group binding "${g.group}" → unknown role/tier "${r}"`, path: ["groupBindings", i, "roles", j] }); });
|
|
1039
|
+
});
|
|
1040
|
+
const seen = new Set();
|
|
1041
|
+
cfg.subjects.forEach((s, i) => {
|
|
1042
|
+
if (seen.has(s.principal))
|
|
1043
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate subject principal "${s.principal}"`, path: ["subjects", i, "principal"] });
|
|
1044
|
+
seen.add(s.principal);
|
|
1045
|
+
if (s.tierId && !tierIds.has(s.tierId))
|
|
1046
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `subject "${s.principal}" → unknown tier "${s.tierId}"`, path: ["subjects", i, "tierId"] });
|
|
1047
|
+
s.roles.forEach((r, j) => { if (!tierIds.has(r))
|
|
1048
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `subject "${s.principal}" → unknown role/tier "${r}"`, path: ["subjects", i, "roles", j] }); });
|
|
1049
|
+
s.overrides.forEach((o, j) => { if (o.principal !== s.principal)
|
|
1050
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `override.principal "${o.principal}" must equal subject "${s.principal}"`, path: ["subjects", i, "overrides", j, "principal"] }); });
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
/** design/170 件A:`?principal=` caps wire 上的 org 记忆解析段(center 铸、server 消费,两侧共此同源锚)。
|
|
1054
|
+
* 段级三值语义:**键整体缺席**=center 不支持该段(能力握手,消费方按「取不到」瞬时处理,不判终局);
|
|
1055
|
+
* **`scopes: {}` 空表**=取数成功的负结果(该 principal 零 org 授权,终局);**有表**=逐条目判。
|
|
1056
|
+
* `gen`=单调世代号(publish 模式=globalRevision,live 模式=store version;消费方拒退挂此,防线为
|
|
1057
|
+
* 进程生命周期内的 LB 陈旧副本——重启后无高水位=接受 center 当刻值,center 是真源)。 */
|
|
1058
|
+
export const PrincipalOrgMemoryWire = z
|
|
1059
|
+
.object({
|
|
1060
|
+
gen: z.number().int().nonnegative(),
|
|
1061
|
+
scopes: z.record(z.string().regex(/^org:\S+$/, "orgMemory scope must be an `org:` scope string"), z.object({ write: z.boolean().optional() }).strict()),
|
|
1062
|
+
})
|
|
1063
|
+
.strict();
|
|
1064
|
+
/** `?principal=` caps wire **整体**的读边界 schema(center 铸、server 消费同源锚;clay 令 2026-08-05:
|
|
1065
|
+
* 网络边界读入必 schema,禁裸 as-cast)。**逐面容错极性是裁定过的语义**,严格化会翻转极性的面在此
|
|
1066
|
+
* 显式保持宽读(每面注明所依裁定),不许硬转严:
|
|
1067
|
+
* - `runtimeCaps`/`budget`(治理面,fail-closed):严格校验已知键 + **passthrough 未知键**——新 center
|
|
1068
|
+
* 的新 cap 键必须原样透传给 core(老 server 剥键=静默放松治理);值漂移 ⇒ 整体 parse 失败 ⇒ 消费端
|
|
1069
|
+
* fail-close(「outage 绝不静默 GRANT」的既裁方向)。
|
|
1070
|
+
* - `scenario`/`allowlist`/`execution`(能力面,fail-open,[565]/[580] 裁定):保持 `unknown` 宽读,
|
|
1071
|
+
* 归一与 drift 审计诊断在消费端(schema 无法产出 executionDrift 诊断串,且整体拒会把 fail-open
|
|
1072
|
+
* 面错翻成 fail-closed)。
|
|
1073
|
+
* - `orgMemory`(授权面,C3 失败域隔离):保持 `unknown`——**只**由 org 目录腿用
|
|
1074
|
+
* {@link PrincipalOrgMemoryWire} 校验;在本 schema 里校验会把 org 段坏形连坐进 caps 腿(两张表
|
|
1075
|
+
* 互不干扰是在档裁定)。
|
|
1076
|
+
* - `principal`(C8 回声核面):严格 string——比对逻辑在消费端。
|
|
1077
|
+
* 顶层 `.passthrough()`:开放世界,新键透传不拒。 */
|
|
1078
|
+
export const PrincipalCapsWire = z
|
|
1079
|
+
.object({
|
|
1080
|
+
principal: z.string().optional(),
|
|
1081
|
+
worker: z.string().nullable().optional(),
|
|
1082
|
+
configured: z.boolean().optional(),
|
|
1083
|
+
runtimeCaps: EntitlementRuntimeCaps.passthrough().nullable().optional(),
|
|
1084
|
+
budget: EntitlementBudget.passthrough().nullable().optional(),
|
|
1085
|
+
scenario: z.unknown().optional(),
|
|
1086
|
+
allowlist: z.unknown().optional(),
|
|
1087
|
+
execution: z.unknown().optional(),
|
|
1088
|
+
orgMemory: z.unknown().optional(),
|
|
1089
|
+
})
|
|
1090
|
+
.passthrough();
|
|
1091
|
+
/** design/171 §5.1 决策者身份断言(跨仓 wire 契约同源锚:server 铸、cli/web/SDK 呈)。
|
|
1092
|
+
* 规范条款(171 原文):**actor 永不参与权限判定**——权威恒属 principal;本形只作审计/回显/呈现。
|
|
1093
|
+
* - `id`:**恒由 server 自铸**=verified principal(auth-off 部署铸 `"_"` 哨兵);wire 上的请求方
|
|
1094
|
+
* 永远只能提交 `label`(未验证展示文本,server 侧 redact+限长),不能提交 id/verified。
|
|
1095
|
+
* - `verified`:server 是否在鉴权语境下铸出此 id(auth-off ⇒ false,如实)。
|
|
1096
|
+
* - `via`(server 扩,additive):此决议经哪个授权路径受理——`owner`=行属主自决,
|
|
1097
|
+
* `operator`=显式运维席(explicitOperatorOk)。审计维度,呈现层可选用。
|
|
1098
|
+
* 读侧一律 `safeParse`;坏形处置归消费端(如回显时省略 actor 字段,不整体失败)。 */
|
|
1099
|
+
export const ActorAssertionWire = z
|
|
1100
|
+
.object({
|
|
1101
|
+
id: z.string().min(1).max(256),
|
|
1102
|
+
label: z.string().max(256).optional(),
|
|
1103
|
+
verified: z.boolean().optional(),
|
|
1104
|
+
via: z.enum(["owner", "operator"]).optional(),
|
|
1105
|
+
})
|
|
1106
|
+
.strict();
|
|
1107
|
+
// ── Domain: execution (S3 执行轴管控 — WHERE work may run, stage7 S3 / P5=A) ──────────────────
|
|
1108
|
+
// The execution-LANE policy an admin sets per scope(「团队任务必须在沙箱/受控执行环境中运行」).
|
|
1109
|
+
// Resolution MIRRORS the governance precedent: a plain SCOPE-level domain — no per-principal overlay,
|
|
1110
|
+
// no layer merge invented here (entitlement is the per-principal axis; this is not it). v1 = scope 级.
|
|
1111
|
+
// ⚠️ ENFORCEMENT HONESTY (P5=A, 2026-07): today the shell enforces this CLIENT-SIDE only (it locks its
|
|
1112
|
+
// sandbox settings by policy, CC managed-settings semantics) — env / a local config-source can bypass it.
|
|
1113
|
+
// The REAL boundary (engine gate refusing a disallowed lane) is the service's, tracked on the service side
|
|
1114
|
+
// until that ships, never present this policy as a hard security boundary.
|
|
1115
|
+
/** sessionMirror TARGET url 的底线传输校验(0.10.1;0.10.2 加 trim 前置):**https,或 loopback http**
|
|
1116
|
+
* (本机第二引擎/dev)。与壳 `evaluateEngineRouting`(shell-host engineTarget.ts,S1 路由闸)语义对齐:
|
|
1117
|
+
* 镜像上行带会话内容+Bearer 凭证,非 loopback 明文 http = 凭证与会话数据裸奔——**写侧 schema 层就拒**
|
|
1118
|
+
* (fail-loud 比读侧执法更早;center 编辑面存不进去,壳读侧闸只兜手改存储的漏网)。基线复用
|
|
1119
|
+
* {@link NON_SECRET_URL}(禁 userinfo 凭证/禁 query/fragment 夹带 secret-shaped 值)。loopback 判定与壳
|
|
1120
|
+
* isLoopbackEngineUrl 逐字同表:127.0.0.1 / localhost / ::1 / [::1](URL.hostname 对 IPv6 带方括号,
|
|
1121
|
+
* 两写法都收)。**trim 前置(0.10.2,附B 三单合评审 M-1)**:前导/尾随空白先归一再验且存储值即 trim
|
|
1122
|
+
* 后值——否则 " https://…" 写侧收(new URL 容忍前导空白)而壳读侧逐字符比对拒 = 全员镜像静默 degraded。 */
|
|
1123
|
+
export const SESSION_MIRROR_ENGINE_URL = z.string().trim().pipe(NON_SECRET_URL).refine((s) => {
|
|
1124
|
+
try {
|
|
1125
|
+
const u = new URL(s);
|
|
1126
|
+
if (u.protocol === "https:")
|
|
1127
|
+
return true;
|
|
1128
|
+
const h = u.hostname;
|
|
1129
|
+
return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]";
|
|
1130
|
+
}
|
|
1131
|
+
catch {
|
|
1132
|
+
return true; // already passed NON_SECRET_URL; unreachable in practice
|
|
1133
|
+
}
|
|
1134
|
+
}, "non-loopback sessionMirror.engineUrl must be https — the mirror carries session content + a Bearer credential; plain http across the network is rejected at WRITE time (shell evaluateEngineRouting 同款传输闸)");
|
|
1135
|
+
/** Org 默认会话镜像目标(WEB-SETUP-RECON 附B,产品拍板两层,0.10.1)。**收编进 execution 域**(非独立
|
|
1136
|
+
* 域)——142-S3 草案同款尺量:①同一治理轴(org 对「会话数据必须落到受控引擎」的 scope 级管控,与
|
|
1137
|
+
* 「任务必须跑在受控 lane」同族;`required` 语义与 {@link ExecutionPolicy}.required 完全同构=壳锁定、
|
|
1138
|
+
* 用户不可关、CC managed-settings 心智);②**无独立读损语义**(损坏退默认=required:true 审计强制静默
|
|
1139
|
+
* 蒸发=放权,与 execution 的 throw 理由逐字同款——tierGroups 收编 models 的同款判据);③无独立写面
|
|
1140
|
+
* (纯声明字段,整文档 PUT 表达,无动作端点;RBAC 同 editor 档,信任面=org 背书与模型下发同级)。
|
|
1141
|
+
* 独立域只换来第 16 域膨胀+#74 双表态+缺域 fixture 全套纯开销。
|
|
1142
|
+
* 两层语义(附B r1 终稿):
|
|
1143
|
+
* - `required:false`(默认)= **便利默认值**:壳解析序第三腿(本地显式 --target > 本值 > connect
|
|
1144
|
+
* 回落),开关仍由用户 `sync on`(隐私 opt-in 先例不破,STAGE7 P3);
|
|
1145
|
+
* - `required:true` = **org 治理强制**:会话镜像默认开且不可关,且**本地覆盖不生效——既不能改目标
|
|
1146
|
+
* 也不能关闭(org 压一切;附B 三单合评审 MAJOR-1 勘误:治理态下本地 --target 完全被忽略)**
|
|
1147
|
+
* (审计场景;壳复用 CC managed-settings 锁定报错串——S3 execution 沙箱锁完全同构先例)。
|
|
1148
|
+
* 整个 sessionMirror **optional,缺省 INERT**(无下发=老 center/老壳/0.8-0.10 存量 execution 文档
|
|
1149
|
+
* 零行为差、零迁移)。⚠️ ENFORCEMENT HONESTY 同域头注:required 的锁今天也是 CLIENT-SIDE 执法。
|
|
1150
|
+
* 开放演化:v2 可能加 audit 字段类(保留窗/脱敏面等)——本对象刻意**不 .strict()**(zod 默认 strip
|
|
1151
|
+
* 未知键:v2 文档被 v1 消费者读到=未知键剥除,forward-compatible;加字段恒 additive patch)。 */
|
|
1152
|
+
export const SessionMirrorPolicy = z.object({
|
|
1153
|
+
/** 镜像目标引擎 url(service 线 /v1 base)。校验见 {@link SESSION_MIRROR_ENGINE_URL}。 */
|
|
1154
|
+
engineUrl: SESSION_MIRROR_ENGINE_URL,
|
|
1155
|
+
/** true = org 治理强制(镜像默认开且不可关);false(默认)= 便利默认值(只补目标,不动开关)。 */
|
|
1156
|
+
required: z.boolean().default(false),
|
|
1157
|
+
});
|
|
1158
|
+
/** 执行域 sealed-box 加密公钥登记(0.10.5,密钥托管线;挂 execution 域=「执行环境的属性」循
|
|
1159
|
+
* sessionMirror 收编先例,零新域)。center **只登记公钥**——私钥由执行侧(service worker/引擎)
|
|
1160
|
+
* 生成并持有,永不上行;浏览器端用这里的公钥 crypto_box_seal 加密模型 key(见 {@link SealedApiKey})。
|
|
1161
|
+
* X25519 公钥恒 32 字节 → base64 恒 43 字符 + "=",regex 钉死(错长度=根本不是 X25519 公钥,写侧
|
|
1162
|
+
* fail-loud 比执行侧解密失败早得多)。 */
|
|
1163
|
+
export const ExecutionPublicKey = z.object({
|
|
1164
|
+
/** 公钥 id(密文的 publicKeyId 指向此键;登记表内唯一)。 */
|
|
1165
|
+
publicKeyId: z.string().min(1).max(128),
|
|
1166
|
+
/** X25519 公钥,base64(32 字节 → 恒 44 字符含 padding)。 */
|
|
1167
|
+
publicKey: z.string().regex(/^[A-Za-z0-9+/]{43}=$/, "publicKey must be a base64 X25519 public key (32 bytes)"),
|
|
1168
|
+
/** 登记时刻(ISO)。center 写面 server 端盖章(projects.registeredAt 同款纪律)。 */
|
|
1169
|
+
createdAt: z.string().min(1),
|
|
1170
|
+
notes: z.string().optional(),
|
|
1171
|
+
/** 归属 worker 标识(0.10.9,service 合意)。**取值 = 上报方 worker 的
|
|
1172
|
+
* `SEMA_REGISTRY_WORKER` env**(service 上报载荷带 `workerId`;该 env 未配 = 单 worker 部署,
|
|
1173
|
+
* 上报省略本字段)。多 worker 部署下 seal 侧凭它按**目标 worker** 选钥(A worker 的密文只有
|
|
1174
|
+
* A 的私钥能解——选错钥 = 执行侧解密失败,晚且难查);ABSENT = 未绑定,seal 侧 fallback
|
|
1175
|
+
* newest(仅适合单 worker 部署)。optional additive:存量登记条目零迁移。 */
|
|
1176
|
+
workerId: z.string().min(1).max(128).optional(),
|
|
1177
|
+
});
|
|
1178
|
+
export const ExecutionPolicy = z.object({
|
|
1179
|
+
/** true = tasks under this scope MUST run in a sandbox / controlled execution lane (the consuming
|
|
1180
|
+
* shell locks sandbox-enabled by policy; user settings can't turn it off — CC 同款语义). */
|
|
1181
|
+
required: z.boolean().default(false),
|
|
1182
|
+
/** Which execution lanes are allowed. OPEN value domain — z.string(), NEVER an enum: adding a lane
|
|
1183
|
+
* must not be a BREAKING schema change (promised to the service). Known lanes
|
|
1184
|
+
* today: `host` / `e2b` / `k8s` / `ssh` / `adb` / `local-docker`. Unknown strings are consumer-defined. */
|
|
1185
|
+
allowedLanes: z.array(z.string().min(1)).default(["host"]),
|
|
1186
|
+
/** Org 默认会话镜像目标(0.10.1,附B 两层)。ABSENT = INERT(center 未管镜像,壳解析序里本腿为空)。
|
|
1187
|
+
* 读损档随域(DOMAIN_READ_FALLBACK.execution="throw" 已覆盖:损坏绝不静默退默认放权)。 */
|
|
1188
|
+
sessionMirror: SessionMirrorPolicy.optional(),
|
|
1189
|
+
/** 执行域 sealed-box 公钥登记表(0.10.5)。**optional 无 default,ABSENT = 未登记**(0.8–0.10 存量
|
|
1190
|
+
* execution 文档字节稳定,sessionMirror 同款零迁移纪律);web 面无已登记公钥时「贴密钥」tab 显示
|
|
1191
|
+
* 引导并回落 env-NAME。多把并存合法(轮换窗口:新密文用新钥,旧密文仍可被旧私钥解)。 */
|
|
1192
|
+
publicKeys: z.array(ExecutionPublicKey).optional(),
|
|
1193
|
+
}).superRefine((p, ctx) => {
|
|
1194
|
+
// publicKeyId 是密文侧 SealedApiKey.publicKeyId 的 ref 目标——登记表内撞 id = 执行侧选私钥二义,写侧拒。
|
|
1195
|
+
const seen = new Set();
|
|
1196
|
+
(p.publicKeys ?? []).forEach((k, i) => {
|
|
1197
|
+
if (seen.has(k.publicKeyId))
|
|
1198
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate publicKeyId "${k.publicKeyId}"`, path: ["publicKeys", i, "publicKeyId"] });
|
|
1199
|
+
seen.add(k.publicKeyId);
|
|
1200
|
+
});
|
|
1201
|
+
});
|
|
1202
|
+
// ── Domain: projects (142-S3 — project identity ledger + 无仓铸造权威, 0.10.0) ─────────────────
|
|
1203
|
+
// 登记簿语义(design/142 §1.3/F11):marker(`.sema/project`,checkout 现场)是身份真相源,本域是
|
|
1204
|
+
// 登记簿(索引/展示/审计)+ 无仓 project 的 projectId 铸造权威。铸造离线自由、使用时注册执法
|
|
1205
|
+
// 佐证:执法位在 service worker(派生 `proj:*` 可读 scope 前查 `(tenant,projectId)`
|
|
1206
|
+
// 已注册),本域只是它查的那张表——登记簿**不读 marker 文件、不镜像 marker schema**(已确认)。
|
|
1207
|
+
// 复合键 `(tenant, projectId)` 的 tenant 半边由 center 的 SCOPE 轴承载(api/scopes.ts:每 scope 一份
|
|
1208
|
+
// 独立域文档),域内 Record 键=projectId only;fork 后同一 projectId 合法存在于两个 scope,互不相见。
|
|
1209
|
+
/** projectId 形状:lowercase 规范 UUID。**宽读严写**(core 口径):读面/claim 收 generic
|
|
1210
|
+
* UUID(外部/历史铸造的 v4 也认——marker 是身份不是格式声明);铸造侧(core formatProjectMarker /
|
|
1211
|
+
* center mint 端点)统一产 **uuidv7**。36 字符,天然满足 design/142 §1.2 段编码 64 上限。
|
|
1212
|
+
* SINGLE-SOURCE 纪律:core 1.268.0 尚未公开导出其内部 UUID_RE——本常量为自持镜像,与 core 的对拍
|
|
1213
|
+
* 走 dev-only conformance(never a runtime import,EffectiveKey/coreTypes 先例)。
|
|
1214
|
+
* TODO(0.10.x): @sema-agent/core 下一班车公开导出 PROJECT_ID_REGEX 后(core 已承诺),
|
|
1215
|
+
* 加 dev-only drift-lock 测试与其对拍(坐标:test/projects-0.10.test.ts「conformance」节)。 */
|
|
1216
|
+
export const PROJECT_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
1217
|
+
export const ProjectRegistration = z.object({
|
|
1218
|
+
/** 人读名(展示/管理面用;不是身份,改名自由——身份=Record 键 projectId)。 */
|
|
1219
|
+
displayName: z.string().min(1).max(120),
|
|
1220
|
+
/** 归一化 git remotes(`host/path` 形态,design/142 §1.1 归一化规范)。归一化动作发生在 scaffold/
|
|
1221
|
+
* 调用方,本契约只验底线形状:非空、无空白、不含 "://"(出现即证据未归一化,fail-loud)。
|
|
1222
|
+
* 无仓 project = 空数组合法。`.max(16)` 防登记簿被单条撑爆。 */
|
|
1223
|
+
gitRemotes: z
|
|
1224
|
+
.array(z
|
|
1225
|
+
.string()
|
|
1226
|
+
.min(1)
|
|
1227
|
+
.max(512)
|
|
1228
|
+
.refine((s) => !/\s/.test(s) && !s.includes("://"), "must be a NORMALIZED remote (host/path — no scheme, no whitespace)"))
|
|
1229
|
+
.max(16)
|
|
1230
|
+
.default([]),
|
|
1231
|
+
/** 该项目会话的默认记忆 scope 词表。OPEN value domain — z.string(),不 enum(纪律:加词
|
|
1232
|
+
* 不得 BREAKING)。方向(S4 细化):消费方=service memoryScope 派生(作 TaskSpec.
|
|
1233
|
+
* memory.scopes 种子),形态=v2 scope 键字符串(过 core parseScopeKey 校验;known 词根:`proj` /
|
|
1234
|
+
* `userproj`)。未知串=消费者自定义;空数组=不下发默认。本域只登记不解释,零耦合。 */
|
|
1235
|
+
defaultScopes: z.array(z.string().min(1)).default([]),
|
|
1236
|
+
/** 登记人 principal(审计锚;center 写面自动填 caller,不信客户端提交)。 */
|
|
1237
|
+
registeredBy: z.string().min(1),
|
|
1238
|
+
/** 登记时刻(ISO)。center 写面 server 端盖章。 */
|
|
1239
|
+
registeredAt: z.string().min(1),
|
|
1240
|
+
notes: z.string().optional(),
|
|
1241
|
+
});
|
|
1242
|
+
export const ProjectsConfig = z
|
|
1243
|
+
.object({
|
|
1244
|
+
/** 键=projectId({@link PROJECT_ID_REGEX},generic lowercase UUID——宽读);tenant 半边=center
|
|
1245
|
+
* scope 轴(不进域文档,见域头注)。 */
|
|
1246
|
+
projects: z.record(z.string().regex(PROJECT_ID_REGEX, "projectId must be a lowercase canonical UUID"), ProjectRegistration).default({}),
|
|
1247
|
+
})
|
|
1248
|
+
.superRefine((cfg, ctx) => {
|
|
1249
|
+
// 同 scope 内一条 remote 只许属于一个 project——「remote → projectId」找回阶梯(design/142
|
|
1250
|
+
// §1.5 ③)与 scaffold 防重铸查询要求该映射在租户内确定;撞了=写入 fail-loud(合法双登记诉求
|
|
1251
|
+
// 出现时再放开,先紧后松 additive)。
|
|
1252
|
+
const seen = new Map();
|
|
1253
|
+
for (const [pid, reg] of Object.entries(cfg.projects)) {
|
|
1254
|
+
for (const [i, r] of reg.gitRemotes.entries()) {
|
|
1255
|
+
const prev = seen.get(r);
|
|
1256
|
+
if (prev !== undefined && prev !== pid) {
|
|
1257
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `remote "${r}" already registered to project "${prev}"`, path: ["projects", pid, "gitRemotes", i] });
|
|
1258
|
+
}
|
|
1259
|
+
seen.set(r, pid);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
// ── Domain: collab (协作模式 — multi-model collaboration templates, 0.7.0) ─────
|
|
1264
|
+
// Replaces the deleted `teams` domain (EXPERT-REDESIGN §8/§9, 五拍裁定 T1-T5).
|
|
1265
|
+
// Unified shape = slots × topology × rounds × finalizer. COMPLETELY separate from rosters (拍板): a slot
|
|
1266
|
+
// member's `modelRef` points at the models CATALOG (`ModelEntry.name`, the key — NOT the gateway modelId,
|
|
1267
|
+
// NOT a roster); roster churn never touches a template. Execution lives service-side ("collab spec →
|
|
1268
|
+
// workflow/team run" translation layer); the center owns the template CONFIG only.
|
|
1269
|
+
export const CollabTopology = z.enum(["star", "ring", "layered", "pair", "blackboard"]);
|
|
1270
|
+
export const CollabFinalizerType = z.enum(["judge", "vote", "fuse", "verify", "summarize", "none"]);
|
|
1271
|
+
export const CollabEarlyStop = z.enum(["consensus", "stability", "judge_call", "verify_pass"]);
|
|
1272
|
+
/** Shared-context persistence. v1 durable lane = "store-blob" (service's existing durable face);
|
|
1273
|
+
* "git" (auditable/replayable history) is a RESERVED enum position — accepted by the contract, execution
|
|
1274
|
+
* is a service follow-on (deliberately not bound to 0.7). */
|
|
1275
|
+
export const CollabPersistence = z.enum(["session", "store-blob", "git"]);
|
|
1276
|
+
const CollabMember = z.object({
|
|
1277
|
+
/** Models-CATALOG name (the `@`-handle key). ABSENT = "跟随用户当前模型" — resolved at run time to the
|
|
1278
|
+
* caller's current model (T1; service resolves at team assembly). Never a roster reference. */
|
|
1279
|
+
modelRef: z.string().min(1).optional(),
|
|
1280
|
+
/** Member-level lens/persona override (各带镜头 — a heterogeneous review panel puts N members with
|
|
1281
|
+
* different lenses in ONE cardinality-"N" slot). Falls back to the slot's `rolePrompt`. */
|
|
1282
|
+
rolePrompt: z.string().max(2000).optional(),
|
|
1283
|
+
});
|
|
1284
|
+
const CollabSlot = z.object({
|
|
1285
|
+
// The slot id is the canvas anchor AND the finalizer/transcript attribution key — same safe-identifier
|
|
1286
|
+
// shape as a model name (it may be concatenated into prompts; no spaces/newlines → no prompt-structure injection).
|
|
1287
|
+
id: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "letters/digits/._- only"),
|
|
1288
|
+
/** Slot-default lens/立场 (template-provided; a member's own `rolePrompt` overrides it). */
|
|
1289
|
+
rolePrompt: z.string().max(2000).optional(),
|
|
1290
|
+
/** 1 = exactly one member; "N" = the slot accepts multiple members (异构评审团). */
|
|
1291
|
+
cardinality: z.union([z.literal(1), z.literal("N")]).default(1),
|
|
1292
|
+
members: z.array(CollabMember).default([]),
|
|
1293
|
+
/** Tool names available to this slot's members (the old team `scenario` binding migrates here). */
|
|
1294
|
+
tools: z.array(z.string().min(1)).optional(),
|
|
1295
|
+
});
|
|
1296
|
+
const CollabTemplate = z.object({
|
|
1297
|
+
/** The template's reference key (shell `/team` selects by id; canvas routes by id). */
|
|
1298
|
+
id: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1299
|
+
name: z.string().min(1),
|
|
1300
|
+
description: z.string().optional(),
|
|
1301
|
+
topology: CollabTopology,
|
|
1302
|
+
slots: z.array(CollabSlot).min(1),
|
|
1303
|
+
/** `max` is ALWAYS the safety cap (blackboard topology: event/round cap — required, 防跑飞);
|
|
1304
|
+
* `earlyStop: "verify_pass"` carries the architect pair's "verified → stop" loop. */
|
|
1305
|
+
rounds: z.object({ max: z.number().int().positive(), earlyStop: CollabEarlyStop.optional() }),
|
|
1306
|
+
finalizer: z.object({
|
|
1307
|
+
type: CollabFinalizerType,
|
|
1308
|
+
/** Slot id the finalizer anchors to (e.g. the judge slot). Meaningless for type "none". */
|
|
1309
|
+
slot: z.string().optional(),
|
|
1310
|
+
}),
|
|
1311
|
+
sharedContext: z
|
|
1312
|
+
.object({
|
|
1313
|
+
kind: z.enum(["transcript", "blackboard"]).default("transcript"),
|
|
1314
|
+
persistence: CollabPersistence.default("session"),
|
|
1315
|
+
})
|
|
1316
|
+
.default({}),
|
|
1317
|
+
/** 多模型烧钱必须有闸 (拍板): the budget object is REQUIRED and at least one axis must be set. */
|
|
1318
|
+
budget: z.object({
|
|
1319
|
+
maxTokens: z.number().int().positive().optional(),
|
|
1320
|
+
maxCost: z.number().finite().positive().optional(),
|
|
1321
|
+
}),
|
|
1322
|
+
enabled: z.boolean().default(true),
|
|
1323
|
+
notes: z.string().optional(),
|
|
1324
|
+
}).superRefine((t, ctx) => {
|
|
1325
|
+
const slotIds = new Set();
|
|
1326
|
+
t.slots.forEach((s, i) => {
|
|
1327
|
+
if (slotIds.has(s.id))
|
|
1328
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate slot id "${s.id}"`, path: ["slots", i, "id"] });
|
|
1329
|
+
slotIds.add(s.id);
|
|
1330
|
+
if (s.cardinality === 1 && s.members.length > 1)
|
|
1331
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `slot "${s.id}" has cardinality 1 but ${s.members.length} members`, path: ["slots", i, "members"] });
|
|
1332
|
+
});
|
|
1333
|
+
if (t.finalizer.type === "none") {
|
|
1334
|
+
if (t.finalizer.slot !== undefined)
|
|
1335
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `finalizer type "none" must not anchor a slot`, path: ["finalizer", "slot"] });
|
|
1336
|
+
}
|
|
1337
|
+
else if (t.finalizer.slot !== undefined && !slotIds.has(t.finalizer.slot)) {
|
|
1338
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `finalizer.slot "${t.finalizer.slot}" is not a slot id of this template`, path: ["finalizer", "slot"] });
|
|
1339
|
+
}
|
|
1340
|
+
if (t.budget.maxTokens === undefined && t.budget.maxCost === undefined) {
|
|
1341
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `template "${t.id}": budget needs at least one of maxTokens/maxCost (多模型烧钱必须有闸)`, path: ["budget"] });
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
export const CollabConfig = z.object({ templates: z.array(CollabTemplate).default([]) }).superRefine((cfg, ctx) => {
|
|
1345
|
+
// `id` is the template's reference key — dedup like every other keyed domain (two same-id templates
|
|
1346
|
+
// shipping → the resolver silently takes the first; same failure class the old teams dedup fixed).
|
|
1347
|
+
const seen = new Set();
|
|
1348
|
+
cfg.templates.forEach((t, i) => {
|
|
1349
|
+
if (seen.has(t.id))
|
|
1350
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate collab template "${t.id}" — the template key must be unique`, path: ["templates", i, "id"] });
|
|
1351
|
+
seen.add(t.id);
|
|
1352
|
+
});
|
|
1353
|
+
});
|
|
1354
|
+
// ── Domain: workers (声明式编排 — service design/22, docs/ORCHESTRATOR.md) ──────
|
|
1355
|
+
// 每个 worker = 一个 sema-server 实例的声明(一份 env profile)。sema-registry 存 DESIRED(逻辑参数
|
|
1356
|
+
// + secret 的 env-NAME 引用,**绝不存 secret 值**);一个 per-host reconciler 拉取后在宿主机本地解析
|
|
1357
|
+
// secret → 渲染 docker compose → up/down 收敛。worker spec ↔ service env 的精确映射见 worker-env.ts。
|
|
1358
|
+
//
|
|
1359
|
+
// 安全(service design/22 经评审):
|
|
1360
|
+
// - 不提供任何挂载字段 → 杜绝 hostPath 攻击向量(合法镜像+合法 secretRef+恶意挂载偷宿主机文件)。
|
|
1361
|
+
// - image 由 reconciler 按白名单强制(这里 default sema-server:latest)。
|
|
1362
|
+
// - secretRefs 只是 env-NAME;真值在宿主机,reconciler 解析,永不进此库/网络/日志。
|
|
1363
|
+
// 时长串:整数 + 单位 s/m/h/d(如 "30m"/"2h"/"7d")。lib/lifecycle/duration.ts 负责解析成 ms。
|
|
1364
|
+
const DURATION = z.string().regex(/^\d+(s|m|h|d)$/, 'duration like "30m", "2h", "7d" (units s/m/h/d)');
|
|
1365
|
+
// 生命周期 (design/25 §3 决策 3/4)。owner/purpose 用于配额 + 级联清理 + 审计;dependsOn 防级联雪崩。
|
|
1366
|
+
export const WorkerLifecycle = z.object({
|
|
1367
|
+
kind: z.enum(["persistent", "ephemeral"]).default("persistent"),
|
|
1368
|
+
/** ephemeral: 绝对存活时长,自 sema-registry 首次见到起算(防时钟漂移),到期硬移除。 */
|
|
1369
|
+
ttl: DURATION.optional(),
|
|
1370
|
+
/** 无活动多久 → 告警(MVP 只告警,不自动 disable)。数据源 = 反代 metrics。 */
|
|
1371
|
+
idleTimeout: DURATION.optional(),
|
|
1372
|
+
/** 归属(团队/项目)→ per-owner 配额 + owner 销毁时级联清理 + 审计。 */
|
|
1373
|
+
owner: z.string().min(1).optional(),
|
|
1374
|
+
purpose: z.string().optional(),
|
|
1375
|
+
/** 依赖的其它 worker name —— sweeper 移除前查依赖,不孤儿化仍被依赖的 worker。 */
|
|
1376
|
+
dependsOn: z.array(z.string().min(1)).optional(),
|
|
1377
|
+
});
|
|
1378
|
+
const WorkerSecretRefs = z.object({
|
|
1379
|
+
/** NAME of the host env var holding the model API key → service MODEL_API_KEY (或 anthropic 时 ANTHROPIC_API_KEY). */
|
|
1380
|
+
apiKeyEnv: ENV_NAME,
|
|
1381
|
+
/** → SERVICE_AUTH_TOKEN (对外门禁). */
|
|
1382
|
+
serviceAuthTokenEnv: ENV_NAME.optional(),
|
|
1383
|
+
/** → GIT_API_TOKEN (只读,读被评审 repo). */
|
|
1384
|
+
gitTokenEnv: ENV_NAME.optional(),
|
|
1385
|
+
/** → TIDB_PASSWORD. */
|
|
1386
|
+
tidbPasswordEnv: ENV_NAME.optional(),
|
|
1387
|
+
});
|
|
1388
|
+
/**
|
|
1389
|
+
* Env keys the structured WorkerSpec fields already produce (worker-env.ts) — the general `env`/`secretEnv`
|
|
1390
|
+
* passthrough may NOT set these (use the dedicated field). Blocks the council's two escape-hatch risks:
|
|
1391
|
+
* redirecting model/traffic or disabling auth (PORT, MODEL_x, SESSION_BACKEND, REQUIRE_PRINCIPAL, gateway), and
|
|
1392
|
+
* shadowing a projected secret with a literal (MODEL_API_KEY, SERVICE_AUTH_TOKEN, …). LOG_LEVEL is intentionally
|
|
1393
|
+
* NOT reserved (benign, overridable for debugging). Keep in sync with workerToServiceEnv.
|
|
1394
|
+
*
|
|
1395
|
+
* ⚠️ SCOPE (0.1.30, after the R22→0.1.29 detour): this ONLY blocks keys that have a DEDICATED structured field
|
|
1396
|
+
* or secretRef — so an operator is steered to that field instead of duplicating/shadowing it via env. It does
|
|
1397
|
+
* NOT block the per-worker EXECUTION-SUBSTRATE config an operator legitimately sets via worker.env, which the
|
|
1398
|
+
* live fleet relies on: `REMOTE_EXEC` (direct=k8s, canary=e2b set it HERE), `K8S_*` (SANDBOX_IMAGE/NAMESPACE/
|
|
1399
|
+
* RUNTIME_CLASS/…), `E2B_*`, `MINIO_*`, `SSH_*`, `ADB_*`. worker.env is the OPERATOR's per-worker deployment env
|
|
1400
|
+
* (publisher-gated, projected by the orchestrator to the worker pod) — NOT untrusted-task-controlled, so there
|
|
1401
|
+
* is no "sandbox escape" to block here. R22 wrongly added those substrate keys treating worker.env as
|
|
1402
|
+
* task-controlled; it would have REJECTED the live direct/canary workers on deploy. Reverted in 0.1.30. (Literal
|
|
1403
|
+
* SECRET values pasted into env are still caught separately by containsSecretToken; substrate CREDS belong in
|
|
1404
|
+
* secretEnv as env-NAME refs.)
|
|
1405
|
+
*/
|
|
1406
|
+
export const RESERVED_WORKER_ENV = new Set([
|
|
1407
|
+
"PORT", "MODEL_PROVIDER", "MODEL_ID", "MODEL_REASONING", "MODEL_CHEAP_ID", "MODEL_MAX_TOKENS",
|
|
1408
|
+
"MODEL_CONTEXT_WINDOW", "MODEL_COST_INPUT", "MODEL_COST_OUTPUT", "MODEL_COST_CACHE_READ",
|
|
1409
|
+
"MODEL_COST_CACHE_WRITE", "MAX_PRINCIPAL_COST_USD", "SESSION_BACKEND", "MEMORY_BACKEND",
|
|
1410
|
+
"REQUIRE_PRINCIPAL", "RATE_LIMIT_RPM", "DEFAULT_SCENARIO", "TIDB_DATABASE", "TIDB_HOST",
|
|
1411
|
+
"TIDB_PORT", "TIDB_USER", "GIT_API_BASEURL", "MODEL_GATEWAY_BASEURL", "ANTHROPIC_BASEURL",
|
|
1412
|
+
"MODEL_API_KEY", "ANTHROPIC_API_KEY", "SERVICE_AUTH_TOKEN", "GIT_API_TOKEN", "TIDB_PASSWORD",
|
|
1413
|
+
// server ≥2.0 的中立后端面(kind mysql|pg|local):MYSQL_* 是首选族(TIDB_* 为其兼容别名)、PG_* 是
|
|
1414
|
+
// 第三族——三族同为 coords 结构化字段的遮蔽面,缺任何一族=该族键可从 worker.env 静默压过结构化配置
|
|
1415
|
+
// (2026-07-31 交叉终扫 G1 补齐;此前只列 TIDB_* 一族)。
|
|
1416
|
+
"MYSQL_DATABASE", "MYSQL_HOST", "MYSQL_PORT", "MYSQL_USER", "MYSQL_PASSWORD",
|
|
1417
|
+
"PG_DATABASE", "PG_HOST", "PG_PORT", "PG_USER", "PG_PASSWORD",
|
|
1418
|
+
]);
|
|
1419
|
+
// Which structured WorkerSpec field owns each reserved env-NAME → so a rejection names the RIGHT field to use
|
|
1420
|
+
// (e.g. TIDB_DATABASE → runtime.db, MODEL_ID → model.modelId), not a vague "use the structured field".
|
|
1421
|
+
const RESERVED_ENV_FIELD = {
|
|
1422
|
+
PORT: "(fixed at 8090 — not configurable)", MODEL_PROVIDER: "model.provider", MODEL_ID: "model.modelId",
|
|
1423
|
+
MODEL_REASONING: "model.reasoning", MODEL_CHEAP_ID: "model.cheapId", MODEL_MAX_TOKENS: "model.maxTokens",
|
|
1424
|
+
MODEL_CONTEXT_WINDOW: "model.contextWindow", MODEL_COST_INPUT: "model.cost.input", MODEL_COST_OUTPUT: "model.cost.output",
|
|
1425
|
+
MODEL_COST_CACHE_READ: "model.cost.cacheRead", MODEL_COST_CACHE_WRITE: "model.cost.cacheWrite",
|
|
1426
|
+
MAX_PRINCIPAL_COST_USD: "runtime.maxPrincipalCostUsd", SESSION_BACKEND: "runtime.sessionBackend",
|
|
1427
|
+
MEMORY_BACKEND: "runtime.memoryBackend", REQUIRE_PRINCIPAL: "runtime.requirePrincipal", RATE_LIMIT_RPM: "runtime.rateLimitRpm",
|
|
1428
|
+
DEFAULT_SCENARIO: "runtime.scenario", TIDB_DATABASE: "runtime.db", TIDB_HOST: "coords.tidbHost", TIDB_PORT: "coords.tidbPort",
|
|
1429
|
+
TIDB_USER: "coords.tidbUser", GIT_API_BASEURL: "coords.gitApiBaseUrl", MODEL_GATEWAY_BASEURL: "model.gatewayBaseUrl",
|
|
1430
|
+
// G1 补齐的两族与 TIDB_* 同归属(coords.tidb* 字段名是历史命名,承 mysql-协议三家;PG 同面)
|
|
1431
|
+
MYSQL_DATABASE: "runtime.db", MYSQL_HOST: "coords.tidbHost", MYSQL_PORT: "coords.tidbPort", MYSQL_USER: "coords.tidbUser", MYSQL_PASSWORD: "secretRefs.tidbPasswordEnv",
|
|
1432
|
+
PG_DATABASE: "runtime.db", PG_HOST: "coords.tidbHost", PG_PORT: "coords.tidbPort", PG_USER: "coords.tidbUser", PG_PASSWORD: "secretRefs.tidbPasswordEnv",
|
|
1433
|
+
ANTHROPIC_BASEURL: "model.gatewayBaseUrl", MODEL_API_KEY: "secretRefs.apiKeyEnv", ANTHROPIC_API_KEY: "secretRefs.apiKeyEnv",
|
|
1434
|
+
SERVICE_AUTH_TOKEN: "secretRefs.serviceAuthTokenEnv", GIT_API_TOKEN: "secretRefs.gitTokenEnv",
|
|
1435
|
+
TIDB_PASSWORD: "secretRefs.tidbPasswordEnv",
|
|
1436
|
+
};
|
|
1437
|
+
export const WorkerSpec = z.object({
|
|
1438
|
+
/** 唯一 → 容器名 / compose project. */
|
|
1439
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
1440
|
+
enabled: z.boolean().default(true),
|
|
1441
|
+
/** 目标宿主机 id —— 哪个 per-host reconciler 认领它. 与 name 同样严格(精确匹配路由,松校验会静默不可达). */
|
|
1442
|
+
host: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces").default("default"),
|
|
1443
|
+
/** 容器镜像 (reconciler 按白名单强制). */
|
|
1444
|
+
image: z.string().min(1).default("sema-server:latest"),
|
|
1445
|
+
/**
|
|
1446
|
+
* 对外路由前缀 (v2① 反代, design/25 决策7)。worker **不再 publish 宿主机端口**:反代 (Traefik) 按
|
|
1447
|
+
* `routePrefix` 路由到容器内 8090。省略 → 用 `name` 作前缀。**唯一性由 WorkersConfig 提交时校验**。
|
|
1448
|
+
* (废弃了原 `port` 字段 —— 宿主机端口是稀缺资源、规模化必撞车,且 hostPort 会被滥用绕过反代鉴权/限流/日志。)
|
|
1449
|
+
*/
|
|
1450
|
+
routePrefix: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces").optional(),
|
|
1451
|
+
/** Worker SCALE — number of pod replicas (k8s backend). Optional, absent → 1 (today's behaviour, no churn).
|
|
1452
|
+
* Hash-INCLUDED → a change re-applies the Deployment (k8s scales IN PLACE via SSA, no pod destruction). The
|
|
1453
|
+
* docker backend is single-container: it warns and runs 1 if this is >1. Capped at 100 (resource-exhaustion
|
|
1454
|
+
* guard — a typo'd 1e6 mustn't try to schedule a million pods; raise the cap if a real workload needs it). */
|
|
1455
|
+
replicas: z.number().int().positive().max(100).optional(),
|
|
1456
|
+
/** AUTOSCALING (k8s backend, HPA) — declare bounds and let the cluster's HorizontalPodAutoscaler own the
|
|
1457
|
+
* replica count (autoscaling/v2, CPU utilization; prove-before-scale 解禁 2026-06-13). Mutually exclusive
|
|
1458
|
+
* with `replicas` (WorkersConfig rejects both — two owners of the same knob WILL fight). The orchestrator
|
|
1459
|
+
* then OMITS spec.replicas from its SSA apply so it never wrestles the HPA controller; switching an
|
|
1460
|
+
* EXISTING fixed-replicas worker to autoscaling transiently resets it to 1 pod until the HPA raises it
|
|
1461
|
+
* to minReplicas (~15s) — enable during low traffic. CPU% needs resources.requests.cpu (backend default
|
|
1462
|
+
* 50m applies if unset — set a realistic request or the percentage is meaningless). docker backend:
|
|
1463
|
+
* single-container, warns + ignores. Hash-INCLUDED; absent → no HPA, today's behaviour (zero churn). */
|
|
1464
|
+
autoscaling: z
|
|
1465
|
+
.object({
|
|
1466
|
+
minReplicas: z.number().int().positive().max(100),
|
|
1467
|
+
maxReplicas: z.number().int().positive().max(100),
|
|
1468
|
+
/** Target average CPU utilization %, vs resources.requests.cpu. Absent → backend default 80. */
|
|
1469
|
+
targetCpuPercent: z.number().int().min(10).max(95).optional(),
|
|
1470
|
+
})
|
|
1471
|
+
.refine((a) => a.maxReplicas >= a.minReplicas, { message: "maxReplicas must be >= minReplicas" })
|
|
1472
|
+
.optional(),
|
|
1473
|
+
/** Per-worker resource requests/limits (k8s backend). Optional → the backend's modest default
|
|
1474
|
+
* (requests cpu 50m/mem 128Mi, no limits). Hash-INCLUDED → a change rolls the pods (resources are
|
|
1475
|
+
* immutable on a running pod). CPU/memory are k8s quantity strings ("250m", "512Mi", "1", "2Gi"). */
|
|
1476
|
+
resources: z
|
|
1477
|
+
.object({
|
|
1478
|
+
requests: z.object({ cpu: K8S_QUANTITY.optional(), memory: K8S_QUANTITY.optional() }).optional(),
|
|
1479
|
+
limits: z.object({ cpu: K8S_QUANTITY.optional(), memory: K8S_QUANTITY.optional() }).optional(),
|
|
1480
|
+
})
|
|
1481
|
+
.optional(),
|
|
1482
|
+
/** Scheduling-framework knobs (CONTROL-PLANE-SCHEDULING-DESIGN, 产品拍板 D1/D3 2026-07-04). Optional and
|
|
1483
|
+
* hash-INCLUDED like every spec field — absent → contract defaults (zero churn on existing fleets). */
|
|
1484
|
+
fleet: z
|
|
1485
|
+
.object({
|
|
1486
|
+
/** Per-worker dispatch policy consumed by center `/api/dispatch` (D3: the gateway hop is optional;
|
|
1487
|
+
* only affects traffic routed through it). Absent → contract defaults (round-robin, affinity on). */
|
|
1488
|
+
dispatch: DispatchPolicy.optional(),
|
|
1489
|
+
/** D1 per-worker drain-grace override → delivered to the container as env `DRAIN_GRACE_MS`
|
|
1490
|
+
* (worker-side default 600s, floor 10s). */
|
|
1491
|
+
drainGraceMs: z.number().int().positive().optional(),
|
|
1492
|
+
})
|
|
1493
|
+
.optional(),
|
|
1494
|
+
// `model` DELETED in 0.7.0 (EXPERT-REDESIGN §3 拍板 + E3 迁移闭环 — a2 存量 5 worker 已迁移目录+roster
|
|
1495
|
+
// 标记,E3):模型/网关配置单一真源=models 目录,worker 经 `roster` 引用;主/cheap boot env
|
|
1496
|
+
// 归宿=roster.primaryModel/cheapModel 标记(0.6.3,service 已承接)。pre-0.7 存量 JSON 里的
|
|
1497
|
+
// `model` 键被 parse 静默剥除(zod strips unknown)→ workerSpecHash 变化=一次性重建;迁移工具输入=
|
|
1498
|
+
// 冻结的 LegacyWorkerModel(migrate.ts),必须在升级 0.7 前跑完(a2 已闭环,E3)。
|
|
1499
|
+
/** 运行时 (非 secret). 映射到 service SESSION_BACKEND/REQUIRE_PRINCIPAL/RATE_LIMIT_RPM/… */
|
|
1500
|
+
runtime: z.object({
|
|
1501
|
+
// default "auto" (was "tidb" — Service AI's e2e-tag response traced the minimal-spec fatal here): the old
|
|
1502
|
+
// default injected SESSION_BACKEND=tidb into EVERY spec that didn't set it, and the service fail-fasts on
|
|
1503
|
+
// explicit tidb without TIDB_HOST (correct Tier-2 hardening — declared persistence must not silently
|
|
1504
|
+
// degrade). "auto" = probe: with coords → tidb, without → memory, never fatal. A minimal `runtime:{}`
|
|
1505
|
+
// worker now boots. No churn: previously-SAVED specs have "tidb" baked in (parseDomain persists defaults),
|
|
1506
|
+
// so only NEW minimal specs see "auto"; explicit-"tidb" loud-fail semantics are untouched.
|
|
1507
|
+
sessionBackend: z.enum(["tidb", "memory", "auto"]).default("auto"),
|
|
1508
|
+
memoryBackend: z.enum(["off", "memory", "tidb"]).default("off"),
|
|
1509
|
+
db: z.string().optional(), // TIDB_DATABASE (独立库)
|
|
1510
|
+
scenario: z.string().optional(), // DEFAULT_SCENARIO
|
|
1511
|
+
requirePrincipal: z.boolean().default(false),
|
|
1512
|
+
rateLimitRpm: z.number().int().nonnegative().default(0),
|
|
1513
|
+
/** Per-principal cumulative cost CAP (USD) → MAX_PRINCIPAL_COST_USD → the service's cost gate ENFORCEMENT
|
|
1514
|
+
* (over-cap → 429). Pairs with model.cost (which only ATTRIBUTES cost). ABSENT → no env → the service
|
|
1515
|
+
* defaults to 0 = off (today's behaviour); EXPLICIT 0 emits "0" = block-all and is HASH-DISTINCT from
|
|
1516
|
+
* absent (so the guard is `!= null`, not `> 0` — the two carry different caller intent). Optional + NO
|
|
1517
|
+
* default → absent never materialises → no spec-hash churn. Hash-included (read at boot → cap change recreates). */
|
|
1518
|
+
maxPrincipalCostUsd: z.number().finite().nonnegative().optional(),
|
|
1519
|
+
}),
|
|
1520
|
+
/** 物理坐标 (非 secret). 映射到 GIT_API_BASEURL / TIDB_HOST 等. */
|
|
1521
|
+
coords: z
|
|
1522
|
+
.object({
|
|
1523
|
+
gitApiBaseUrl: NON_SECRET_URL.optional().or(z.literal("")),
|
|
1524
|
+
tidbHost: z.string().optional(),
|
|
1525
|
+
tidbPort: TCP_PORT.optional(),
|
|
1526
|
+
tidbUser: z.string().optional(),
|
|
1527
|
+
})
|
|
1528
|
+
.default({}),
|
|
1529
|
+
secretRefs: WorkerSecretRefs,
|
|
1530
|
+
/**
|
|
1531
|
+
* 通用 env 透传(逃生口)——给**没有专属结构化字段**的 service env 用,让任意 worker 声明自己的 app 专属变量
|
|
1532
|
+
* (如 OA 的 `OA_API_BASEURL`),不必为每个变量改契约。值是**字面非密值**(≤2048,且拒绝明显的密钥串——密钥走
|
|
1533
|
+
* `secretEnv`)。**只能加结构化字段没产生的键**:`RESERVED_WORKER_ENV` 里的键(PORT、MODEL_x、gateway、auth、
|
|
1534
|
+
* secret 名…)被拒,防 env 改路由/关鉴权/遮蔽密钥(council)。可覆盖非保留键(如 LOG_LEVEL)。hash-included(改它重建)。
|
|
1535
|
+
*/
|
|
1536
|
+
env: z
|
|
1537
|
+
.record(ENV_NAME, z.string().max(2048))
|
|
1538
|
+
.superRefine((rec, ctx) => {
|
|
1539
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
1540
|
+
if (RESERVED_WORKER_ENV.has(k))
|
|
1541
|
+
ctx.addIssue({ code: "custom", message: `env cannot set reserved key "${k}" — set it via ${RESERVED_ENV_FIELD[k] ?? "the structured field"}` });
|
|
1542
|
+
// 0.1.32: NO name exemption — a PUBLIC key is allowed because SECRET_LITERAL_RE now only flags PRIVATE keys
|
|
1543
|
+
// (root fix), so a literal private key / token in env (ANY name, incl `*_PUBKEY`) is still rejected.
|
|
1544
|
+
if (containsSecretToken(v))
|
|
1545
|
+
ctx.addIssue({ code: "custom", message: `env["${k}"] looks like a literal secret — put secrets in secretEnv (an env-NAME), never a value in env` }); // tokenized (mid-string too). R8
|
|
1546
|
+
}
|
|
1547
|
+
})
|
|
1548
|
+
.optional(),
|
|
1549
|
+
/**
|
|
1550
|
+
* 通用**密钥** env 透传:`{ service 的环境变量名 → 宿主机 env-NAME }`。编排器把宿主机 env-NAME 从它的 secret 源
|
|
1551
|
+
* 解析成真值、经 secretKeyRef 注入——**sema-registry 只存名、绝不存值**(同 `secretRefs` 边界,值也是 `ENV_NAME`
|
|
1552
|
+
* 故无法粘贴真值)。给 app 专属令牌用(如 `OA_SERVICE_TOKEN` → 某 env-名)。在 reconciler 里于 `env` 之后解析,
|
|
1553
|
+
* 故密钥永不被 `env` 同名键遮蔽。`RESERVED_WORKER_ENV` 里的键被拒(那些结构化 secret 走 `secretRefs`)。
|
|
1554
|
+
*/
|
|
1555
|
+
secretEnv: z
|
|
1556
|
+
.record(ENV_NAME, ENV_NAME)
|
|
1557
|
+
.superRefine((rec, ctx) => {
|
|
1558
|
+
for (const k of Object.keys(rec))
|
|
1559
|
+
if (RESERVED_WORKER_ENV.has(k))
|
|
1560
|
+
ctx.addIssue({ code: "custom", message: `secretEnv cannot set reserved key "${k}" — set it via ${RESERVED_ENV_FIELD[k] ?? "secretRefs"}` });
|
|
1561
|
+
})
|
|
1562
|
+
.optional(),
|
|
1563
|
+
/** Opaque recreate trigger (hash-INCLUDED): bump it to force the reconciler to recreate the worker — e.g. to
|
|
1564
|
+
* re-project a secret VALUE that changed (a secret write doesn't alter the spec, so without this the running
|
|
1565
|
+
* pod keeps the old value). Set by POST /api/config/workers/<name>/rotate. */
|
|
1566
|
+
recreateNonce: z.string().max(64).optional(),
|
|
1567
|
+
/**
|
|
1568
|
+
* 生命周期 (v2②, design/25 §3)。可选——不填 = `persistent`(默认安全,绝不被 GC 误杀)。
|
|
1569
|
+
* **reconciler 不读这个字段**(保持纯收敛);TTL/idle 决策全在 sema-registry 侧 sweeper:到期就把 worker
|
|
1570
|
+
* 从 desired 移除,reconciler 声明式自然 teardown。`ttl`/`idleTimeout` 用时长串("30m"/"2h"/"7d")。
|
|
1571
|
+
*/
|
|
1572
|
+
lifecycle: WorkerLifecycle.optional(),
|
|
1573
|
+
/**
|
|
1574
|
+
* 多主机调度 (可选, 非 breaking — reconciler 不读)。`auto:true` → sema-registry 的 placement 按容量/标签约束
|
|
1575
|
+
* 给它挑一台 host 并写回具体 `host`(reconciler 仍按具体 host 认领,契约不变)。`requires` = host 必须具备的
|
|
1576
|
+
* 全部标签(hosts 域声明)。不填 = 手动指定 host(运维自己负责)。
|
|
1577
|
+
*/
|
|
1578
|
+
placement: z
|
|
1579
|
+
.object({
|
|
1580
|
+
auto: z.boolean().default(false),
|
|
1581
|
+
requires: z.array(z.string().min(1)).default([]),
|
|
1582
|
+
/** SOFT anti-affinity group: auto workers sharing this key are spread across hosts when possible
|
|
1583
|
+
* (preferred, never blocks placement — design SCHEDULING.md). 不填 = 无 spread 约束。 */
|
|
1584
|
+
spread: z.string().min(1).optional(),
|
|
1585
|
+
})
|
|
1586
|
+
.optional(),
|
|
1587
|
+
/**
|
|
1588
|
+
* per-worker roster 引用 (docs/MULTI-ROSTER.md)。指向 `rosters` 域里一个命名 roster;该 worker 的 service 拉
|
|
1589
|
+
* `GET /api/config/effective?worker=<name>` 时,sema-registry 用这个 roster 解析它专属的 models/roles/@白名单。
|
|
1590
|
+
* 不填 → 该 worker 拿全局 models(向后兼容)。**不进 `workerSpecHash`**(改 roster 只让 service 重拉、不重建容器)。
|
|
1591
|
+
*/
|
|
1592
|
+
roster: z.string().min(1).optional(),
|
|
1593
|
+
/** 预留 spec 签名钩子 (MVP 不验; reconciler 侧可加验签 — design/22 决策③). */
|
|
1594
|
+
signature: z.string().optional(),
|
|
1595
|
+
notes: z.string().optional(),
|
|
1596
|
+
});
|
|
1597
|
+
/** Effective route key for a worker = explicit routePrefix, else its name. */
|
|
1598
|
+
export function workerRouteKey(w) {
|
|
1599
|
+
return w.routePrefix ?? w.name;
|
|
1600
|
+
}
|
|
1601
|
+
/** S/M/L 资源套餐常量(EXPERT-REDESIGN §3 四问③,E1 定档)——workers 向导的规格套餐与 E2 placement 的
|
|
1602
|
+
* 默认折算**同源引用这一份**(worker 无 requests 时按 S 折算,§2-4)。值为 k8s quantity 串,直接落
|
|
1603
|
+
* `WorkerSpec.resources`;「自定义」= 不用套餐、直接写 resources。定档口径:S=轻量单副本(默认档,略高于
|
|
1604
|
+
* backend 兜底 50m/128Mi 的真实工作水位);M=常规编码 agent;L=重载(大 repo/多并发 turn)。 */
|
|
1605
|
+
export const WORKER_RESOURCE_PRESETS = {
|
|
1606
|
+
S: { requests: { cpu: "250m", memory: "512Mi" }, limits: { cpu: "1", memory: "1Gi" } },
|
|
1607
|
+
M: { requests: { cpu: "500m", memory: "1Gi" }, limits: { cpu: "2", memory: "2Gi" } },
|
|
1608
|
+
L: { requests: { cpu: "1", memory: "2Gi" }, limits: { cpu: "4", memory: "4Gi" } },
|
|
1609
|
+
};
|
|
1610
|
+
export const WorkersConfig = z
|
|
1611
|
+
.object({ workers: z.array(WorkerSpec).default([]) })
|
|
1612
|
+
.superRefine((cfg, ctx) => {
|
|
1613
|
+
// worker `name` and effective routePrefix must each be unique across the fleet (design/25 决策7:
|
|
1614
|
+
// routePrefix 唯一性 sema-registry 提交时校验). Dup name → container-name clash; dup route → ambiguous
|
|
1615
|
+
// reverse-proxy routing.
|
|
1616
|
+
const names = new Set();
|
|
1617
|
+
const routes = new Set();
|
|
1618
|
+
cfg.workers.forEach((w, i) => {
|
|
1619
|
+
if (names.has(w.name))
|
|
1620
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate worker name "${w.name}"`, path: ["workers", i, "name"] });
|
|
1621
|
+
names.add(w.name);
|
|
1622
|
+
const route = workerRouteKey(w);
|
|
1623
|
+
// point the error at the field that actually set the route (explicit routePrefix, else the name fallback).
|
|
1624
|
+
const routeField = w.routePrefix !== undefined ? "routePrefix" : "name";
|
|
1625
|
+
if (routes.has(route))
|
|
1626
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate routePrefix "${route}" (collides with another worker's routePrefix/name)`, path: ["workers", i, routeField] });
|
|
1627
|
+
routes.add(route);
|
|
1628
|
+
// replicas + autoscaling = two owners of the same knob (the orchestrator's SSA vs the HPA controller
|
|
1629
|
+
// would endlessly fight over spec.replicas) → reject at save, not at reconcile.
|
|
1630
|
+
if (w.replicas !== undefined && w.autoscaling) {
|
|
1631
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "replicas and autoscaling are mutually exclusive — the HPA owns the replica count when autoscaling is set", path: ["workers", i, "autoscaling"] });
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
});
|
|
1635
|
+
// Worker STATUS — reconciler 回写的 ACTUAL state. NOT an admin domain: spec(admin 写) 与 status(reconciler
|
|
1636
|
+
// 写) 分离更新、各自乐观锁,避免 reconciler 覆盖刚改的 spec (design/22 决策⑤). 存在 worker_status 表/单独 API.
|
|
1637
|
+
export const WorkerCondition = z.object({
|
|
1638
|
+
type: z.enum(["Reconciled", "Healthy", "Conflicted"]),
|
|
1639
|
+
status: z.enum(["True", "False", "Unknown"]),
|
|
1640
|
+
reason: z.string().optional(), // e.g. "HostUnreachable" | "InsufficientResources" | "ImagePullFailed"
|
|
1641
|
+
message: z.string().optional(),
|
|
1642
|
+
lastTransitionTime: z.string(),
|
|
1643
|
+
});
|
|
1644
|
+
export const WorkerStatus = z.object({
|
|
1645
|
+
name: z.string().min(1),
|
|
1646
|
+
host: z.string().min(1), // 哪个 reconciler 上报的
|
|
1647
|
+
phase: z.enum(["Pending", "Running", "Failed", "Unknown"]),
|
|
1648
|
+
conditions: z.array(WorkerCondition).default([]),
|
|
1649
|
+
/** reconciler 处理时的 effective `version` — admin 改 spec 后 version 变,据此判断 status 是否陈旧. */
|
|
1650
|
+
observedVersion: z.number().int().nonnegative(),
|
|
1651
|
+
/** reconciler 算的逻辑 spec hash (漂移检测 + 防振荡, design/22 失败模式#1). */
|
|
1652
|
+
observedSpecHash: z.string().optional(),
|
|
1653
|
+
message: z.string().optional(),
|
|
1654
|
+
updatedAt: z.string(),
|
|
1655
|
+
});
|
|
1656
|
+
// ── Hosts registry (机器 — 自注册+待批, EXPERT-REDESIGN §2) ───────────────────────
|
|
1657
|
+
// 声明 fleet 里有哪些 host + 标签/drain/批准状态。host `name` 对应 reconciler 的 ORCH_HOST_ID 与
|
|
1658
|
+
// WorkerSpec.host(同字符集)。0.6.0 改形(拍板:capacity 手填票数废除,机器自注册+默认待批):
|
|
1659
|
+
// - `capacity` DELETED —— 可放性改由探测值折算(HostStatus.probes − Σ已排 worker requests;计数制→资源制
|
|
1660
|
+
// 是探测数据齐备后的**独立开关**,E2 placement 承接,切换前沿用计数制兜底、不按票数)。
|
|
1661
|
+
// - `approved` NEW —— 默认 false = 待批(不参与自动排布;enforcement 随 E2 placement 生效,E1 只落契约)。
|
|
1662
|
+
// 存量迁移 = grandfathering 批量回填 approved:true(migrate.ts),存量机器不出现排布空窗(§11-9)。
|
|
1663
|
+
/** DNS-label bound on a host NAME (0.8.0 tightening; single source — schema + the 0.8.1 read-path
|
|
1664
|
+
* grandfather clamp in config-fns both use it, so the two can never drift). */
|
|
1665
|
+
export const HOST_NAME_MAX = 63;
|
|
1666
|
+
/** Ceiling on the hosts ARRAY (0.8.0 tightening; same single-source rule as {@link HOST_NAME_MAX}). */
|
|
1667
|
+
export const HOSTS_ARRAY_MAX = 1024;
|
|
1668
|
+
export const HostEntry = z.object({
|
|
1669
|
+
// .max(HOST_NAME_MAX=63) = DNS-label bound (E2 F3③ 挂账收账, 0.8.0): center-side probe clamp stays as双保险.
|
|
1670
|
+
name: z.string().min(1).max(HOST_NAME_MAX).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
1671
|
+
/** 待批准入编状态机(§2-3):自注册/新登记默认 false = 待批,不参与自动排布;admin 批准(走 publish)→
|
|
1672
|
+
* true 参排(enforcement 随 E2)。既有 worker 不受影响(reconciler 仍按具体 host 认领)。 */
|
|
1673
|
+
approved: z.boolean().default(false),
|
|
1674
|
+
/** 调度标签(如 "gpu"/"region-us"),worker 的 `placement.requires` 据此匹配。 */
|
|
1675
|
+
labels: z.array(z.string().min(1)).default([]),
|
|
1676
|
+
/** 关闭 = 完全不可调度(既有 worker 仍由 reconciler 管,但 placement 视它不可用)。 */
|
|
1677
|
+
enabled: z.boolean().default(true),
|
|
1678
|
+
/** drain = 不再往这台**新**放 worker(既有的留着),用于下线前腾空。 */
|
|
1679
|
+
drain: z.boolean().default(false),
|
|
1680
|
+
notes: z.string().optional(),
|
|
1681
|
+
});
|
|
1682
|
+
export const HostsConfig = z
|
|
1683
|
+
// .max(HOSTS_ARRAY_MAX=1024) hosts (E2 F3③ 挂账收账, 0.8.0) — a fleet registry, not a datacenter inventory;
|
|
1684
|
+
// an absurdly long array is a poisoned publish, fail-loud at save time. READ path: 0.7-era stored data that
|
|
1685
|
+
// exceeds these 0.8 bounds is GRANDFATHERED (clamped + warned, never thrown) inside buildEffective —
|
|
1686
|
+
// see grandfatherHostsRead in config-fns (0.8.1, S3 M-1: 收紧不毁存量,E2 血训).
|
|
1687
|
+
.object({ hosts: z.array(HostEntry).max(HOSTS_ARRAY_MAX).default([]) })
|
|
1688
|
+
.superRefine((cfg, ctx) => {
|
|
1689
|
+
const seen = new Set();
|
|
1690
|
+
cfg.hosts.forEach((h, i) => {
|
|
1691
|
+
if (seen.has(h.name))
|
|
1692
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate host "${h.name}"`, path: ["hosts", i, "name"] });
|
|
1693
|
+
seen.add(h.name);
|
|
1694
|
+
});
|
|
1695
|
+
});
|
|
1696
|
+
// Host STATUS — orchestrator 周期(60s)`PUT /api/config/hosts/status` 自报的探测值(与 workers/status 同款
|
|
1697
|
+
// token+CAS 姿势,同 WorkerStatus 先例:spec/admin 写 与 status/orchestrator 写 分离)。**探测值只进状态侧表
|
|
1698
|
+
// (fleet_host_status),不进配置域**(意图与实态分离);未知 host 名上报 → server 在 hosts 域登记待批条目
|
|
1699
|
+
// (仅身份字段 {name, approved:false},幂等一条,来源 token 记审计——EXPERT-REDESIGN §2-1/§11-4)。E2
|
|
1700
|
+
// placement 的资源制折算消费它(可分配 = probes − Σ已排 worker requests);从未上报的机器不参与自动排布。
|
|
1701
|
+
// Sanity ceilings (E2 F3③ 挂账收账, 0.8.0): a forged/absurd probe (cpuCores=1e12) would suck every auto
|
|
1702
|
+
// worker onto one host. Ceilings MIRROR center's probeCapacities clamp (PROBE_CPU_CORES_CEILING=4096 /
|
|
1703
|
+
// PROBE_MEM_BYTES_CEILING=2^50=1PiB) — schema now fail-louds at report time; center's clamp stays 双保险.
|
|
1704
|
+
export const PROBE_CPU_CORES_MAX = 4096;
|
|
1705
|
+
export const PROBE_MEM_BYTES_MAX = 2 ** 50; // 1 PiB
|
|
1706
|
+
export const HostProbes = z.object({
|
|
1707
|
+
/** 探测到的 CPU 核数(docker info NCPU / k8s node allocatable cpu)。可为小数(k8s allocatable 如 3.9)。 */
|
|
1708
|
+
cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX),
|
|
1709
|
+
/** 探测到的内存字节数(docker info MemTotal / k8s node allocatable memory)。 */
|
|
1710
|
+
memBytes: z.number().int().positive().max(PROBE_MEM_BYTES_MAX),
|
|
1711
|
+
/** 当前跑着的容器数(docker info ContainersRunning / k8s node 上本 fleet 的 pod 数)。 */
|
|
1712
|
+
containersRunning: z.number().int().nonnegative(),
|
|
1713
|
+
/** k8s backend 补充:node 名 + allocatable(与顶层同单位——cpuCores 核数 / memBytes 字节)。docker 无此段。 */
|
|
1714
|
+
k8sNode: z
|
|
1715
|
+
.object({
|
|
1716
|
+
name: z.string().min(1),
|
|
1717
|
+
allocatable: z.object({ cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX).optional(), memBytes: z.number().int().positive().max(PROBE_MEM_BYTES_MAX).optional() }).optional(),
|
|
1718
|
+
})
|
|
1719
|
+
.optional(),
|
|
1720
|
+
});
|
|
1721
|
+
export const HostStatus = z.object({
|
|
1722
|
+
/** host name(hosts 域 HostEntry.name / ORCH_HOST_ID 同字符集,同 .max(HOST_NAME_MAX=63) DNS-label bound)。
|
|
1723
|
+
* status 上报是 WRITE 路径 → fail-loud 保持(有意设计,S3 M-1 订正注记:center hosts/status PUT 无独立
|
|
1724
|
+
* probe 上限,schema 即闸;读路径 grandfather 只涉 config-hosts 域,不涉此处)。 */
|
|
1725
|
+
host: z.string().min(1).max(HOST_NAME_MAX).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
1726
|
+
backend: z.enum(["docker", "k8s"]),
|
|
1727
|
+
probes: HostProbes,
|
|
1728
|
+
/** 探测时刻(ISO)——机器视图 hover 注明数据口径与此时间(§2-5:是分配额口径的底数,非实时负载)。 */
|
|
1729
|
+
ts: z.string().min(1),
|
|
1730
|
+
});
|
|
1731
|
+
// ── Domain: skills (中心化 skill 分发 — docs/MCP-SKILLS.md, Roadmap B1) ─────────
|
|
1732
|
+
// A skill is a prompt-level capability: frontmatter (name/description/scenarios) + markdown `content`
|
|
1733
|
+
// the service feeds into the agent's model context when the skill is invoked (core progressive disclosure
|
|
1734
|
+
// — NOT the base system prompt). Still centrally-delivered agent instructions. The center stores the FULL skill
|
|
1735
|
+
// (incl. content, admin-edited); /effective ships a MANIFEST (content → contentHash) and the service
|
|
1736
|
+
// lazily pulls the body by hash (slice 2). Targeting is by `scenarios[]`, filtered service-side exactly
|
|
1737
|
+
// like the in-image `skillsForScenario` (MCP-SKILLS.md §3.1, §6 Q2). Applied at service boot/reconcile,
|
|
1738
|
+
// NOT hot (restart-to-apply, same as models/roles). ⚠️ content is centrally-delivered agent instructions
|
|
1739
|
+
// (injected into the model on invoke) → writing this domain is gated to `publisher` + the publish gate
|
|
1740
|
+
// (§3.3, DOMAIN_WRITE_ROLE in store.ts).
|
|
1741
|
+
export const SkillSpec = z.object({
|
|
1742
|
+
/** Catalog key — must match the in-image SKILLS_DIR name to OVERRIDE it (§6 Q4 中心按名覆盖). */
|
|
1743
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1744
|
+
/** One-line summary (frontmatter `description`) — surfaced to the model for skill selection. */
|
|
1745
|
+
description: z.string().default(""),
|
|
1746
|
+
/** Scenarios this skill applies to; empty = all scenarios (service-side filter, same as skillsForScenario). */
|
|
1747
|
+
scenarios: z.array(z.string().min(1)).default([]),
|
|
1748
|
+
/** Per-roster targeting (v2, docs/MCP-SKILLS.md §6 Q2): roster NAMEs this skill is for. Empty = all rosters.
|
|
1749
|
+
* Filtered at GET /effective?worker=<name> by the worker's roster; the unscoped global pull ships all. */
|
|
1750
|
+
rosters: z.array(z.string().min(1)).default([]),
|
|
1751
|
+
/** The markdown body injected into the model context on invoke (progressive disclosure). Stored in full here; shipped as a hash in /effective. */
|
|
1752
|
+
content: z.string().default(""),
|
|
1753
|
+
enabled: z.boolean().default(true),
|
|
1754
|
+
notes: z.string().optional(),
|
|
1755
|
+
});
|
|
1756
|
+
export const SkillsConfig = z
|
|
1757
|
+
.object({ skills: z.array(SkillSpec).default([]) })
|
|
1758
|
+
.superRefine((cfg, ctx) => {
|
|
1759
|
+
const seen = new Set();
|
|
1760
|
+
cfg.skills.forEach((s, i) => {
|
|
1761
|
+
if (seen.has(s.name))
|
|
1762
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate skill "${s.name}"`, path: ["skills", i, "name"] });
|
|
1763
|
+
seen.add(s.name);
|
|
1764
|
+
});
|
|
1765
|
+
});
|
|
1766
|
+
/** Runtime (zod) twin of {@link SkillsManifest} — the READ-side judgment for a wire payload's `skills` key
|
|
1767
|
+
* (`readEffectiveWire`). Derived from {@link SkillSpec} exactly like {@link SkillManifestEntry} is (omit
|
|
1768
|
+
* `content` / require `contentHash`) so schema, entry type and interface can never drift apart. Only
|
|
1769
|
+
* `min(1)` on contentHash: the hash is the service's PULL KEY for the content endpoint, its exact format
|
|
1770
|
+
* is the center's writing concern — re-pinning it here would make an old reader reject a newer center's
|
|
1771
|
+
* legal hash scheme. */
|
|
1772
|
+
export const SkillsManifestSchema = z.object({
|
|
1773
|
+
skills: z.array(SkillSpec.omit({ content: true }).extend({ contentHash: z.string().min(1) })).default([]),
|
|
1774
|
+
});
|
|
1775
|
+
// ── Domain: mcp (MCP server 注册表 — docs/MCP-SKILLS.md, Roadmap B1) ────────────
|
|
1776
|
+
// sema-registry = an MCP-server REGISTRY: a scenario mounts a subset by `scenarios[]`. Maps onto core's
|
|
1777
|
+
// `McpServerSpec = { name, transport: {kind:"stdio",command,args?,env?} | {kind:"http",url,headers?}, allowTools? }`.
|
|
1778
|
+
// SECRET BOUNDARY (§3.1, design/15): a transport's env/header VALUES may be secret → the center stores only
|
|
1779
|
+
// env-NAME references (`envRefs`/`headerRefs`: target-key → an ENV_NAME in the SERVICE's env). The service's
|
|
1780
|
+
// `resolveSpec` reads the real values from its own environment — this DB never holds a credential. Like
|
|
1781
|
+
// skills, MCP is applied at boot/reconcile (not hot) and writing is gated to `publisher` + the publish gate.
|
|
1782
|
+
const McpEnvRefs = z.record(ENV_NAME, ENV_NAME); // target ENV key → ENV_NAME holding its value (both env-name-shaped)
|
|
1783
|
+
// An HTTP header NAME: an RFC 7230 token that ALSO must not be a secret literal. A secret-SHAPED header name
|
|
1784
|
+
// (a headerRefs key `sk-live-…`, or principalHeader `ghp_…`) is stored + shipped verbatim on /effective — the
|
|
1785
|
+
// SAME invariant-① gap as worker.env / model.extraBody / mcp stdio command+args (credentials belong on the
|
|
1786
|
+
// VALUE side = an env-NAME ref, never the name). The token charset includes `%`, so an encoded secret could ride
|
|
1787
|
+
// the name — containsSecretToken percent-decodes, catching it too. ONE shared schema for both header-name fields. R19.
|
|
1788
|
+
const HTTP_HEADER_NAME = z
|
|
1789
|
+
.string()
|
|
1790
|
+
.regex(/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/, "must be an HTTP header NAME")
|
|
1791
|
+
.refine((s) => !containsSecretToken(s), "HTTP header name must not contain a secret literal — credentials go in the env-NAME value, never the header name");
|
|
1792
|
+
// Header NAME → ENV_NAME holding its value. Header names are tokens (RFC 7230) — not env-name-shaped.
|
|
1793
|
+
const McpHeaderRefs = z.record(HTTP_HEADER_NAME, ENV_NAME);
|
|
1794
|
+
export const McpTransport = z.discriminatedUnion("kind", [
|
|
1795
|
+
z.object({
|
|
1796
|
+
kind: z.literal("stdio"),
|
|
1797
|
+
// command + args are free-form values shipped verbatim on /effective. Like worker.env values + model
|
|
1798
|
+
// extraBody (the sibling free-form fields), reject a secret-shaped literal — an admin pasting
|
|
1799
|
+
// `args:["--api-key","sk-…"]` would otherwise store the VALUE + ship it (invariant ① gap). Credentials go in
|
|
1800
|
+
// envRefs (env-NAME). FIELD-level refine (not a member superRefine) to preserve the discriminatedUnion. R6.
|
|
1801
|
+
command: z
|
|
1802
|
+
.string()
|
|
1803
|
+
.min(1)
|
|
1804
|
+
.refine((s) => !containsSecretToken(s), "mcp stdio command must not contain a secret literal — put credentials in envRefs (an env-NAME)")
|
|
1805
|
+
.refine((s) => !hasNul(s), "mcp stdio command must not contain a NUL byte (Node cannot spawn it)"),
|
|
1806
|
+
args: z
|
|
1807
|
+
.array(z
|
|
1808
|
+
.string()
|
|
1809
|
+
.refine((s) => !containsSecretToken(s), "mcp stdio arg must not contain a secret literal — put credentials in envRefs (an env-NAME)")
|
|
1810
|
+
.refine((s) => !hasNul(s), "mcp stdio arg must not contain a NUL byte (Node cannot spawn it)"))
|
|
1811
|
+
.optional(),
|
|
1812
|
+
/** target ENV key → ENV_NAME in the service env (never a secret value). */
|
|
1813
|
+
envRefs: McpEnvRefs.optional(),
|
|
1814
|
+
}),
|
|
1815
|
+
z.object({
|
|
1816
|
+
kind: z.literal("http"),
|
|
1817
|
+
url: NON_SECRET_URL,
|
|
1818
|
+
/** header NAME → ENV_NAME in the service env (e.g. Authorization → SEARCH_TOKEN_ENV). */
|
|
1819
|
+
headerRefs: McpHeaderRefs.optional(),
|
|
1820
|
+
/** Header NAME the SERVICE fills with the END-USER principal when proxying a tool call to this server, so the
|
|
1821
|
+
* server can do per-user RBAC (e.g. "x-agent-principal"). Distinct from headerRefs (static secret headers):
|
|
1822
|
+
* this is dynamic — the caller's principal, set per-request by the worker — so it's a header NAME, not an
|
|
1823
|
+
* env-NAME ref (no secret). Empty → the worker forwards no principal (server sees an anonymous call). */
|
|
1824
|
+
principalHeader: HTTP_HEADER_NAME.optional(),
|
|
1825
|
+
}),
|
|
1826
|
+
]);
|
|
1827
|
+
export const McpServerSpec = z.object({
|
|
1828
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1829
|
+
transport: McpTransport,
|
|
1830
|
+
/** Allowlist of tool names exposed from this server (core `allowTools`). Empty/undefined = all. */
|
|
1831
|
+
allowTools: z.array(z.string().min(1)).optional(),
|
|
1832
|
+
/** Scenarios this server is mounted in; empty = all (service-side filter, same as skills). */
|
|
1833
|
+
scenarios: z.array(z.string().min(1)).default([]),
|
|
1834
|
+
/** Per-roster targeting (v2): roster NAMEs this server is for. Empty = all rosters. Filtered at
|
|
1835
|
+
* GET /effective?worker=<name> by the worker's roster; the unscoped global pull ships all. */
|
|
1836
|
+
rosters: z.array(z.string().min(1)).default([]),
|
|
1837
|
+
enabled: z.boolean().default(true),
|
|
1838
|
+
/** Per-server opt-in for INBOUND MCP elicitation (core `McpServerSpec.elicitation`, design/99 §E23). Default
|
|
1839
|
+
* OFF = fail-closed: only an opted-in server may prompt the user mid-tool-call. Service forwards this into the
|
|
1840
|
+
* core spec (was read untyped as `(s as {elicitation?})` before this field — service ask). */
|
|
1841
|
+
elicitation: z.boolean().default(false),
|
|
1842
|
+
notes: z.string().optional(),
|
|
1843
|
+
});
|
|
1844
|
+
export const McpConfig = z
|
|
1845
|
+
.object({ servers: z.array(McpServerSpec).default([]) })
|
|
1846
|
+
.superRefine((cfg, ctx) => {
|
|
1847
|
+
const seen = new Set();
|
|
1848
|
+
cfg.servers.forEach((s, i) => {
|
|
1849
|
+
if (seen.has(s.name))
|
|
1850
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate mcp server "${s.name}"`, path: ["servers", i, "name"] });
|
|
1851
|
+
seen.add(s.name);
|
|
1852
|
+
});
|
|
1853
|
+
});
|
|
1854
|
+
// ── Domain: plugins (plugin SOURCE REFERENCES — C2 plugins 上云, additive 0.5.0) ──────────────────────────
|
|
1855
|
+
// The cloud stores WHERE a plugin comes from, NEVER its content (产品拍板): {name, source, version/sha pin}.
|
|
1856
|
+
// The consuming shell materializes the plugin locally from the ref (git clone / marketplace install) — the
|
|
1857
|
+
// registry never hosts executable plugin bytes, so there is nothing here to content-scan or lazily serve.
|
|
1858
|
+
// Plugins are an EXECUTABLE surface (commands/hooks/MCP ride in) → write-gated to `publisher` like skills/
|
|
1859
|
+
// mcp (DOMAIN_WRITE_ROLE) and capped per-principal by entitlement `visiblePlugins` (deny-wins, same
|
|
1860
|
+
// mechanics as visibleMcp/visibleSkills — B1: a personal plugin ref is a net-new capability).
|
|
1861
|
+
/** A plugin/marketplace ORIGIN string: **https only**(contract freeze board [1362] tightening-① /
|
|
1862
|
+
* [1366]①,0.10.23 单真源修:此前这条规则只落在 web UI 层的表单校验,API 直写仍放行任意形——
|
|
1863
|
+
* 同一条规则两处真源,API 这处敞了 8 天)。`ssh:`/`scp 形`/`git:`/`file:`/裸 `http:`/`owner/repo`
|
|
1864
|
+
* shorthand 一律拒:worker 只 clone 声明里的 url,能入库的形必须是消费端能安全物化的形;国内镜像
|
|
1865
|
+
* 走 `PLUGINS_ALLOW_HOSTS` 加 https 镜像域,不开 http 口;shorthand 的展开域消费端无法判定,写全。
|
|
1866
|
+
* Secrets 不变严:no secret-shaped literal, no NUL, no userinfo, no secret-bearing query/fragment
|
|
1867
|
+
* (same invariant-① posture as NON_SECRET_URL — private remotes authenticate via each machine's own
|
|
1868
|
+
* git credential helper, never an inline token). */
|
|
1869
|
+
export const PLUGIN_ORIGIN = z
|
|
1870
|
+
.string()
|
|
1871
|
+
.min(1)
|
|
1872
|
+
.refine((s) => !containsSecretToken(s), "plugin source must not contain a secret literal — private remotes authenticate via the consuming machine's git credentials, never an inline token")
|
|
1873
|
+
.refine((s) => !hasNul(s), "plugin source must not contain a NUL byte")
|
|
1874
|
+
.refine((s) => /^https:\/\//i.test(s), "plugin source must be an https:// URL (contract: https-only; ssh/scp/git/file/plain-http and owner/repo shorthand are not accepted — domestic mirrors ride an https mirror domain via PLUGINS_ALLOW_HOSTS)")
|
|
1875
|
+
.refine((s) => {
|
|
1876
|
+
if (!/^https:\/\//i.test(s))
|
|
1877
|
+
return true; // 已被上一条拒;避免对非 URL 再报一条噪声
|
|
1878
|
+
try {
|
|
1879
|
+
const u = new URL(s);
|
|
1880
|
+
return !u.username && !u.password && !urlQueryFragmentHasSecret(u);
|
|
1881
|
+
}
|
|
1882
|
+
catch {
|
|
1883
|
+
return false; // claims to be https but does not parse → reject
|
|
1884
|
+
}
|
|
1885
|
+
}, "an https plugin source must not embed credentials (userinfo) or a secret-bearing query/fragment");
|
|
1886
|
+
/** A full 40-hex git commit SHA — the ONLY pin form the supply-side treats as 钉死 (a branch/tag `version`
|
|
1887
|
+
* is a moving ref; only a commit pin makes the materialized bytes reproducible). */
|
|
1888
|
+
export const GIT_COMMIT_SHA = z.string().regex(/^[a-f0-9]{40}$/, "must be a full 40-character lowercase git commit SHA");
|
|
1889
|
+
/** WHERE a plugin comes from — two forms (产品拍板: git URL 与 marketplace ref 两形态):
|
|
1890
|
+
* - `git`: the plugin lives at a git URL directly (repo root = plugin root).
|
|
1891
|
+
* - `marketplace`: the plugin is `<name>@<marketplace>` in a plugin marketplace; `marketplace` is the
|
|
1892
|
+
* marketplace NAME and `url` its own origin (so a machine that never registered it can: add the
|
|
1893
|
+
* marketplace by url, then install by name). `url` absent = the consumer must already know the
|
|
1894
|
+
* marketplace (works inside a fleet with pre-seeded marketplaces; a dangling name installs nothing). */
|
|
1895
|
+
export const PluginSource = z.discriminatedUnion("kind", [
|
|
1896
|
+
z.object({ kind: z.literal("git"), url: PLUGIN_ORIGIN }),
|
|
1897
|
+
z.object({
|
|
1898
|
+
kind: z.literal("marketplace"),
|
|
1899
|
+
marketplace: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1900
|
+
url: PLUGIN_ORIGIN.optional(),
|
|
1901
|
+
}),
|
|
1902
|
+
]);
|
|
1903
|
+
export const PluginSpec = z.object({
|
|
1904
|
+
/** Plugin name (the `<name>` half of a `<name>@<marketplace>` id; the whole id for a direct git plugin). */
|
|
1905
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1906
|
+
source: PluginSource,
|
|
1907
|
+
/** Human version label (semver/tag). A moving ref — NOT a pin; pin with `sha`. */
|
|
1908
|
+
version: z.string().optional(),
|
|
1909
|
+
/** Git commit pin (independent of `version`, 产品拍板): present ⇒ the ref is 钉死 — consumers materialize
|
|
1910
|
+
* exactly this commit; absent ⇒ the consumer tracks the source's default/`version` ref. */
|
|
1911
|
+
sha: GIT_COMMIT_SHA.optional(),
|
|
1912
|
+
enabled: z.boolean().default(true),
|
|
1913
|
+
notes: z.string().optional(),
|
|
1914
|
+
});
|
|
1915
|
+
export const PluginsConfig = z
|
|
1916
|
+
.object({ plugins: z.array(PluginSpec).default([]) })
|
|
1917
|
+
.superRefine((cfg, ctx) => {
|
|
1918
|
+
const seen = new Set();
|
|
1919
|
+
cfg.plugins.forEach((p, i) => {
|
|
1920
|
+
if (seen.has(p.name))
|
|
1921
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate plugin "${p.name}"`, path: ["plugins", i, "name"] });
|
|
1922
|
+
seen.add(p.name);
|
|
1923
|
+
});
|
|
1924
|
+
});
|
|
1925
|
+
// ── The domain registry ───────────────────────────────────────────────────────
|
|
1926
|
+
// ═══ prompts 域(center prompts 战役 v1;board [1050] 五定夺/[1051] clay 产品终稿/[1057] M2 下发形)═══
|
|
1927
|
+
// schema=编译目标非用户界面([1051]⓪):三卡(角色卡/场景说明/行为守则)是 web UI 编译层心智,
|
|
1928
|
+
// 本契约只承载编译产物(typed 声明)与 binding。单真源纪律:存储/解析在 center(sema-web),
|
|
1929
|
+
// 消费投影在 server(声明数组逐字喂 core typed stableBlocks 钩子),schema 恒在本仓(零第二份)。
|
|
1930
|
+
/** core `PromptTextDeclaration` 的契约层同形(core prompts/default.d.ts 逐字:id/slot 闭集/text)。
|
|
1931
|
+
* slot ↔ 三卡:identity=角色卡、scenario=场景说明、behavior=行为守则([1051]①)。 */
|
|
1932
|
+
export const PromptTextDeclaration = z.object({
|
|
1933
|
+
id: z.string().min(1).max(128),
|
|
1934
|
+
slot: z.enum(["identity", "scenario", "behavior"]),
|
|
1935
|
+
text: z.string().max(65536),
|
|
1936
|
+
/** core 1.315 opt-in 逐 section 对账锚([1062]①):声明方对 `text` 铸 sha256,core 校验后逐字
|
|
1937
|
+
* 透传进 manifest `contentHash`(错值丢弃+warn,core 永不自算非盐化 digest)。缺省=退化为
|
|
1938
|
+
* 整包 contentDigest 对账。契约层显式声明防非 strict parse strip 断锚([1062]③)。 */
|
|
1939
|
+
contentHash: z.string().regex(/^sha256:[0-9a-f]{64}$/).optional(),
|
|
1940
|
+
});
|
|
1941
|
+
/** 三维 binding(board [1050]①a:v1=global/worker/scenario,砍 model 维——per-model 微调走
|
|
1942
|
+
* ModelEntry.promptGuidance 专用通道)。covering 语义([1050]①b,解析只在 center 一处):
|
|
1943
|
+
* 同 slot **层间整体替换**(胜出层该 slot 声明数组整体替换低层同 slot 全部条目;层内数组保序,
|
|
1944
|
+
* 与 core assemble「declaration order preserved」无缝);层序 scenario > worker > global。
|
|
1945
|
+
* 发布即物化([1051]②a):declarations 是 publish 时物化的不可变声明(意图层/编译器版本归
|
|
1946
|
+
* center 编辑面存储,非跨仓契约面)。 */
|
|
1947
|
+
export const PromptBinding = z.object({
|
|
1948
|
+
/** 绑定层。 */
|
|
1949
|
+
level: z.enum(["global", "worker", "scenario"]),
|
|
1950
|
+
/** level=worker 时必填:worker 名。 */
|
|
1951
|
+
worker: z.string().min(1).max(190).optional(),
|
|
1952
|
+
/** level=scenario 时必填:scenario slug(ScenarioEntry.name 同形)。 */
|
|
1953
|
+
scenario: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/).optional(),
|
|
1954
|
+
/** 该层声明(可含多 slot;覆盖粒度=slot,见上)。 */
|
|
1955
|
+
declarations: z.array(PromptTextDeclaration).max(64),
|
|
1956
|
+
enabled: z.boolean().default(true),
|
|
1957
|
+
notes: z.string().optional(),
|
|
1958
|
+
}).superRefine((b, ctx) => {
|
|
1959
|
+
if (b.level === "worker" && !b.worker)
|
|
1960
|
+
ctx.addIssue({ code: "custom", message: "level=worker requires `worker`" });
|
|
1961
|
+
if (b.level === "scenario" && !b.scenario)
|
|
1962
|
+
ctx.addIssue({ code: "custom", message: "level=scenario requires `scenario`" });
|
|
1963
|
+
if (b.level === "global" && (b.worker || b.scenario))
|
|
1964
|
+
ctx.addIssue({ code: "custom", message: "level=global must not carry a selector" });
|
|
1965
|
+
});
|
|
1966
|
+
// ═══ prompts 域 catalog 轴(0.10.20;board [985]a/[1242]/[1248] 过轮定稿;core design/148 S1 seam)═══
|
|
1967
|
+
// 形 = core 1.336+ 的 `PublishedPromptArtifactEnvelope`(sema-core src/prompt-assembly/artifact.ts,
|
|
1968
|
+
// [1236]① seam 形即终形):v1 骑 declaration 轴的 immutable by-digest artifact——协议 §5.1 全 catalog
|
|
1969
|
+
// (overlays/profiles/modelPolicies/bindings)是后续 additive 切片,本轴到时 additive 扩,零迁移。
|
|
1970
|
+
// 单真源纪律:server 候选门用 core 导出的 `verifyPromptArtifact`(digest 复算+上限+range 全管线),
|
|
1971
|
+
// 本 zod 只是**传输门**(防 DoS/坏形早拒);上限=[[镜像常量]],见 PROMPT_ARTIFACT_LIMITS。
|
|
1972
|
+
/** core `ARTIFACT_LIMITS` 的镜像常量([1247]②/[1254] 拍:web/registry-core 不加 core 依赖轴 ⇒ 镜像
|
|
1973
|
+
* + **server 仓 CI 对表钉「逐键 === core 导出」**——改上限必须同 PR 动两处+过钉,三门数值恒等自证)。 */
|
|
1974
|
+
export const PROMPT_ARTIFACT_LIMITS = {
|
|
1975
|
+
maxSections: 64,
|
|
1976
|
+
maxSectionBytes: 32_000,
|
|
1977
|
+
maxTotalBytes: 131_072,
|
|
1978
|
+
maxIdLength: 128,
|
|
1979
|
+
maxPackIdLength: 200,
|
|
1980
|
+
};
|
|
1981
|
+
/** catalog 轴 section(core `CenterOverlaySection` 同形):center lane 的 contentHash **必备**
|
|
1982
|
+
* (对账锚,[1050]② tiering;与 declaration 轴的 optional 不同)。text 上限按镜像常量(zod length
|
|
1983
|
+
* 为 UTF-16 码元数,core verify 按 UTF-8 字节复核——传输门宽、语义门准)。 */
|
|
1984
|
+
export const PromptArtifactSection = z.object({
|
|
1985
|
+
id: z.string().min(1).max(PROMPT_ARTIFACT_LIMITS.maxIdLength).regex(/^[a-z0-9][a-z0-9/._-]*$/),
|
|
1986
|
+
slot: z.enum(["identity", "scenario", "behavior"]),
|
|
1987
|
+
text: z.string().min(1).max(PROMPT_ARTIFACT_LIMITS.maxSectionBytes),
|
|
1988
|
+
contentHash: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
1989
|
+
});
|
|
1990
|
+
/** core `PromptCatalogSnapshot` 同形。packId=逻辑名(如 `center:acme@3`),历史身份恒在 digest。 */
|
|
1991
|
+
export const PromptCatalogSnapshot = z.object({
|
|
1992
|
+
packId: z.string().min(1).max(PROMPT_ARTIFACT_LIMITS.maxPackIdLength),
|
|
1993
|
+
sections: z.array(PromptArtifactSection).min(1).max(PROMPT_ARTIFACT_LIMITS.maxSections),
|
|
1994
|
+
});
|
|
1995
|
+
/** core `PromptEpochArtifactPayload` 同形(schemaVersion/assemblyApi 常量 1;catalogDigest=
|
|
1996
|
+
* sha256(canonical(closure.promptCatalog)),server 候选门经 core verify 逐位复算;compatibleCore=
|
|
1997
|
+
* 无依赖 dotted-triple range,schema 层只判形不解释)。 */
|
|
1998
|
+
export const PromptEpochArtifactPayload = z.object({
|
|
1999
|
+
schemaVersion: z.literal(1),
|
|
2000
|
+
assemblyApi: z.literal(1),
|
|
2001
|
+
catalogDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
2002
|
+
compatibleCore: z
|
|
2003
|
+
.object({
|
|
2004
|
+
minCore: z.string().regex(/^\d+\.\d+\.\d+$/).optional(),
|
|
2005
|
+
maxCoreExclusive: z.string().regex(/^\d+\.\d+\.\d+$/).optional(),
|
|
2006
|
+
})
|
|
2007
|
+
.optional(),
|
|
2008
|
+
closure: z.object({
|
|
2009
|
+
basePackId: z.string().min(1).max(PROMPT_ARTIFACT_LIMITS.maxPackIdLength),
|
|
2010
|
+
promptCatalog: PromptCatalogSnapshot,
|
|
2011
|
+
}),
|
|
2012
|
+
});
|
|
2013
|
+
/** core `PublishedPromptArtifactEnvelope` 同形:发布信封=artifact(payload+artifactDigest)+ opaque
|
|
2014
|
+
* sourceRevision(只比较相等、绝不排序;不进任何 digest——协议 §9.2 内容身份/发布事件分离)。 */
|
|
2015
|
+
export const PublishedPromptArtifactEnvelope = z.object({
|
|
2016
|
+
artifact: z.object({
|
|
2017
|
+
payload: PromptEpochArtifactPayload,
|
|
2018
|
+
artifactDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
2019
|
+
}),
|
|
2020
|
+
sourceRevision: z.string().min(1).max(256),
|
|
2021
|
+
});
|
|
2022
|
+
/** prompts 域存储形 v1(center 存储真形;[1043] PromptCatalogPayload 的 v1 最小面)。
|
|
2023
|
+
* overlays/profiles/modelPolicies = 协议 §5.1 的后续 additive 面,留位不预设形(等 core S6
|
|
2024
|
+
* catalog schema 班车,additive 补键零迁移)。 */
|
|
2025
|
+
export const PromptsConfig = z.object({
|
|
2026
|
+
bindings: z.array(PromptBinding).max(256).default([]),
|
|
2027
|
+
/** catalog 轴存储位(0.10.20,[985]a):center 发布门铸好的 immutable envelope 原样存(发布即
|
|
2028
|
+
* 物化,店内零解析);local FileConfigStore lane 的 config.d/prompts.json 也可直接携带——catalog
|
|
2029
|
+
* 免 covering 解析,两 lane 自此同形(结束 local lane prompts 不透传的过渡态,[1234]① 位)。 */
|
|
2030
|
+
catalog: PublishedPromptArtifactEnvelope.optional(),
|
|
2031
|
+
});
|
|
2032
|
+
/** effective 下发形(board [1057] 逐字;`GET /api/config/effective?worker=` 的 additive `prompts`
|
|
2033
|
+
* key)。语义定死([1057]①):packId=`center:`+contentDigest 前 12(纯内容寻址,无 rev);
|
|
2034
|
+
* contentDigest=canonical JSON 全量 sha256 非盐化(operator 对账位,digest 函数在 /node 绑定
|
|
2035
|
+
* `promptsContentDigest`);sections=covering 已走完的基线终形(worker/core 零解析义务);
|
|
2036
|
+
* scenarioOverrides[s]=该场景完整重算终形(整包替换非增量),无键=用基线;fail-closed=
|
|
2037
|
+
* publish 闸关时整个 key 不出现。 */
|
|
2038
|
+
export const EffectivePrompts = z.object({
|
|
2039
|
+
packId: z.string().regex(/^center:[0-9a-f]{12}$/),
|
|
2040
|
+
contentDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
2041
|
+
sections: z.array(PromptTextDeclaration),
|
|
2042
|
+
scenarioOverrides: z.record(z.string(), z.array(PromptTextDeclaration)).optional(),
|
|
2043
|
+
});
|
|
2044
|
+
/** effective 下发形 v2(双轴,[985]a §1/[1242] Q1 裁:单 domain 单键、两轴是过渡窗内的两种表达)。
|
|
2045
|
+
* declarations=现行 A 形(centerPromptProvider 继续消费);catalog=B 形(core S1 verify 管线的输入,
|
|
2046
|
+
* server 原样交 core 不解释)。至少一轴必须在场(空对象=坏形);催收面语义照旧=publish 闸关时整个
|
|
2047
|
+
* `prompts` key 消失(fail-closed)。存量兼容:server 候选门同时认本形与 legacy 平铺 EffectivePrompts
|
|
2048
|
+
* (center 停发平铺后自然退役,server 零改动)。 */
|
|
2049
|
+
export const EffectivePromptsWire = z
|
|
2050
|
+
.object({
|
|
2051
|
+
schemaVersion: z.literal(1),
|
|
2052
|
+
declarations: EffectivePrompts.optional(),
|
|
2053
|
+
catalog: PublishedPromptArtifactEnvelope.optional(),
|
|
2054
|
+
})
|
|
2055
|
+
.refine((w) => w.declarations !== undefined || w.catalog !== undefined, { message: "at least one axis (declarations|catalog) required" });
|
|
2056
|
+
export const DOMAIN_SCHEMAS = {
|
|
2057
|
+
models: ModelsConfig,
|
|
2058
|
+
rosters: RostersConfig,
|
|
2059
|
+
skills: SkillsConfig,
|
|
2060
|
+
mcp: McpConfig,
|
|
2061
|
+
plugins: PluginsConfig,
|
|
2062
|
+
scenarios: ScenarioConfig,
|
|
2063
|
+
systems: SystemsConfig,
|
|
2064
|
+
collab: CollabConfig, // 0.7.0: replaces `teams` (EXPERT-REDESIGN §8/§9 同批换刀 — no editing gap)
|
|
2065
|
+
workers: WorkersConfig,
|
|
2066
|
+
hosts: HostsConfig,
|
|
2067
|
+
runtime: RuntimeConfig,
|
|
2068
|
+
governance: GovernanceConfig,
|
|
2069
|
+
entitlement: EntitlementConfig,
|
|
2070
|
+
execution: ExecutionPolicy, // 0.8.0: S3 execution-lane policy (scope 级; client-advisory until service gate)
|
|
2071
|
+
projects: ProjectsConfig, // 0.10.0: 142-S3 project identity ledger + 无仓铸造权威 (scope=tenant 轴; NOT portable — 租户身份数据)
|
|
2072
|
+
prompts: PromptsConfig, // 0.10.18: center prompts 战役 v1(board [1050]/[1051]/[1057];写门/store 携带解锁=[1064]②)
|
|
2073
|
+
limits: LimitsConfig, // 0.19.0: cost/限流族热更新发布位(sema-server #322 批1;本地腿闭集缺口的源头修复)
|
|
2074
|
+
// integrations + access: DELETED in 0.6.0 (EXPERT-REDESIGN §9) — parseDomain/effective/路由随之 404。
|
|
2075
|
+
};
|
|
2076
|
+
// ── Effective-config PROVENANCE (the unified shape TOC `sema config --effective` and TOB `/effective?principal=`
|
|
2077
|
+
// step-5 trace both emit, so "why is this value what it is" renders identically across both seats). Settled
|
|
2078
|
+
// via the center control-plane design gate③ (docs/design/CENTER-CONTROL-PLANE-DESIGN.md §R2-T/§10). A pure
|
|
2079
|
+
// CONTRACT type — no runtime dep on the engine; the axis VALUES (enforcement/lockBehavior) mirror @sema-agent/
|
|
2080
|
+
// core's safety vocabulary, validated by a dev-only conformance test (never a runtime import).
|
|
2081
|
+
/** One provenance source in a key's resolution trail. Two orthogonal axes: the TOC file LAYER, or the TOB
|
|
2082
|
+
* PRINCIPAL dimension. `applied:false` + `reason` mark a source whose attempt was REJECTED by the monotone
|
|
2083
|
+
* tighten-only law (e.g. a layer trying to LOOSEN a principal floor → `reason:"trust-rejected"`). */
|
|
2084
|
+
export const SettingSource = z.discriminatedUnion("axis", [
|
|
2085
|
+
z.object({ axis: z.literal("layer"), layer: z.enum(["MANAGED", "PROJECT", "LOCAL"]), applied: z.boolean().optional(), reason: z.string().optional() }),
|
|
2086
|
+
z.object({ axis: z.literal("principal"), via: z.enum(["tier", "role", "override", "worker-base"]), id: z.string(), applied: z.boolean().optional(), reason: z.string().optional() }),
|
|
2087
|
+
]);
|
|
2088
|
+
/** The resolved provenance of ONE setting/config key. `winningSource` = the source that won; `sources[]` = the
|
|
2089
|
+
* full trail (a hybrid deploy carries both a principal floor and a layer tightening). `enforcement` (STRENGTH /
|
|
2090
|
+
* where enforced) drives fail-mode — fail-OPEN iff `client && !secret`; `gate`/`server`/secret → fail-CLOSED.
|
|
2091
|
+
* STRENGTH tiers: `gate`=data-plane engine (core tool-policy/exec-gate/sandbox/per-RUN budget); `server`=
|
|
2092
|
+
* control-plane/TOB boundary (service createAuthorizer / entitlement / per-period budget / SSO/SCIM/audit/org —
|
|
2093
|
+
* the verify-and-constrain point BEFORE the data plane, ⊥ gate); `resolver`=merger correctness; `client`=UI only.
|
|
2094
|
+
* `lockBehavior` (BEHAVIOR / what the managed layer does) is orthogonal, only meaningful under managed enforcement. */
|
|
2095
|
+
export const EffectiveKey = z.object({
|
|
2096
|
+
key: z.string(),
|
|
2097
|
+
value: z.unknown(),
|
|
2098
|
+
winningSource: SettingSource,
|
|
2099
|
+
locked: z.boolean(),
|
|
2100
|
+
enforcement: z.enum(["gate", "server", "resolver", "client"]),
|
|
2101
|
+
lockBehavior: z.enum(["locked", "advisory", "forced-off", "tighten-only"]).optional(),
|
|
2102
|
+
sources: z.array(SettingSource).optional(),
|
|
2103
|
+
});
|
|
2104
|
+
/** Reserved audit "domain" for RBAC/user-management events (not a config domain → no snapshot, not
|
|
2105
|
+
* rollback-able). Lets user changes share the one audit stream the Audit page already renders. */
|
|
2106
|
+
export const RBAC_AUDIT = "rbac";
|
|
2107
|
+
/** Audit category for secret write-through (value injected into the orchestrator secret source) — name + action
|
|
2108
|
+
* ONLY, never the value (config_change.value=null), same discipline as RBAC_AUDIT. docs/SELF-SERVICE-FLEET.md §3. */
|
|
2109
|
+
export const SECRETS_AUDIT = "secrets";
|
|
2110
|
+
// Single source: derived from DOMAIN_SCHEMAS (insertion order = menu order) so the two can't drift —
|
|
2111
|
+
// adding a domain to DOMAIN_SCHEMAS is enough. Object.keys preserves declaration order.
|
|
2112
|
+
export const DOMAINS = Object.keys(DOMAIN_SCHEMAS);
|
|
2113
|
+
/** The canonical "is this a config domain?" predicate. Use it instead of hard-coding a domain list when
|
|
2114
|
+
* filtering arbitrary keys (e.g. a `config.d/` directory scan that also turns up the non-domain
|
|
2115
|
+
* `remote-exec.json`) — `remote-exec` is NOT a domain (it's the exec backend; see writeRemoteExec). */
|
|
2116
|
+
export function isDomainName(x) {
|
|
2117
|
+
return typeof x === "string" && Object.prototype.hasOwnProperty.call(DOMAIN_SCHEMAS, x);
|
|
2118
|
+
}
|