@sema-ai/registry-core 0.10.3
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 +131 -0
- package/README.md +84 -0
- package/dist/api/auth-bridge.d.ts +332 -0
- package/dist/api/auth-bridge.d.ts.map +1 -0
- package/dist/api/auth-bridge.js +211 -0
- package/dist/api/auth-bridge.js.map +1 -0
- package/dist/api/auth.d.ts +258 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +204 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/scopes.d.ts +353 -0
- package/dist/api/scopes.d.ts.map +1 -0
- package/dist/api/scopes.js +229 -0
- package/dist/api/scopes.js.map +1 -0
- package/dist/bundle.d.ts +14 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +68 -0
- package/dist/bundle.js.map +1 -0
- package/dist/config-fns.d.ts +236 -0
- package/dist/config-fns.d.ts.map +1 -0
- package/dist/config-fns.js +272 -0
- package/dist/config-fns.js.map +1 -0
- package/dist/cross-domain.d.ts +35 -0
- package/dist/cross-domain.d.ts.map +1 -0
- package/dist/cross-domain.js +119 -0
- package/dist/cross-domain.js.map +1 -0
- package/dist/file-edit.d.ts +37 -0
- package/dist/file-edit.d.ts.map +1 -0
- package/dist/file-edit.js +126 -0
- package/dist/file-edit.js.map +1 -0
- package/dist/file-store.d.ts +39 -0
- package/dist/file-store.d.ts.map +1 -0
- package/dist/file-store.js +142 -0
- package/dist/file-store.js.map +1 -0
- package/dist/fleet.d.ts +479 -0
- package/dist/fleet.d.ts.map +1 -0
- package/dist/fleet.js +303 -0
- package/dist/fleet.js.map +1 -0
- package/dist/hash.d.ts +33 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +60 -0
- package/dist/hash.js.map +1 -0
- package/dist/hooks.d.ts +5478 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +628 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/local-load.d.ts +33 -0
- package/dist/local-load.d.ts.map +1 -0
- package/dist/local-load.js +164 -0
- package/dist/local-load.js.map +1 -0
- package/dist/migrate.d.ts +245 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +543 -0
- package/dist/migrate.js.map +1 -0
- package/dist/node.d.ts +12 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +12 -0
- package/dist/node.js.map +1 -0
- package/dist/reader.d.ts +19 -0
- package/dist/reader.d.ts.map +1 -0
- package/dist/reader.js +2 -0
- package/dist/reader.js.map +1 -0
- package/dist/remote-exec.d.ts +275 -0
- package/dist/remote-exec.d.ts.map +1 -0
- package/dist/remote-exec.js +183 -0
- package/dist/remote-exec.js.map +1 -0
- package/dist/resolve-roster.d.ts +29 -0
- package/dist/resolve-roster.d.ts.map +1 -0
- package/dist/resolve-roster.js +105 -0
- package/dist/resolve-roster.js.map +1 -0
- package/dist/safety-merge-spec.d.ts +328 -0
- package/dist/safety-merge-spec.d.ts.map +1 -0
- package/dist/safety-merge-spec.js +65 -0
- package/dist/safety-merge-spec.js.map +1 -0
- package/dist/scheduler-store-node.d.ts +15 -0
- package/dist/scheduler-store-node.d.ts.map +1 -0
- package/dist/scheduler-store-node.js +43 -0
- package/dist/scheduler-store-node.js.map +1 -0
- package/dist/scheduler-store.d.ts +65 -0
- package/dist/scheduler-store.d.ts.map +1 -0
- package/dist/scheduler-store.js +59 -0
- package/dist/scheduler-store.js.map +1 -0
- package/dist/secret-refs.d.ts +34 -0
- package/dist/secret-refs.d.ts.map +1 -0
- package/dist/secret-refs.js +49 -0
- package/dist/secret-refs.js.map +1 -0
- package/dist/sha256.d.ts +17 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +115 -0
- package/dist/sha256.js.map +1 -0
- package/dist/skills-manifest.d.ts +13 -0
- package/dist/skills-manifest.d.ts.map +1 -0
- package/dist/skills-manifest.js +55 -0
- package/dist/skills-manifest.js.map +1 -0
- package/dist/types.d.ts +9911 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1727 -0
- package/dist/types.js.map +1 -0
- package/package.json +130 -0
package/dist/types.js
ADDED
|
@@ -0,0 +1,1727 @@
|
|
|
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 (codex).
|
|
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 Codex 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 (codex)。
|
|
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 (codex).
|
|
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 (codex). */
|
|
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 (codex: 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 (codex). 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 (codex). */
|
|
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 (codex). */
|
|
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 (codex). */
|
|
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 (codex). */
|
|
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 (codex). */
|
|
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 (codex).
|
|
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 (codex).
|
|
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
|
+
export const ModelEntry = z.object({
|
|
235
|
+
/** Catalog key + `@name` mention handle (stable, url/word-safe), e.g. "qwen-72b". */
|
|
236
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
237
|
+
/** The gateway's model id, e.g. "Qwen3.5-72B" (what actually goes on the wire). */
|
|
238
|
+
id: z.string().min(1),
|
|
239
|
+
/** Routes the brain in the service (createRoutingBrain by provider): "gateway"/"vllm" → OpenAI brain,
|
|
240
|
+
* "anthropic" → Anthropic brain. */
|
|
241
|
+
provider: z.string().min(1).default("gateway"),
|
|
242
|
+
/** API family — must match the serving brain or cache accounting mis-counts (service design/18).
|
|
243
|
+
* "openai-completions" for vLLM/OpenAI gateways, "anthropic-messages" for the Anthropic route. */
|
|
244
|
+
api: z.enum(["openai-completions", "anthropic-messages"]).default("openai-completions"),
|
|
245
|
+
/** Gateway base URL (non-secret). Empty → the service's default gateway for that provider. */
|
|
246
|
+
baseUrl: NON_SECRET_URL.optional().or(z.literal("")),
|
|
247
|
+
/** NAME of the env var (in the SERVICE's env) holding this model's API key — never the key itself. */
|
|
248
|
+
apiKeyEnv: ENV_NAME.optional(),
|
|
249
|
+
/** Price/capability tier — fuels core 1.25 `selectModel({ tier })` role resolution. */
|
|
250
|
+
tier: z.enum(["cheap", "standard", "premium"]).default("standard"),
|
|
251
|
+
reasoning: z.boolean().default(false),
|
|
252
|
+
vision: z.boolean().default(false),
|
|
253
|
+
contextWindow: z.number().int().positive().optional(),
|
|
254
|
+
maxTokens: z.number().int().positive().optional(),
|
|
255
|
+
/** $/Mtok. A genuinely-free model still declares 0s (core 1.26: missing cost ≠ cheapest). */
|
|
256
|
+
cost: ModelCost.optional(),
|
|
257
|
+
/** Extra OpenAI/Anthropic request-body params merged into every call to this model (core passes them
|
|
258
|
+
* through), e.g. {frequency_penalty:0.5, presence_penalty:0.3}. Non-secret model metadata — same class
|
|
259
|
+
* as cost/tier. Shipped on /effective.models.models[].extraBody; the service merges it into the request. */
|
|
260
|
+
// z.unknown() (NOT z.record — that iterates + reads top-level getters during parse, firing a malicious getter
|
|
261
|
+
// before our check). The getter-free descriptor walk (extraBodyUnsafe) validates the RAW value without
|
|
262
|
+
// invoking any getter; a Proxy that fakes its descriptors is caught by re-scanning the JSON clone; and the
|
|
263
|
+
// .transform persists that CLONE (concrete plain JSON) so a non-idempotent toJSON can't differ at persist.
|
|
264
|
+
// Round 2-5 hardening (codex).
|
|
265
|
+
extraBody: z
|
|
266
|
+
.unknown()
|
|
267
|
+
.optional()
|
|
268
|
+
// ONE transform that validates AND canonicalizes with a SINGLE JSON.stringify — the scanned clone IS the
|
|
269
|
+
// persisted value, so a non-idempotent getter/Proxy can't validate one serialization and persist another
|
|
270
|
+
// (the prior superRefine+transform stringified TWICE — codex R10). getter-free descriptor walk bounds size
|
|
271
|
+
// BEFORE the stringify; the clone is re-scanned so a Proxy that fakes its descriptors still can't ship a secret.
|
|
272
|
+
.transform((v, ctx) => {
|
|
273
|
+
if (v === undefined)
|
|
274
|
+
return undefined;
|
|
275
|
+
const proto = typeof v === "object" && v !== null ? Object.getPrototypeOf(v) : undefined;
|
|
276
|
+
if (typeof v !== "object" || v === null || Array.isArray(v) || (proto !== Object.prototype && proto !== null)) {
|
|
277
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody must be a plain JSON object" });
|
|
278
|
+
return z.NEVER;
|
|
279
|
+
}
|
|
280
|
+
// wrap in try/catch: a hostile Proxy's getOwnPropertyDescriptor/ownKeys trap could THROW a secret-bearing
|
|
281
|
+
// message — never surface it; treat any reflective failure as invalid with a FIXED message. R11 (codex).
|
|
282
|
+
let rawUnsafe;
|
|
283
|
+
try {
|
|
284
|
+
rawUnsafe = extraBodyUnsafe(v);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody could not be validated (reflective access threw)" });
|
|
288
|
+
return z.NEVER;
|
|
289
|
+
}
|
|
290
|
+
if (rawUnsafe) {
|
|
291
|
+
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" });
|
|
292
|
+
return z.NEVER;
|
|
293
|
+
}
|
|
294
|
+
let clone;
|
|
295
|
+
try {
|
|
296
|
+
clone = JSON.parse(JSON.stringify(v)); // the ONLY serialization — its result is what we scan AND persist
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody is not JSON-serializable" });
|
|
300
|
+
return z.NEVER;
|
|
301
|
+
}
|
|
302
|
+
if (extraBodyUnsafe(clone)) {
|
|
303
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "extraBody: its serialized form carries a secret" });
|
|
304
|
+
return z.NEVER;
|
|
305
|
+
}
|
|
306
|
+
return clone;
|
|
307
|
+
}),
|
|
308
|
+
/** 配额倍率(EXPERT-REDESIGN §4-1):该模型烧 token 双窗额度的倍率,默认 1。折算时点=weight-at-burn
|
|
309
|
+
* (记账时固化到 UsageEntry.quotaWeightAtUse/weightedTokens):改倍率只影响此后记账,不回算窗口内历史。 */
|
|
310
|
+
quotaWeight: z.number().finite().nonnegative().default(1),
|
|
311
|
+
enabled: z.boolean().default(true),
|
|
312
|
+
notes: z.string().optional(),
|
|
313
|
+
}).superRefine((m, ctx) => {
|
|
314
|
+
// provider↔api pairing (service createRoutingBrain / design/18): the Anthropic brain needs `anthropic-messages`
|
|
315
|
+
// and that api needs the Anthropic provider — a mismatch mis-counts cache/cost at runtime. The console FORM
|
|
316
|
+
// suggested this; enforce it in the SCHEMA so a direct PUT / local TOC config can't ship a mis-paired model.
|
|
317
|
+
// (provider:"anthropic" must set api explicitly; gateway/vllm keep the openai-completions default.) Round-4 (codex).
|
|
318
|
+
if ((m.provider === "anthropic") !== (m.api === "anthropic-messages"))
|
|
319
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `provider "${m.provider}" + api "${m.api}" mismatch — provider:"anthropic" ⟺ api:"anthropic-messages"`, path: ["api"] });
|
|
320
|
+
});
|
|
321
|
+
// `verifier` (2026-06-11, SERVICE-RESPONSE-…-PHASE2): real consumer = core's verification gate
|
|
322
|
+
// (runWithVerification/resumeWithVerification resolve the verifier sub-task model via this role). Fallback
|
|
323
|
+
// semantics per core: a roster that doesn't set `verifier` uses `default`. core also has `advisor` (teacher
|
|
324
|
+
// mode) — deliberately NOT added: Service AI evaluated teacher mode and won't consume it (no consumer, no key).
|
|
325
|
+
export const ModelRole = z.enum(["default", "summarize", "subagent", "team", "synthesize", "verifier"]);
|
|
326
|
+
/** A role resolves either to a model NAME, or by capability/price (core 1.25 `{ select }`). */
|
|
327
|
+
export const RoleTarget = z.union([
|
|
328
|
+
z.object({ model: z.string().min(1) }),
|
|
329
|
+
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() }) }),
|
|
330
|
+
]);
|
|
331
|
+
// ── sema 档位(tiers)与档位组(tier groups)──([599]③ center 半场, core 1.265 档位车) ──────
|
|
332
|
+
// core 侧接缝 = `RunnerDeps.tiers`(档位词→模型绑定的**单表**, catalog 单点增广 expandTiers);center 侧
|
|
333
|
+
// 把「多套命名预配置」存成 tierGroups,下发时只解析 ACTIVE 那一套成单表(组切换=center 换表下发,零新
|
|
334
|
+
// 引擎机制)。与 `ModelEntry.tier`(cheap/standard/premium 价格分级, core 1.25 selectModel)是两回事——
|
|
335
|
+
// 那是能力/价格元数据,这是用户面的模型预配置绑定;词表也不同(max/pro/flash/lite + ultra 预留)。
|
|
336
|
+
/** 档位词→catalog 模型 `name` 的绑定。值=目录键(string 引用,不 enum——开放性照 execution
|
|
337
|
+
* allowedLanes 先例);`ultra` 预留在 max 之上,绑定即生效零码改([598]②)。未绑档引擎侧沿链降档
|
|
338
|
+
* fail-open;整表未配=引擎恒惰性(INERT)。 */
|
|
339
|
+
export const TierBindings = z.object({
|
|
340
|
+
ultra: z.string().min(1).optional(),
|
|
341
|
+
max: z.string().min(1).optional(),
|
|
342
|
+
pro: z.string().min(1).optional(),
|
|
343
|
+
flash: z.string().min(1).optional(),
|
|
344
|
+
lite: z.string().min(1).optional(),
|
|
345
|
+
});
|
|
346
|
+
/** 「档位组」= 一套命名的档位绑定表预配置,如「日常组=v4-pro/v4-flash」vs「攻坚组=fable-5/v4-pro」。 */
|
|
347
|
+
export const TierGroup = z.object({
|
|
348
|
+
/** Group key (same charset as catalog names), e.g. "daily" / "heavy". */
|
|
349
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
350
|
+
tiers: TierBindings.default({}),
|
|
351
|
+
notes: z.string().optional(),
|
|
352
|
+
});
|
|
353
|
+
export const ModelsConfig = z
|
|
354
|
+
.object({
|
|
355
|
+
models: z.array(ModelEntry).default([]),
|
|
356
|
+
/** role → target. Unset roles fall back to `default` (core fallback chain). */
|
|
357
|
+
roles: z.record(ModelRole, RoleTarget).default({}),
|
|
358
|
+
/** Which model names an end user may `@`-mention. Empty/undefined → all enabled models. */
|
|
359
|
+
atModelAllowlist: z.array(z.string()).optional(),
|
|
360
|
+
/** 命名档位组预配置([599]③)。只有 ACTIVE 组的绑定表会下发为引擎的 tiers 单表。 */
|
|
361
|
+
tierGroups: z.array(TierGroup).default([]),
|
|
362
|
+
/** 当前生效的档位组 name。未设(或无组)→ 不下发 tiers,引擎档位层恒惰性。 */
|
|
363
|
+
activeTierGroup: z.string().optional(),
|
|
364
|
+
})
|
|
365
|
+
// `name` is the catalog key AND the @-handle AND the cross-domain ref target (rosters/collab/roles/allowlist
|
|
366
|
+
// all reference it by name). EVERY other name-keyed domain dedups (rosters/scenarios/systems/...); models —
|
|
367
|
+
// the most-referenced — was the lone gap. A duplicate name ships two conflicting defs (id/apiKeyEnv/cost/
|
|
368
|
+
// enabled) on the wire and makes every ref ambiguous (the `new Set(names)` existence checks collapse them).
|
|
369
|
+
// Round-3 fix (codex, reproduced).
|
|
370
|
+
.superRefine((cfg, ctx) => {
|
|
371
|
+
const seen = new Set();
|
|
372
|
+
cfg.models.forEach((m, i) => {
|
|
373
|
+
if (seen.has(m.name))
|
|
374
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate model "${m.name}" — the catalog key (and @-handle) must be unique`, path: ["models", i, "name"] });
|
|
375
|
+
seen.add(m.name);
|
|
376
|
+
});
|
|
377
|
+
// Catalog self-references must resolve to a real model — a dangling global `roles.{role}.model` or an
|
|
378
|
+
// `atModelAllowlist` name not in `models` ships a broken wire (the global /effective path uses eff.models
|
|
379
|
+
// directly, with no roster filter). This lived ONLY in refIntegrityIssues (sema-registry) — but the schema
|
|
380
|
+
// is the only guard for a local TOC config / a direct PUT. Round-4 (codex, reproduced).
|
|
381
|
+
const names = new Set(cfg.models.map((m) => m.name));
|
|
382
|
+
for (const [role, t] of Object.entries(cfg.roles ?? {})) {
|
|
383
|
+
if (t && "model" in t && !names.has(t.model))
|
|
384
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `role "${role}" → model "${t.model}" is not in the catalog`, path: ["roles", role, "model"] });
|
|
385
|
+
}
|
|
386
|
+
(cfg.atModelAllowlist ?? []).forEach((n, i) => {
|
|
387
|
+
if (!names.has(n))
|
|
388
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `atModelAllowlist["${n}"] is not in the catalog`, path: ["atModelAllowlist", i] });
|
|
389
|
+
});
|
|
390
|
+
// 档位组 ref 完整性(照上面 roles/allowlist 悬空检测同款):组名去重 + 绑定必须命中 catalog +
|
|
391
|
+
// activeTierGroup 必须命中某组(悬空 active = 下发面从「换表」静默滑成「无表」,写入时 fail-loud)。
|
|
392
|
+
const groupSeen = new Set();
|
|
393
|
+
cfg.tierGroups.forEach((g, i) => {
|
|
394
|
+
if (groupSeen.has(g.name))
|
|
395
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate tier group "${g.name}"`, path: ["tierGroups", i, "name"] });
|
|
396
|
+
groupSeen.add(g.name);
|
|
397
|
+
for (const [tier, model] of Object.entries(g.tiers)) {
|
|
398
|
+
if (typeof model === "string" && !names.has(model))
|
|
399
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `tier group "${g.name}" ${tier} → model "${model}" is not in the catalog`, path: ["tierGroups", i, "tiers", tier] });
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
if (cfg.activeTierGroup !== undefined && !groupSeen.has(cfg.activeTierGroup)) {
|
|
403
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `activeTierGroup "${cfg.activeTierGroup}" is not a tier group name`, path: ["activeTierGroup"] });
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
// ── Domain: rosters (per-worker model roster — docs/MULTI-ROSTER.md) ───────────
|
|
407
|
+
// A named VIEW over the global `models` catalog: which models a worker exposes + its role map + @-allowlist.
|
|
408
|
+
// A worker references one by name (WorkerSpec.roster); GET /effective?worker=<name> resolves it. Models stay
|
|
409
|
+
// single-sourced in the `models` domain — a roster only references them by name (cross-domain ref checked at
|
|
410
|
+
// resolution/PUT, see docs/MULTI-ROSTER.md §6). This is what lets one sema-registry serve heterogeneous
|
|
411
|
+
// instances (OA=Qwen vs review=DeepSeek) instead of a single global roster.
|
|
412
|
+
export const RosterEntry = z.object({
|
|
413
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
414
|
+
/** Catalog model names this roster exposes (must exist in models.models[].name). Empty = the full catalog. */
|
|
415
|
+
models: z.array(z.string().min(1)).default([]),
|
|
416
|
+
/**
|
|
417
|
+
* 主模型角色标记(0.6.3, E3/[546]/[548]②)——catalog model NAME。worker.model 废除后 service 从 roster
|
|
418
|
+
* 解析 boot 主模型的归宿位:解析语义(resolve-roster)= primary 喂 `default`/`team`/`synthesize` 三角色、
|
|
419
|
+
* cheap 喂 `subagent`/`summarize`(service 现有 ModelRoles 口径;`verifier` 不派生——unset→default 兜底链)。
|
|
420
|
+
* 显式 `roles` **逐角色**优先于标记派生;标记优先于全局 models.roles。additive:不设=行为与 0.6.2 全等。
|
|
421
|
+
*/
|
|
422
|
+
primaryModel: z.string().min(1).optional(),
|
|
423
|
+
/** cheap 模型角色标记(同上;worker.model.cheapId 的迁移归宿,§3②)。 */
|
|
424
|
+
cheapModel: z.string().min(1).optional(),
|
|
425
|
+
/** Role map override; unset roles fall back to marker-derived roles, then the global models.roles. */
|
|
426
|
+
roles: z.record(ModelRole, RoleTarget).optional(),
|
|
427
|
+
/** @-mention allowlist override; unset → inherits the global models.atModelAllowlist. */
|
|
428
|
+
atModelAllowlist: z.array(z.string()).optional(),
|
|
429
|
+
notes: z.string().optional(),
|
|
430
|
+
});
|
|
431
|
+
export const RostersConfig = z
|
|
432
|
+
.object({ rosters: z.array(RosterEntry).default([]) })
|
|
433
|
+
.superRefine((cfg, ctx) => {
|
|
434
|
+
const seen = new Set();
|
|
435
|
+
cfg.rosters.forEach((r, i) => {
|
|
436
|
+
if (seen.has(r.name))
|
|
437
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate roster "${r.name}"`, path: ["rosters", i, "name"] });
|
|
438
|
+
seen.add(r.name);
|
|
439
|
+
// intra-roster consistency: a role target / an @-allowlist entry pinned to a model NAME must be one
|
|
440
|
+
// this roster exposes (when it's a subset). Cross-domain (the name exists in the catalog) is checked at
|
|
441
|
+
// PUT time — see MULTI-ROSTER.md. (Both layers matter: this catches subset-internal inconsistency even
|
|
442
|
+
// when models is the full catalog is left to the PUT check.)
|
|
443
|
+
if (r.models.length) {
|
|
444
|
+
const allowed = new Set(r.models);
|
|
445
|
+
// 0.6.3 markers: same subset-consistency line as roles/@-allowlist — a marker naming a model this
|
|
446
|
+
// roster doesn't expose would derive a dangling role (dropped at resolution, but reject loudly at PUT).
|
|
447
|
+
if (r.primaryModel && !allowed.has(r.primaryModel)) {
|
|
448
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" primaryModel → "${r.primaryModel}" not in roster.models`, path: ["rosters", i, "primaryModel"] });
|
|
449
|
+
}
|
|
450
|
+
if (r.cheapModel && !allowed.has(r.cheapModel)) {
|
|
451
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" cheapModel → "${r.cheapModel}" not in roster.models`, path: ["rosters", i, "cheapModel"] });
|
|
452
|
+
}
|
|
453
|
+
for (const [role, target] of Object.entries(r.roles ?? {})) {
|
|
454
|
+
if (target && "model" in target && !allowed.has(target.model)) {
|
|
455
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" role.${role} → "${target.model}" not in roster.models`, path: ["rosters", i, "roles", role] });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
(r.atModelAllowlist ?? []).forEach((name, j) => {
|
|
459
|
+
if (!allowed.has(name))
|
|
460
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `roster "${r.name}" atModelAllowlist "${name}" not in roster.models`, path: ["rosters", i, "atModelAllowlist", j] });
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
// ── Domain: scenarios (场景组合配置化 — ASK-CENTER-2026-06-11-SCENARIOS, v1) ───────────────────────────
|
|
466
|
+
// "组合即配置、能力钉死在部署": the center COMPOSES scenarios from the toolsets the deployment registers —
|
|
467
|
+
// it can never mint a new capability (a new toolset = a service code change, deliberately expensive). The
|
|
468
|
+
// service overlays these onto its built-in scenario table at boot; SAME NAME OVERRIDES the built-in (clay 拍
|
|
469
|
+
// ①, the worker logs `sema_registry_scenarios.shadowsBuiltin` as the audit hook). Invalid entries are
|
|
470
|
+
// skipped+warned worker-side, never fail boot. restart-to-apply like skills/mcp (clay 拍 ③).
|
|
471
|
+
// The old skeletal roles/tools fields are gone (never stored anywhere, zero consumers, no migration).
|
|
472
|
+
export const ScenarioEntry = z.object({
|
|
473
|
+
/** Scenario name — may equal a built-in name (then the center definition SHADOWS it). */
|
|
474
|
+
name: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/, "lowercase slug, 2-32 chars (a-z, 0-9, -)"),
|
|
475
|
+
/** Toolset key from the SERVICE's registry (v1: "none" | "repo-readonly"). Deliberately a shaped string,
|
|
476
|
+
* not an enum: the worker registry is the authority (unknown → skip+warn there), so the center never needs
|
|
477
|
+
* a release just to NAME a toolset a newer service ships. */
|
|
478
|
+
toolset: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/, "toolset key (lowercase slug)"),
|
|
479
|
+
/** System-prompt-level content (clay 拍 ②: ≤4KB; the full text lands in the change/publish audit via the
|
|
480
|
+
* domain snapshot). Optional — absent = a neutral scenario (no system framing). Wrapped by the service's
|
|
481
|
+
* stablePrompt (prefix-cache discipline is code-side; the config plane can't bypass it). */
|
|
482
|
+
prompt: z.string().max(4096).optional(),
|
|
483
|
+
enabled: z.boolean().default(true),
|
|
484
|
+
notes: z.string().optional(),
|
|
485
|
+
});
|
|
486
|
+
export const ScenarioConfig = z
|
|
487
|
+
.object({ scenarios: z.array(ScenarioEntry).default([]) })
|
|
488
|
+
.superRefine((cfg, ctx) => {
|
|
489
|
+
const seen = new Set();
|
|
490
|
+
cfg.scenarios.forEach((sc, i) => {
|
|
491
|
+
if (seen.has(sc.name))
|
|
492
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate scenario "${sc.name}"`, path: ["scenarios", i, "name"] });
|
|
493
|
+
seen.add(sc.name);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
// ── Domain: systems (系统接入/凭证生命周期 — SERVICE-PROPOSAL-SYSTEMS-DOMAIN, S1) ─────────────────────────
|
|
497
|
+
// Declarative SYSTEM onboarding: which external systems (OA / CC-MCP facade / portal …) may call which
|
|
498
|
+
// workers, with the credential LIFECYCLE (mint/deliver/rotate/revoke) owned by the orchestrator (S2/S3).
|
|
499
|
+
// `name` doubles as the worker-derived run.source (the worker attributes a run to whichever credential
|
|
500
|
+
// authenticated — never self-reported). This domain holds METADATA + nonces ONLY — credential VALUES live
|
|
501
|
+
// exclusively in the secret store (I1: audit/UI/API see fingerprints, never values).
|
|
502
|
+
/** Platform credential surfaces a system may NEVER deliver into (红线1 — would let a publish overwrite the
|
|
503
|
+
* platform's own secrets). Runtime delivery (S3) re-checks; this is the save-time half. */
|
|
504
|
+
export const DELIVER_DENYLIST = new Set([
|
|
505
|
+
"a2-platform-secrets",
|
|
506
|
+
"tidb-creds",
|
|
507
|
+
"gitea-notify",
|
|
508
|
+
"worker-secrets",
|
|
509
|
+
// the orchestrator's OWN minted-token store (S2) — a system must never deliver into its credential source.
|
|
510
|
+
"systems-credentials",
|
|
511
|
+
]);
|
|
512
|
+
export const SystemEntry = z.object({
|
|
513
|
+
/** System id = run.source value. Same shape the worker enforces for attribution. */
|
|
514
|
+
name: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/, "lowercase slug, 2-32 chars (a-z, 0-9, -)"),
|
|
515
|
+
/** Workers this system may call — its token lands in each one's SERVICE_AUTH_TOKENS CSV (S2).
|
|
516
|
+
* Cross-domain ref-checked against the workers domain. */
|
|
517
|
+
callsWorkers: z.array(z.string().min(1)).default([]),
|
|
518
|
+
/** Where the orchestrator delivers this system's credential (the CALLER's secret). v1: a BARE secret name
|
|
519
|
+
* in the orchestrator's OWN namespace (no "ns/name" — cross-namespace = an RBAC expansion, deferred until
|
|
520
|
+
* a real need). Optional: a system without deliverTo gets a minted token visible only via out-of-band ops. */
|
|
521
|
+
deliverTo: z
|
|
522
|
+
.object({
|
|
523
|
+
k8sSecret: z
|
|
524
|
+
.string()
|
|
525
|
+
.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)')
|
|
526
|
+
.max(253)
|
|
527
|
+
// The `<worker>-env` suffix is the orchestrator's worker-credential projection pattern. A target
|
|
528
|
+
// worker that isn't provisioned yet has no Secret to label-check, so a name-level reservation is
|
|
529
|
+
// the only thing closing the "squat on a future worker's credential surface" timing hole (S3 设计
|
|
530
|
+
// §4.4, council DESIGN-6/13). Cost: callers can't use a secret that happens to end in -env.
|
|
531
|
+
.refine((n) => !n.endsWith("-env"), { message: 'secret names ending in "-env" are reserved for orchestrator worker-credential projections (红线1)' }),
|
|
532
|
+
key: z.string().regex(/^[-._a-zA-Z0-9]+$/, "k8s secret data key").max(253),
|
|
533
|
+
})
|
|
534
|
+
.optional(),
|
|
535
|
+
/** Bump to ROTATE this system's credential (S3 choreography: mint new → dual-token CSV → deliver → grace →
|
|
536
|
+
* retire old). Same idiom as WorkerSpec.recreateNonce. */
|
|
537
|
+
rotateNonce: z.string().max(64).optional(),
|
|
538
|
+
/** Overlap window (seconds) during rotation before the OLD token is retired. Absent → orchestrator default
|
|
539
|
+
* (service AI to confirm; S3). */
|
|
540
|
+
rotateGraceSec: z.number().int().positive().optional(),
|
|
541
|
+
enabled: z.boolean().default(true),
|
|
542
|
+
notes: z.string().optional(),
|
|
543
|
+
});
|
|
544
|
+
export const SystemsConfig = z
|
|
545
|
+
.object({ systems: z.array(SystemEntry).default([]) })
|
|
546
|
+
.superRefine((cfg, ctx) => {
|
|
547
|
+
const seen = new Set();
|
|
548
|
+
const targets = new Map(); // "secret\0key" -> index of first ENABLED claimant
|
|
549
|
+
cfg.systems.forEach((sys, i) => {
|
|
550
|
+
if (seen.has(sys.name))
|
|
551
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate system "${sys.name}"`, path: ["systems", i, "name"] });
|
|
552
|
+
seen.add(sys.name);
|
|
553
|
+
if (sys.deliverTo && DELIVER_DENYLIST.has(sys.deliverTo.k8sSecret)) {
|
|
554
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `deliverTo.k8sSecret "${sys.deliverTo.k8sSecret}" is a PLATFORM credential surface — refused (红线1)`, path: ["systems", i, "deliverTo", "k8sSecret"] });
|
|
555
|
+
}
|
|
556
|
+
// Cross-system deliverTo collision (S3 设计 §4.5, council DESIGN-4): two enabled systems writing the
|
|
557
|
+
// same {secret,key} would silently overwrite each other (same SSA fieldManager). Save-time half; the
|
|
558
|
+
// orchestrator re-checks at delivery time.
|
|
559
|
+
if (sys.deliverTo && sys.enabled) {
|
|
560
|
+
const t = `${sys.deliverTo.k8sSecret}\0${sys.deliverTo.key}`;
|
|
561
|
+
const prior = targets.get(t);
|
|
562
|
+
if (prior !== undefined) {
|
|
563
|
+
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"] });
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
targets.set(t, i);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
/** One system's credential-lifecycle OBSERVATION (S3 设计 §7) — written by the orchestrator each cycle via
|
|
572
|
+
* `PUT /api/config/systems/status` (batch), read by the S4 UI. Carries FINGERPRINTS only, never values (I1). */
|
|
573
|
+
export const SystemStatusEntry = z.object({
|
|
574
|
+
name: z.string().min(1),
|
|
575
|
+
/** lkg-hold = the systems domain went empty/absent and the orchestrator is holding the last-known-good
|
|
576
|
+
* injection (S3 设计 §5); refused = a deliverTo runtime red-line check failed (see `reason`). */
|
|
577
|
+
phase: z.enum(["bootstrap", "steady", "rotating", "grace", "refused", "lkg-hold"]),
|
|
578
|
+
/** sha256/8 of the CURRENT token. */
|
|
579
|
+
fp: z.string().regex(/^[0-9a-f]{8}$/),
|
|
580
|
+
/** sha256/8 of the retiring token while a rotation grace window is open. */
|
|
581
|
+
prevFp: z.string().regex(/^[0-9a-f]{8}$/).optional(),
|
|
582
|
+
mintedAt: z.string().optional(),
|
|
583
|
+
/** When the retiring token will be dropped (set at delivery; absent while awaiting convergence). */
|
|
584
|
+
retireAt: z.string().optional(),
|
|
585
|
+
/** sha256/8 of the token last successfully written to deliverTo. */
|
|
586
|
+
deliveredFp: z.string().regex(/^[0-9a-f]{8}$/).optional(),
|
|
587
|
+
/** Which check refused delivery / why the orchestrator is holding (operator-facing, never a value). */
|
|
588
|
+
reason: z.string().max(500).optional(),
|
|
589
|
+
updatedAt: z.string(),
|
|
590
|
+
});
|
|
591
|
+
// ── Domain: runtime (限额残余 — API/CLI-only, EXPERT-REDESIGN §5) ─────────────────
|
|
592
|
+
// 0.6.0 (runtime 域废除拆三刀): the governance trio (autonomy/commandPolicy/approvalRequire) moved OUT into
|
|
593
|
+
// the new `governance` domain; the legacy-skeletal `approvalDeny`/`runStaleSec` (service never read them) are
|
|
594
|
+
// DELETED outright. What remains = the fleet cost/rate LIMIT residue — kept API/CLI-only (the web renders NO
|
|
595
|
+
// runtime surface; the "runtime" UI concept is abolished).
|
|
596
|
+
// WIRE TRANSITION (双写, §5): the service still consumes the negotiated 6-gate contract off the runtime slot of
|
|
597
|
+
// /effective — so `buildEffective` MIRRORS the governance keys into the runtime slot ({@link RuntimeWire})
|
|
598
|
+
// until the service confirms the switch to the governance slot (blackboard ask⑤). governance is the TRUTH;
|
|
599
|
+
// the runtime mirror is derived output only (never stored, never editable).
|
|
600
|
+
export const RuntimeConfig = z.object({
|
|
601
|
+
// `.optional()` on every gate (settled discipline): ABSENT = "the center isn't managing this — keep your
|
|
602
|
+
// env value"; present — INCLUDING an explicit 0 — overrides (publishable "gate OFF"). A default here would
|
|
603
|
+
// silently override a deployment's env.
|
|
604
|
+
rateLimitPerMin: z.number().int().nonnegative().optional(),
|
|
605
|
+
// Fleet-level cost governance (SERVICE-RESPONSE-2026-06-11-PHASE2). All restart-to-apply, none secret.
|
|
606
|
+
// Per-worker WorkerSpec.runtime.maxPrincipalCostUsd (injected env) outranks this fleet default — precedence
|
|
607
|
+
// is the consumer's, documented in SERVICE-INTEGRATION.
|
|
608
|
+
maxTaskCostUsd: z.number().finite().nonnegative().optional(),
|
|
609
|
+
maxTaskTokens: z.number().int().nonnegative().optional(),
|
|
610
|
+
maxPrincipalCostUsd: z.number().finite().nonnegative().optional(),
|
|
611
|
+
costQuotaWindowSec: z.number().int().positive().optional(),
|
|
612
|
+
});
|
|
613
|
+
// ── Domain: governance (治理三件 — split out of runtime, EXPERT-REDESIGN §5) ──────
|
|
614
|
+
// The governance trio an operator tunes via publish: autonomy ladder + command guardrails + approval-required
|
|
615
|
+
// tools. NOT下沉 — gets its own admin tab (E5 form; E1 ships a transitional generic editor). Same `.optional()`
|
|
616
|
+
// no-default discipline as the six gates: ABSENT = center 不管这项(keep env / 消费者默认),present 才生效。
|
|
617
|
+
export const GovernanceConfig = z.object({
|
|
618
|
+
// autonomy:一个跨 knob 的安全总开关 / 放权梯度(普通用户敢用 + 专业用户放权)。
|
|
619
|
+
// read-only(只读规划,不落改)| ask(每个放权动作问)| plan(plan-review 门)| auto(放开)。
|
|
620
|
+
// ⚠️ 消费侧(service)把它**展开**到 core 已有三原语(handsReadOnly / shellGate+onAsk / plan_review),
|
|
621
|
+
// 经 core 的收紧-only `tightenTaskSpec` 合并。core 裁决:不在 core 加并行 autonomy 枚举(双实现陷阱),
|
|
622
|
+
// 不静默覆盖调用者已设安全字段(auto 模式尤其不能静默清门控)。详见 sema-registry docs/CONFIG-RESEARCH §10。
|
|
623
|
+
autonomy: z.enum(["read-only", "ask", "plan", "auto"]).optional(),
|
|
624
|
+
// commandPolicy:命令级 allow/ask/deny 护栏(remoteExec 管"在哪跑",这管"能跑什么命令")。每条 = 一个
|
|
625
|
+
// **精确 argv[0] 命令名** → 决策;**DENY-WINS**(冲突 deny 赢,与 core `combinePolicies` 一致,NOT last-match-wins)。
|
|
626
|
+
// ⚠️ EXACT,不是 glob(service [202] 裁决①,2026-06-22):core 1.114.0 shipped 的 `createCoarseCommandNamePolicy`
|
|
627
|
+
// 是 **精确 Set 匹配**(§10 把 `createCommandGlobPolicy` 降级时 glob 能力即移除——glob-on-argv[0] 正是 codex
|
|
628
|
+
// 要避的 bypass 面)。所以 `git*` 这类 glob 在此**被 fail-loud 拒绝**(不是静默永不匹配=放行的 fail-open 洞);
|
|
629
|
+
// 要扩面靠 allowlist/blocklist 模式(消费侧),不靠通配。
|
|
630
|
+
// ⚠️ COARSE / 非沙箱:只匹配 leading simple-command 名(经 core `parseLeadingCommandName` fail-closed 解析器);
|
|
631
|
+
// `$(...)` / `sh -c` / `env` / `xargs` / `find -exec` / `python -c` / 子命令都绕得过——是 egress/irreversibility
|
|
632
|
+
// 安全轴之下的**纵深防御,绝非唯一隔离**(真隔离靠 executionEnv sandbox)。消费侧(service)必须经
|
|
633
|
+
// `combinePolicies(部署默认, 本策略)` 编译进 `TaskSpec.toolPolicy`,绝不裸覆盖清掉部署级护栏;`shellGate:
|
|
634
|
+
// "classify"` 下普通 ask 会被 irreversibility 提升为不可预算的 irreversible_ask。core 建
|
|
635
|
+
// `createCoarseCommandNamePolicy` 消费它(config-research §10)。
|
|
636
|
+
commandPolicy: z
|
|
637
|
+
.array(z.object({
|
|
638
|
+
// 精确 argv[0] 命令名(如 "rm" / "git" / "docker-compose" / "python3.11")—— NOT a glob、NOT a path、NOT a
|
|
639
|
+
// shell 片段。拒 `*`/`?`(glob)、`/`(路径)、空格/shell 算符:运行时只拿 argv[0] 的名做精确匹配,任何这些
|
|
640
|
+
// 字符都永不匹配 = 规则静默失效(fail-open)→ 在此 fail-loud 拒收,逼操作员写成能真正生效的精确名。
|
|
641
|
+
command: z
|
|
642
|
+
.string()
|
|
643
|
+
.min(1)
|
|
644
|
+
.max(128)
|
|
645
|
+
.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"),
|
|
646
|
+
decision: z.enum(["allow", "ask", "deny"]),
|
|
647
|
+
}))
|
|
648
|
+
.optional(),
|
|
649
|
+
/** Tool names needing operator approval (F4). Moved verbatim from runtime (same ABSENT/explicit-[] semantics). */
|
|
650
|
+
approvalRequire: z.array(z.string()).optional(),
|
|
651
|
+
});
|
|
652
|
+
/** The governance trio keys. 0.7.1:写出方向的 runtime 双写镜像已撤(service 1.150.0 已切新位,[549]
|
|
653
|
+
* 回执确认)——此常量今日唯一职责=READ 方向的 legacy lift(governance 域从未设时从旧 runtime 文档抬升,
|
|
654
|
+
* config-fns legacyGovernanceFromRuntime),单一键源防逐键漂移。 */
|
|
655
|
+
export const GOVERNANCE_MIRROR_KEYS = ["autonomy", "commandPolicy", "approvalRequire"];
|
|
656
|
+
// ── integrations domain: DELETED in 0.6.0 (EXPERT-REDESIGN §9 — 零消费者实锤:service 明确排除,OA/Gitea
|
|
657
|
+
// 实走 worker.env)。“按 scope connect GitHub” 是 backlog 的全新设计,不复用此形状。
|
|
658
|
+
// ── access domain: DELETED in 0.6.0 (EXPERT-REDESIGN §9 — 彻底删除不留兼容;唯一消费者 agent-client portal
|
|
659
|
+
// 已迁 env 名单,CHANNEL [339])。人员自助申请 access_request 与 git-identities 是不同前缀,保留,与此无关。
|
|
660
|
+
// ── Domain: entitlement (per-PRINCIPAL governance — the "管人" axis, ⊥ console RBAC) ───────────────────────
|
|
661
|
+
// L2 of the control plane (design CENTER-CONTROL-PLANE §3): who-can-USE-what (models / skills / mcp / budget /
|
|
662
|
+
// runtime caps), keyed by the runtime PRINCIPAL — orthogonal to the api-token RBAC (who-can-EDIT-config); mixing
|
|
663
|
+
// the two is a security incident. Stored in full here; the worker `/effective` EMPTIES it (privacy — never leak
|
|
664
|
+
// every principal's tier/budget to a worker), and `/effective?principal=` resolves the per-principal view
|
|
665
|
+
// (effective = worker-base ∩ tier⊕roles ∩ time-bounded overrides; intersection = either side's "deny" wins).
|
|
666
|
+
// Minimal records — never raw usage numbers (those live in the data plane / telemetry).
|
|
667
|
+
/** Token budget — LiteLLM-interop vocab + the center-specific DEGRADE mode + a per-RUN admission gate. */
|
|
668
|
+
export const EntitlementBudget = z.object({
|
|
669
|
+
/** soft = alert only · hard = pre-call block · degrade = fall back to a cheaper model (center-specific). */
|
|
670
|
+
mode: z.enum(["soft", "hard", "degrade"]).optional(),
|
|
671
|
+
// .finite() on every $/USD float: z.number().nonnegative() accepts Infinity, which JSON.stringify's to null on
|
|
672
|
+
// persist → a later strict reparse throws (fail-open on write, fail-hard on read). Same discipline as ModelCost.
|
|
673
|
+
maxBudgetUsd: z.number().finite().nonnegative().optional(),
|
|
674
|
+
budgetDuration: z.string().optional(), // e.g. "30d"
|
|
675
|
+
tpmLimit: z.number().int().nonnegative().optional(),
|
|
676
|
+
rpmLimit: z.number().int().nonnegative().optional(),
|
|
677
|
+
maxParallelRequests: z.number().int().nonnegative().optional(),
|
|
678
|
+
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(),
|
|
679
|
+
/** Per-RUN admission gate (agentic-runaway guard, §3.3 🔴) — period budgets can't stop ONE runaway run. */
|
|
680
|
+
maxRunCostUsd: z.number().finite().nonnegative().optional(),
|
|
681
|
+
maxIterations: z.number().int().positive().optional(),
|
|
682
|
+
/** WHERE this budget is enforced (scheduling framework D4, clay 拍 2026-07-04): `local` = worker-only
|
|
683
|
+
* (per-instance or shared-DB — today's posture) · `lease` = AP pre-authorized QuotaLease from center +
|
|
684
|
+
* after-the-fact reconciliation (default for sema-managed fleets) · `central` = CP pre-call check against
|
|
685
|
+
* center (strict tenants; slow but exact). ABSENT = local. Orthogonal to `mode` (what happens on breach). */
|
|
686
|
+
enforcement: z.enum(["local", "lease", "central"]).optional(),
|
|
687
|
+
});
|
|
688
|
+
/** Token 双窗额度(配额轴, EXPERT-REDESIGN §4 — Claude 式 5h 滚动窗 + 自然周窗),单位=加权 tokens
|
|
689
|
+
* (ModelEntry.quotaWeight 折算,weight-at-burn 记账时固化)。挂载点**只三处**:defaults(全局默认)/
|
|
690
|
+
* subject(用户)/groupBinding(组) —— **绝不挂 tier**(B1 违宪修复,EntitlementTier fail-loud 拒收)。
|
|
691
|
+
* 取值极性(与 budget 的 min-wins 不同,明定):最具体一层生效(用户 > 组 > 全局默认),同层多组命中取
|
|
692
|
+
* 更严;tokenQuota 不参与 budget 的 min-of-budgets 归并。任一窗耗尽即拒(CC 式拒绝+恢复时间,E4)。 */
|
|
693
|
+
export const TokenQuota = z.object({
|
|
694
|
+
/** 5h 滚动窗额度(加权 tokens)。ABSENT = 此窗不设限。 */
|
|
695
|
+
fiveHourTokens: z.number().int().nonnegative().optional(),
|
|
696
|
+
/** 自然周窗额度(加权 tokens)。ABSENT = 此窗不设限。 */
|
|
697
|
+
weeklyTokens: z.number().int().nonnegative().optional(),
|
|
698
|
+
});
|
|
699
|
+
/** 预算组(共享钱池,§4-1)——与个人 budget 双轨并行:一笔花费计入个人轨 + 所有所属池;多池并存取最先
|
|
700
|
+
* 耗尽;tokens 配额轴(TokenQuota)与它是两轨,先到顶者生效。成员条目带 `memberSince`:池只聚合入组时刻
|
|
701
|
+
* 之后的用量(入组不带入已烧量、退组不回落)——契约随 E1 冻结。 */
|
|
702
|
+
export const BudgetGroupMember = z.object({
|
|
703
|
+
/** 池成员生效时刻(ISO)——用量聚合的下边界。 */
|
|
704
|
+
memberSince: z.string().min(1),
|
|
705
|
+
});
|
|
706
|
+
export const BudgetGroup = z.object({
|
|
707
|
+
id: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "budget group id: letters/digits/._- , no spaces"),
|
|
708
|
+
name: z.string().min(1),
|
|
709
|
+
maxBudgetUsd: z.number().finite().nonnegative(),
|
|
710
|
+
/** 池预算周期,e.g. "30d"(同 EntitlementBudget.budgetDuration 词汇)。ABSENT = 不按周期重置。 */
|
|
711
|
+
budgetDuration: z.string().optional(),
|
|
712
|
+
members: z
|
|
713
|
+
.object({
|
|
714
|
+
/** instance-level group ids(registry `groups` 表——store 对象,zod 看不见,存在性同 groupBindings
|
|
715
|
+
* 先例不做 schema 校验,dangling 由 UI 标红)。 */
|
|
716
|
+
groups: z.array(BudgetGroupMember.extend({ group: z.string().min(1) })).default([]),
|
|
717
|
+
principals: z.array(BudgetGroupMember.extend({ principal: z.string().min(1) })).default([]),
|
|
718
|
+
})
|
|
719
|
+
.default({ groups: [], principals: [] }),
|
|
720
|
+
});
|
|
721
|
+
/** 生效场景分配(§7,与配额同一挂载心智):全局默认 + 按组 + 按用户三层;解析优先级=用户 > 组 > 全局默认
|
|
722
|
+
* > worker 级 runtime.scenario 兜底(引擎兜底行为待 ask② 确认)。`scenario` 是场景 NAME——可指 center
|
|
723
|
+
* scenarios 域条目,也可指 service 内建场景名(内建不在域里,故**不做** cross-domain 存在性硬校验,
|
|
724
|
+
* dangling 由 UI 标注)。 */
|
|
725
|
+
export const ScenarioAssignments = z.object({
|
|
726
|
+
/** 全局默认场景名。ABSENT = 不设全局默认(落到 worker 级 runtime.scenario / 引擎内建)。 */
|
|
727
|
+
default: z.string().min(1).optional(),
|
|
728
|
+
groups: z.array(z.object({ group: z.string().min(1), scenario: z.string().min(1) })).default([]),
|
|
729
|
+
principals: z.array(z.object({ principal: z.string().min(1), scenario: z.string().min(1) })).default([]),
|
|
730
|
+
});
|
|
731
|
+
/** Org-forced-off runtime ceilings (gate②). ABSENT = no ceiling (local controls); `false` = org-disabled
|
|
732
|
+
* fleet-wide. Only ever pushes the "off" ceiling — never force-enables (monotone tighten-only). */
|
|
733
|
+
export const EntitlementRuntimeCaps = z.object({
|
|
734
|
+
allowBypassPermissions: z.boolean().optional(),
|
|
735
|
+
allowAutoMode: z.boolean().optional(),
|
|
736
|
+
/** Per-principal governance of the workflow/ultracode capability (K-2, clay 2026-06-28). center treats this as
|
|
737
|
+
* the "准不准" axis; the engine separately advertises "能不能" (Capabilities.workflows). The shell enables
|
|
738
|
+
* ultracode/workflows iff engine-capable ∧ principal-entitled. Like the others: ABSENT = no ceiling,
|
|
739
|
+
* `false` = org-disabled fleet-wide (tighten-only false-wins; never force-enables). Full server-side
|
|
740
|
+
* enforcement awaits core adding a per-principal authz axis that consumes this; until then it gates the shell UI. */
|
|
741
|
+
allowWorkflows: z.boolean().optional(),
|
|
742
|
+
/** Per-principal governance of the Fork (fork-session) tool — center's "准不准" axis, mirroring allowWorkflows.
|
|
743
|
+
* The engine advertises "能不能" separately; a Fork is admitted iff engine-capable ∧ principal-entitled. Like the
|
|
744
|
+
* others: ABSENT = no ceiling (Fork allowed), `false` = org-disabled fleet-wide (tighten-only false-wins; never
|
|
745
|
+
* force-enables). core (RuntimeCaps.allowFork, prepare-task) consumes this per-principal: `allowFork === false`
|
|
746
|
+
* → Fork DENIED, else allowed. */
|
|
747
|
+
allowFork: z.boolean().optional(),
|
|
748
|
+
/** Per-principal governance of observer agents (core 1.270.0/1.273.0 OBS-2, 板 [661]④/[668]①/[671]②) —
|
|
749
|
+
* center's "准不准" axis for the AgentDefinition.observer pairing surface. ⚠️ core's consumption polarity is
|
|
750
|
+
* INVERTED vs the other allow* caps: the engine is `=== true` explicit opt-in (default OFF — an undefined cap
|
|
751
|
+
* ships dark), so this key is the ENABLE line, not just a ceiling. Three-state semantics (ABSENT ≠ false):
|
|
752
|
+
* ABSENT = center says nothing → key NOT emitted on the wire (core stays the SOLE default source; service's
|
|
753
|
+
* toCoreRuntimeCaps must not backfill a missing key into an explicit verdict — that would poison the
|
|
754
|
+
* env-baseline compose order); `true` = explicitly enable observer agents for this principal; `false` =
|
|
755
|
+
* explicit org deny — overrides the node-level env baseline EXPERIMENTAL_OBSERVER_AGENTS (center speaks →
|
|
756
|
+
* center wins). On compose across bundles, `false` (deny) still wins — see RUNTIME_CAP_STRICT; core's
|
|
757
|
+
* `=== true` opt-in check is orthogonal to that merge polarity. */
|
|
758
|
+
allowObservers: z.boolean().optional(),
|
|
759
|
+
allowUltracode: z.boolean().optional(),
|
|
760
|
+
/** Force this principal's run onto durable-approval (per-tool gate) — center's fleet-wide handle for CC-faithful
|
|
761
|
+
* per-tool interception (K-3, core [318] confirmed it consumes this). Polarity is INVERTED vs the allow* caps:
|
|
762
|
+
* here `true` is the STRICT/tightening value (forcing the gate ON), so it wins on compose — see RUNTIME_CAP_STRICT.
|
|
763
|
+
* ABSENT = no fleet override (local/default resolution decides). Server-side enforcement = core's per-principal axis. */
|
|
764
|
+
forceDurableGate: z.boolean().optional(),
|
|
765
|
+
});
|
|
766
|
+
/** The STRICT (tightening) value per RuntimeCap — the value that WINS on compose (monotone tighten-only). The
|
|
767
|
+
* `allow*` ceilings tighten toward `false` (disabled wins); `forceDurableGate` tightens toward `true` (forced
|
|
768
|
+
* wins). Single source for the merge polarity so resolvers don't hardcode it. `Record<keyof …>` = a new cap
|
|
769
|
+
* without an entry is a COMPILE error (no silent wrong-direction merge). */
|
|
770
|
+
export const RUNTIME_CAP_STRICT = {
|
|
771
|
+
allowBypassPermissions: false,
|
|
772
|
+
allowAutoMode: false,
|
|
773
|
+
allowWorkflows: false,
|
|
774
|
+
allowFork: false,
|
|
775
|
+
allowObservers: false,
|
|
776
|
+
allowUltracode: false,
|
|
777
|
+
forceDurableGate: true,
|
|
778
|
+
};
|
|
779
|
+
/** A named entitlement bundle (a `tier`, or a composable `role`). Bind a `roster` to inherit its model/skill/mcp
|
|
780
|
+
* visibility for free; explicit allowlists intersect on top. Edit a tier once → re-prices everyone on it. */
|
|
781
|
+
export const EntitlementTier = z.object({
|
|
782
|
+
id: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "tier id: letters/digits/._- , no spaces"),
|
|
783
|
+
roster: z.string().optional(),
|
|
784
|
+
models: z.array(z.string()).optional(),
|
|
785
|
+
visibleSkills: z.array(z.string()).optional(),
|
|
786
|
+
visibleMcp: z.array(z.string()).optional(),
|
|
787
|
+
/** Plugin allowlist (plugins domain NAMEs) — plugins are an executable surface, so the same null/absent =
|
|
788
|
+
* "no constraint on this axis" semantics as visibleMcp (deny-wins on intersection; C2, additive 0.5.0). */
|
|
789
|
+
visiblePlugins: z.array(z.string()).optional(),
|
|
790
|
+
budget: EntitlementBudget.optional(),
|
|
791
|
+
runtimeCaps: EntitlementRuntimeCaps.optional(),
|
|
792
|
+
/** 🔴 B1 违宪修复(EXPERT-REDESIGN §4-1):tokenQuota **不挂 tier** —— 只挂 defaults/subject/groupBinding。
|
|
793
|
+
* fail-loud 而非 zod 默认的静默剥除(admin 挂上去以为生效才是最糟的)。 */
|
|
794
|
+
tokenQuota: z
|
|
795
|
+
.custom(() => false, { message: "tokenQuota does not mount on a tier — mount it on defaults / subjects[] / groupBindings[] (拍板:配额挂用户/组,非访问级别)" })
|
|
796
|
+
.optional(),
|
|
797
|
+
});
|
|
798
|
+
/** A time-bounded, audited per-principal override (escape hatch above the tier/role defaults). */
|
|
799
|
+
export const EntitlementOverride = z
|
|
800
|
+
.object({
|
|
801
|
+
principal: z.string().min(1),
|
|
802
|
+
key: z.string().min(1),
|
|
803
|
+
value: z.unknown(),
|
|
804
|
+
effectiveFrom: z.string().optional(),
|
|
805
|
+
effectiveUntil: z.string().optional(),
|
|
806
|
+
approvedBy: z.string().min(1),
|
|
807
|
+
reason: z.string().optional(),
|
|
808
|
+
})
|
|
809
|
+
.superRefine((o, ctx) => {
|
|
810
|
+
// value is persisted in the entitlement domain + frozen into PublishedSnapshot + audited — a pasted secret
|
|
811
|
+
// would ride along. Reject any secret-looking token (string or anywhere in a JSON value). Reference secrets
|
|
812
|
+
// by env-NAME, never inline. (Budget overrides are numbers/enums, so this never fires on the v1 use.)
|
|
813
|
+
const s = typeof o.value === "string" ? o.value : o.value == null ? "" : JSON.stringify(o.value);
|
|
814
|
+
if (s && containsSecretToken(s))
|
|
815
|
+
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"] });
|
|
816
|
+
});
|
|
817
|
+
export const EntitlementSubject = z.object({
|
|
818
|
+
principal: z.string().min(1),
|
|
819
|
+
displayName: z.string().optional(),
|
|
820
|
+
tierId: z.string().optional(),
|
|
821
|
+
/** Additional tier ids composed on top of `tierId` (union of allows, min of budgets) — the "role combo". */
|
|
822
|
+
roles: z.array(z.string()).default([]),
|
|
823
|
+
overrides: z.array(EntitlementOverride).default([]),
|
|
824
|
+
/** 用户层 token 双窗额度(最具体层,压过组与全局默认——极性见 {@link TokenQuota})。 */
|
|
825
|
+
tokenQuota: TokenQuota.optional(),
|
|
826
|
+
});
|
|
827
|
+
/** A GROUP → tier/roles binding (W4-Q01 已拍: 组绑定住在 entitlement 域内, per-scope 天然). `group` is the
|
|
828
|
+
* INSTANCE-LEVEL group id (registry `groups` table — a store object, invisible to zod, so group EXISTENCE
|
|
829
|
+
* cannot be schema-checked; a dangling group binding silently matches nobody and the UI red-flags it).
|
|
830
|
+
* Merge semantics (W4-Q02 已拍, 并列合并): a member of the group gets the binding's tier ⊕ roles bundles
|
|
831
|
+
* UNIONED with their personal subject bundles on the allow axes; budget/runtimeCaps stay tighten-only
|
|
832
|
+
* (更严者胜) — a group binding can NARROW a personal budget, never widen it (top invariant). Overrides
|
|
833
|
+
* remain personal-only (subject.overrides) — no group-wide escape hatch. */
|
|
834
|
+
export const EntitlementGroupBinding = z.object({
|
|
835
|
+
group: z.string().min(1),
|
|
836
|
+
tierId: z.string().optional(),
|
|
837
|
+
/** Additional tier ids composed on top of `tierId` — same "role combo" shape as EntitlementSubject. */
|
|
838
|
+
roles: z.array(z.string()).default([]),
|
|
839
|
+
/** 组层 token 双窗额度(同层多组命中取更严;被用户层压过——极性见 {@link TokenQuota})。 */
|
|
840
|
+
tokenQuota: TokenQuota.optional(),
|
|
841
|
+
});
|
|
842
|
+
export const EntitlementConfig = z
|
|
843
|
+
.object({
|
|
844
|
+
/** FLEET-WIDE baseline applied to EVERY principal — including those with NO subject record. This is how an
|
|
845
|
+
* org kill-switch (e.g. runtimeCaps.allowBypassPermissions:false) reaches unconfigured principals: a tier
|
|
846
|
+
* can only TIGHTEN further (monotone), never re-loosen the default. Leave unset for per-tier-only control. */
|
|
847
|
+
defaults: z
|
|
848
|
+
.object({
|
|
849
|
+
runtimeCaps: EntitlementRuntimeCaps.optional(),
|
|
850
|
+
budget: EntitlementBudget.optional(),
|
|
851
|
+
/** 全局默认 token 双窗额度(最不具体层——被组/用户层压过)。 */
|
|
852
|
+
tokenQuota: TokenQuota.optional(),
|
|
853
|
+
})
|
|
854
|
+
.optional(),
|
|
855
|
+
tiers: z.array(EntitlementTier).default([]),
|
|
856
|
+
subjects: z.array(EntitlementSubject).default([]),
|
|
857
|
+
/** Group bindings (W4, additive 0.4.0) — bind an instance-level GROUP to a tier ⊕ roles combo. */
|
|
858
|
+
groupBindings: z.array(EntitlementGroupBinding).default([]),
|
|
859
|
+
/** 预算组(共享钱池,0.6.0 §4)——见 {@link BudgetGroup}。 */
|
|
860
|
+
budgetGroups: z.array(BudgetGroup).default([]),
|
|
861
|
+
/** 生效场景分配(0.6.0 §7)——见 {@link ScenarioAssignments}。 */
|
|
862
|
+
scenarioAssignments: ScenarioAssignments.optional(),
|
|
863
|
+
})
|
|
864
|
+
.superRefine((cfg, ctx) => {
|
|
865
|
+
const tierIds = new Set();
|
|
866
|
+
cfg.tiers.forEach((t, i) => {
|
|
867
|
+
if (tierIds.has(t.id))
|
|
868
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate tier id "${t.id}"`, path: ["tiers", i, "id"] });
|
|
869
|
+
tierIds.add(t.id);
|
|
870
|
+
});
|
|
871
|
+
const seenPools = new Set();
|
|
872
|
+
cfg.budgetGroups.forEach((b, i) => {
|
|
873
|
+
if (seenPools.has(b.id))
|
|
874
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate budget group id "${b.id}"`, path: ["budgetGroups", i, "id"] });
|
|
875
|
+
seenPools.add(b.id);
|
|
876
|
+
});
|
|
877
|
+
const seenGroups = new Set();
|
|
878
|
+
cfg.groupBindings.forEach((g, i) => {
|
|
879
|
+
if (seenGroups.has(g.group))
|
|
880
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate group binding "${g.group}"`, path: ["groupBindings", i, "group"] });
|
|
881
|
+
seenGroups.add(g.group);
|
|
882
|
+
if (g.tierId && !tierIds.has(g.tierId))
|
|
883
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `group binding "${g.group}" → unknown tier "${g.tierId}"`, path: ["groupBindings", i, "tierId"] });
|
|
884
|
+
g.roles.forEach((r, j) => { if (!tierIds.has(r))
|
|
885
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `group binding "${g.group}" → unknown role/tier "${r}"`, path: ["groupBindings", i, "roles", j] }); });
|
|
886
|
+
});
|
|
887
|
+
const seen = new Set();
|
|
888
|
+
cfg.subjects.forEach((s, i) => {
|
|
889
|
+
if (seen.has(s.principal))
|
|
890
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate subject principal "${s.principal}"`, path: ["subjects", i, "principal"] });
|
|
891
|
+
seen.add(s.principal);
|
|
892
|
+
if (s.tierId && !tierIds.has(s.tierId))
|
|
893
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `subject "${s.principal}" → unknown tier "${s.tierId}"`, path: ["subjects", i, "tierId"] });
|
|
894
|
+
s.roles.forEach((r, j) => { if (!tierIds.has(r))
|
|
895
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `subject "${s.principal}" → unknown role/tier "${r}"`, path: ["subjects", i, "roles", j] }); });
|
|
896
|
+
s.overrides.forEach((o, j) => { if (o.principal !== s.principal)
|
|
897
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `override.principal "${o.principal}" must equal subject "${s.principal}"`, path: ["subjects", i, "overrides", j, "principal"] }); });
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
// ── Domain: execution (S3 执行轴管控 — WHERE work may run, stage7 S3 / P5=A) ──────────────────
|
|
901
|
+
// The execution-LANE policy an admin sets per scope(「团队任务必须在沙箱/受控执行环境中运行」).
|
|
902
|
+
// Resolution MIRRORS the governance precedent: a plain SCOPE-level domain — no per-principal overlay,
|
|
903
|
+
// no layer merge invented here (entitlement is the per-principal axis; this is not it). v1 = scope 级.
|
|
904
|
+
// ⚠️ ENFORCEMENT HONESTY (P5=A, 2026-07): today the shell enforces this CLIENT-SIDE only (it locks its
|
|
905
|
+
// sandbox settings by policy, CC managed-settings semantics) — env / a local config-source can bypass it.
|
|
906
|
+
// The REAL boundary (engine gate refusing a disallowed lane) is the service's, tracked on blackboard
|
|
907
|
+
// [580]; until that ships, never present this policy as a hard security boundary.
|
|
908
|
+
/** sessionMirror TARGET url 的底线传输校验(0.10.1;0.10.2 加 trim 前置):**https,或 loopback http**
|
|
909
|
+
* (本机第二引擎/dev)。与壳 `evaluateEngineRouting`(sema-shell engineTarget.ts,S1 路由闸)语义对齐:
|
|
910
|
+
* 镜像上行带会话内容+Bearer 凭证,非 loopback 明文 http = 凭证与会话数据裸奔——**写侧 schema 层就拒**
|
|
911
|
+
* (fail-loud 比读侧执法更早;center 编辑面存不进去,壳读侧闸只兜手改存储的漏网)。基线复用
|
|
912
|
+
* {@link NON_SECRET_URL}(禁 userinfo 凭证/禁 query/fragment 夹带 secret-shaped 值)。loopback 判定与壳
|
|
913
|
+
* isLoopbackEngineUrl 逐字同表:127.0.0.1 / localhost / ::1 / [::1](URL.hostname 对 IPv6 带方括号,
|
|
914
|
+
* 两写法都收)。**trim 前置(0.10.2,附B 三单合评审 M-1)**:前导/尾随空白先归一再验且存储值即 trim
|
|
915
|
+
* 后值——否则 " https://…" 写侧收(new URL 容忍前导空白)而壳读侧逐字符比对拒 = 全员镜像静默 degraded。 */
|
|
916
|
+
export const SESSION_MIRROR_ENGINE_URL = z.string().trim().pipe(NON_SECRET_URL).refine((s) => {
|
|
917
|
+
try {
|
|
918
|
+
const u = new URL(s);
|
|
919
|
+
if (u.protocol === "https:")
|
|
920
|
+
return true;
|
|
921
|
+
const h = u.hostname;
|
|
922
|
+
return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]";
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
return true; // already passed NON_SECRET_URL; unreachable in practice
|
|
926
|
+
}
|
|
927
|
+
}, "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 同款传输闸)");
|
|
928
|
+
/** Org 默认会话镜像目标(WEB-SETUP-RECON 附B,clay 拍两层,0.10.1)。**收编进 execution 域**(非独立
|
|
929
|
+
* 域)——142-S3 草案同款尺量:①同一治理轴(org 对「会话数据必须落到受控引擎」的 scope 级管控,与
|
|
930
|
+
* 「任务必须跑在受控 lane」同族;`required` 语义与 {@link ExecutionPolicy}.required 完全同构=壳锁定、
|
|
931
|
+
* 用户不可关、CC managed-settings 心智);②**无独立读损语义**(损坏退默认=required:true 审计强制静默
|
|
932
|
+
* 蒸发=放权,与 execution 的 throw 理由逐字同款——tierGroups 收编 models 的同款判据);③无独立写面
|
|
933
|
+
* (纯声明字段,整文档 PUT 表达,无动作端点;RBAC 同 editor 档,信任面=org 背书与模型下发同级)。
|
|
934
|
+
* 独立域只换来第 16 域膨胀+#74 双表态+缺域 fixture 全套纯开销。
|
|
935
|
+
* 两层语义(附B r1 终稿):
|
|
936
|
+
* - `required:false`(默认)= **便利默认值**:壳解析序第三腿(本地显式 --target > 本值 > connect
|
|
937
|
+
* 回落),开关仍由用户 `sync on`(隐私 opt-in 先例不破,STAGE7 P3);
|
|
938
|
+
* - `required:true` = **org 治理强制**:会话镜像默认开且不可关,且**本地覆盖不生效——既不能改目标
|
|
939
|
+
* 也不能关闭(org 压一切;附B 三单合评审 MAJOR-1 勘误:治理态下本地 --target 完全被忽略)**
|
|
940
|
+
* (审计场景;壳复用 CC managed-settings 锁定报错串——S3 execution 沙箱锁完全同构先例)。
|
|
941
|
+
* 整个 sessionMirror **optional,缺省 INERT**(无下发=老 center/老壳/0.8-0.10 存量 execution 文档
|
|
942
|
+
* 零行为差、零迁移)。⚠️ ENFORCEMENT HONESTY 同域头注:required 的锁今天也是 CLIENT-SIDE 执法。
|
|
943
|
+
* 开放演化:v2 可能加 audit 字段类(保留窗/脱敏面等)——本对象刻意**不 .strict()**(zod 默认 strip
|
|
944
|
+
* 未知键:v2 文档被 v1 消费者读到=未知键剥除,forward-compatible;加字段恒 additive patch)。 */
|
|
945
|
+
export const SessionMirrorPolicy = z.object({
|
|
946
|
+
/** 镜像目标引擎 url(service 线 /v1 base)。校验见 {@link SESSION_MIRROR_ENGINE_URL}。 */
|
|
947
|
+
engineUrl: SESSION_MIRROR_ENGINE_URL,
|
|
948
|
+
/** true = org 治理强制(镜像默认开且不可关);false(默认)= 便利默认值(只补目标,不动开关)。 */
|
|
949
|
+
required: z.boolean().default(false),
|
|
950
|
+
});
|
|
951
|
+
export const ExecutionPolicy = z.object({
|
|
952
|
+
/** true = tasks under this scope MUST run in a sandbox / controlled execution lane (the consuming
|
|
953
|
+
* shell locks sandbox-enabled by policy; user settings can't turn it off — CC 同款语义). */
|
|
954
|
+
required: z.boolean().default(false),
|
|
955
|
+
/** Which execution lanes are allowed. OPEN value domain — z.string(), NEVER an enum: adding a lane
|
|
956
|
+
* must not be a BREAKING schema change (promised to the service on blackboard [580]④). Known lanes
|
|
957
|
+
* today: `host` / `e2b` / `k8s` / `ssh` / `adb` / `local-docker`. Unknown strings are consumer-defined. */
|
|
958
|
+
allowedLanes: z.array(z.string().min(1)).default(["host"]),
|
|
959
|
+
/** Org 默认会话镜像目标(0.10.1,附B 两层)。ABSENT = INERT(center 未管镜像,壳解析序里本腿为空)。
|
|
960
|
+
* 读损档随域(DOMAIN_READ_FALLBACK.execution="throw" 已覆盖:损坏绝不静默退默认放权)。 */
|
|
961
|
+
sessionMirror: SessionMirrorPolicy.optional(),
|
|
962
|
+
});
|
|
963
|
+
// ── Domain: projects (142-S3 — project identity ledger + 无仓铸造权威, 0.10.0) ─────────────────
|
|
964
|
+
// 登记簿语义(design/142 §1.3/F11):marker(`.sema/project`,checkout 现场)是身份真相源,本域是
|
|
965
|
+
// 登记簿(索引/展示/审计)+ 无仓 project 的 projectId 铸造权威。铸造离线自由、使用时注册执法
|
|
966
|
+
// (blackboard [633]①):执法位在 service worker(派生 `proj:*` 可读 scope 前查 `(tenant,projectId)`
|
|
967
|
+
// 已注册),本域只是它查的那张表——登记簿**不读 marker 文件、不镜像 marker schema**([641]④ 确认)。
|
|
968
|
+
// 复合键 `(tenant, projectId)` 的 tenant 半边由 center 的 SCOPE 轴承载(api/scopes.ts:每 scope 一份
|
|
969
|
+
// 独立域文档),域内 Record 键=projectId only;fork 后同一 projectId 合法存在于两个 scope,互不相见。
|
|
970
|
+
/** projectId 形状:lowercase 规范 UUID。**宽读严写**([641]②,core 口径):读面/claim 收 generic
|
|
971
|
+
* UUID(外部/历史铸造的 v4 也认——marker 是身份不是格式声明);铸造侧(core formatProjectMarker /
|
|
972
|
+
* center mint 端点)统一产 **uuidv7**。36 字符,天然满足 design/142 §1.2 段编码 64 上限。
|
|
973
|
+
* SINGLE-SOURCE 纪律:core 1.268.0 尚未公开导出其内部 UUID_RE——本常量为自持镜像,与 core 的对拍
|
|
974
|
+
* 走 dev-only conformance(never a runtime import,EffectiveKey/coreTypes 先例)。
|
|
975
|
+
* TODO(0.10.x): @sema-ai/core 下一班车公开导出 PROJECT_ID_REGEX 后([641]②-①承诺,黑板点名),
|
|
976
|
+
* 加 dev-only drift-lock 测试与其对拍(坐标:test/projects-0.10.test.ts「conformance」节)。 */
|
|
977
|
+
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}$/;
|
|
978
|
+
export const ProjectRegistration = z.object({
|
|
979
|
+
/** 人读名(展示/管理面用;不是身份,改名自由——身份=Record 键 projectId)。 */
|
|
980
|
+
displayName: z.string().min(1).max(120),
|
|
981
|
+
/** 归一化 git remotes(`host/path` 形态,design/142 §1.1 归一化规范)。归一化动作发生在 scaffold/
|
|
982
|
+
* 调用方,本契约只验底线形状:非空、无空白、不含 "://"(出现即证据未归一化,fail-loud)。
|
|
983
|
+
* 无仓 project = 空数组合法。`.max(16)` 防登记簿被单条撑爆。 */
|
|
984
|
+
gitRemotes: z
|
|
985
|
+
.array(z
|
|
986
|
+
.string()
|
|
987
|
+
.min(1)
|
|
988
|
+
.max(512)
|
|
989
|
+
.refine((s) => !/\s/.test(s) && !s.includes("://"), "must be a NORMALIZED remote (host/path — no scheme, no whitespace)"))
|
|
990
|
+
.max(16)
|
|
991
|
+
.default([]),
|
|
992
|
+
/** 该项目会话的默认记忆 scope 词表。OPEN value domain — z.string(),不 enum([580]④ 纪律:加词
|
|
993
|
+
* 不得 BREAKING)。方向([641]②-③,S4 细化):消费方=service memoryScope 派生(作 TaskSpec.
|
|
994
|
+
* memory.scopes 种子),形态=v2 scope 键字符串(过 core parseScopeKey 校验;known 词根:`proj` /
|
|
995
|
+
* `userproj`)。未知串=消费者自定义;空数组=不下发默认。本域只登记不解释,零耦合。 */
|
|
996
|
+
defaultScopes: z.array(z.string().min(1)).default([]),
|
|
997
|
+
/** 登记人 principal(审计锚;center 写面自动填 caller,不信客户端提交)。 */
|
|
998
|
+
registeredBy: z.string().min(1),
|
|
999
|
+
/** 登记时刻(ISO)。center 写面 server 端盖章。 */
|
|
1000
|
+
registeredAt: z.string().min(1),
|
|
1001
|
+
notes: z.string().optional(),
|
|
1002
|
+
});
|
|
1003
|
+
export const ProjectsConfig = z
|
|
1004
|
+
.object({
|
|
1005
|
+
/** 键=projectId({@link PROJECT_ID_REGEX},generic lowercase UUID——宽读);tenant 半边=center
|
|
1006
|
+
* scope 轴(不进域文档,见域头注)。 */
|
|
1007
|
+
projects: z.record(z.string().regex(PROJECT_ID_REGEX, "projectId must be a lowercase canonical UUID"), ProjectRegistration).default({}),
|
|
1008
|
+
})
|
|
1009
|
+
.superRefine((cfg, ctx) => {
|
|
1010
|
+
// 同 scope 内一条 remote 只许属于一个 project——「remote → projectId」找回阶梯(design/142
|
|
1011
|
+
// §1.5 ③)与 scaffold 防重铸查询要求该映射在租户内确定;撞了=写入 fail-loud(合法双登记诉求
|
|
1012
|
+
// 出现时再放开,先紧后松 additive)。
|
|
1013
|
+
const seen = new Map();
|
|
1014
|
+
for (const [pid, reg] of Object.entries(cfg.projects)) {
|
|
1015
|
+
for (const [i, r] of reg.gitRemotes.entries()) {
|
|
1016
|
+
const prev = seen.get(r);
|
|
1017
|
+
if (prev !== undefined && prev !== pid) {
|
|
1018
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `remote "${r}" already registered to project "${prev}"`, path: ["projects", pid, "gitRemotes", i] });
|
|
1019
|
+
}
|
|
1020
|
+
seen.set(r, pid);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
// ── Domain: collab (协作模式 — multi-model collaboration templates, 0.7.0) ─────
|
|
1025
|
+
// Replaces the deleted `teams` domain (EXPERT-REDESIGN §8/§9, clay 五拍 T1-T5, blackboard [544]/[548]④).
|
|
1026
|
+
// Unified shape = slots × topology × rounds × finalizer. COMPLETELY separate from rosters (拍板): a slot
|
|
1027
|
+
// member's `modelRef` points at the models CATALOG (`ModelEntry.name`, the key — NOT the gateway modelId,
|
|
1028
|
+
// NOT a roster); roster churn never touches a template. Execution lives service-side ("collab spec →
|
|
1029
|
+
// workflow/team run" translation layer, [544]①); the center owns the template CONFIG only.
|
|
1030
|
+
export const CollabTopology = z.enum(["star", "ring", "layered", "pair", "blackboard"]);
|
|
1031
|
+
export const CollabFinalizerType = z.enum(["judge", "vote", "fuse", "verify", "summarize", "none"]);
|
|
1032
|
+
export const CollabEarlyStop = z.enum(["consensus", "stability", "judge_call", "verify_pass"]);
|
|
1033
|
+
/** Shared-context persistence. v1 durable lane = "store-blob" (service's existing durable face, [548]④);
|
|
1034
|
+
* "git" (auditable/replayable history) is a RESERVED enum position — accepted by the contract, execution
|
|
1035
|
+
* is a service follow-on (deliberately not bound to 0.7). */
|
|
1036
|
+
export const CollabPersistence = z.enum(["session", "store-blob", "git"]);
|
|
1037
|
+
const CollabMember = z.object({
|
|
1038
|
+
/** Models-CATALOG name (the `@`-handle key). ABSENT = "跟随用户当前模型" — resolved at run time to the
|
|
1039
|
+
* caller's current model (T1; service resolves at team assembly, [544]③). Never a roster reference. */
|
|
1040
|
+
modelRef: z.string().min(1).optional(),
|
|
1041
|
+
/** Member-level lens/persona override (各带镜头 — a heterogeneous review panel puts N members with
|
|
1042
|
+
* different lenses in ONE cardinality-"N" slot). Falls back to the slot's `rolePrompt`. */
|
|
1043
|
+
rolePrompt: z.string().max(2000).optional(),
|
|
1044
|
+
});
|
|
1045
|
+
const CollabSlot = z.object({
|
|
1046
|
+
// The slot id is the canvas anchor AND the finalizer/transcript attribution key — same safe-identifier
|
|
1047
|
+
// shape as a model name (it may be concatenated into prompts; no spaces/newlines → no prompt-structure injection).
|
|
1048
|
+
id: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "letters/digits/._- only"),
|
|
1049
|
+
/** Slot-default lens/立场 (template-provided; a member's own `rolePrompt` overrides it). */
|
|
1050
|
+
rolePrompt: z.string().max(2000).optional(),
|
|
1051
|
+
/** 1 = exactly one member; "N" = the slot accepts multiple members (异构评审团). */
|
|
1052
|
+
cardinality: z.union([z.literal(1), z.literal("N")]).default(1),
|
|
1053
|
+
members: z.array(CollabMember).default([]),
|
|
1054
|
+
/** Tool names available to this slot's members (the old team `scenario` binding migrates here). */
|
|
1055
|
+
tools: z.array(z.string().min(1)).optional(),
|
|
1056
|
+
});
|
|
1057
|
+
const CollabTemplate = z.object({
|
|
1058
|
+
/** The template's reference key (shell `/team` selects by id; canvas routes by id). */
|
|
1059
|
+
id: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1060
|
+
name: z.string().min(1),
|
|
1061
|
+
description: z.string().optional(),
|
|
1062
|
+
topology: CollabTopology,
|
|
1063
|
+
slots: z.array(CollabSlot).min(1),
|
|
1064
|
+
/** `max` is ALWAYS the safety cap (blackboard topology: event/round cap — required, 防跑飞);
|
|
1065
|
+
* `earlyStop: "verify_pass"` carries the architect pair's "verified → stop" loop. */
|
|
1066
|
+
rounds: z.object({ max: z.number().int().positive(), earlyStop: CollabEarlyStop.optional() }),
|
|
1067
|
+
finalizer: z.object({
|
|
1068
|
+
type: CollabFinalizerType,
|
|
1069
|
+
/** Slot id the finalizer anchors to (e.g. the judge slot). Meaningless for type "none". */
|
|
1070
|
+
slot: z.string().optional(),
|
|
1071
|
+
}),
|
|
1072
|
+
sharedContext: z
|
|
1073
|
+
.object({
|
|
1074
|
+
kind: z.enum(["transcript", "blackboard"]).default("transcript"),
|
|
1075
|
+
persistence: CollabPersistence.default("session"),
|
|
1076
|
+
})
|
|
1077
|
+
.default({}),
|
|
1078
|
+
/** 多模型烧钱必须有闸 (拍板): the budget object is REQUIRED and at least one axis must be set. */
|
|
1079
|
+
budget: z.object({
|
|
1080
|
+
maxTokens: z.number().int().positive().optional(),
|
|
1081
|
+
maxCost: z.number().finite().positive().optional(),
|
|
1082
|
+
}),
|
|
1083
|
+
enabled: z.boolean().default(true),
|
|
1084
|
+
notes: z.string().optional(),
|
|
1085
|
+
}).superRefine((t, ctx) => {
|
|
1086
|
+
const slotIds = new Set();
|
|
1087
|
+
t.slots.forEach((s, i) => {
|
|
1088
|
+
if (slotIds.has(s.id))
|
|
1089
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate slot id "${s.id}"`, path: ["slots", i, "id"] });
|
|
1090
|
+
slotIds.add(s.id);
|
|
1091
|
+
if (s.cardinality === 1 && s.members.length > 1)
|
|
1092
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `slot "${s.id}" has cardinality 1 but ${s.members.length} members`, path: ["slots", i, "members"] });
|
|
1093
|
+
});
|
|
1094
|
+
if (t.finalizer.type === "none") {
|
|
1095
|
+
if (t.finalizer.slot !== undefined)
|
|
1096
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `finalizer type "none" must not anchor a slot`, path: ["finalizer", "slot"] });
|
|
1097
|
+
}
|
|
1098
|
+
else if (t.finalizer.slot !== undefined && !slotIds.has(t.finalizer.slot)) {
|
|
1099
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `finalizer.slot "${t.finalizer.slot}" is not a slot id of this template`, path: ["finalizer", "slot"] });
|
|
1100
|
+
}
|
|
1101
|
+
if (t.budget.maxTokens === undefined && t.budget.maxCost === undefined) {
|
|
1102
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `template "${t.id}": budget needs at least one of maxTokens/maxCost (多模型烧钱必须有闸)`, path: ["budget"] });
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
export const CollabConfig = z.object({ templates: z.array(CollabTemplate).default([]) }).superRefine((cfg, ctx) => {
|
|
1106
|
+
// `id` is the template's reference key — dedup like every other keyed domain (two same-id templates
|
|
1107
|
+
// shipping → the resolver silently takes the first; same failure class the old teams dedup fixed).
|
|
1108
|
+
const seen = new Set();
|
|
1109
|
+
cfg.templates.forEach((t, i) => {
|
|
1110
|
+
if (seen.has(t.id))
|
|
1111
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate collab template "${t.id}" — the template key must be unique`, path: ["templates", i, "id"] });
|
|
1112
|
+
seen.add(t.id);
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
// ── Domain: workers (声明式编排 — service design/22, docs/ORCHESTRATOR.md) ──────
|
|
1116
|
+
// 每个 worker = 一个 sema-server 实例的声明(一份 env profile)。sema-registry 存 DESIRED(逻辑参数
|
|
1117
|
+
// + secret 的 env-NAME 引用,**绝不存 secret 值**);一个 per-host reconciler 拉取后在宿主机本地解析
|
|
1118
|
+
// secret → 渲染 docker compose → up/down 收敛。worker spec ↔ service env 的精确映射见 worker-env.ts。
|
|
1119
|
+
//
|
|
1120
|
+
// 安全(service design/22 经 DeepSeek team 评审):
|
|
1121
|
+
// - 不提供任何挂载字段 → 杜绝 hostPath 攻击向量(合法镜像+合法 secretRef+恶意挂载偷宿主机文件)。
|
|
1122
|
+
// - image 由 reconciler 按白名单强制(这里 default sema-server:latest)。
|
|
1123
|
+
// - secretRefs 只是 env-NAME;真值在宿主机,reconciler 解析,永不进此库/网络/日志。
|
|
1124
|
+
// 时长串:整数 + 单位 s/m/h/d(如 "30m"/"2h"/"7d")。lib/lifecycle/duration.ts 负责解析成 ms。
|
|
1125
|
+
const DURATION = z.string().regex(/^\d+(s|m|h|d)$/, 'duration like "30m", "2h", "7d" (units s/m/h/d)');
|
|
1126
|
+
// 生命周期 (design/25 §3 决策 3/4)。owner/purpose 用于配额 + 级联清理 + 审计;dependsOn 防级联雪崩。
|
|
1127
|
+
export const WorkerLifecycle = z.object({
|
|
1128
|
+
kind: z.enum(["persistent", "ephemeral"]).default("persistent"),
|
|
1129
|
+
/** ephemeral: 绝对存活时长,自 sema-registry 首次见到起算(防时钟漂移),到期硬移除。 */
|
|
1130
|
+
ttl: DURATION.optional(),
|
|
1131
|
+
/** 无活动多久 → 告警(MVP 只告警,不自动 disable)。数据源 = 反代 metrics。 */
|
|
1132
|
+
idleTimeout: DURATION.optional(),
|
|
1133
|
+
/** 归属(团队/项目)→ per-owner 配额 + owner 销毁时级联清理 + 审计。 */
|
|
1134
|
+
owner: z.string().min(1).optional(),
|
|
1135
|
+
purpose: z.string().optional(),
|
|
1136
|
+
/** 依赖的其它 worker name —— sweeper 移除前查依赖,不孤儿化仍被依赖的 worker。 */
|
|
1137
|
+
dependsOn: z.array(z.string().min(1)).optional(),
|
|
1138
|
+
});
|
|
1139
|
+
const WorkerSecretRefs = z.object({
|
|
1140
|
+
/** NAME of the host env var holding the model API key → service MODEL_API_KEY (或 anthropic 时 ANTHROPIC_API_KEY). */
|
|
1141
|
+
apiKeyEnv: ENV_NAME,
|
|
1142
|
+
/** → SERVICE_AUTH_TOKEN (对外门禁). */
|
|
1143
|
+
serviceAuthTokenEnv: ENV_NAME.optional(),
|
|
1144
|
+
/** → GIT_API_TOKEN (只读,读被评审 repo). */
|
|
1145
|
+
gitTokenEnv: ENV_NAME.optional(),
|
|
1146
|
+
/** → TIDB_PASSWORD. */
|
|
1147
|
+
tidbPasswordEnv: ENV_NAME.optional(),
|
|
1148
|
+
});
|
|
1149
|
+
/**
|
|
1150
|
+
* Env keys the structured WorkerSpec fields already produce (worker-env.ts) — the general `env`/`secretEnv`
|
|
1151
|
+
* passthrough may NOT set these (use the dedicated field). Blocks the council's two escape-hatch risks:
|
|
1152
|
+
* redirecting model/traffic or disabling auth (PORT, MODEL_x, SESSION_BACKEND, REQUIRE_PRINCIPAL, gateway), and
|
|
1153
|
+
* shadowing a projected secret with a literal (MODEL_API_KEY, SERVICE_AUTH_TOKEN, …). LOG_LEVEL is intentionally
|
|
1154
|
+
* NOT reserved (benign, overridable for debugging). Keep in sync with workerToServiceEnv.
|
|
1155
|
+
*
|
|
1156
|
+
* ⚠️ SCOPE (0.1.30, after the R22→0.1.29 detour): this ONLY blocks keys that have a DEDICATED structured field
|
|
1157
|
+
* or secretRef — so an operator is steered to that field instead of duplicating/shadowing it via env. It does
|
|
1158
|
+
* NOT block the per-worker EXECUTION-SUBSTRATE config an operator legitimately sets via worker.env, which the
|
|
1159
|
+
* live fleet relies on: `REMOTE_EXEC` (direct=k8s, canary=e2b set it HERE), `K8S_*` (SANDBOX_IMAGE/NAMESPACE/
|
|
1160
|
+
* RUNTIME_CLASS/…), `E2B_*`, `MINIO_*`, `SSH_*`, `ADB_*`. worker.env is the OPERATOR's per-worker deployment env
|
|
1161
|
+
* (publisher-gated, projected by the orchestrator to the worker pod) — NOT untrusted-task-controlled, so there
|
|
1162
|
+
* is no "sandbox escape" to block here. R22 (codex) wrongly added those substrate keys treating worker.env as
|
|
1163
|
+
* task-controlled; it would have REJECTED the live direct/canary workers on deploy. Reverted in 0.1.30. (Literal
|
|
1164
|
+
* SECRET values pasted into env are still caught separately by containsSecretToken; substrate CREDS belong in
|
|
1165
|
+
* secretEnv as env-NAME refs.)
|
|
1166
|
+
*/
|
|
1167
|
+
export const RESERVED_WORKER_ENV = new Set([
|
|
1168
|
+
"PORT", "MODEL_PROVIDER", "MODEL_ID", "MODEL_REASONING", "MODEL_CHEAP_ID", "MODEL_MAX_TOKENS",
|
|
1169
|
+
"MODEL_CONTEXT_WINDOW", "MODEL_COST_INPUT", "MODEL_COST_OUTPUT", "MODEL_COST_CACHE_READ",
|
|
1170
|
+
"MODEL_COST_CACHE_WRITE", "MAX_PRINCIPAL_COST_USD", "SESSION_BACKEND", "MEMORY_BACKEND",
|
|
1171
|
+
"REQUIRE_PRINCIPAL", "RATE_LIMIT_RPM", "DEFAULT_SCENARIO", "TIDB_DATABASE", "TIDB_HOST",
|
|
1172
|
+
"TIDB_PORT", "TIDB_USER", "GIT_API_BASEURL", "MODEL_GATEWAY_BASEURL", "ANTHROPIC_BASEURL",
|
|
1173
|
+
"MODEL_API_KEY", "ANTHROPIC_API_KEY", "SERVICE_AUTH_TOKEN", "GIT_API_TOKEN", "TIDB_PASSWORD",
|
|
1174
|
+
]);
|
|
1175
|
+
// Which structured WorkerSpec field owns each reserved env-NAME → so a rejection names the RIGHT field to use
|
|
1176
|
+
// (e.g. TIDB_DATABASE → runtime.db, MODEL_ID → model.modelId), not a vague "use the structured field".
|
|
1177
|
+
const RESERVED_ENV_FIELD = {
|
|
1178
|
+
PORT: "(fixed at 8090 — not configurable)", MODEL_PROVIDER: "model.provider", MODEL_ID: "model.modelId",
|
|
1179
|
+
MODEL_REASONING: "model.reasoning", MODEL_CHEAP_ID: "model.cheapId", MODEL_MAX_TOKENS: "model.maxTokens",
|
|
1180
|
+
MODEL_CONTEXT_WINDOW: "model.contextWindow", MODEL_COST_INPUT: "model.cost.input", MODEL_COST_OUTPUT: "model.cost.output",
|
|
1181
|
+
MODEL_COST_CACHE_READ: "model.cost.cacheRead", MODEL_COST_CACHE_WRITE: "model.cost.cacheWrite",
|
|
1182
|
+
MAX_PRINCIPAL_COST_USD: "runtime.maxPrincipalCostUsd", SESSION_BACKEND: "runtime.sessionBackend",
|
|
1183
|
+
MEMORY_BACKEND: "runtime.memoryBackend", REQUIRE_PRINCIPAL: "runtime.requirePrincipal", RATE_LIMIT_RPM: "runtime.rateLimitRpm",
|
|
1184
|
+
DEFAULT_SCENARIO: "runtime.scenario", TIDB_DATABASE: "runtime.db", TIDB_HOST: "coords.tidbHost", TIDB_PORT: "coords.tidbPort",
|
|
1185
|
+
TIDB_USER: "coords.tidbUser", GIT_API_BASEURL: "coords.gitApiBaseUrl", MODEL_GATEWAY_BASEURL: "model.gatewayBaseUrl",
|
|
1186
|
+
ANTHROPIC_BASEURL: "model.gatewayBaseUrl", MODEL_API_KEY: "secretRefs.apiKeyEnv", ANTHROPIC_API_KEY: "secretRefs.apiKeyEnv",
|
|
1187
|
+
SERVICE_AUTH_TOKEN: "secretRefs.serviceAuthTokenEnv", GIT_API_TOKEN: "secretRefs.gitTokenEnv",
|
|
1188
|
+
TIDB_PASSWORD: "secretRefs.tidbPasswordEnv",
|
|
1189
|
+
};
|
|
1190
|
+
export const WorkerSpec = z.object({
|
|
1191
|
+
/** 唯一 → 容器名 / compose project. */
|
|
1192
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
1193
|
+
enabled: z.boolean().default(true),
|
|
1194
|
+
/** 目标宿主机 id —— 哪个 per-host reconciler 认领它. 与 name 同样严格(精确匹配路由,松校验会静默不可达). */
|
|
1195
|
+
host: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces").default("default"),
|
|
1196
|
+
/** 容器镜像 (reconciler 按白名单强制). */
|
|
1197
|
+
image: z.string().min(1).default("sema-server:latest"),
|
|
1198
|
+
/**
|
|
1199
|
+
* 对外路由前缀 (v2① 反代, design/25 决策7)。worker **不再 publish 宿主机端口**:反代 (Traefik) 按
|
|
1200
|
+
* `routePrefix` 路由到容器内 8090。省略 → 用 `name` 作前缀。**唯一性由 WorkersConfig 提交时校验**。
|
|
1201
|
+
* (废弃了原 `port` 字段 —— 宿主机端口是稀缺资源、规模化必撞车,且 hostPort 会被滥用绕过反代鉴权/限流/日志。)
|
|
1202
|
+
*/
|
|
1203
|
+
routePrefix: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces").optional(),
|
|
1204
|
+
/** Worker SCALE — number of pod replicas (k8s backend). Optional, absent → 1 (today's behaviour, no churn).
|
|
1205
|
+
* Hash-INCLUDED → a change re-applies the Deployment (k8s scales IN PLACE via SSA, no pod destruction). The
|
|
1206
|
+
* docker backend is single-container: it warns and runs 1 if this is >1. Capped at 100 (resource-exhaustion
|
|
1207
|
+
* guard — a typo'd 1e6 mustn't try to schedule a million pods; raise the cap if a real workload needs it). */
|
|
1208
|
+
replicas: z.number().int().positive().max(100).optional(),
|
|
1209
|
+
/** AUTOSCALING (k8s backend, HPA) — declare bounds and let the cluster's HorizontalPodAutoscaler own the
|
|
1210
|
+
* replica count (autoscaling/v2, CPU utilization; prove-before-scale 解禁 2026-06-13). Mutually exclusive
|
|
1211
|
+
* with `replicas` (WorkersConfig rejects both — two owners of the same knob WILL fight). The orchestrator
|
|
1212
|
+
* then OMITS spec.replicas from its SSA apply so it never wrestles the HPA controller; switching an
|
|
1213
|
+
* EXISTING fixed-replicas worker to autoscaling transiently resets it to 1 pod until the HPA raises it
|
|
1214
|
+
* to minReplicas (~15s) — enable during low traffic. CPU% needs resources.requests.cpu (backend default
|
|
1215
|
+
* 50m applies if unset — set a realistic request or the percentage is meaningless). docker backend:
|
|
1216
|
+
* single-container, warns + ignores. Hash-INCLUDED; absent → no HPA, today's behaviour (zero churn). */
|
|
1217
|
+
autoscaling: z
|
|
1218
|
+
.object({
|
|
1219
|
+
minReplicas: z.number().int().positive().max(100),
|
|
1220
|
+
maxReplicas: z.number().int().positive().max(100),
|
|
1221
|
+
/** Target average CPU utilization %, vs resources.requests.cpu. Absent → backend default 80. */
|
|
1222
|
+
targetCpuPercent: z.number().int().min(10).max(95).optional(),
|
|
1223
|
+
})
|
|
1224
|
+
.refine((a) => a.maxReplicas >= a.minReplicas, { message: "maxReplicas must be >= minReplicas" })
|
|
1225
|
+
.optional(),
|
|
1226
|
+
/** Per-worker resource requests/limits (k8s backend). Optional → the backend's modest default
|
|
1227
|
+
* (requests cpu 50m/mem 128Mi, no limits). Hash-INCLUDED → a change rolls the pods (resources are
|
|
1228
|
+
* immutable on a running pod). CPU/memory are k8s quantity strings ("250m", "512Mi", "1", "2Gi"). */
|
|
1229
|
+
resources: z
|
|
1230
|
+
.object({
|
|
1231
|
+
requests: z.object({ cpu: K8S_QUANTITY.optional(), memory: K8S_QUANTITY.optional() }).optional(),
|
|
1232
|
+
limits: z.object({ cpu: K8S_QUANTITY.optional(), memory: K8S_QUANTITY.optional() }).optional(),
|
|
1233
|
+
})
|
|
1234
|
+
.optional(),
|
|
1235
|
+
/** Scheduling-framework knobs (CONTROL-PLANE-SCHEDULING-DESIGN, clay 拍 D1/D3 2026-07-04). Optional and
|
|
1236
|
+
* hash-INCLUDED like every spec field — absent → contract defaults (zero churn on existing fleets). */
|
|
1237
|
+
fleet: z
|
|
1238
|
+
.object({
|
|
1239
|
+
/** Per-worker dispatch policy consumed by center `/api/dispatch` (D3: the gateway hop is optional;
|
|
1240
|
+
* only affects traffic routed through it). Absent → contract defaults (round-robin, affinity on). */
|
|
1241
|
+
dispatch: DispatchPolicy.optional(),
|
|
1242
|
+
/** D1 per-worker drain-grace override → delivered to the container as env `DRAIN_GRACE_MS`
|
|
1243
|
+
* (worker-side default 600s, floor 10s). */
|
|
1244
|
+
drainGraceMs: z.number().int().positive().optional(),
|
|
1245
|
+
})
|
|
1246
|
+
.optional(),
|
|
1247
|
+
// `model` DELETED in 0.7.0 (EXPERT-REDESIGN §3 拍板 + E3 迁移闭环 — a2 存量 5 worker 已迁移目录+roster
|
|
1248
|
+
// 标记,黑板 [551]/E3):模型/网关配置单一真源=models 目录,worker 经 `roster` 引用;主/cheap boot env
|
|
1249
|
+
// 归宿=roster.primaryModel/cheapModel 标记(0.6.3,service [546]/[548]② 已承接)。pre-0.7 存量 JSON 里的
|
|
1250
|
+
// `model` 键被 parse 静默剥除(zod strips unknown)→ workerSpecHash 变化=一次性重建;迁移工具输入=
|
|
1251
|
+
// 冻结的 LegacyWorkerModel(migrate.ts),必须在升级 0.7 前跑完(a2 已闭环,E3 [551])。
|
|
1252
|
+
/** 运行时 (非 secret). 映射到 service SESSION_BACKEND/REQUIRE_PRINCIPAL/RATE_LIMIT_RPM/… */
|
|
1253
|
+
runtime: z.object({
|
|
1254
|
+
// default "auto" (was "tidb" — Service AI's e2e-tag response traced the minimal-spec fatal here): the old
|
|
1255
|
+
// default injected SESSION_BACKEND=tidb into EVERY spec that didn't set it, and the service fail-fasts on
|
|
1256
|
+
// explicit tidb without TIDB_HOST (correct Tier-2 hardening — declared persistence must not silently
|
|
1257
|
+
// degrade). "auto" = probe: with coords → tidb, without → memory, never fatal. A minimal `runtime:{}`
|
|
1258
|
+
// worker now boots. No churn: previously-SAVED specs have "tidb" baked in (parseDomain persists defaults),
|
|
1259
|
+
// so only NEW minimal specs see "auto"; explicit-"tidb" loud-fail semantics are untouched.
|
|
1260
|
+
sessionBackend: z.enum(["tidb", "memory", "auto"]).default("auto"),
|
|
1261
|
+
memoryBackend: z.enum(["off", "memory", "tidb"]).default("off"),
|
|
1262
|
+
db: z.string().optional(), // TIDB_DATABASE (独立库)
|
|
1263
|
+
scenario: z.string().optional(), // DEFAULT_SCENARIO
|
|
1264
|
+
requirePrincipal: z.boolean().default(false),
|
|
1265
|
+
rateLimitRpm: z.number().int().nonnegative().default(0),
|
|
1266
|
+
/** Per-principal cumulative cost CAP (USD) → MAX_PRINCIPAL_COST_USD → the service's cost gate ENFORCEMENT
|
|
1267
|
+
* (over-cap → 429). Pairs with model.cost (which only ATTRIBUTES cost). ABSENT → no env → the service
|
|
1268
|
+
* defaults to 0 = off (today's behaviour); EXPLICIT 0 emits "0" = block-all and is HASH-DISTINCT from
|
|
1269
|
+
* absent (so the guard is `!= null`, not `> 0` — the two carry different caller intent). Optional + NO
|
|
1270
|
+
* default → absent never materialises → no spec-hash churn. Hash-included (read at boot → cap change recreates). */
|
|
1271
|
+
maxPrincipalCostUsd: z.number().finite().nonnegative().optional(),
|
|
1272
|
+
}),
|
|
1273
|
+
/** 物理坐标 (非 secret). 映射到 GIT_API_BASEURL / TIDB_HOST 等. */
|
|
1274
|
+
coords: z
|
|
1275
|
+
.object({
|
|
1276
|
+
gitApiBaseUrl: NON_SECRET_URL.optional().or(z.literal("")),
|
|
1277
|
+
tidbHost: z.string().optional(),
|
|
1278
|
+
tidbPort: TCP_PORT.optional(),
|
|
1279
|
+
tidbUser: z.string().optional(),
|
|
1280
|
+
})
|
|
1281
|
+
.default({}),
|
|
1282
|
+
secretRefs: WorkerSecretRefs,
|
|
1283
|
+
/**
|
|
1284
|
+
* 通用 env 透传(逃生口)——给**没有专属结构化字段**的 service env 用,让任意 worker 声明自己的 app 专属变量
|
|
1285
|
+
* (如 OA 的 `OA_API_BASEURL`),不必为每个变量改契约。值是**字面非密值**(≤2048,且拒绝明显的密钥串——密钥走
|
|
1286
|
+
* `secretEnv`)。**只能加结构化字段没产生的键**:`RESERVED_WORKER_ENV` 里的键(PORT、MODEL_x、gateway、auth、
|
|
1287
|
+
* secret 名…)被拒,防 env 改路由/关鉴权/遮蔽密钥(council)。可覆盖非保留键(如 LOG_LEVEL)。hash-included(改它重建)。
|
|
1288
|
+
*/
|
|
1289
|
+
env: z
|
|
1290
|
+
.record(ENV_NAME, z.string().max(2048))
|
|
1291
|
+
.superRefine((rec, ctx) => {
|
|
1292
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
1293
|
+
if (RESERVED_WORKER_ENV.has(k))
|
|
1294
|
+
ctx.addIssue({ code: "custom", message: `env cannot set reserved key "${k}" — set it via ${RESERVED_ENV_FIELD[k] ?? "the structured field"}` });
|
|
1295
|
+
// 0.1.32: NO name exemption — a PUBLIC key is allowed because SECRET_LITERAL_RE now only flags PRIVATE keys
|
|
1296
|
+
// (root fix), so a literal private key / token in env (ANY name, incl `*_PUBKEY`) is still rejected.
|
|
1297
|
+
if (containsSecretToken(v))
|
|
1298
|
+
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
|
|
1299
|
+
}
|
|
1300
|
+
})
|
|
1301
|
+
.optional(),
|
|
1302
|
+
/**
|
|
1303
|
+
* 通用**密钥** env 透传:`{ service 的环境变量名 → 宿主机 env-NAME }`。编排器把宿主机 env-NAME 从它的 secret 源
|
|
1304
|
+
* 解析成真值、经 secretKeyRef 注入——**sema-registry 只存名、绝不存值**(同 `secretRefs` 边界,值也是 `ENV_NAME`
|
|
1305
|
+
* 故无法粘贴真值)。给 app 专属令牌用(如 `OA_SERVICE_TOKEN` → 某 env-名)。在 reconciler 里于 `env` 之后解析,
|
|
1306
|
+
* 故密钥永不被 `env` 同名键遮蔽。`RESERVED_WORKER_ENV` 里的键被拒(那些结构化 secret 走 `secretRefs`)。
|
|
1307
|
+
*/
|
|
1308
|
+
secretEnv: z
|
|
1309
|
+
.record(ENV_NAME, ENV_NAME)
|
|
1310
|
+
.superRefine((rec, ctx) => {
|
|
1311
|
+
for (const k of Object.keys(rec))
|
|
1312
|
+
if (RESERVED_WORKER_ENV.has(k))
|
|
1313
|
+
ctx.addIssue({ code: "custom", message: `secretEnv cannot set reserved key "${k}" — set it via ${RESERVED_ENV_FIELD[k] ?? "secretRefs"}` });
|
|
1314
|
+
})
|
|
1315
|
+
.optional(),
|
|
1316
|
+
/** Opaque recreate trigger (hash-INCLUDED): bump it to force the reconciler to recreate the worker — e.g. to
|
|
1317
|
+
* re-project a secret VALUE that changed (a secret write doesn't alter the spec, so without this the running
|
|
1318
|
+
* pod keeps the old value). Set by POST /api/config/workers/<name>/rotate. */
|
|
1319
|
+
recreateNonce: z.string().max(64).optional(),
|
|
1320
|
+
/**
|
|
1321
|
+
* 生命周期 (v2②, design/25 §3)。可选——不填 = `persistent`(默认安全,绝不被 GC 误杀)。
|
|
1322
|
+
* **reconciler 不读这个字段**(保持纯收敛);TTL/idle 决策全在 sema-registry 侧 sweeper:到期就把 worker
|
|
1323
|
+
* 从 desired 移除,reconciler 声明式自然 teardown。`ttl`/`idleTimeout` 用时长串("30m"/"2h"/"7d")。
|
|
1324
|
+
*/
|
|
1325
|
+
lifecycle: WorkerLifecycle.optional(),
|
|
1326
|
+
/**
|
|
1327
|
+
* 多主机调度 (可选, 非 breaking — reconciler 不读)。`auto:true` → sema-registry 的 placement 按容量/标签约束
|
|
1328
|
+
* 给它挑一台 host 并写回具体 `host`(reconciler 仍按具体 host 认领,契约不变)。`requires` = host 必须具备的
|
|
1329
|
+
* 全部标签(hosts 域声明)。不填 = 手动指定 host(运维自己负责)。
|
|
1330
|
+
*/
|
|
1331
|
+
placement: z
|
|
1332
|
+
.object({
|
|
1333
|
+
auto: z.boolean().default(false),
|
|
1334
|
+
requires: z.array(z.string().min(1)).default([]),
|
|
1335
|
+
/** SOFT anti-affinity group: auto workers sharing this key are spread across hosts when possible
|
|
1336
|
+
* (preferred, never blocks placement — design SCHEDULING.md). 不填 = 无 spread 约束。 */
|
|
1337
|
+
spread: z.string().min(1).optional(),
|
|
1338
|
+
})
|
|
1339
|
+
.optional(),
|
|
1340
|
+
/**
|
|
1341
|
+
* per-worker roster 引用 (docs/MULTI-ROSTER.md)。指向 `rosters` 域里一个命名 roster;该 worker 的 service 拉
|
|
1342
|
+
* `GET /api/config/effective?worker=<name>` 时,sema-registry 用这个 roster 解析它专属的 models/roles/@白名单。
|
|
1343
|
+
* 不填 → 该 worker 拿全局 models(向后兼容)。**不进 `workerSpecHash`**(改 roster 只让 service 重拉、不重建容器)。
|
|
1344
|
+
*/
|
|
1345
|
+
roster: z.string().min(1).optional(),
|
|
1346
|
+
/** 预留 spec 签名钩子 (MVP 不验; reconciler 侧可加验签 — design/22 决策③). */
|
|
1347
|
+
signature: z.string().optional(),
|
|
1348
|
+
notes: z.string().optional(),
|
|
1349
|
+
});
|
|
1350
|
+
/** Effective route key for a worker = explicit routePrefix, else its name. */
|
|
1351
|
+
export function workerRouteKey(w) {
|
|
1352
|
+
return w.routePrefix ?? w.name;
|
|
1353
|
+
}
|
|
1354
|
+
/** S/M/L 资源套餐常量(EXPERT-REDESIGN §3 四问③,E1 定档)——workers 向导的规格套餐与 E2 placement 的
|
|
1355
|
+
* 默认折算**同源引用这一份**(worker 无 requests 时按 S 折算,§2-4)。值为 k8s quantity 串,直接落
|
|
1356
|
+
* `WorkerSpec.resources`;「自定义」= 不用套餐、直接写 resources。定档口径:S=轻量单副本(默认档,略高于
|
|
1357
|
+
* backend 兜底 50m/128Mi 的真实工作水位);M=常规编码 agent;L=重载(大 repo/多并发 turn)。 */
|
|
1358
|
+
export const WORKER_RESOURCE_PRESETS = {
|
|
1359
|
+
S: { requests: { cpu: "250m", memory: "512Mi" }, limits: { cpu: "1", memory: "1Gi" } },
|
|
1360
|
+
M: { requests: { cpu: "500m", memory: "1Gi" }, limits: { cpu: "2", memory: "2Gi" } },
|
|
1361
|
+
L: { requests: { cpu: "1", memory: "2Gi" }, limits: { cpu: "4", memory: "4Gi" } },
|
|
1362
|
+
};
|
|
1363
|
+
export const WorkersConfig = z
|
|
1364
|
+
.object({ workers: z.array(WorkerSpec).default([]) })
|
|
1365
|
+
.superRefine((cfg, ctx) => {
|
|
1366
|
+
// worker `name` and effective routePrefix must each be unique across the fleet (design/25 决策7:
|
|
1367
|
+
// routePrefix 唯一性 sema-registry 提交时校验). Dup name → container-name clash; dup route → ambiguous
|
|
1368
|
+
// reverse-proxy routing.
|
|
1369
|
+
const names = new Set();
|
|
1370
|
+
const routes = new Set();
|
|
1371
|
+
cfg.workers.forEach((w, i) => {
|
|
1372
|
+
if (names.has(w.name))
|
|
1373
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate worker name "${w.name}"`, path: ["workers", i, "name"] });
|
|
1374
|
+
names.add(w.name);
|
|
1375
|
+
const route = workerRouteKey(w);
|
|
1376
|
+
// point the error at the field that actually set the route (explicit routePrefix, else the name fallback).
|
|
1377
|
+
const routeField = w.routePrefix !== undefined ? "routePrefix" : "name";
|
|
1378
|
+
if (routes.has(route))
|
|
1379
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate routePrefix "${route}" (collides with another worker's routePrefix/name)`, path: ["workers", i, routeField] });
|
|
1380
|
+
routes.add(route);
|
|
1381
|
+
// replicas + autoscaling = two owners of the same knob (the orchestrator's SSA vs the HPA controller
|
|
1382
|
+
// would endlessly fight over spec.replicas) → reject at save, not at reconcile.
|
|
1383
|
+
if (w.replicas !== undefined && w.autoscaling) {
|
|
1384
|
+
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"] });
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
// Worker STATUS — reconciler 回写的 ACTUAL state. NOT an admin domain: spec(admin 写) 与 status(reconciler
|
|
1389
|
+
// 写) 分离更新、各自乐观锁,避免 reconciler 覆盖刚改的 spec (design/22 决策⑤). 存在 worker_status 表/单独 API.
|
|
1390
|
+
export const WorkerCondition = z.object({
|
|
1391
|
+
type: z.enum(["Reconciled", "Healthy", "Conflicted"]),
|
|
1392
|
+
status: z.enum(["True", "False", "Unknown"]),
|
|
1393
|
+
reason: z.string().optional(), // e.g. "HostUnreachable" | "InsufficientResources" | "ImagePullFailed"
|
|
1394
|
+
message: z.string().optional(),
|
|
1395
|
+
lastTransitionTime: z.string(),
|
|
1396
|
+
});
|
|
1397
|
+
export const WorkerStatus = z.object({
|
|
1398
|
+
name: z.string().min(1),
|
|
1399
|
+
host: z.string().min(1), // 哪个 reconciler 上报的
|
|
1400
|
+
phase: z.enum(["Pending", "Running", "Failed", "Unknown"]),
|
|
1401
|
+
conditions: z.array(WorkerCondition).default([]),
|
|
1402
|
+
/** reconciler 处理时的 effective `version` — admin 改 spec 后 version 变,据此判断 status 是否陈旧. */
|
|
1403
|
+
observedVersion: z.number().int().nonnegative(),
|
|
1404
|
+
/** reconciler 算的逻辑 spec hash (漂移检测 + 防振荡, design/22 失败模式#1). */
|
|
1405
|
+
observedSpecHash: z.string().optional(),
|
|
1406
|
+
message: z.string().optional(),
|
|
1407
|
+
updatedAt: z.string(),
|
|
1408
|
+
});
|
|
1409
|
+
// ── Hosts registry (机器 — 自注册+待批, EXPERT-REDESIGN §2) ───────────────────────
|
|
1410
|
+
// 声明 fleet 里有哪些 host + 标签/drain/批准状态。host `name` 对应 reconciler 的 ORCH_HOST_ID 与
|
|
1411
|
+
// WorkerSpec.host(同字符集)。0.6.0 改形(拍板:capacity 手填票数废除,机器自注册+默认待批):
|
|
1412
|
+
// - `capacity` DELETED —— 可放性改由探测值折算(HostStatus.probes − Σ已排 worker requests;计数制→资源制
|
|
1413
|
+
// 是探测数据齐备后的**独立开关**,E2 placement 承接,切换前沿用计数制兜底、不按票数)。
|
|
1414
|
+
// - `approved` NEW —— 默认 false = 待批(不参与自动排布;enforcement 随 E2 placement 生效,E1 只落契约)。
|
|
1415
|
+
// 存量迁移 = grandfathering 批量回填 approved:true(migrate.ts),存量机器不出现排布空窗(§11-9)。
|
|
1416
|
+
/** DNS-label bound on a host NAME (0.8.0 tightening; single source — schema + the 0.8.1 read-path
|
|
1417
|
+
* grandfather clamp in config-fns both use it, so the two can never drift). */
|
|
1418
|
+
export const HOST_NAME_MAX = 63;
|
|
1419
|
+
/** Ceiling on the hosts ARRAY (0.8.0 tightening; same single-source rule as {@link HOST_NAME_MAX}). */
|
|
1420
|
+
export const HOSTS_ARRAY_MAX = 1024;
|
|
1421
|
+
export const HostEntry = z.object({
|
|
1422
|
+
// .max(HOST_NAME_MAX=63) = DNS-label bound (E2 F3③ 挂账收账, 0.8.0): center-side probe clamp stays as双保险.
|
|
1423
|
+
name: z.string().min(1).max(HOST_NAME_MAX).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
1424
|
+
/** 待批准入编状态机(§2-3):自注册/新登记默认 false = 待批,不参与自动排布;admin 批准(走 publish)→
|
|
1425
|
+
* true 参排(enforcement 随 E2)。既有 worker 不受影响(reconciler 仍按具体 host 认领)。 */
|
|
1426
|
+
approved: z.boolean().default(false),
|
|
1427
|
+
/** 调度标签(如 "gpu"/"region-us"),worker 的 `placement.requires` 据此匹配。 */
|
|
1428
|
+
labels: z.array(z.string().min(1)).default([]),
|
|
1429
|
+
/** 关闭 = 完全不可调度(既有 worker 仍由 reconciler 管,但 placement 视它不可用)。 */
|
|
1430
|
+
enabled: z.boolean().default(true),
|
|
1431
|
+
/** drain = 不再往这台**新**放 worker(既有的留着),用于下线前腾空。 */
|
|
1432
|
+
drain: z.boolean().default(false),
|
|
1433
|
+
notes: z.string().optional(),
|
|
1434
|
+
});
|
|
1435
|
+
export const HostsConfig = z
|
|
1436
|
+
// .max(HOSTS_ARRAY_MAX=1024) hosts (E2 F3③ 挂账收账, 0.8.0) — a fleet registry, not a datacenter inventory;
|
|
1437
|
+
// an absurdly long array is a poisoned publish, fail-loud at save time. READ path: 0.7-era stored data that
|
|
1438
|
+
// exceeds these 0.8 bounds is GRANDFATHERED (clamped + warned, never thrown) inside buildEffective —
|
|
1439
|
+
// see grandfatherHostsRead in config-fns (0.8.1, S3 M-1: 收紧不毁存量,E2 血训).
|
|
1440
|
+
.object({ hosts: z.array(HostEntry).max(HOSTS_ARRAY_MAX).default([]) })
|
|
1441
|
+
.superRefine((cfg, ctx) => {
|
|
1442
|
+
const seen = new Set();
|
|
1443
|
+
cfg.hosts.forEach((h, i) => {
|
|
1444
|
+
if (seen.has(h.name))
|
|
1445
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate host "${h.name}"`, path: ["hosts", i, "name"] });
|
|
1446
|
+
seen.add(h.name);
|
|
1447
|
+
});
|
|
1448
|
+
});
|
|
1449
|
+
// Host STATUS — orchestrator 周期(60s)`PUT /api/config/hosts/status` 自报的探测值(与 workers/status 同款
|
|
1450
|
+
// token+CAS 姿势,同 WorkerStatus 先例:spec/admin 写 与 status/orchestrator 写 分离)。**探测值只进状态侧表
|
|
1451
|
+
// (fleet_host_status),不进配置域**(意图与实态分离);未知 host 名上报 → server 在 hosts 域登记待批条目
|
|
1452
|
+
// (仅身份字段 {name, approved:false},幂等一条,来源 token 记审计——EXPERT-REDESIGN §2-1/§11-4)。E2
|
|
1453
|
+
// placement 的资源制折算消费它(可分配 = probes − Σ已排 worker requests);从未上报的机器不参与自动排布。
|
|
1454
|
+
// Sanity ceilings (E2 F3③ 挂账收账, 0.8.0): a forged/absurd probe (cpuCores=1e12) would suck every auto
|
|
1455
|
+
// worker onto one host. Ceilings MIRROR center's probeCapacities clamp (PROBE_CPU_CORES_CEILING=4096 /
|
|
1456
|
+
// PROBE_MEM_BYTES_CEILING=2^50=1PiB) — schema now fail-louds at report time; center's clamp stays 双保险.
|
|
1457
|
+
export const PROBE_CPU_CORES_MAX = 4096;
|
|
1458
|
+
export const PROBE_MEM_BYTES_MAX = 2 ** 50; // 1 PiB
|
|
1459
|
+
export const HostProbes = z.object({
|
|
1460
|
+
/** 探测到的 CPU 核数(docker info NCPU / k8s node allocatable cpu)。可为小数(k8s allocatable 如 3.9)。 */
|
|
1461
|
+
cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX),
|
|
1462
|
+
/** 探测到的内存字节数(docker info MemTotal / k8s node allocatable memory)。 */
|
|
1463
|
+
memBytes: z.number().int().positive().max(PROBE_MEM_BYTES_MAX),
|
|
1464
|
+
/** 当前跑着的容器数(docker info ContainersRunning / k8s node 上本 fleet 的 pod 数)。 */
|
|
1465
|
+
containersRunning: z.number().int().nonnegative(),
|
|
1466
|
+
/** k8s backend 补充:node 名 + allocatable(与顶层同单位——cpuCores 核数 / memBytes 字节)。docker 无此段。 */
|
|
1467
|
+
k8sNode: z
|
|
1468
|
+
.object({
|
|
1469
|
+
name: z.string().min(1),
|
|
1470
|
+
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(),
|
|
1471
|
+
})
|
|
1472
|
+
.optional(),
|
|
1473
|
+
});
|
|
1474
|
+
export const HostStatus = z.object({
|
|
1475
|
+
/** host name(hosts 域 HostEntry.name / ORCH_HOST_ID 同字符集,同 .max(HOST_NAME_MAX=63) DNS-label bound)。
|
|
1476
|
+
* status 上报是 WRITE 路径 → fail-loud 保持(有意设计,S3 M-1 订正注记:center hosts/status PUT 无独立
|
|
1477
|
+
* probe 上限,schema 即闸;读路径 grandfather 只涉 config-hosts 域,不涉此处)。 */
|
|
1478
|
+
host: z.string().min(1).max(HOST_NAME_MAX).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
1479
|
+
backend: z.enum(["docker", "k8s"]),
|
|
1480
|
+
probes: HostProbes,
|
|
1481
|
+
/** 探测时刻(ISO)——机器视图 hover 注明数据口径与此时间(§2-5:是分配额口径的底数,非实时负载)。 */
|
|
1482
|
+
ts: z.string().min(1),
|
|
1483
|
+
});
|
|
1484
|
+
// ── Domain: skills (中心化 skill 分发 — docs/MCP-SKILLS.md, Roadmap B1) ─────────
|
|
1485
|
+
// A skill is a prompt-level capability: frontmatter (name/description/scenarios) + markdown `content`
|
|
1486
|
+
// the service feeds into the agent's model context when the skill is invoked (core progressive disclosure
|
|
1487
|
+
// — NOT the base system prompt). Still centrally-delivered agent instructions. The center stores the FULL skill
|
|
1488
|
+
// (incl. content, admin-edited); /effective ships a MANIFEST (content → contentHash) and the service
|
|
1489
|
+
// lazily pulls the body by hash (slice 2). Targeting is by `scenarios[]`, filtered service-side exactly
|
|
1490
|
+
// like the in-image `skillsForScenario` (MCP-SKILLS.md §3.1, §6 Q2). Applied at service boot/reconcile,
|
|
1491
|
+
// NOT hot (restart-to-apply, same as models/roles). ⚠️ content is centrally-delivered agent instructions
|
|
1492
|
+
// (injected into the model on invoke) → writing this domain is gated to `publisher` + the publish gate
|
|
1493
|
+
// (§3.3, DOMAIN_WRITE_ROLE in store.ts).
|
|
1494
|
+
export const SkillSpec = z.object({
|
|
1495
|
+
/** Catalog key — must match the in-image SKILLS_DIR name to OVERRIDE it (§6 Q4 中心按名覆盖). */
|
|
1496
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1497
|
+
/** One-line summary (frontmatter `description`) — surfaced to the model for skill selection. */
|
|
1498
|
+
description: z.string().default(""),
|
|
1499
|
+
/** Scenarios this skill applies to; empty = all scenarios (service-side filter, same as skillsForScenario). */
|
|
1500
|
+
scenarios: z.array(z.string().min(1)).default([]),
|
|
1501
|
+
/** Per-roster targeting (v2, docs/MCP-SKILLS.md §6 Q2): roster NAMEs this skill is for. Empty = all rosters.
|
|
1502
|
+
* Filtered at GET /effective?worker=<name> by the worker's roster; the unscoped global pull ships all. */
|
|
1503
|
+
rosters: z.array(z.string().min(1)).default([]),
|
|
1504
|
+
/** The markdown body injected into the model context on invoke (progressive disclosure). Stored in full here; shipped as a hash in /effective. */
|
|
1505
|
+
content: z.string().default(""),
|
|
1506
|
+
enabled: z.boolean().default(true),
|
|
1507
|
+
notes: z.string().optional(),
|
|
1508
|
+
});
|
|
1509
|
+
export const SkillsConfig = z
|
|
1510
|
+
.object({ skills: z.array(SkillSpec).default([]) })
|
|
1511
|
+
.superRefine((cfg, ctx) => {
|
|
1512
|
+
const seen = new Set();
|
|
1513
|
+
cfg.skills.forEach((s, i) => {
|
|
1514
|
+
if (seen.has(s.name))
|
|
1515
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate skill "${s.name}"`, path: ["skills", i, "name"] });
|
|
1516
|
+
seen.add(s.name);
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
// ── Domain: mcp (MCP server 注册表 — docs/MCP-SKILLS.md, Roadmap B1) ────────────
|
|
1520
|
+
// sema-registry = an MCP-server REGISTRY: a scenario mounts a subset by `scenarios[]`. Maps onto core's
|
|
1521
|
+
// `McpServerSpec = { name, transport: {kind:"stdio",command,args?,env?} | {kind:"http",url,headers?}, allowTools? }`.
|
|
1522
|
+
// SECRET BOUNDARY (§3.1, design/15): a transport's env/header VALUES may be secret → the center stores only
|
|
1523
|
+
// env-NAME references (`envRefs`/`headerRefs`: target-key → an ENV_NAME in the SERVICE's env). The service's
|
|
1524
|
+
// `resolveSpec` reads the real values from its own environment — this DB never holds a credential. Like
|
|
1525
|
+
// skills, MCP is applied at boot/reconcile (not hot) and writing is gated to `publisher` + the publish gate.
|
|
1526
|
+
const McpEnvRefs = z.record(ENV_NAME, ENV_NAME); // target ENV key → ENV_NAME holding its value (both env-name-shaped)
|
|
1527
|
+
// An HTTP header NAME: an RFC 7230 token that ALSO must not be a secret literal. A secret-SHAPED header name
|
|
1528
|
+
// (a headerRefs key `sk-live-…`, or principalHeader `ghp_…`) is stored + shipped verbatim on /effective — the
|
|
1529
|
+
// SAME invariant-① gap as worker.env / model.extraBody / mcp stdio command+args (credentials belong on the
|
|
1530
|
+
// VALUE side = an env-NAME ref, never the name). The token charset includes `%`, so an encoded secret could ride
|
|
1531
|
+
// the name — containsSecretToken percent-decodes, catching it too. ONE shared schema for both header-name fields. R19 (codex).
|
|
1532
|
+
const HTTP_HEADER_NAME = z
|
|
1533
|
+
.string()
|
|
1534
|
+
.regex(/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/, "must be an HTTP header NAME")
|
|
1535
|
+
.refine((s) => !containsSecretToken(s), "HTTP header name must not contain a secret literal — credentials go in the env-NAME value, never the header name");
|
|
1536
|
+
// Header NAME → ENV_NAME holding its value. Header names are tokens (RFC 7230) — not env-name-shaped.
|
|
1537
|
+
const McpHeaderRefs = z.record(HTTP_HEADER_NAME, ENV_NAME);
|
|
1538
|
+
export const McpTransport = z.discriminatedUnion("kind", [
|
|
1539
|
+
z.object({
|
|
1540
|
+
kind: z.literal("stdio"),
|
|
1541
|
+
// command + args are free-form values shipped verbatim on /effective. Like worker.env values + model
|
|
1542
|
+
// extraBody (the sibling free-form fields), reject a secret-shaped literal — an admin pasting
|
|
1543
|
+
// `args:["--api-key","sk-…"]` would otherwise store the VALUE + ship it (invariant ① gap). Credentials go in
|
|
1544
|
+
// envRefs (env-NAME). FIELD-level refine (not a member superRefine) to preserve the discriminatedUnion. R6 (codex).
|
|
1545
|
+
command: z
|
|
1546
|
+
.string()
|
|
1547
|
+
.min(1)
|
|
1548
|
+
.refine((s) => !containsSecretToken(s), "mcp stdio command must not contain a secret literal — put credentials in envRefs (an env-NAME)")
|
|
1549
|
+
.refine((s) => !hasNul(s), "mcp stdio command must not contain a NUL byte (Node cannot spawn it)"),
|
|
1550
|
+
args: z
|
|
1551
|
+
.array(z
|
|
1552
|
+
.string()
|
|
1553
|
+
.refine((s) => !containsSecretToken(s), "mcp stdio arg must not contain a secret literal — put credentials in envRefs (an env-NAME)")
|
|
1554
|
+
.refine((s) => !hasNul(s), "mcp stdio arg must not contain a NUL byte (Node cannot spawn it)"))
|
|
1555
|
+
.optional(),
|
|
1556
|
+
/** target ENV key → ENV_NAME in the service env (never a secret value). */
|
|
1557
|
+
envRefs: McpEnvRefs.optional(),
|
|
1558
|
+
}),
|
|
1559
|
+
z.object({
|
|
1560
|
+
kind: z.literal("http"),
|
|
1561
|
+
url: NON_SECRET_URL,
|
|
1562
|
+
/** header NAME → ENV_NAME in the service env (e.g. Authorization → SEARCH_TOKEN_ENV). */
|
|
1563
|
+
headerRefs: McpHeaderRefs.optional(),
|
|
1564
|
+
/** Header NAME the SERVICE fills with the END-USER principal when proxying a tool call to this server, so the
|
|
1565
|
+
* server can do per-user RBAC (e.g. "x-agent-principal"). Distinct from headerRefs (static secret headers):
|
|
1566
|
+
* this is dynamic — the caller's principal, set per-request by the worker — so it's a header NAME, not an
|
|
1567
|
+
* env-NAME ref (no secret). Empty → the worker forwards no principal (server sees an anonymous call). */
|
|
1568
|
+
principalHeader: HTTP_HEADER_NAME.optional(),
|
|
1569
|
+
}),
|
|
1570
|
+
]);
|
|
1571
|
+
export const McpServerSpec = z.object({
|
|
1572
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1573
|
+
transport: McpTransport,
|
|
1574
|
+
/** Allowlist of tool names exposed from this server (core `allowTools`). Empty/undefined = all. */
|
|
1575
|
+
allowTools: z.array(z.string().min(1)).optional(),
|
|
1576
|
+
/** Scenarios this server is mounted in; empty = all (service-side filter, same as skills). */
|
|
1577
|
+
scenarios: z.array(z.string().min(1)).default([]),
|
|
1578
|
+
/** Per-roster targeting (v2): roster NAMEs this server is for. Empty = all rosters. Filtered at
|
|
1579
|
+
* GET /effective?worker=<name> by the worker's roster; the unscoped global pull ships all. */
|
|
1580
|
+
rosters: z.array(z.string().min(1)).default([]),
|
|
1581
|
+
enabled: z.boolean().default(true),
|
|
1582
|
+
/** Per-server opt-in for INBOUND MCP elicitation (core `McpServerSpec.elicitation`, design/99 §E23). Default
|
|
1583
|
+
* OFF = fail-closed: only an opted-in server may prompt the user mid-tool-call. Service forwards this into the
|
|
1584
|
+
* core spec (was read untyped as `(s as {elicitation?})` before this field — service [253] ask). */
|
|
1585
|
+
elicitation: z.boolean().default(false),
|
|
1586
|
+
notes: z.string().optional(),
|
|
1587
|
+
});
|
|
1588
|
+
export const McpConfig = z
|
|
1589
|
+
.object({ servers: z.array(McpServerSpec).default([]) })
|
|
1590
|
+
.superRefine((cfg, ctx) => {
|
|
1591
|
+
const seen = new Set();
|
|
1592
|
+
cfg.servers.forEach((s, i) => {
|
|
1593
|
+
if (seen.has(s.name))
|
|
1594
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate mcp server "${s.name}"`, path: ["servers", i, "name"] });
|
|
1595
|
+
seen.add(s.name);
|
|
1596
|
+
});
|
|
1597
|
+
});
|
|
1598
|
+
// ── Domain: plugins (plugin SOURCE REFERENCES — C2 plugins 上云, additive 0.5.0) ──────────────────────────
|
|
1599
|
+
// The cloud stores WHERE a plugin comes from, NEVER its content (clay 拍): {name, source, version/sha pin}.
|
|
1600
|
+
// The consuming shell materializes the plugin locally from the ref (git clone / marketplace install) — the
|
|
1601
|
+
// registry never hosts executable plugin bytes, so there is nothing here to content-scan or lazily serve.
|
|
1602
|
+
// Plugins are an EXECUTABLE surface (commands/hooks/MCP ride in) → write-gated to `publisher` like skills/
|
|
1603
|
+
// mcp (DOMAIN_WRITE_ROLE) and capped per-principal by entitlement `visiblePlugins` (deny-wins, same
|
|
1604
|
+
// mechanics as visibleMcp/visibleSkills — B1: a personal plugin ref is a net-new capability).
|
|
1605
|
+
/** A plugin/marketplace ORIGIN string: a git URL (http(s)/ssh/scp-style) or a forge `owner/repo` shorthand.
|
|
1606
|
+
* Deliberately permissive on FORM (git accepts many), strict on SECRETS: no secret-shaped literal, no NUL,
|
|
1607
|
+
* and an http(s) origin must not carry userinfo credentials or a secret-bearing query/fragment (same
|
|
1608
|
+
* invariant-① posture as NON_SECRET_URL — credentials never ride a stored origin; private remotes
|
|
1609
|
+
* authenticate via each machine's own git credential helper). */
|
|
1610
|
+
export const PLUGIN_ORIGIN = z
|
|
1611
|
+
.string()
|
|
1612
|
+
.min(1)
|
|
1613
|
+
.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")
|
|
1614
|
+
.refine((s) => !hasNul(s), "plugin source must not contain a NUL byte")
|
|
1615
|
+
.refine((s) => {
|
|
1616
|
+
if (!/^https?:\/\//i.test(s))
|
|
1617
|
+
return true; // ssh/scp-style/owner-repo shorthand: no URL userinfo to check
|
|
1618
|
+
try {
|
|
1619
|
+
const u = new URL(s);
|
|
1620
|
+
return !u.username && !u.password && !urlQueryFragmentHasSecret(u);
|
|
1621
|
+
}
|
|
1622
|
+
catch {
|
|
1623
|
+
return false; // claims to be http(s) but does not parse → reject
|
|
1624
|
+
}
|
|
1625
|
+
}, "an http(s) plugin source must not embed credentials (userinfo) or a secret-bearing query/fragment");
|
|
1626
|
+
/** A full 40-hex git commit SHA — the ONLY pin form the supply-side treats as 钉死 (a branch/tag `version`
|
|
1627
|
+
* is a moving ref; only a commit pin makes the materialized bytes reproducible). */
|
|
1628
|
+
export const GIT_COMMIT_SHA = z.string().regex(/^[a-f0-9]{40}$/, "must be a full 40-character lowercase git commit SHA");
|
|
1629
|
+
/** WHERE a plugin comes from — two forms (clay 拍: git URL 与 marketplace ref 两形态):
|
|
1630
|
+
* - `git`: the plugin lives at a git URL directly (repo root = plugin root).
|
|
1631
|
+
* - `marketplace`: the plugin is `<name>@<marketplace>` in a plugin marketplace; `marketplace` is the
|
|
1632
|
+
* marketplace NAME and `url` its own origin (so a machine that never registered it can: add the
|
|
1633
|
+
* marketplace by url, then install by name). `url` absent = the consumer must already know the
|
|
1634
|
+
* marketplace (works inside a fleet with pre-seeded marketplaces; a dangling name installs nothing). */
|
|
1635
|
+
export const PluginSource = z.discriminatedUnion("kind", [
|
|
1636
|
+
z.object({ kind: z.literal("git"), url: PLUGIN_ORIGIN }),
|
|
1637
|
+
z.object({
|
|
1638
|
+
kind: z.literal("marketplace"),
|
|
1639
|
+
marketplace: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1640
|
+
url: PLUGIN_ORIGIN.optional(),
|
|
1641
|
+
}),
|
|
1642
|
+
]);
|
|
1643
|
+
export const PluginSpec = z.object({
|
|
1644
|
+
/** Plugin name (the `<name>` half of a `<name>@<marketplace>` id; the whole id for a direct git plugin). */
|
|
1645
|
+
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
1646
|
+
source: PluginSource,
|
|
1647
|
+
/** Human version label (semver/tag). A moving ref — NOT a pin; pin with `sha`. */
|
|
1648
|
+
version: z.string().optional(),
|
|
1649
|
+
/** Git commit pin (independent of `version`, clay 拍): present ⇒ the ref is 钉死 — consumers materialize
|
|
1650
|
+
* exactly this commit; absent ⇒ the consumer tracks the source's default/`version` ref. */
|
|
1651
|
+
sha: GIT_COMMIT_SHA.optional(),
|
|
1652
|
+
enabled: z.boolean().default(true),
|
|
1653
|
+
notes: z.string().optional(),
|
|
1654
|
+
});
|
|
1655
|
+
export const PluginsConfig = z
|
|
1656
|
+
.object({ plugins: z.array(PluginSpec).default([]) })
|
|
1657
|
+
.superRefine((cfg, ctx) => {
|
|
1658
|
+
const seen = new Set();
|
|
1659
|
+
cfg.plugins.forEach((p, i) => {
|
|
1660
|
+
if (seen.has(p.name))
|
|
1661
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `duplicate plugin "${p.name}"`, path: ["plugins", i, "name"] });
|
|
1662
|
+
seen.add(p.name);
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
// ── The domain registry ───────────────────────────────────────────────────────
|
|
1666
|
+
export const DOMAIN_SCHEMAS = {
|
|
1667
|
+
models: ModelsConfig,
|
|
1668
|
+
rosters: RostersConfig,
|
|
1669
|
+
skills: SkillsConfig,
|
|
1670
|
+
mcp: McpConfig,
|
|
1671
|
+
plugins: PluginsConfig,
|
|
1672
|
+
scenarios: ScenarioConfig,
|
|
1673
|
+
systems: SystemsConfig,
|
|
1674
|
+
collab: CollabConfig, // 0.7.0: replaces `teams` (EXPERT-REDESIGN §8/§9 同批换刀 — no editing gap)
|
|
1675
|
+
workers: WorkersConfig,
|
|
1676
|
+
hosts: HostsConfig,
|
|
1677
|
+
runtime: RuntimeConfig,
|
|
1678
|
+
governance: GovernanceConfig,
|
|
1679
|
+
entitlement: EntitlementConfig,
|
|
1680
|
+
execution: ExecutionPolicy, // 0.8.0: S3 execution-lane policy (scope 级; client-advisory until service gate [580])
|
|
1681
|
+
projects: ProjectsConfig, // 0.10.0: 142-S3 project identity ledger + 无仓铸造权威 (scope=tenant 轴; NOT portable — 租户身份数据)
|
|
1682
|
+
// integrations + access: DELETED in 0.6.0 (EXPERT-REDESIGN §9) — parseDomain/effective/路由随之 404。
|
|
1683
|
+
};
|
|
1684
|
+
// ── Effective-config PROVENANCE (the unified shape TOC `sema config --effective` and TOB `/effective?principal=`
|
|
1685
|
+
// step-5 trace both emit, so "why is this value what it is" renders identically across both seats). Settled
|
|
1686
|
+
// via the center control-plane design gate③ (docs/design/CENTER-CONTROL-PLANE-DESIGN.md §R2-T/§10). A pure
|
|
1687
|
+
// CONTRACT type — no runtime dep on the engine; the axis VALUES (enforcement/lockBehavior) mirror @sema-ai/
|
|
1688
|
+
// core's safety vocabulary, validated by a dev-only conformance test (never a runtime import).
|
|
1689
|
+
/** One provenance source in a key's resolution trail. Two orthogonal axes: the TOC file LAYER, or the TOB
|
|
1690
|
+
* PRINCIPAL dimension. `applied:false` + `reason` mark a source whose attempt was REJECTED by the monotone
|
|
1691
|
+
* tighten-only law (e.g. a layer trying to LOOSEN a principal floor → `reason:"trust-rejected"`). */
|
|
1692
|
+
export const SettingSource = z.discriminatedUnion("axis", [
|
|
1693
|
+
z.object({ axis: z.literal("layer"), layer: z.enum(["MANAGED", "PROJECT", "LOCAL"]), applied: z.boolean().optional(), reason: z.string().optional() }),
|
|
1694
|
+
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() }),
|
|
1695
|
+
]);
|
|
1696
|
+
/** The resolved provenance of ONE setting/config key. `winningSource` = the source that won; `sources[]` = the
|
|
1697
|
+
* full trail (a hybrid deploy carries both a principal floor and a layer tightening). `enforcement` (STRENGTH /
|
|
1698
|
+
* where enforced) drives fail-mode — fail-OPEN iff `client && !secret`; `gate`/`server`/secret → fail-CLOSED.
|
|
1699
|
+
* STRENGTH tiers: `gate`=data-plane engine (core tool-policy/exec-gate/sandbox/per-RUN budget); `server`=
|
|
1700
|
+
* control-plane/TOB boundary (service createAuthorizer / entitlement / per-period budget / SSO/SCIM/audit/org —
|
|
1701
|
+
* the verify-and-constrain point BEFORE the data plane, ⊥ gate); `resolver`=merger correctness; `client`=UI only.
|
|
1702
|
+
* `lockBehavior` (BEHAVIOR / what the managed layer does) is orthogonal, only meaningful under managed enforcement. */
|
|
1703
|
+
export const EffectiveKey = z.object({
|
|
1704
|
+
key: z.string(),
|
|
1705
|
+
value: z.unknown(),
|
|
1706
|
+
winningSource: SettingSource,
|
|
1707
|
+
locked: z.boolean(),
|
|
1708
|
+
enforcement: z.enum(["gate", "server", "resolver", "client"]),
|
|
1709
|
+
lockBehavior: z.enum(["locked", "advisory", "forced-off", "tighten-only"]).optional(),
|
|
1710
|
+
sources: z.array(SettingSource).optional(),
|
|
1711
|
+
});
|
|
1712
|
+
/** Reserved audit "domain" for RBAC/user-management events (not a config domain → no snapshot, not
|
|
1713
|
+
* rollback-able). Lets user changes share the one audit stream the Audit page already renders. */
|
|
1714
|
+
export const RBAC_AUDIT = "rbac";
|
|
1715
|
+
/** Audit category for secret write-through (value injected into the orchestrator secret source) — name + action
|
|
1716
|
+
* ONLY, never the value (config_change.value=null), same discipline as RBAC_AUDIT. docs/SELF-SERVICE-FLEET.md §3. */
|
|
1717
|
+
export const SECRETS_AUDIT = "secrets";
|
|
1718
|
+
// Single source: derived from DOMAIN_SCHEMAS (insertion order = menu order) so the two can't drift —
|
|
1719
|
+
// adding a domain to DOMAIN_SCHEMAS is enough. Object.keys preserves declaration order.
|
|
1720
|
+
export const DOMAINS = Object.keys(DOMAIN_SCHEMAS);
|
|
1721
|
+
/** The canonical "is this a config domain?" predicate. Use it instead of hard-coding a domain list when
|
|
1722
|
+
* filtering arbitrary keys (e.g. a `config.d/` directory scan that also turns up the non-domain
|
|
1723
|
+
* `remote-exec.json`) — `remote-exec` is NOT a domain (it's the exec backend; see writeRemoteExec). */
|
|
1724
|
+
export function isDomainName(x) {
|
|
1725
|
+
return typeof x === "string" && Object.prototype.hasOwnProperty.call(DOMAIN_SCHEMAS, x);
|
|
1726
|
+
}
|
|
1727
|
+
//# sourceMappingURL=types.js.map
|