@sema-agent/settings-schema 10.1.1 → 11.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +469 -1
- package/README.md +144 -66
- package/bin/settings-schema.mjs +10 -12
- package/conformance/VERSION +1 -1
- package/conformance/baseline.json +3 -1
- package/conformance/cases/c01-entry-salvage-then-typo/expected.json +3 -1
- package/conformance/cases/c02-hosts-clamp-stacked/expected.json +12 -5
- package/conformance/cases/c02-hosts-clamp-stacked/meta.json +1 -1
- package/conformance/cases/c03-literal-dotted-top-key/expected.json +22 -2
- package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +3 -1
- package/conformance/cases/c06-legacy-governance-lift/expected.json +11 -101
- package/conformance/cases/c06-legacy-governance-lift/meta.json +1 -1
- package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +3 -1
- package/conformance/cases/c08-limits-unknown-carried-64/expected.json +203 -2
- package/conformance/cases/c09-limits-unknown-carried-65/expected.json +204 -2
- package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +3 -1
- package/conformance/cases/c11-unicode-unknown-keys/expected.json +20 -2
- package/conformance/cases/c12-prototype-name-top-keys/expected.json +3 -1
- package/conformance/cases/c13-models-field-null/expected.json +3 -1
- package/conformance/cases/c14-models-field-empty-array/expected.json +6 -1
- package/conformance/cases/c15-models-field-number/expected.json +3 -1
- package/conformance/cases/c16-deep-nesting-205/expected.json +14 -2
- package/conformance/cases/c17-empty-domain-file/expected.json +3 -1
- package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +3 -1
- package/conformance/cases/c19-wire-envelope-no-version/expected.json +3 -1
- package/conformance/cases/c20-governance-absent-lifts/expected.json +11 -86
- package/conformance/cases/c20-governance-absent-lifts/meta.json +1 -1
- package/conformance/cases/c21-governance-empty-object-wins/expected.json +6 -5
- package/conformance/cases/c21-governance-empty-object-wins/input.json +1 -4
- package/conformance/cases/c21-governance-empty-object-wins/meta.json +1 -1
- package/conformance/cases/c23-mixed-validity-models/expected.json +3 -1
- package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +22 -2
- package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +6 -1
- package/conformance/cases/c26-hosts-array-1024/expected.json +3 -1
- package/conformance/cases/c27-hosts-array-1025/expected.json +6 -7175
- package/conformance/cases/c27-hosts-array-1025/meta.json +1 -1
- package/conformance/cases/c28-large-document-over-1mb/expected.json +14 -2
- package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +3 -1
- package/conformance/cases/c30-version-triple-a-first/expected.json +6 -1
- package/conformance/cases/c31-version-triple-b-changed/expected.json +6 -1
- package/conformance/cases/c32-version-triple-a-restored/expected.json +6 -1
- package/conformance/cases/c33-limits-nested-carried-key/expected.json +18 -2
- package/conformance/cases/c34-skills-content-vs-manifest/expected.json +19 -3
- package/conformance/cases/c35-portable-domain-file-is-null/expected.json +3 -1
- package/conformance/cases/c36-skills-entry-without-content/expected.json +6 -2
- package/conformance/cases/c37-skills-field-null/expected.json +6 -2
- package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +3 -1
- package/conformance/cases/c39-hidden-file-in-config-d/expected.json +3 -1
- package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +11 -4
- package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +9 -3
- package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +39 -2
- package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +4 -1
- package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +3 -1
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +19 -10
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/meta.json +1 -1
- package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/expected.json +12 -5
- package/conformance/cases/c46-s811-host-name-url-userinfo-withheld/meta.json +9 -0
- package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/expected.json +23 -3
- package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/input.json +6 -0
- package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/meta.json +10 -0
- package/conformance/cases/c48-s935-model-headers-transported/expected.json +6 -1
- package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +3 -1
- package/conformance/cases/c50-s784-lane-host-legal/expected.json +3 -1
- package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +3 -1
- package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +3 -1
- package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +3 -1
- package/conformance/cases/c54-s784-lane-adb-legal/expected.json +3 -1
- package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +3 -1
- package/conformance/cases/c56-s784-lane-device-legal/expected.json +3 -1
- package/conformance/cases/c57-s784-g1-enabled-no-lane/expected.json +3 -1
- package/conformance/cases/c58-s784-g2-multitenant-host/expected.json +3 -1
- package/conformance/cases/c59-s784-g3-k8s-missing-image/expected.json +3 -1
- package/conformance/cases/c60-s784-g3-e2b-missing-key/expected.json +3 -1
- package/conformance/cases/c61-s784-g3-other-lane-key/expected.json +3 -1
- package/conformance/cases/c62-s784-g7a-session-tidb/expected.json +3 -1
- package/conformance/cases/c63-s784-host-two-replicas/expected.json +3 -1
- package/conformance/cases/c64-s784-multitenant-local-store/expected.json +3 -1
- package/conformance/cases/c65-s784-pg-without-db/expected.json +3 -1
- package/conformance/cases/c66-s784-memory-backend-mismatch/expected.json +3 -1
- package/conformance/cases/c67-s784-retired-field-loud/expected.json +3 -1
- package/conformance/cases/c67-s784-retired-field-loud/meta.json +1 -1
- package/conformance/cases/c68-s784-device-local-store/expected.json +3 -1
- package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +3 -1
- package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +3 -1
- package/conformance/cases/c71-one-scanner-write-reads-display-forms/expected.json +119 -0
- package/conformance/cases/c71-one-scanner-write-reads-display-forms/input.json +24 -0
- package/conformance/cases/c71-one-scanner-write-reads-display-forms/meta.json +10 -0
- package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +111 -0
- package/conformance/cases/c72-dangling-reference-write-seat-refuses/input.json +26 -0
- package/conformance/cases/c72-dangling-reference-write-seat-refuses/meta.json +6 -0
- package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +3 -1
- package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +3 -1
- package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +6 -1
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +11 -1
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +11 -1
- package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +16 -2
- package/conformance/cases/cli04-env-value-with-newline/expected.json +3 -1
- package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +6 -1
- package/conformance/cases/t01-absent-domain-key-not-null/expected.json +3 -1
- package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +29 -2
- package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +3 -1
- package/conformance/cases/z01-every-domain-empty-object/expected.json +8 -4
- package/conformance/cases/z01-every-domain-empty-object/input.json +0 -1
- package/conformance/cases/z01-every-domain-empty-object/meta.json +1 -1
- package/conformance/cases/z02-every-domain-unknown-key/expected.json +163 -23
- package/conformance/cases/z02-every-domain-unknown-key/input.json +0 -3
- package/conformance/cases/z02-every-domain-unknown-key/meta.json +1 -1
- package/dist/api/auth-bridge.d.ts +51 -181
- package/dist/api/auth-bridge.js +58 -33
- package/dist/api/auth.d.ts +37 -146
- package/dist/api/auth.js +16 -26
- package/dist/api/feedback.d.ts +87 -0
- package/dist/api/feedback.js +66 -0
- package/dist/api/pull-token.d.ts +32 -0
- package/dist/api/pull-token.js +88 -0
- package/dist/api/scopes.d.ts +82 -174
- package/dist/api/scopes.js +23 -18
- package/dist/atomic-write.d.ts +14 -0
- package/dist/atomic-write.js +60 -0
- package/dist/bundle.d.ts +15 -7
- package/dist/bundle.js +28 -27
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +149 -80
- package/dist/config-d-file.d.ts +14 -0
- package/dist/config-d-file.js +67 -0
- package/dist/config-fns.d.ts +87 -186
- package/dist/config-fns.js +207 -262
- package/dist/conformance/contract-adapter.d.ts +9 -2
- package/dist/conformance/contract-adapter.js +15 -7
- package/dist/conformance/corpus.d.ts +3 -0
- package/dist/conformance/corpus.js +5 -0
- package/dist/conformance/derive.d.ts +3 -0
- package/dist/conformance/index.d.ts +2 -2
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/run.js +67 -22
- package/dist/conformance/types.d.ts +31 -15
- package/dist/conformance/types.js +5 -0
- package/dist/conformance/warnings.d.ts +1 -1
- package/dist/conformance/warnings.js +1 -4
- package/dist/cross-domain.d.ts +66 -22
- package/dist/cross-domain.js +164 -93
- package/dist/cross-session.d.ts +29 -11
- package/dist/default-model.d.ts +43 -0
- package/dist/default-model.js +34 -0
- package/dist/execution-lane-index.d.ts +4 -8
- package/dist/execution-lane-index.js +4 -8
- package/dist/execution-lanes.d.ts +31 -8
- package/dist/execution-lanes.js +23 -5
- package/dist/file-edit.d.ts +3 -3
- package/dist/file-edit.js +11 -18
- package/dist/file-store.d.ts +7 -6
- package/dist/file-store.js +37 -50
- package/dist/fleet.d.ts +364 -762
- package/dist/fleet.js +277 -224
- package/dist/hooks.d.ts +1151 -4737
- package/dist/hooks.js +59 -15
- package/dist/index.d.ts +10 -4
- package/dist/index.js +13 -7
- package/dist/integer.d.ts +11 -0
- package/dist/integer.js +11 -0
- package/dist/issue-path.d.ts +10 -0
- package/dist/issue-path.js +10 -0
- package/dist/local-load.d.ts +1 -1
- package/dist/local-load.js +4 -14
- package/dist/loopback.d.ts +26 -0
- package/dist/loopback.js +54 -0
- package/dist/migration-receipt.d.ts +42 -0
- package/dist/migration-receipt.js +12 -0
- package/dist/one-time-migrations.d.ts +62 -0
- package/dist/one-time-migrations.js +828 -0
- package/dist/prompts-digest.d.ts +1 -1
- package/dist/prompts-digest.js +3 -2
- package/dist/reader.d.ts +2 -2
- package/dist/remote-exec.d.ts +45 -187
- package/dist/remote-exec.js +16 -10
- package/dist/resolve-roster.d.ts +18 -3
- package/dist/resolve-roster.js +28 -25
- package/dist/retired-envs.d.ts +24 -0
- package/dist/retired-envs.js +114 -0
- package/dist/retired-form.d.ts +31 -0
- package/dist/retired-form.js +28 -0
- package/dist/safety-merge-spec.d.ts +131 -191
- package/dist/safety-merge-spec.js +18 -14
- package/dist/scheduler-lifetime.d.ts +5 -0
- package/dist/scheduler-lifetime.js +7 -0
- package/dist/scheduler-store-migration.d.ts +14 -0
- package/dist/scheduler-store-migration.js +14 -0
- package/dist/scheduler-store-node.d.ts +28 -12
- package/dist/scheduler-store-node.js +32 -23
- package/dist/scheduler-store.d.ts +47 -14
- package/dist/scheduler-store.js +32 -61
- package/dist/secret-refs.d.ts +3 -3
- package/dist/secret-refs.js +1 -1
- package/dist/secret-scan.d.ts +14 -24
- package/dist/secret-scan.js +45 -23
- package/dist/sha256-core.d.ts +16 -0
- package/dist/sha256-core.js +138 -0
- package/dist/sha256.d.ts +6 -1
- package/dist/sha256.js +8 -98
- package/dist/skills-manifest.js +1 -1
- package/dist/types.d.ts +1509 -13536
- package/dist/types.js +526 -438
- package/dist/url-shape.d.ts +34 -0
- package/dist/url-shape.js +61 -0
- package/dist/web-admin-credential.d.ts +96 -0
- package/dist/{center-credential.js → web-admin-credential.js} +49 -46
- package/dist/web-admin-env.d.ts +33 -0
- package/dist/web-admin-env.js +33 -0
- package/dist/worker-row-verdict.d.ts +16 -0
- package/dist/worker-row-verdict.js +31 -0
- package/dist/workers-rows.d.ts +34 -0
- package/dist/workers-rows.js +42 -0
- package/dist/zod-unwrap.d.ts +11 -0
- package/dist/zod-unwrap.js +25 -0
- package/package.json +17 -9
- package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +0 -6
- package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +0 -6
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/expected.json +0 -49
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/input.json +0 -26
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/meta.json +0 -4
- package/conformance/migrate-workers/m02-g5-idempotent/expected.json +0 -38
- package/conformance/migrate-workers/m02-g5-idempotent/input.json +0 -33
- package/conformance/migrate-workers/m02-g5-idempotent/meta.json +0 -5
- package/conformance/migrate-workers/m03-g5-refused-no-lane/expected.json +0 -71
- package/conformance/migrate-workers/m03-g5-refused-no-lane/input.json +0 -42
- package/conformance/migrate-workers/m03-g5-refused-no-lane/meta.json +0 -4
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/expected.json +0 -52
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/input.json +0 -30
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/meta.json +0 -4
- package/conformance/migrate-workers/m05-row-verdict-reserved-env/expected.json +0 -82
- package/conformance/migrate-workers/m05-row-verdict-reserved-env/input.json +0 -68
- package/conformance/migrate-workers/m05-row-verdict-reserved-env/meta.json +0 -4
- package/dist/center-credential.d.ts +0 -116
- package/dist/issue-text.d.ts +0 -10
- package/dist/issue-text.js +0 -8
- package/dist/migrate-workers.d.ts +0 -71
- package/dist/migrate-workers.js +0 -351
- package/dist/migrate.d.ts +0 -255
- package/dist/migrate.js +0 -542
- /package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/input.json +0 -0
package/dist/types.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Config schema — the single source of truth for what
|
|
2
|
+
* Config schema — the single source of truth for what web-admin manages.
|
|
3
3
|
*
|
|
4
4
|
* Organized into DOMAINS (one top-level menu each). The UI, the store, and the service-facing API all
|
|
5
5
|
* speak these types. Use zod both to validate UI input AND to derive the TS types (one definition).
|
|
6
6
|
*
|
|
7
7
|
* Detail level is deliberate (the user asked for "详略设计"): `models` is the MVP — fully specified —
|
|
8
|
-
* while `scenarios`
|
|
9
|
-
* (see CHANGELOG.md; `integrations`/`access` were deleted in 0.6.0, EXPERT-REDESIGN §9
|
|
8
|
+
* while `scenarios` carries a real-but-skeletal schema to be fleshed out in later phases
|
|
9
|
+
* (see CHANGELOG.md; `integrations`/`access` were deleted in 0.6.0, EXPERT-REDESIGN §9; `runtime` was retired in 11.0.0 —
|
|
10
|
+
* see {@link RETIRED_DOMAINS}). Adding a
|
|
10
11
|
* field is: extend the zod schema here → the form + the effective-config API pick it up.
|
|
11
12
|
*
|
|
12
|
-
* ⚠️ SECURITY BOUNDARY (see the service-integration design note):
|
|
13
|
+
* ⚠️ SECURITY BOUNDARY (see the service-integration design note): web-admin stores **non-secret**
|
|
13
14
|
* config only. A model's API key / a backend token is NEVER stored here as a value — we store the NAME
|
|
14
15
|
* of the env var that holds it (`apiKeyEnv`, `tokenEnv`), and the agent-service reads the real secret
|
|
15
16
|
* from its own environment. So a leak of this DB never leaks a credential. This is **enforced** by the
|
|
16
17
|
* `ENV_NAME` schema (regex) on every `*Env` field — a pasted secret value is rejected, not just discouraged.
|
|
17
18
|
*/
|
|
18
19
|
import { z } from "zod";
|
|
20
|
+
import { INTEGER } from "./integer.js";
|
|
19
21
|
import { MAX_EXTRA_BODY_DEPTH } from "./hash.js"; // reconcile the extraBody depth cap with the hash cap (R18)
|
|
20
22
|
import { DispatchPolicy, FLEET_GENERATION_ENV, FLEET_SCOPE_ENV } from "./fleet.js"; // WorkerSpec.fleet.dispatch (scheduling framework D3)
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
23
|
+
import { isLoopbackAuthHost } from "./loopback.js";
|
|
24
|
+
import { httpUrlFlaws, httpUrlRule } from "./url-shape.js"; // 11.0.0: THE URL shape judge // 11.0.0: the one narrow「本机」predicate (auth relaxation)
|
|
25
|
+
import { secretScanForms } from "./secret-scan.js"; // package-private normalization — the one set of forms both scanners read (not in the barrel)
|
|
26
|
+
import { WEB_ADMIN_ENV } from "./web-admin-env.js"; // the three connection keys the config renderer writes are reserved worker env
|
|
27
|
+
import { retiredEnvRefusal } from "./retired-envs.js"; // the worker env write gate asks the retired-env tombstone table too (one table, one refusal sentence)
|
|
28
|
+
import { Sha256Digest } from "./sha256.js"; // the one `sha256:<hex>` digest form
|
|
29
|
+
import { EXECUTION_LANES, EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS, retiredLaneWordRefusal } from "./execution-lanes.js"; // 7.0.0 M-R2: the lane / store vocabulary (one exported table)
|
|
30
|
+
import { LANE_ENV_OWNER, isMigratableEnvKey, laneEnvSeat, laneRowOf } from "./execution-lane-index.js"; // package-private indexes derived from that table (not in the barrel)
|
|
31
|
+
import { migrationSignpost, retiredFormSentence, retiredKey } from "./retired-form.js"; // 11.0.0: the ONE retired-form sentence + key tombstone (package-private)
|
|
24
32
|
// COARSE, best-effort literal-secret SHAPE detector (NOT exhaustive — the real boundary is the env-NAME schema
|
|
25
33
|
// on structured fields). Rejected in free-form value fields (worker `env`, model `extraBody`), URL components,
|
|
26
34
|
// 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-
|
|
@@ -30,9 +38,8 @@ import { LANE_ENV_OWNER, MIGRATION_SIGNPOST, isMigratableEnvKey, laneEnvSeat, la
|
|
|
30
38
|
// 0.1.32: this is the root fix for the public-key false-positive — replaces the 0.1.31 name-based exemption,
|
|
31
39
|
// which review flagged as a bypass (a private key misnamed `*_PUBKEY` skipped scanning entirely).
|
|
32
40
|
/** A private-key PEM header (`-----BEGIN [RSA |EC |OPENSSH |ENCRYPTED ]PRIVATE KEY`), unanchored — the ONE definition
|
|
33
|
-
* of that shape, read ANYWHERE in a string by BOTH
|
|
34
|
-
* (`scanForSecret`,
|
|
35
|
-
* normalized form). {@link SECRET_LITERAL_RE} is also composed from it (anchored, as one of its branches — the exported per-token
|
|
41
|
+
* of that shape, read ANYWHERE in a string by BOTH sides of the one scanner: the validation detector {@link containsSecretToken}
|
|
42
|
+
* (`scanForSecret`, 10.0.0) and the display redactor `redactSecrets` (config-fns) — on the same normalized forms since 11.0.0. {@link SECRET_LITERAL_RE} is also composed from it (anchored, as one of its branches — the exported per-token
|
|
36
43
|
* literal shape). The header carries spaces, so the token-splitting passes can never isolate it; before 10.0.0 the validation side
|
|
37
44
|
* therefore saw it only at the very start of a string, and a PEM behind a newline, a `key: ` prefix or any other text rode past
|
|
38
45
|
* every free-text write port while the display side already withheld it (a `key=`-prefixed percent-encoded
|
|
@@ -63,6 +70,11 @@ function scanForSecret(s) {
|
|
|
63
70
|
// the token; the JWT dot is handled above). R12. */
|
|
64
71
|
if (/(?:^|[^A-Za-z0-9_-])(?:bearer|basic)[ \t]+\S{8,4096}/i.test(s))
|
|
65
72
|
return true;
|
|
73
|
+
// The same scheme followed by an ENCODED space (`+` form-encoding, `%20` percent-encoding) and any token at all (S-797, 11.0.0):
|
|
74
|
+
// an encoded space is the mark of an encoded header VALUE (`note:Basic+dTpw`, `x-Bearer%20abc123`), not prose, so the 8-char floor
|
|
75
|
+
// the plain-space arm keeps against prose (`Basic auth`) does not apply. The boundary admits `-` / `_` (`x-Bearer%20…`). Bounded.
|
|
76
|
+
if (/(?:^|[^A-Za-z0-9])(?:bearer|basic)(?:\+|%20)[^\s+%]{1,4096}/i.test(s))
|
|
77
|
+
return true;
|
|
66
78
|
// a JWT (3 dot-joined base64url segments, header starts `eyJ`) searched anywhere — so a mid-string one
|
|
67
79
|
// survives the tokenizer's dot-split. ReDoS-SAFE: a token-boundary prefix `(?:^|[^A-Za-z0-9_-])` (one start
|
|
68
80
|
// per word, so `"eyJ".repeat(n)` is a single anchored start = linear, not the quadratic an unanchored scan
|
|
@@ -81,18 +93,43 @@ function scanForSecret(s) {
|
|
|
81
93
|
* fs path — so the worker dies at exec/boot, not at config edit. A NUL is never legitimate in a command / path
|
|
82
94
|
* / host / image, so reject it on every exec-bound string field (mcp stdio + the whole remoteExec contract). R20. */
|
|
83
95
|
export const hasNul = (s) => s.includes("\0");
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
96
|
+
/** The one rendering of a quoted sentence: `template[0] + show(operands[0]) + template[1] + … + template[n]`. `show` defaults to
|
|
97
|
+
* the value as written (that is how `message` is minted); a sanitizer / log boundary passes its own. */
|
|
98
|
+
export function renderQuotedSentence(p, show = String) {
|
|
99
|
+
return p.template.reduce((out, part, i) => (i === 0 ? part : `${out}${show(p.operands[i - 1])}${part}`), "");
|
|
100
|
+
}
|
|
101
|
+
const fixed = (text) => ({ fixedText: text });
|
|
102
|
+
/** Mint a custom issue's `message` + `params` from a tagged template (`buildQuotedIssue\`duplicate model "${name}"\``): every
|
|
103
|
+
* interpolation is an operand unless wrapped in {@link fixed} (then it is template text). The ONE minting point of quoted
|
|
104
|
+
* sentences in this file — a template literal written straight into `message` would put a value into a sentence with no record
|
|
105
|
+
* of where it is (a source gate pins that none is left). */
|
|
106
|
+
function buildQuotedIssue(strings, ...parts) {
|
|
107
|
+
const template = [strings[0]];
|
|
108
|
+
const operands = [];
|
|
109
|
+
parts.forEach((part, i) => {
|
|
110
|
+
if (typeof part === "object")
|
|
111
|
+
template[template.length - 1] += part.fixedText + strings[i + 1];
|
|
112
|
+
else {
|
|
113
|
+
operands.push(part);
|
|
114
|
+
template.push(strings[i + 1]);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const params = { template, operands };
|
|
118
|
+
return { message: renderQuotedSentence(params), params };
|
|
119
|
+
}
|
|
120
|
+
/** Does `s` contain a secret-shaped token? —— **the one scanner** (11.0.0, S-1274): the write side (this detector, behind every
|
|
121
|
+
* free-text write port — NON_SECRET_URL, mcp stdio, execution coords, ssh login, worker env, extraBody, …) and the display side
|
|
122
|
+
* (`redactSecrets`) read the SAME normalized forms ({@link secretScanForms} in the package-private `./secret-scan`: the raw string,
|
|
123
|
+
* up to three tolerant percent-decoded layers, each layer's boundary reading, each of those read the form-encoded way with `+` as a
|
|
124
|
+
* space) through the SAME shape table ({@link scanForSecret}). A string whose normalization did not finish (a fourth layer would
|
|
125
|
+
* still decode something) counts as secret-shaped: what cannot be normalized is not judged clean. Before 11.0.0 this detector read
|
|
126
|
+
* only the percent layers, so a header spelled only in a deeper layer or with `+` for its spaces passed write validation while the
|
|
127
|
+
* display side withheld it. We do NOT fail-closed on a bare `%` — that would false-reject legit values like `100%` / `%s`. A secret
|
|
128
|
+
* glued to a malformed escape (`%ZZsk-`) is an accepted coarse-heuristic gap (the real guarantee is the env-NAME boundary on
|
|
129
|
+
* structured fields). R11/R13/R14. */
|
|
94
130
|
export function containsSecretToken(s) {
|
|
95
|
-
|
|
131
|
+
const scan = secretScanForms(s);
|
|
132
|
+
return scan.unresolved || scan.forms.some((form) => scanForSecret(form));
|
|
96
133
|
}
|
|
97
134
|
/** Scan a URL's PATH + query + fragment (KEYS and values, decoded, tokenized) for a secret-shaped literal.
|
|
98
135
|
* Userinfo is handled separately (NON_SECRET_URL bans it; the ssh username is scanned by `containsSecretToken`). Round-4/5. */
|
|
@@ -126,24 +163,57 @@ export function urlQueryFragmentHasSecret(u) {
|
|
|
126
163
|
}
|
|
127
164
|
return false;
|
|
128
165
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
/** The parts every {@link NON_SECRET_URL} seat forbids: userinfo (a credential in the URL — it would store a secret VALUE in the
|
|
167
|
+
* config and ship it on /effective, defeating the env-NAME boundary) and a fragment (never sent; anything a consumer appends
|
|
168
|
+
* after it is dropped with it — the engine and server refuse a model route root that carries one, so a stored one only fails
|
|
169
|
+
* later, on the worker). A query is a legitimate request parameter (`?api-version=…`) and stays. */
|
|
170
|
+
const NON_SECRET_URL_FORBIDS = ["userinfo", "fragment"];
|
|
171
|
+
/** The refusal sentence of one flaw at a {@link NON_SECRET_URL} seat: the judge's rule sentence, and — for the one flaw that stored data
|
|
172
|
+
* can carry from before 11.0.0 (a fragment) at a seat the one-time migration reaches — the signpost to the migration that strips
|
|
173
|
+
* it, joined the way the other signposted refusals join it (`<rule>; <signpost>`). Closed over the judge's flaw words: a new word is
|
|
174
|
+
* a compile error here. */
|
|
175
|
+
function nonSecretUrlSentence(flaw, reachedByMigration) {
|
|
176
|
+
switch (flaw) {
|
|
177
|
+
case "fragment":
|
|
178
|
+
return reachedByMigration ? `${httpUrlRule(flaw)}; ${migrationSignpost("SS-M7", "11.0.0")}` : httpUrlRule(flaw);
|
|
179
|
+
case "not-http-url":
|
|
180
|
+
case "userinfo":
|
|
181
|
+
case "query":
|
|
182
|
+
return httpUrlRule(flaw);
|
|
183
|
+
default: {
|
|
184
|
+
const unhandled = flaw;
|
|
185
|
+
throw new Error("nonSecretUrlSentence: unhandled flaw word", { cause: unhandled });
|
|
186
|
+
}
|
|
145
187
|
}
|
|
146
|
-
}
|
|
188
|
+
}
|
|
189
|
+
/** One judge, two seat kinds — the only difference is the fragment refusal's migration signpost. `reachedByMigration` = the seat sits in a
|
|
190
|
+
* stored domain document, which the one-time migration SS-M7 rewrites (it finds its seats by this schema's identity, so "carries the
|
|
191
|
+
* signpost" and "is rewritten by SS-M7" cannot drift apart). */
|
|
192
|
+
function createNonSecretUrl(reachedByMigration) {
|
|
193
|
+
return z.string().superRefine((s, ctx) => {
|
|
194
|
+
const flaws = httpUrlFlaws(s, NON_SECRET_URL_FORBIDS);
|
|
195
|
+
for (const flaw of flaws)
|
|
196
|
+
ctx.addIssue({ code: "custom", message: nonSecretUrlSentence(flaw, reachedByMigration), params: { flaw } });
|
|
197
|
+
if (flaws.length === 0 && urlQueryFragmentHasSecret(new URL(s))) {
|
|
198
|
+
ctx.addIssue({ code: "custom", message: "must not carry a secret-shaped value in its path or query — secrets go in the *Env (env-NAME) fields" });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/** A URL handed to `fetch` that carries no credential: judged by THE URL shape judge ({@link httpUrlFlaws}, `./url-shape`) — a
|
|
203
|
+
* hierarchical http(s) URL with neither userinfo nor a fragment (an empty `#` included, 11.0.0) — and with no secret-shaped value
|
|
204
|
+
* in its path or query ({@link urlQueryFragmentHasSecret}). One issue per flaw, its message the judge's rule sentence (no value
|
|
205
|
+
* echoed; a fragment's also names its migration, {@link nonSecretUrlSentence}); `params.flaw` carries the rule word.
|
|
206
|
+
*
|
|
207
|
+
* This is the seat of every URL in a stored DOMAIN document (models `baseUrl`, mcp `url`, `sessionMirror.engineUrl`, workers
|
|
208
|
+
* `gitApiBaseUrl`). A URL outside the domain documents — `remote-exec.json`'s endpoints, the web-admin credential envelope's
|
|
209
|
+
* `issuer` — uses {@link NON_SECRET_URL_NO_MIGRATION}.
|
|
210
|
+
*
|
|
211
|
+
* The value is kept EXACTLY as written: nothing here trims or normalizes it (zod's own `.url()` would hand back a rewritten string). */
|
|
212
|
+
export const NON_SECRET_URL = createNonSecretUrl(true);
|
|
213
|
+
/** {@link NON_SECRET_URL}'s judge for a seat no one-time migration reaches (not a domain document): identical rules and rule
|
|
214
|
+
* sentences, but a fragment's refusal carries no migration signpost — pointing at a migration that cannot rewrite the value would
|
|
215
|
+
* send the operator to a command that does nothing for it. */
|
|
216
|
+
export const NON_SECRET_URL_NO_MIGRATION = createNonSecretUrl(false);
|
|
147
217
|
/** Validate a free-form `extraBody`: returns true = UNSAFE (reject). A GETTER-FREE, descriptor-based, bounded
|
|
148
218
|
* walk that rejects — WITHOUT ever invoking a getter (a malicious getter could throw a secret-bearing message,
|
|
149
219
|
* or return different values across the validate vs persist JSON.stringify calls) — anything that isn't plain
|
|
@@ -228,19 +298,19 @@ function vetFreeFormJson(v, field) {
|
|
|
228
298
|
rawUnsafe = extraBodyUnsafe(v);
|
|
229
299
|
}
|
|
230
300
|
catch {
|
|
231
|
-
return { ok: false,
|
|
301
|
+
return { ok: false, ...buildQuotedIssue `${fixed(field)} could not be validated (reflective access threw)` }; // R11
|
|
232
302
|
}
|
|
233
303
|
if (rawUnsafe)
|
|
234
|
-
return { ok: false,
|
|
304
|
+
return { ok: false, ...buildQuotedIssue `${fixed(field)}: a key/value is a secret shape, non-JSON (Infinity/function/Map), an accessor, cyclic, or too deep` };
|
|
235
305
|
let clone;
|
|
236
306
|
try {
|
|
237
307
|
clone = JSON.parse(JSON.stringify(v)); // the ONLY serialization — its result is what we scan AND persist
|
|
238
308
|
}
|
|
239
309
|
catch {
|
|
240
|
-
return { ok: false,
|
|
310
|
+
return { ok: false, ...buildQuotedIssue `${fixed(field)} is not JSON-serializable` };
|
|
241
311
|
}
|
|
242
312
|
if (extraBodyUnsafe(clone))
|
|
243
|
-
return { ok: false,
|
|
313
|
+
return { ok: false, ...buildQuotedIssue `${fixed(field)}: its serialized form carries a secret` };
|
|
244
314
|
return { ok: true, value: clone };
|
|
245
315
|
}
|
|
246
316
|
/** The transform of every **parse-transparent free-form** key (`z.unknown().optional().transform(createFreeFormTransform(field))`:
|
|
@@ -253,7 +323,7 @@ function createFreeFormTransform(field) {
|
|
|
253
323
|
return undefined;
|
|
254
324
|
const vetted = vetFreeFormJson(v, field);
|
|
255
325
|
if (!vetted.ok) {
|
|
256
|
-
ctx.addIssue({ code:
|
|
326
|
+
ctx.addIssue({ code: "custom", message: vetted.message, params: vetted.params });
|
|
257
327
|
return z.NEVER;
|
|
258
328
|
}
|
|
259
329
|
return vetted.value;
|
|
@@ -264,14 +334,14 @@ function createFreeFormTransform(field) {
|
|
|
264
334
|
export const K8S_QUANTITY = z.string().regex(/^\d+(\.\d+)?(m|[kKMGTPE]i?)?$/, 'must be a k8s quantity like "250m", "512Mi", "1Gi"');
|
|
265
335
|
/** A TCP port: 1–65535. ONE shared schema so every port field (workers.runtime.store.db.port, ssh.port, …) bounds
|
|
266
336
|
* the SAME way — a port of 65536/0/-1 is config corruption, reject it at validation, not at connect time. R16. */
|
|
267
|
-
export const TCP_PORT =
|
|
337
|
+
export const TCP_PORT = INTEGER.positive().max(65535);
|
|
268
338
|
/** Per-1M-token price ($/Mtok). Maps to the service's MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE
|
|
269
339
|
* (budget.ts buildPricing → per-principal cost gate). Lives on the models catalog (ModelEntry.cost) — the
|
|
270
340
|
* single pricing source (worker.model, the old second consumer, was deleted in 0.7.0). A free model declares
|
|
271
341
|
* 0s (core 1.26: missing cost ≠ cheapest). */
|
|
272
|
-
// `.finite()` on every $/Mtok field
|
|
273
|
-
// JSON.stringify's to `null` on write → a later read fails "expected number,
|
|
274
|
-
// WRONG time).
|
|
342
|
+
// `.finite()` on every $/Mtok field states the rule HERE rather than leaning on whether the library's `number()` admits
|
|
343
|
+
// Infinity (zod 3's did): a non-finite number JSON.stringify's to `null` on write → a later read fails "expected number,
|
|
344
|
+
// received null" (fail-closed at the WRONG time). Integer fields ({@link INTEGER}) are Infinity-safe by their own rule. Round-4.
|
|
275
345
|
export const ModelCost = z.object({
|
|
276
346
|
input: z.number().finite().nonnegative(),
|
|
277
347
|
output: z.number().finite().nonnegative(),
|
|
@@ -282,12 +352,12 @@ export const ModelCost = z.object({
|
|
|
282
352
|
// Maps 1:1 onto the worker service's config.models / config.roles / @-model allowlist (core 1.24 role
|
|
283
353
|
// map). `name` is the catalog key AND the `@name` handle; `id` is the gateway's model id.
|
|
284
354
|
// ── sealed-box 密钥托管(0.10.5,产品拍板铁律修订:「不存 secret 值」精化为「不存**可解明文**」)──
|
|
285
|
-
//
|
|
286
|
-
// XSalsa20-Poly1305)加密后提交,
|
|
287
|
-
// 明文 key 绝不出浏览器 =
|
|
355
|
+
// web-admin 可存"不可解密的密文":浏览器端用执行域公钥 libsodium sealed-box(crypto_box_seal,X25519+
|
|
356
|
+
// XSalsa20-Poly1305)加密后提交,web-admin 无私钥永远解不开;caps/config 原样下发,执行侧私钥解密注入 env。
|
|
357
|
+
// 明文 key 绝不出浏览器 = web-admin 服务端代码路径里绝不出现明文。
|
|
288
358
|
/** sealed-box 算法标识(v1 唯一取值)。z.literal 钉死:换算法=换值=显式契约演进,不许静默漂移。 */
|
|
289
359
|
export const SEALED_BOX_ALG = "libsodium-sealedbox-x25519";
|
|
290
|
-
/** base64 形状底线(标准字母表,可带 padding)。**只校验形状不校验内容**——
|
|
360
|
+
/** base64 形状底线(标准字母表,可带 padding)。**只校验形状不校验内容**——web-admin 是托管方不是消费方,
|
|
291
361
|
* 解密/解不开是执行侧运行时的事;这里只挡"根本不是 base64"的写入事故。 */
|
|
292
362
|
export const BASE64_SHAPE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
293
363
|
/** 接入模型的 sealed-box 托管密文(默认模式「直接贴 key」的存储形态)。
|
|
@@ -295,19 +365,19 @@ export const BASE64_SHAPE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
|
295
365
|
* 真实 API key ≤ 几百字节;`.max(8192)` 只防 blob 滥用,不是格式承诺。
|
|
296
366
|
* - `publicKeyId`:加密时用的执行域公钥 id(指向 execution.publicKeys[].publicKeyId)——执行侧凭它选私钥;
|
|
297
367
|
* 轮换后旧密文仍带旧 id,可解性由执行侧留存的旧私钥决定。
|
|
298
|
-
* - **write-only 纪律(消费方约定)**:
|
|
368
|
+
* - **write-only 纪律(消费方约定)**:web-admin 的 GET 面永不回 ciphertext 本体(只回 publicKeyId+设置
|
|
299
369
|
* 时间);密文字段绝不进任何日志。schema 层不执法读面,这是 API 层的契约义务。
|
|
300
370
|
* - **与 `apiKeyEnv` 的互斥语义**:见 ModelEntry.sealedApiKey JSDoc(两者都设 = sealed 优先)。 */
|
|
301
371
|
export const SealedApiKey = z.object({
|
|
302
|
-
/** sealed-box 密文,base64(形状校验 only——内容对
|
|
372
|
+
/** sealed-box 密文,base64(形状校验 only——内容对 web-admin 永远不透明)。 */
|
|
303
373
|
ciphertext: z.string().min(1).max(8192).regex(BASE64_SHAPE, "ciphertext must be base64"),
|
|
304
374
|
/** 加密所用执行域公钥的 id(execution.publicKeys 登记表的键)。 */
|
|
305
375
|
publicKeyId: z.string().min(1).max(128),
|
|
306
376
|
/** 算法标识,v1 恒为 {@link SEALED_BOX_ALG}。 */
|
|
307
377
|
alg: z.literal(SEALED_BOX_ALG),
|
|
308
|
-
/** 密文设置时刻(ISO)。
|
|
378
|
+
/** 密文设置时刻(ISO)。web-admin 写面 server 端盖章(projects.registeredAt 同款纪律);write-only
|
|
309
379
|
* 的 masked GET 面靠它+publicKeyId 展示「已于 X 设置」——密文本体永不回读。optional:执行侧
|
|
310
|
-
* 解密不依赖它,非
|
|
380
|
+
* 解密不依赖它,非 web-admin 写面(本地 TOC 手写配置)缺省合法。 */
|
|
311
381
|
setAt: z.string().min(1).optional(),
|
|
312
382
|
});
|
|
313
383
|
/**
|
|
@@ -367,6 +437,10 @@ export const PARSE_TRANSPARENT_KEYS = [
|
|
|
367
437
|
why: "它的形由同一条 override 的 `key` 决定(一个 key 一种形),执法点在读那个 key 的消费方;在这里写死形等于把所有可覆盖键的形表搬进本包。",
|
|
368
438
|
},
|
|
369
439
|
];
|
|
440
|
+
/** A catalog model's price / capability tier — fuels core 1.25 `selectModel({ tier })` role resolution. ONE closed set for both
|
|
441
|
+
* places that name it (`ModelEntry.tier`, `RoleTarget.select.tier`). Not the tier-group ladder of {@link TierBindings}
|
|
442
|
+
* (`ultra` … `lite`), which binds model NAMES to rungs. */
|
|
443
|
+
export const ModelTier = z.enum(["cheap", "standard", "premium"]);
|
|
370
444
|
export const ModelEntry = z.object({
|
|
371
445
|
/** Catalog key + `@name` mention handle (stable, url/word-safe), e.g. "model-a". */
|
|
372
446
|
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
@@ -384,27 +458,26 @@ export const ModelEntry = z.object({
|
|
|
384
458
|
* 专家模式(env-NAME 引用,web 面折叠进「高级」);默认模式见 {@link ModelEntry}.sealedApiKey。 */
|
|
385
459
|
apiKeyEnv: ENV_NAME.optional(),
|
|
386
460
|
/** sealed-box 托管密文(0.10.5 默认模式「直接贴 key」;形状见 {@link SealedApiKey})。
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* ABSENT = 未托管(纯 env-NAME 现状),零迁移。 */
|
|
461
|
+
* **二选一**(11.0.0):一个模型的钥匙只有一种写法 —— `sealedApiKey` 与 `apiKeyEnv` 同设 ⇒ 本条目被拒(读写同一只判据,见本
|
|
462
|
+
* schema 的 superRefine)。切换写法的写者负责清掉另一字段。11.0.0 之前存下的双设条目由一次性迁移 SS-M5 改写(删 `apiKeyEnv`
|
|
463
|
+
* = 当年的生效读法「sealed 赢」)。ABSENT = 未托管(纯 env-NAME 现状)。 */
|
|
391
464
|
sealedApiKey: SealedApiKey.optional(),
|
|
392
465
|
/** Price/capability tier — fuels core 1.25 `selectModel({ tier })` role resolution. */
|
|
393
|
-
tier:
|
|
466
|
+
tier: ModelTier.default("standard"),
|
|
394
467
|
reasoning: z.boolean().default(false),
|
|
395
468
|
vision: z.boolean().default(false),
|
|
396
|
-
contextWindow:
|
|
397
|
-
maxTokens:
|
|
398
|
-
/** Auto-compaction trigger threshold (tokens) for this model —
|
|
469
|
+
contextWindow: INTEGER.positive().optional(),
|
|
470
|
+
maxTokens: INTEGER.positive().optional(),
|
|
471
|
+
/** Auto-compaction trigger threshold (tokens) for this model — web-admin 显式下发时本地 FileConfigStore
|
|
399
472
|
* lane 必须原样保真(0.10.10:strip 导致 parse 期即丢)。消费方=壳/引擎的 compaction
|
|
400
473
|
* 配置(safety-merge autoCompaction seam);ABSENT = 消费方自身默认,零迁移。 */
|
|
401
|
-
autoCompactTokens:
|
|
474
|
+
autoCompactTokens: INTEGER.positive().optional(),
|
|
402
475
|
/** Chars-per-token estimate for local token estimation (may be fractional, e.g. 3.5;CJK-heavy 模型
|
|
403
476
|
* 比英文低)。与 autoCompactTokens 同班车(0.10.10 [830]④a);ABSENT = 消费方自身默认,零迁移。 */
|
|
404
477
|
charsPerToken: z.number().finite().positive().optional(),
|
|
405
478
|
/** Per-model prompt-guidance lines(装配 behavior 槽的 per-model 缓解句)。形对齐 core 消费面
|
|
406
479
|
* `model.promptGuidance?: string[]`(engine/llm types;prompt-assembly 战役起装配位固定于
|
|
407
|
-
* volatile 尾前)。web/
|
|
480
|
+
* volatile 尾前)。web/web-admin models 域先行小切片(不等 pack 大架构)。Non-secret model
|
|
408
481
|
* metadata(cost/tier 同类);ABSENT = 无 per-model 句,零迁移。 */
|
|
409
482
|
promptGuidance: z.array(z.string()).optional(),
|
|
410
483
|
/** $/Mtok. A genuinely-free model still declares 0s (core 1.26: missing cost ≠ cheapest). */
|
|
@@ -430,12 +503,12 @@ export const ModelEntry = z.object({
|
|
|
430
503
|
return undefined;
|
|
431
504
|
const proto = typeof v === "object" && v !== null ? Object.getPrototypeOf(v) : undefined;
|
|
432
505
|
if (typeof v !== "object" || v === null || Array.isArray(v) || (proto !== Object.prototype && proto !== null)) {
|
|
433
|
-
ctx.addIssue({ code:
|
|
506
|
+
ctx.addIssue({ code: "custom", message: "extraBody must be a plain JSON object" });
|
|
434
507
|
return z.NEVER;
|
|
435
508
|
}
|
|
436
509
|
const vetted = vetFreeFormJson(v, "extraBody");
|
|
437
510
|
if (!vetted.ok) {
|
|
438
|
-
ctx.addIssue({ code:
|
|
511
|
+
ctx.addIssue({ code: "custom", message: vetted.message, params: vetted.params });
|
|
439
512
|
return z.NEVER;
|
|
440
513
|
}
|
|
441
514
|
return vetted.value;
|
|
@@ -481,7 +554,10 @@ export const ModelEntry = z.object({
|
|
|
481
554
|
// suggested this; enforce it in the SCHEMA so a direct PUT / local TOC config can't ship a mis-paired model.
|
|
482
555
|
// (provider:"anthropic" must set api explicitly; gateway/vllm keep the openai-completions default.) Round-4.
|
|
483
556
|
if ((m.provider === "anthropic") !== (m.api === "anthropic-messages"))
|
|
484
|
-
ctx.addIssue({ code:
|
|
557
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `provider "${m.provider}" + api "${m.api}" mismatch — provider:"anthropic" ⟺ api:"anthropic-messages"`, path: ["api"] });
|
|
558
|
+
// 钥匙二选一(11.0.0):同设 = 两个答案回答「这个模型用哪把钥匙」。此前「sealed 压过 env」只活在注里与消费方三处优先级臂上。
|
|
559
|
+
if (m.sealedApiKey !== undefined && m.apiKeyEnv !== undefined)
|
|
560
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `sealedApiKey and apiKeyEnv are both set — a model's key has ONE form: keep sealedApiKey (a pasted key) or apiKeyEnv (an env-NAME), not both; ${fixed(migrationSignpost("SS-M5", "11.0.0"))}`, path: ["apiKeyEnv"] });
|
|
485
561
|
});
|
|
486
562
|
// `verifier` (2026-06-11, SERVICE-RESPONSE-…-PHASE2): real consumer = core's verification gate
|
|
487
563
|
// (runWithVerification/resumeWithVerification resolve the verifier sub-task model via this role). Fallback
|
|
@@ -498,11 +574,26 @@ export const ModelRole = z.enum(["default", "summarize", "subagent", "team", "sy
|
|
|
498
574
|
/** A role resolves either to a model NAME, or by capability/price (core 1.25 `{ select }`). */
|
|
499
575
|
export const RoleTarget = z.union([
|
|
500
576
|
z.object({ model: z.string().min(1) }),
|
|
501
|
-
z.object({ select: z.object({ tier:
|
|
577
|
+
z.object({ select: z.object({ tier: ModelTier.optional(), needs: z.array(z.enum(["vision", "reasoning"])).optional(), prefer: z.enum(["cheapest", "premium"]).optional() }) }),
|
|
502
578
|
]);
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
579
|
+
/** {@link AtMention} 的模式闭集:`all` = 每个启用的模型都可 `@`;`none` = 一个都不许;`only` = 只许 `models` 里点名的那几个。 */
|
|
580
|
+
export const AT_MENTION_MODES = ["all", "none", "only"];
|
|
581
|
+
/** 「`@` 能点名哪些模型」(11.0.0,取代 `atModelAllowlist`)。义由 `mode` 说,不由数组空不空说:旧形一个数组靠「缺席 / 空 / 非空」
|
|
582
|
+
* 三种在场性背三种义,「一个都不许」表达不了,三个消费方各写了一只绕行。`only` 的名单至少一个名(空名单的义就是 `none`,一个义一种写法)。
|
|
583
|
+
* 全局位(`models.atMention`)缺省 `{mode:"all"}`;roster 位(`rosters[].atMention`)缺席 = 继承全局。收窄到某台 worker 的模型集
|
|
584
|
+
* 用 {@link narrowAtMention}(`./resolve-roster`)—— 一只读法。 */
|
|
585
|
+
export const AtMention = z.discriminatedUnion("mode", [
|
|
586
|
+
z.object({ mode: z.literal("all") }),
|
|
587
|
+
z.object({ mode: z.literal("none") }),
|
|
588
|
+
z.object({ mode: z.literal("only"), models: z.array(z.string().min(1)).min(1) }),
|
|
589
|
+
]);
|
|
590
|
+
/** `models.default` 的墓碑(11.0.0,N-7):缺省模型只有 `roles.default` 一位。 */
|
|
591
|
+
const MODELS_DEFAULT_RETIRED = { retiredIn: "11.0.0", now: "roles.default ({model:<catalog name>}) — the one default-model seat", migration: "SS-M9" };
|
|
592
|
+
/** `atModelAllowlist` 的墓碑(11.0.0):全局位与 roster 位同一行。 */
|
|
593
|
+
const AT_MODEL_ALLOWLIST_RETIRED = { retiredIn: "11.0.0", now: "atMention ({mode:\"all\"} | {mode:\"none\"} | {mode:\"only\",models:[…]})", migration: "SS-M6" };
|
|
594
|
+
// ── sema 档位(tiers)与档位组(tier groups)──(web-admin 半场, core 1.265 档位车) ──────
|
|
595
|
+
// core 侧接缝 = `RunnerDeps.tiers`(档位词→模型绑定的**单表**, catalog 单点增广 expandTiers);web-admin 侧
|
|
596
|
+
// 把「多套命名预配置」存成 tierGroups,下发时只解析 ACTIVE 那一套成单表(组切换=web-admin 换表下发,零新
|
|
506
597
|
// 引擎机制)。与 `ModelEntry.tier`(cheap/standard/premium 价格分级, core 1.25 selectModel)是两回事——
|
|
507
598
|
// 那是能力/价格元数据,这是用户面的模型预配置绑定;词表也不同(max/pro/flash/lite + ultra 预留)。
|
|
508
599
|
/** 档位词→catalog 模型 `name` 的绑定。值=目录键(string 引用,不 enum——开放性照 execution
|
|
@@ -519,23 +610,25 @@ export const TierBindings = z.object({
|
|
|
519
610
|
export const TierGroup = z.object({
|
|
520
611
|
/** Group key (same charset as catalog names), e.g. "daily" / "heavy". */
|
|
521
612
|
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
522
|
-
tiers: TierBindings.
|
|
613
|
+
tiers: TierBindings.prefault({}),
|
|
523
614
|
notes: z.string().optional(),
|
|
524
615
|
});
|
|
525
616
|
export const ModelsConfig = z
|
|
526
617
|
.object({
|
|
527
|
-
models: z.array(ModelEntry).
|
|
528
|
-
/**
|
|
529
|
-
* (
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
618
|
+
models: z.array(ModelEntry).prefault([]),
|
|
619
|
+
/** role → target. Unset roles fall back to `default` (core fallback chain). `roles.default` is THE default model — the one seat
|
|
620
|
+
* (11.0.0, N-7: the structural `models.default` key is retired; it and `roles.default` used to answer the same question twice,
|
|
621
|
+
* the main task ran the first and the core `default` role resolved the second). Which catalog entry is the default is
|
|
622
|
+
* `resolveCatalogDefaultModel` (`./default-model`; a `{select}` default is handed back as-is for the engine to resolve). A stored
|
|
623
|
+
* `default` key is moved here by the one-time migration SS-M9. */
|
|
624
|
+
roles: z.partialRecord(ModelRole, RoleTarget).prefault({}),
|
|
625
|
+
/** Which model names an end user may `@`-mention({@link AtMention});缺省 `{mode:"all"}`。 */
|
|
626
|
+
atMention: AtMention.prefault({ mode: "all" }),
|
|
627
|
+
/** retired 键墓碑(11.0.0):在场即拒,拒句点名一次性迁移 SS-M6。 */
|
|
628
|
+
atModelAllowlist: retiredKey("atModelAllowlist", AT_MODEL_ALLOWLIST_RETIRED),
|
|
629
|
+
default: retiredKey("models.default", MODELS_DEFAULT_RETIRED),
|
|
537
630
|
/** 命名档位组预配置。只有 ACTIVE 组的绑定表会下发为引擎的 tiers 单表。 */
|
|
538
|
-
tierGroups: z.array(TierGroup).
|
|
631
|
+
tierGroups: z.array(TierGroup).prefault([]),
|
|
539
632
|
/** 当前生效的档位组 name。未设(或无组)→ 不下发 tiers,引擎档位层恒惰性。 */
|
|
540
633
|
activeTierGroup: z.string().optional(),
|
|
541
634
|
})
|
|
@@ -548,53 +641,50 @@ export const ModelsConfig = z
|
|
|
548
641
|
const seen = new Set();
|
|
549
642
|
cfg.models.forEach((m, i) => {
|
|
550
643
|
if (seen.has(m.name))
|
|
551
|
-
ctx.addIssue({ code:
|
|
644
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate model "${m.name}" — the catalog key (and @-handle) must be unique`, path: ["models", i, "name"] });
|
|
552
645
|
seen.add(m.name);
|
|
553
646
|
});
|
|
554
647
|
// Catalog self-references must resolve to a real model — a dangling global `roles.{role}.model` or an
|
|
555
|
-
// `
|
|
556
|
-
// directly, with no roster filter). This lived ONLY in refIntegrityIssues (
|
|
648
|
+
// `atMention.models` name not in `models` ships a broken wire (the global /effective path uses eff.models
|
|
649
|
+
// directly, with no roster filter). This lived ONLY in refIntegrityIssues (web-admin) — but the schema
|
|
557
650
|
// is the only guard for a local TOC config / a direct PUT. Round-4 (reproduced).
|
|
558
651
|
const names = new Set(cfg.models.map((m) => m.name));
|
|
559
652
|
for (const [role, t] of Object.entries(cfg.roles ?? {})) {
|
|
560
653
|
if (t && "model" in t && !names.has(t.model))
|
|
561
|
-
ctx.addIssue({ code:
|
|
654
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `role "${role}" → model "${t.model}" is not in the catalog`, path: ["roles", role, "model"] });
|
|
562
655
|
}
|
|
563
|
-
(cfg.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
// 存量/本地 lane 仍 warn 降级——与 activeTierGroup 的「写面 fail-loud + 读面 graceful」双层姿势一致)。
|
|
569
|
-
if (cfg.default !== undefined && !names.has(cfg.default)) {
|
|
570
|
-
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `default "${cfg.default}" is not in the catalog`, path: ["default"] });
|
|
656
|
+
if (cfg.atMention.mode === "only") {
|
|
657
|
+
cfg.atMention.models.forEach((n, i) => {
|
|
658
|
+
if (!names.has(n))
|
|
659
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `atMention.models["${n}"] is not in the catalog`, path: ["atMention", "models", i] });
|
|
660
|
+
});
|
|
571
661
|
}
|
|
572
662
|
// 档位组 ref 完整性(照上面 roles/allowlist 悬空检测同款):组名去重 + 绑定必须命中 catalog +
|
|
573
663
|
// activeTierGroup 必须命中某组(悬空 active = 下发面从「换表」静默滑成「无表」,写入时 fail-loud)。
|
|
574
664
|
const groupSeen = new Set();
|
|
575
665
|
cfg.tierGroups.forEach((g, i) => {
|
|
576
666
|
if (groupSeen.has(g.name))
|
|
577
|
-
ctx.addIssue({ code:
|
|
667
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate tier group "${g.name}"`, path: ["tierGroups", i, "name"] });
|
|
578
668
|
groupSeen.add(g.name);
|
|
579
669
|
for (const [tier, model] of Object.entries(g.tiers)) {
|
|
580
670
|
if (typeof model === "string" && !names.has(model))
|
|
581
|
-
ctx.addIssue({ code:
|
|
671
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `tier group "${g.name}" ${tier} → model "${model}" is not in the catalog`, path: ["tierGroups", i, "tiers", tier] });
|
|
582
672
|
}
|
|
583
673
|
});
|
|
584
674
|
if (cfg.activeTierGroup !== undefined && !groupSeen.has(cfg.activeTierGroup)) {
|
|
585
|
-
ctx.addIssue({ code:
|
|
675
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `activeTierGroup "${cfg.activeTierGroup}" is not a tier group name`, path: ["activeTierGroup"] });
|
|
586
676
|
}
|
|
587
677
|
});
|
|
588
678
|
// ── Domain: rosters (per-worker model roster — the multi-roster design note) ───────────
|
|
589
679
|
// A named VIEW over the global `models` catalog: which models a worker exposes + its role map + @-allowlist.
|
|
590
680
|
// A worker references one by name (WorkerSpec.roster); GET /effective?worker=<name> resolves it. Models stay
|
|
591
681
|
// single-sourced in the `models` domain — a roster only references them by name (cross-domain ref checked at
|
|
592
|
-
// resolution/PUT, see the multi-roster design note §6). This is what lets one
|
|
682
|
+
// resolution/PUT, see the multi-roster design note §6). This is what lets one web-admin serve heterogeneous
|
|
593
683
|
// instances (OA vs review 各持异构模型) instead of a single global roster.
|
|
594
684
|
export const RosterEntry = z.object({
|
|
595
685
|
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
596
686
|
/** Catalog model names this roster exposes (must exist in models.models[].name). Empty = the full catalog. */
|
|
597
|
-
models: z.array(z.string().min(1)).
|
|
687
|
+
models: z.array(z.string().min(1)).prefault([]),
|
|
598
688
|
/**
|
|
599
689
|
* 主模型角色标记(0.6.3, E3)——catalog model NAME。worker.model 废除后 service 从 roster
|
|
600
690
|
* 解析 boot 主模型的归宿位:解析语义(resolve-roster)= primary 喂 `default`/`team`/`synthesize` 三角色、
|
|
@@ -605,18 +695,20 @@ export const RosterEntry = z.object({
|
|
|
605
695
|
/** cheap 模型角色标记(同上;worker.model.cheapId 的迁移归宿,§3②)。 */
|
|
606
696
|
cheapModel: z.string().min(1).optional(),
|
|
607
697
|
/** Role map override; unset roles fall back to marker-derived roles, then the global models.roles. */
|
|
608
|
-
roles: z.
|
|
609
|
-
/**
|
|
610
|
-
|
|
698
|
+
roles: z.partialRecord(ModelRole, RoleTarget).optional(),
|
|
699
|
+
/** `@` 点名覆盖({@link AtMention});缺席 = 继承全局 `models.atMention`。 */
|
|
700
|
+
atMention: AtMention.optional(),
|
|
701
|
+
/** retired 键墓碑(11.0.0):在场即拒,拒句点名一次性迁移 SS-M6。 */
|
|
702
|
+
atModelAllowlist: retiredKey("atModelAllowlist", AT_MODEL_ALLOWLIST_RETIRED),
|
|
611
703
|
notes: z.string().optional(),
|
|
612
704
|
});
|
|
613
705
|
export const RostersConfig = z
|
|
614
|
-
.object({ rosters: z.array(RosterEntry).
|
|
706
|
+
.object({ rosters: z.array(RosterEntry).prefault([]) })
|
|
615
707
|
.superRefine((cfg, ctx) => {
|
|
616
708
|
const seen = new Set();
|
|
617
709
|
cfg.rosters.forEach((r, i) => {
|
|
618
710
|
if (seen.has(r.name))
|
|
619
|
-
ctx.addIssue({ code:
|
|
711
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate roster "${r.name}"`, path: ["rosters", i, "name"] });
|
|
620
712
|
seen.add(r.name);
|
|
621
713
|
// intra-roster consistency: a role target / an @-allowlist entry pinned to a model NAME must be one
|
|
622
714
|
// this roster exposes (when it's a subset). Cross-domain (the name exists in the catalog) is checked at
|
|
@@ -627,39 +719,41 @@ export const RostersConfig = z
|
|
|
627
719
|
// 0.6.3 markers: same subset-consistency line as roles/@-allowlist — a marker naming a model this
|
|
628
720
|
// roster doesn't expose would derive a dangling role (dropped at resolution, but reject loudly at PUT).
|
|
629
721
|
if (r.primaryModel && !allowed.has(r.primaryModel)) {
|
|
630
|
-
ctx.addIssue({ code:
|
|
722
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `roster "${r.name}" primaryModel → "${r.primaryModel}" not in roster.models`, path: ["rosters", i, "primaryModel"] });
|
|
631
723
|
}
|
|
632
724
|
if (r.cheapModel && !allowed.has(r.cheapModel)) {
|
|
633
|
-
ctx.addIssue({ code:
|
|
725
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `roster "${r.name}" cheapModel → "${r.cheapModel}" not in roster.models`, path: ["rosters", i, "cheapModel"] });
|
|
634
726
|
}
|
|
635
727
|
for (const [role, target] of Object.entries(r.roles ?? {})) {
|
|
636
728
|
if (target && "model" in target && !allowed.has(target.model)) {
|
|
637
|
-
ctx.addIssue({ code:
|
|
729
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `roster "${r.name}" role.${role} → "${target.model}" not in roster.models`, path: ["rosters", i, "roles", role] });
|
|
638
730
|
}
|
|
639
731
|
}
|
|
640
|
-
(r.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
732
|
+
if (r.atMention?.mode === "only") {
|
|
733
|
+
r.atMention.models.forEach((name, j) => {
|
|
734
|
+
if (!allowed.has(name))
|
|
735
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `roster "${r.name}" atMention.models "${name}" not in roster.models`, path: ["rosters", i, "atMention", "models", j] });
|
|
736
|
+
});
|
|
737
|
+
}
|
|
644
738
|
}
|
|
645
739
|
});
|
|
646
740
|
});
|
|
647
741
|
// ── Domain: scenarios (场景组合配置化 — ASK-CENTER-2026-06-11-SCENARIOS, v1) ───────────────────────────
|
|
648
|
-
// "组合即配置、能力钉死在部署":
|
|
742
|
+
// "组合即配置、能力钉死在部署": web-admin COMPOSES scenarios from the toolsets the deployment registers —
|
|
649
743
|
// it can never mint a new capability (a new toolset = a service code change, deliberately expensive). The
|
|
650
744
|
// service overlays these onto its built-in scenario table at boot; SAME NAME OVERRIDES the built-in (产品拍板
|
|
651
745
|
// ①, the worker logs `sema_registry_scenarios.shadowsBuiltin` as the audit hook). Invalid entries are
|
|
652
746
|
// skipped+warned worker-side, never fail boot. restart-to-apply like skills/mcp (产品拍板 ③).
|
|
653
747
|
// The old skeletal roles/tools fields are gone (never stored anywhere, zero consumers, no migration).
|
|
654
748
|
export const ScenarioEntry = z.object({
|
|
655
|
-
/** Scenario name — may equal a built-in name (then
|
|
749
|
+
/** Scenario name — may equal a built-in name (then web-admin definition SHADOWS it). */
|
|
656
750
|
name: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/, "lowercase slug, 2-32 chars (a-z, 0-9, -)"),
|
|
657
751
|
/** Toolset key from the SERVICE's registry (v1: "none" | "repo-readonly"). Deliberately a shaped string,
|
|
658
|
-
* not an enum: the worker registry is the authority (unknown → skip+warn there), so
|
|
752
|
+
* not an enum: the worker registry is the authority (unknown → skip+warn there), so web-admin never needs
|
|
659
753
|
* a release just to NAME a toolset a newer service ships. */
|
|
660
754
|
toolset: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/, "toolset key (lowercase slug)"),
|
|
661
755
|
// ScenarioEntry.prompt 已废弃(产品裁定 2026-07-17,[ref]③a;第二评审独立同判「second
|
|
662
|
-
// registry field would become an ambiguous precedence channel」):配置域 prompt 单入口=
|
|
756
|
+
// registry field would become an ambiguous precedence channel」):配置域 prompt 单入口=web-admin
|
|
663
757
|
// prompts binding(scenario 维度);引擎 API 层 TaskSpec.systemPrompt 保留(不同层,勿混)。
|
|
664
758
|
// 现网无用户直删;z.object 非 strict ⇒ 存量数据带 prompt 被 strip,parse 不炸。
|
|
665
759
|
// 落日(2.0.0):本条是**纯墓碑**(零代码,靠 strip 生效),没有要删的兼容码;它留在这里的唯一作用
|
|
@@ -669,12 +763,12 @@ export const ScenarioEntry = z.object({
|
|
|
669
763
|
notes: z.string().optional(),
|
|
670
764
|
});
|
|
671
765
|
export const ScenarioConfig = z
|
|
672
|
-
.object({ scenarios: z.array(ScenarioEntry).
|
|
766
|
+
.object({ scenarios: z.array(ScenarioEntry).prefault([]) })
|
|
673
767
|
.superRefine((cfg, ctx) => {
|
|
674
768
|
const seen = new Set();
|
|
675
769
|
cfg.scenarios.forEach((sc, i) => {
|
|
676
770
|
if (seen.has(sc.name))
|
|
677
|
-
ctx.addIssue({ code:
|
|
771
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate scenario "${sc.name}"`, path: ["scenarios", i, "name"] });
|
|
678
772
|
seen.add(sc.name);
|
|
679
773
|
});
|
|
680
774
|
});
|
|
@@ -699,7 +793,7 @@ export const SystemEntry = z.object({
|
|
|
699
793
|
name: z.string().regex(/^[a-z][a-z0-9-]{1,31}$/, "lowercase slug, 2-32 chars (a-z, 0-9, -)"),
|
|
700
794
|
/** Workers this system may call — its token lands in each one's SERVICE_AUTH_TOKENS CSV (S2).
|
|
701
795
|
* Cross-domain ref-checked against the workers domain. */
|
|
702
|
-
callsWorkers: z.array(z.string().min(1)).
|
|
796
|
+
callsWorkers: z.array(z.string().min(1)).prefault([]),
|
|
703
797
|
/** Where the orchestrator delivers this system's credential (the CALLER's secret). v1: a BARE secret name
|
|
704
798
|
* in the orchestrator's OWN namespace (no "ns/name" — cross-namespace = an RBAC expansion, deferred until
|
|
705
799
|
* a real need). Optional: a system without deliverTo gets a minted token visible only via out-of-band ops. */
|
|
@@ -722,21 +816,21 @@ export const SystemEntry = z.object({
|
|
|
722
816
|
rotateNonce: z.string().max(64).optional(),
|
|
723
817
|
/** Overlap window (seconds) during rotation before the OLD token is retired. Absent → orchestrator default
|
|
724
818
|
* (service AI to confirm; S3). */
|
|
725
|
-
rotateGraceSec:
|
|
819
|
+
rotateGraceSec: INTEGER.positive().optional(),
|
|
726
820
|
enabled: z.boolean().default(true),
|
|
727
821
|
notes: z.string().optional(),
|
|
728
822
|
});
|
|
729
823
|
export const SystemsConfig = z
|
|
730
|
-
.object({ systems: z.array(SystemEntry).
|
|
824
|
+
.object({ systems: z.array(SystemEntry).prefault([]) })
|
|
731
825
|
.superRefine((cfg, ctx) => {
|
|
732
826
|
const seen = new Set();
|
|
733
827
|
const targets = new Map(); // "secret\0key" -> index of first ENABLED claimant
|
|
734
828
|
cfg.systems.forEach((sys, i) => {
|
|
735
829
|
if (seen.has(sys.name))
|
|
736
|
-
ctx.addIssue({ code:
|
|
830
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate system "${sys.name}"`, path: ["systems", i, "name"] });
|
|
737
831
|
seen.add(sys.name);
|
|
738
832
|
if (sys.deliverTo && DELIVER_DENYLIST.has(sys.deliverTo.k8sSecret)) {
|
|
739
|
-
ctx.addIssue({ code:
|
|
833
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `deliverTo.k8sSecret "${sys.deliverTo.k8sSecret}" is a PLATFORM credential surface — refused (红线1)`, path: ["systems", i, "deliverTo", "k8sSecret"] });
|
|
740
834
|
}
|
|
741
835
|
// Cross-system deliverTo collision (S3 设计 §4.5, council DESIGN-4): two enabled systems writing the
|
|
742
836
|
// same {secret,key} would silently overwrite each other (same SSA fieldManager). Save-time half; the
|
|
@@ -745,7 +839,7 @@ export const SystemsConfig = z
|
|
|
745
839
|
const t = `${sys.deliverTo.k8sSecret}\0${sys.deliverTo.key}`;
|
|
746
840
|
const prior = targets.get(t);
|
|
747
841
|
if (prior !== undefined) {
|
|
748
|
-
ctx.addIssue({ code:
|
|
842
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `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"] });
|
|
749
843
|
}
|
|
750
844
|
else {
|
|
751
845
|
targets.set(t, i);
|
|
@@ -773,30 +867,28 @@ export const SystemStatusEntry = z.object({
|
|
|
773
867
|
reason: z.string().max(500).optional(),
|
|
774
868
|
updatedAt: z.string(),
|
|
775
869
|
});
|
|
776
|
-
// ──
|
|
777
|
-
// 0.6.0
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
costQuotaWindowSec: z.number().int().positive().optional(),
|
|
799
|
-
});
|
|
870
|
+
// ── retired 域(11.0.0)─────────────────────────────────────────────────────────────────────────────
|
|
871
|
+
// `runtime` 域retired:0.6.0 起它只剩五个限额键,而 `limits`(0.19.0)全覆盖那五个键 —— 同一个限额两处可发布、判据不同、「limits 赢」
|
|
872
|
+
// 只活在消费方的注里;0.5 期的 runtime 文档还带着治理三闸,读侧靠一只「governance 从未设过就抬升」的臂接住。11.0.0 两件都收:
|
|
873
|
+
// 五个限额键只在 `limits`,治理三闸只在 `governance`;存量由一次性迁移 SS-M1 改写(`./one-time-migrations`)。
|
|
874
|
+
/** retired 域的**墓碑表**:域名 → retired版本 / 事实今天住哪 / 改写存量的一次性迁移。读存储文档的每个读者同判(`parseDomain` 拒写、`buildEffective`
|
|
875
|
+
* 向店要retired 域、`readEffectiveWire` 看 wire、`FileConfigStore` 与 `readBundle` 读 `config.d/<名>.json`):文档**带事实**(在场且不是 `{}`)⇒ 响亮拒
|
|
876
|
+
* (`RetiredDomainError`,句子点名迁移 id);缺席或 `{}`(没有可丢的事实)⇒ 不理会。墓碑是拒,不是读法 —— 它不把旧文档读成任何值。 */
|
|
877
|
+
export const RETIRED_DOMAINS = {
|
|
878
|
+
runtime: {
|
|
879
|
+
retiredIn: "11.0.0",
|
|
880
|
+
now: "governance (autonomy / commandPolicy / approvalRequire) and limits (rateLimitPerMin / maxTaskCostUsd / maxTaskTokens / maxPrincipalCostUsd / costQuotaWindowSec)",
|
|
881
|
+
migration: "SS-M1",
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
/** 是不是一个retired 域名(墓碑表成员)。 */
|
|
885
|
+
export function isRetiredDomainName(x) {
|
|
886
|
+
return typeof x === "string" && Object.prototype.hasOwnProperty.call(RETIRED_DOMAINS, x);
|
|
887
|
+
}
|
|
888
|
+
/** 一个retired 域的拒句(读写两面同一句)。 */
|
|
889
|
+
export function retiredDomainSentence(domain) {
|
|
890
|
+
return retiredFormSentence(`the "${domain}" config domain`, RETIRED_DOMAINS[domain]);
|
|
891
|
+
}
|
|
800
892
|
// ── Domain: limits (cost/限流族的热更新发布位 — 0.19.0, 消费方 #322 批1) ──────
|
|
801
893
|
// 为什么是**新顶层域**而不是往 `runtime` 里加键(消费方亲验,不是推测):`RuntimeConfig` 是 zod 默认的
|
|
802
894
|
// `"strip"` 对象,`runtime` 文档里的未知键在两条腿上都被剥掉(实测 `{rateLimitPerMin:7,
|
|
@@ -805,10 +897,9 @@ export const RuntimeConfig = z.object({
|
|
|
805
897
|
// 域表是闭集)读不到**:`config.d/limits.json` 根本不进 effective,且零告警。本域 = 那个缺口的源头修复
|
|
806
898
|
// (CLI/桌面单机形正是热更新战役的主诉求形)。同族先例:governance 当年也是从 runtime 拆成独立顶层域。
|
|
807
899
|
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
// 折叠会让「旧位有值、新位撤键」这类过渡态在两个消费者之间产生两种答案。
|
|
900
|
+
// 五个限额键(rateLimitPerMin / maxTaskCostUsd / maxTaskTokens / maxPrincipalCostUsd / costQuotaWindowSec)11.0.0 起只住这里:
|
|
901
|
+
// 旧位 `runtime` 域已retired(墓碑见 {@link RETIRED_DOMAINS};存量由一次性迁移 SS-M1 搬进本域,本域已有该键则旧值丢弃 —— 那是当年消费方
|
|
902
|
+
// 「新位赢」的生效读法)。
|
|
812
903
|
//
|
|
813
904
|
// 判据分工(**宽进严出**,与既有域一致):本域只判**结构**——类型、有限数、数组/记录的元素类型。
|
|
814
905
|
// 业务域值(下限、`0` 关断哨兵、跨键不变量如「窗长必 > 0」「runStaleSec 必严格大于心跳」)的判据属主
|
|
@@ -828,7 +919,7 @@ export const InfraCostRates = z
|
|
|
828
919
|
})
|
|
829
920
|
.passthrough();
|
|
830
921
|
export const LimitsConfig = z.object({
|
|
831
|
-
// `.optional()`
|
|
922
|
+
// `.optional()` 无默认:ABSENT = 「web-admin 不管这一键」⇒ 消费方回自己的 env 底;
|
|
832
923
|
// 显式值(**包括 0**)= 发布了一个真值(0 多半是「关断哨兵」,语义归消费方)。给默认值 = 静默覆盖部署。
|
|
833
924
|
/** 每 principal 每分钟请求上限。 */
|
|
834
925
|
rateLimitPerMin: z.number().finite().optional(),
|
|
@@ -871,7 +962,7 @@ export const LimitsConfig = z.object({
|
|
|
871
962
|
// · 默认的 strip 会把这个窗口变成**键被静默剥掉 ⇒ 那条天花板悄悄回落到部署 env 底** —— 正是本域
|
|
872
963
|
// 要根治的那种病换了个位置复发。已知键照旧严判结构(坏值 ⇒ 本域 throw 档 ⇒ 消费方 fail-static
|
|
873
964
|
// 在上一份好配置);未知键原样承运,由消费方(键表在它手里)决定认或不认。
|
|
874
|
-
// · 附带收益:老编辑器(TOC)写回新
|
|
965
|
+
// · 附带收益:老编辑器(TOC)写回新 web-admin 的 limits 文档时不会把自己不认识的键写没了(strip 语义下
|
|
875
966
|
// writeDomainFile 会真的丢键)。
|
|
876
967
|
// · **承运 ≠ 沉默**(对抗复审 复审 round2 F2):未知成员会被 `"unknown-keys-carried"` 警告逐个点名
|
|
877
968
|
// (config-fns 的已知键表由本 schema 的 shape 单源派生)——拼错的键名于是有人说,而值仍然到得了
|
|
@@ -882,13 +973,13 @@ export const LimitsConfig = z.object({
|
|
|
882
973
|
// ── Domain: governance (治理三件 — split out of runtime, EXPERT-REDESIGN §5) ──────
|
|
883
974
|
// The governance trio an operator tunes via publish: autonomy ladder + command guardrails + approval-required
|
|
884
975
|
// tools. NOT下沉 — gets its own admin tab (E5 form; E1 ships a transitional generic editor). Same `.optional()`
|
|
885
|
-
// no-default discipline as the six gates: ABSENT =
|
|
976
|
+
// no-default discipline as the six gates: ABSENT = web-admin 不管这项(keep env / 消费者默认),present 才生效。
|
|
886
977
|
export const GovernanceConfig = z.object({
|
|
887
978
|
// autonomy:一个跨 knob 的安全总开关 / 放权梯度(普通用户敢用 + 专业用户放权)。
|
|
888
979
|
// read-only(只读规划,不落改)| ask(每个放权动作问)| plan(plan-review 门)| auto(放开)。
|
|
889
980
|
// ⚠️ 消费侧(service)把它**展开**到 core 已有三原语(handsReadOnly / shellGate+onAsk / plan_review),
|
|
890
981
|
// 经 core 的收紧-only `tightenTaskSpec` 合并。core 裁决:不在 core 加并行 autonomy 枚举(双实现陷阱),
|
|
891
|
-
// 不静默覆盖调用者已设安全字段(auto 模式尤其不能静默清门控)。详见
|
|
982
|
+
// 不静默覆盖调用者已设安全字段(auto 模式尤其不能静默清门控)。详见 web-admin docs/CONFIG-RESEARCH §10。
|
|
892
983
|
autonomy: z.enum(["read-only", "ask", "plan", "auto"]).optional(),
|
|
893
984
|
// commandPolicy:命令级 allow/ask/deny 护栏(remoteExec 管"在哪跑",这管"能跑什么命令")。每条 = 一个
|
|
894
985
|
// **精确 argv[0] 命令名** → 决策;**DENY-WINS**(冲突 deny 赢,与 core `combinePolicies` 一致,NOT last-match-wins)。
|
|
@@ -899,8 +990,7 @@ export const GovernanceConfig = z.object({
|
|
|
899
990
|
// ⚠️ COARSE / 非沙箱:只匹配 leading simple-command 名(经 core `parseLeadingCommandName` fail-closed 解析器);
|
|
900
991
|
// `$(...)` / `sh -c` / `env` / `xargs` / `find -exec` / `python -c` / 子命令都绕得过——是 egress/irreversibility
|
|
901
992
|
// 安全轴之下的**纵深防御,绝非唯一隔离**(真隔离靠 executionEnv sandbox)。消费侧(service)必须经
|
|
902
|
-
// `combinePolicies(部署默认, 本策略)` 编译进 `TaskSpec.toolPolicy
|
|
903
|
-
// "classify"` 下普通 ask 会被 irreversibility 提升为不可预算的 irreversible_ask。core 建
|
|
993
|
+
// `combinePolicies(部署默认, 本策略)` 编译进 `TaskSpec.toolPolicy`,绝不裸覆盖清掉部署级护栏。core 建
|
|
904
994
|
// `createCoarseCommandNamePolicy` 消费它(config-research §10)。
|
|
905
995
|
commandPolicy: z
|
|
906
996
|
.array(z.object({
|
|
@@ -915,13 +1005,9 @@ export const GovernanceConfig = z.object({
|
|
|
915
1005
|
decision: z.enum(["allow", "ask", "deny"]),
|
|
916
1006
|
}))
|
|
917
1007
|
.optional(),
|
|
918
|
-
/** Tool names needing operator approval (F4).
|
|
1008
|
+
/** Tool names needing operator approval (F4). ABSENT = web-admin 不管;显式 `[]` = 一个都不需要审批。 */
|
|
919
1009
|
approvalRequire: z.array(z.string()).optional(),
|
|
920
1010
|
});
|
|
921
|
-
/** The governance trio keys. 0.7.1:写出方向的 runtime 双写镜像已撤(service 1.150.0 已切新位,
|
|
922
|
-
* 回执确认)——此常量今日唯一职责=READ 方向的 legacy lift(governance 域从未设时从旧 runtime 文档抬升,
|
|
923
|
-
* config-fns legacyGovernanceFromRuntime),单一键源防逐键漂移。 */
|
|
924
|
-
export const GOVERNANCE_MIRROR_KEYS = ["autonomy", "commandPolicy", "approvalRequire"];
|
|
925
1011
|
// ── integrations domain: DELETED in 0.6.0 (EXPERT-REDESIGN §9 — 零消费者实锤:service 明确排除,OA/Gitea
|
|
926
1012
|
// 实走 worker.env)。“按 scope connect GitHub” 是 backlog 的全新设计,不复用此形状。
|
|
927
1013
|
// ── access domain: DELETED in 0.6.0 (EXPERT-REDESIGN §9 — 彻底删除不留兼容;唯一消费者 agent-client portal
|
|
@@ -933,25 +1019,25 @@ export const GOVERNANCE_MIRROR_KEYS = ["autonomy", "commandPolicy", "approvalReq
|
|
|
933
1019
|
// every principal's tier/budget to a worker), and `/effective?principal=` resolves the per-principal view
|
|
934
1020
|
// (effective = worker-base ∩ tier⊕roles ∩ time-bounded overrides; intersection = either side's "deny" wins).
|
|
935
1021
|
// Minimal records — never raw usage numbers (those live in the data plane / telemetry).
|
|
936
|
-
/** Token budget — LiteLLM-interop vocab +
|
|
1022
|
+
/** Token budget — LiteLLM-interop vocab + web-admin-specific DEGRADE mode + a per-RUN admission gate. */
|
|
937
1023
|
export const EntitlementBudget = z.object({
|
|
938
|
-
/** soft = alert only · hard = pre-call block · degrade = fall back to a cheaper model (
|
|
1024
|
+
/** soft = alert only · hard = pre-call block · degrade = fall back to a cheaper model (web-admin-specific). */
|
|
939
1025
|
mode: z.enum(["soft", "hard", "degrade"]).optional(),
|
|
940
1026
|
// .finite() on every $/USD float: z.number().nonnegative() accepts Infinity, which JSON.stringify's to null on
|
|
941
1027
|
// persist → a later strict reparse throws (fail-open on write, fail-hard on read). Same discipline as ModelCost.
|
|
942
1028
|
maxBudgetUsd: z.number().finite().nonnegative().optional(),
|
|
943
1029
|
budgetDuration: z.string().optional(), // e.g. "30d"
|
|
944
|
-
tpmLimit:
|
|
945
|
-
rpmLimit:
|
|
946
|
-
maxParallelRequests:
|
|
947
|
-
perModel: z.record(z.string(), z.object({ maxBudgetUsd: z.number().finite().nonnegative().optional(), tpmLimit:
|
|
1030
|
+
tpmLimit: INTEGER.nonnegative().optional(),
|
|
1031
|
+
rpmLimit: INTEGER.nonnegative().optional(),
|
|
1032
|
+
maxParallelRequests: INTEGER.nonnegative().optional(),
|
|
1033
|
+
perModel: z.record(z.string(), z.object({ maxBudgetUsd: z.number().finite().nonnegative().optional(), tpmLimit: INTEGER.nonnegative().optional(), rpmLimit: INTEGER.nonnegative().optional() })).optional(),
|
|
948
1034
|
/** Per-RUN admission gate (agentic-runaway guard, §3.3 🔴) — period budgets can't stop ONE runaway run. */
|
|
949
1035
|
maxRunCostUsd: z.number().finite().nonnegative().optional(),
|
|
950
|
-
maxIterations:
|
|
1036
|
+
maxIterations: INTEGER.positive().optional(),
|
|
951
1037
|
/** WHERE this budget is enforced (scheduling framework D4, 产品拍板 2026-07-04): `local` = worker-only
|
|
952
|
-
* (per-instance or shared-DB — today's posture) · `lease` = AP pre-authorized QuotaLease from
|
|
1038
|
+
* (per-instance or shared-DB — today's posture) · `lease` = AP pre-authorized QuotaLease from web-admin +
|
|
953
1039
|
* after-the-fact reconciliation (default for sema-managed fleets) · `central` = CP pre-call check against
|
|
954
|
-
*
|
|
1040
|
+
* web-admin (strict tenants; slow but exact). ABSENT = local. Orthogonal to `mode` (what happens on breach). */
|
|
955
1041
|
enforcement: z.enum(["local", "lease", "central"]).optional(),
|
|
956
1042
|
});
|
|
957
1043
|
/** Token 双窗额度(配额轴, EXPERT-REDESIGN §4 — Claude 式 5h 滚动窗 + 自然周窗),单位=加权 tokens
|
|
@@ -961,9 +1047,9 @@ export const EntitlementBudget = z.object({
|
|
|
961
1047
|
* 更严;tokenQuota 不参与 budget 的 min-of-budgets 归并。任一窗耗尽即拒(CC 式拒绝+恢复时间,E4)。 */
|
|
962
1048
|
export const TokenQuota = z.object({
|
|
963
1049
|
/** 5h 滚动窗额度(加权 tokens)。ABSENT = 此窗不设限。 */
|
|
964
|
-
fiveHourTokens:
|
|
1050
|
+
fiveHourTokens: INTEGER.nonnegative().optional(),
|
|
965
1051
|
/** 自然周窗额度(加权 tokens)。ABSENT = 此窗不设限。 */
|
|
966
|
-
weeklyTokens:
|
|
1052
|
+
weeklyTokens: INTEGER.nonnegative().optional(),
|
|
967
1053
|
});
|
|
968
1054
|
/** 预算组(共享钱池,§4-1)——与个人 budget 双轨并行:一笔花费计入个人轨 + 所有所属池;多池并存取最先
|
|
969
1055
|
* 耗尽;tokens 配额轴(TokenQuota)与它是两轨,先到顶者生效。成员条目带 `memberSince`:池只聚合入组时刻
|
|
@@ -982,60 +1068,60 @@ export const BudgetGroup = z.object({
|
|
|
982
1068
|
.object({
|
|
983
1069
|
/** instance-level group ids(registry `groups` 表——store 对象,zod 看不见,存在性同 groupBindings
|
|
984
1070
|
* 先例不做 schema 校验,dangling 由 UI 标红)。 */
|
|
985
|
-
groups: z.array(BudgetGroupMember.extend({ group: z.string().min(1) })).
|
|
986
|
-
principals: z.array(BudgetGroupMember.extend({ principal: z.string().min(1) })).
|
|
1071
|
+
groups: z.array(BudgetGroupMember.extend({ group: z.string().min(1) })).prefault([]),
|
|
1072
|
+
principals: z.array(BudgetGroupMember.extend({ principal: z.string().min(1) })).prefault([]),
|
|
987
1073
|
})
|
|
988
|
-
.
|
|
1074
|
+
.prefault({ groups: [], principals: [] }),
|
|
989
1075
|
});
|
|
990
1076
|
/** 生效场景分配(§7,与配额同一挂载心智):全局默认 + 按组 + 按用户三层;解析优先级=用户 > 组 > 全局默认
|
|
991
|
-
* > worker 级 runtime.scenario 兜底(引擎兜底行为待 ask② 确认)。`scenario` 是场景 NAME——可指
|
|
1077
|
+
* > worker 级 runtime.scenario 兜底(引擎兜底行为待 ask② 确认)。`scenario` 是场景 NAME——可指 web-admin
|
|
992
1078
|
* scenarios 域条目,也可指 service 内建场景名(内建不在域里,故**不做** cross-domain 存在性硬校验,
|
|
993
1079
|
* dangling 由 UI 标注)。 */
|
|
994
1080
|
export const ScenarioAssignments = z.object({
|
|
995
1081
|
/** 全局默认场景名。ABSENT = 不设全局默认(落到 worker 级 runtime.scenario / 引擎内建)。 */
|
|
996
1082
|
default: z.string().min(1).optional(),
|
|
997
|
-
groups: z.array(z.object({ group: z.string().min(1), scenario: z.string().min(1) })).
|
|
998
|
-
principals: z.array(z.object({ principal: z.string().min(1), scenario: z.string().min(1) })).
|
|
1083
|
+
groups: z.array(z.object({ group: z.string().min(1), scenario: z.string().min(1) })).prefault([]),
|
|
1084
|
+
principals: z.array(z.object({ principal: z.string().min(1), scenario: z.string().min(1) })).prefault([]),
|
|
999
1085
|
});
|
|
1000
1086
|
/** Org-forced-off runtime ceilings (gate②). ABSENT = no ceiling (local controls); `false` = org-disabled
|
|
1001
1087
|
* fleet-wide. Only ever pushes the "off" ceiling — never force-enables (monotone tighten-only). */
|
|
1002
1088
|
export const EntitlementRuntimeCaps = z.object({
|
|
1003
1089
|
allowBypassPermissions: z.boolean().optional(),
|
|
1004
1090
|
allowAutoMode: z.boolean().optional(),
|
|
1005
|
-
/** Per-principal governance of the workflow/ultracode capability (K-2, 产品拍板 2026-06-28).
|
|
1091
|
+
/** Per-principal governance of the workflow/ultracode capability (K-2, 产品拍板 2026-06-28). web-admin treats this as
|
|
1006
1092
|
* the "准不准" axis; the engine separately advertises "能不能" (Capabilities.workflows). The shell enables
|
|
1007
1093
|
* ultracode/workflows iff engine-capable ∧ principal-entitled. Like the others: ABSENT = no ceiling,
|
|
1008
1094
|
* `false` = org-disabled fleet-wide (tighten-only false-wins; never force-enables). Full server-side
|
|
1009
1095
|
* enforcement awaits core adding a per-principal authz axis that consumes this; until then it gates the shell UI. */
|
|
1010
1096
|
allowWorkflows: z.boolean().optional(),
|
|
1011
|
-
/** Per-principal governance of the Fork (fork-session) tool —
|
|
1097
|
+
/** Per-principal governance of the Fork (fork-session) tool — web-admin's "准不准" axis, mirroring allowWorkflows.
|
|
1012
1098
|
* The engine advertises "能不能" separately; a Fork is admitted iff engine-capable ∧ principal-entitled. Like the
|
|
1013
1099
|
* others: ABSENT = no ceiling (Fork allowed), `false` = org-disabled fleet-wide (tighten-only false-wins; never
|
|
1014
1100
|
* force-enables). core (RuntimeCaps.allowFork, prepare-task) consumes this per-principal: `allowFork === false`
|
|
1015
1101
|
* → Fork DENIED, else allowed. */
|
|
1016
1102
|
allowFork: z.boolean().optional(),
|
|
1017
1103
|
/** Per-principal governance of observer agents (core 1.270.0/1.273.0 OBS-2) —
|
|
1018
|
-
*
|
|
1104
|
+
* web-admin's "准不准" axis for the AgentDefinition.observer pairing surface. ⚠️ core's consumption polarity is
|
|
1019
1105
|
* INVERTED vs the other allow* caps: the engine is `=== true` explicit opt-in (default OFF — an undefined cap
|
|
1020
1106
|
* ships dark), so this key is the ENABLE line, not just a ceiling. Three-state semantics (ABSENT ≠ false):
|
|
1021
|
-
* ABSENT =
|
|
1107
|
+
* ABSENT = web-admin says nothing → key NOT emitted on the wire (core stays the SOLE default source; service's
|
|
1022
1108
|
* toCoreRuntimeCaps must not backfill a missing key into an explicit verdict — that would poison the
|
|
1023
1109
|
* env-baseline compose order); `true` = explicitly enable observer agents for this principal; `false` =
|
|
1024
|
-
* explicit org deny — overrides the node-level env baseline EXPERIMENTAL_OBSERVER_AGENTS (
|
|
1025
|
-
*
|
|
1110
|
+
* explicit org deny — overrides the node-level env baseline EXPERIMENTAL_OBSERVER_AGENTS (web-admin speaks →
|
|
1111
|
+
* web-admin wins). On compose across bundles, `false` (deny) still wins — see RUNTIME_CAP_STRICT; core's
|
|
1026
1112
|
* `=== true` opt-in check is orthogonal to that merge polarity. */
|
|
1027
1113
|
allowObservers: z.boolean().optional(),
|
|
1028
1114
|
/** Per-principal ARM switch for the auto-mode CLASSIFIER (core 1.276 批2 seam `RuntimeCaps.autoMode`, 板
|
|
1029
1115
|
* A surviving permission `ask` is routed to the small-model security classifier before any
|
|
1030
1116
|
* human/durable resolution). ⚠️ Same INVERTED consumption polarity as `allowObservers`: core is `=== true`
|
|
1031
1117
|
* explicit opt-in, default OFF — this key is the ENABLE line, not just a ceiling. Three-state semantics
|
|
1032
|
-
* (ABSENT ≠ false): ABSENT =
|
|
1118
|
+
* (ABSENT ≠ false): ABSENT = web-admin says nothing → key NOT emitted on the wire (core stays the SOLE default
|
|
1033
1119
|
* source — never backfill absence into an explicit verdict); `true` = explicitly arm the classifier for this
|
|
1034
1120
|
* principal (a no-op on a deployment without a wired classify leg — the capability gate lives in core);
|
|
1035
1121
|
* `false` = explicit org deny. On compose across bundles `false` (deny) wins — see RUNTIME_CAP_STRICT;
|
|
1036
1122
|
* core's `=== true` opt-in check is orthogonal to that merge polarity. DIVISION OF LABOR vs
|
|
1037
1123
|
* {@link allowAutoMode}: `allowAutoMode` is the 准入 may-I ceiling on the USER-facing auto permission mode
|
|
1038
|
-
* (shell/settings face, tighten-only); `autoMode` is
|
|
1124
|
+
* (shell/settings face, tighten-only); `autoMode` is web-admin's 部署 arm decision for the server-side
|
|
1039
1125
|
* classifier leg itself. */
|
|
1040
1126
|
autoMode: z.boolean().optional(),
|
|
1041
1127
|
allowUltracode: z.boolean().optional(),
|
|
@@ -1043,18 +1129,18 @@ export const EntitlementRuntimeCaps = z.object({
|
|
|
1043
1129
|
* (design/383 §3.1): may this principal declare `memory.capture: "off"` for a run? `false` = the deployment
|
|
1044
1130
|
* REQUIRES capture for this principal (compliance / knowledge retention) — the declaration is refused, terminal
|
|
1045
1131
|
* (`memory.capture_optout_denied`). `true` = explicitly no per-principal restriction. Three-state, ABSENT ≠ false:
|
|
1046
|
-
* ABSENT =
|
|
1132
|
+
* ABSENT = web-admin says nothing → key NOT emitted on the wire (never coined into a verdict). The consumer's
|
|
1047
1133
|
* fault-transit contract for THIS member runs opposite to the `{allowWorkflows:false}` degrade habit: a resolver
|
|
1048
1134
|
* FAULT must surface as ABSENT (or a throw), never as a stand-in `false` — on the privacy axis "deny" is the
|
|
1049
1135
|
* IRREVERSIBLE side (capturing a session the user said not to capture), so the default/`open` posture reads a
|
|
1050
1136
|
* fault as "opt-out honoured + named disclosure", and a `governed` posture reads it as "verdict missing ⇒
|
|
1051
|
-
* refuse the run". The consumer composes this key from its own local grant store FIRST and
|
|
1137
|
+
* refuse the run". The consumer composes this key from its own local grant store FIRST and web-admin value
|
|
1052
1138
|
* second. Compose polarity across bundles is the allow* family's: `false` (capture required) wins — see
|
|
1053
|
-
* RUNTIME_CAP_STRICT; the consumer's posture arms are orthogonal to that merge. Consumer-side:
|
|
1139
|
+
* RUNTIME_CAP_STRICT; the consumer's posture arms are orthogonal to that merge. Consumer-side: web-admin value
|
|
1054
1140
|
* reaches the engine caps only once the consumer's cap mapping picks this member up; until then its local grant
|
|
1055
1141
|
* store is the sole verdict source (the key parses and is carried on the wire from this version on). */
|
|
1056
1142
|
allowMemoryOptOut: z.boolean().optional(),
|
|
1057
|
-
/** Force this principal's run onto durable-approval (per-tool gate) —
|
|
1143
|
+
/** Force this principal's run onto durable-approval (per-tool gate) — web-admin's fleet-wide handle for CC-faithful
|
|
1058
1144
|
* per-tool interception (K-3, core confirmed it consumes this). Polarity is INVERTED vs the allow* caps:
|
|
1059
1145
|
* here `true` is the STRICT/tightening value (forcing the gate ON), so it wins on compose — see RUNTIME_CAP_STRICT.
|
|
1060
1146
|
* ABSENT = no fleet override (local/default resolution decides). Server-side enforcement = core's per-principal axis. */
|
|
@@ -1106,7 +1192,7 @@ export const EntitlementOverride = z
|
|
|
1106
1192
|
key: z.string().min(1),
|
|
1107
1193
|
/** The override's value — its SHAPE follows its `key`, so it is parse-transparent here and validated by
|
|
1108
1194
|
* whoever enforces that key: {@link PARSE_TRANSPARENT_KEYS} (`entitlement.subjects[].overrides[].value`). */
|
|
1109
|
-
value: z.unknown(),
|
|
1195
|
+
value: z.unknown().optional(),
|
|
1110
1196
|
effectiveFrom: z.string().optional(),
|
|
1111
1197
|
effectiveUntil: z.string().optional(),
|
|
1112
1198
|
approvedBy: z.string().min(1),
|
|
@@ -1118,15 +1204,15 @@ export const EntitlementOverride = z
|
|
|
1118
1204
|
// by env-NAME, never inline. (Budget overrides are numbers/enums, so this never fires on the v1 use.)
|
|
1119
1205
|
const s = typeof o.value === "string" ? o.value : o.value == null ? "" : JSON.stringify(o.value);
|
|
1120
1206
|
if (s && containsSecretToken(s))
|
|
1121
|
-
ctx.addIssue({ code:
|
|
1207
|
+
ctx.addIssue({ code: "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"] });
|
|
1122
1208
|
});
|
|
1123
1209
|
export const EntitlementSubject = z.object({
|
|
1124
1210
|
principal: z.string().min(1),
|
|
1125
1211
|
displayName: z.string().optional(),
|
|
1126
1212
|
tierId: z.string().optional(),
|
|
1127
1213
|
/** Additional tier ids composed on top of `tierId` (union of allows, min of budgets) — the "role combo". */
|
|
1128
|
-
roles: z.array(z.string()).
|
|
1129
|
-
overrides: z.array(EntitlementOverride).
|
|
1214
|
+
roles: z.array(z.string()).prefault([]),
|
|
1215
|
+
overrides: z.array(EntitlementOverride).prefault([]),
|
|
1130
1216
|
/** 用户层 token 双窗额度(最具体层,压过组与全局默认——极性见 {@link TokenQuota})。 */
|
|
1131
1217
|
tokenQuota: TokenQuota.optional(),
|
|
1132
1218
|
});
|
|
@@ -1141,7 +1227,7 @@ export const EntitlementGroupBinding = z.object({
|
|
|
1141
1227
|
group: z.string().min(1),
|
|
1142
1228
|
tierId: z.string().optional(),
|
|
1143
1229
|
/** Additional tier ids composed on top of `tierId` — same "role combo" shape as EntitlementSubject. */
|
|
1144
|
-
roles: z.array(z.string()).
|
|
1230
|
+
roles: z.array(z.string()).prefault([]),
|
|
1145
1231
|
/** 组层 token 双窗额度(同层多组命中取更严;被用户层压过——极性见 {@link TokenQuota})。 */
|
|
1146
1232
|
tokenQuota: TokenQuota.optional(),
|
|
1147
1233
|
});
|
|
@@ -1158,12 +1244,12 @@ export const EntitlementConfig = z
|
|
|
1158
1244
|
tokenQuota: TokenQuota.optional(),
|
|
1159
1245
|
})
|
|
1160
1246
|
.optional(),
|
|
1161
|
-
tiers: z.array(EntitlementTier).
|
|
1162
|
-
subjects: z.array(EntitlementSubject).
|
|
1247
|
+
tiers: z.array(EntitlementTier).prefault([]),
|
|
1248
|
+
subjects: z.array(EntitlementSubject).prefault([]),
|
|
1163
1249
|
/** Group bindings (W4, additive 0.4.0) — bind an instance-level GROUP to a tier ⊕ roles combo. */
|
|
1164
|
-
groupBindings: z.array(EntitlementGroupBinding).
|
|
1250
|
+
groupBindings: z.array(EntitlementGroupBinding).prefault([]),
|
|
1165
1251
|
/** 预算组(共享钱池,0.6.0 §4)——见 {@link BudgetGroup}。 */
|
|
1166
|
-
budgetGroups: z.array(BudgetGroup).
|
|
1252
|
+
budgetGroups: z.array(BudgetGroup).prefault([]),
|
|
1167
1253
|
/** 生效场景分配(0.6.0 §7)——见 {@link ScenarioAssignments}。 */
|
|
1168
1254
|
scenarioAssignments: ScenarioAssignments.optional(),
|
|
1169
1255
|
})
|
|
@@ -1171,53 +1257,53 @@ export const EntitlementConfig = z
|
|
|
1171
1257
|
const tierIds = new Set();
|
|
1172
1258
|
cfg.tiers.forEach((t, i) => {
|
|
1173
1259
|
if (tierIds.has(t.id))
|
|
1174
|
-
ctx.addIssue({ code:
|
|
1260
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate tier id "${t.id}"`, path: ["tiers", i, "id"] });
|
|
1175
1261
|
tierIds.add(t.id);
|
|
1176
1262
|
});
|
|
1177
1263
|
const seenPools = new Set();
|
|
1178
1264
|
cfg.budgetGroups.forEach((b, i) => {
|
|
1179
1265
|
if (seenPools.has(b.id))
|
|
1180
|
-
ctx.addIssue({ code:
|
|
1266
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate budget group id "${b.id}"`, path: ["budgetGroups", i, "id"] });
|
|
1181
1267
|
seenPools.add(b.id);
|
|
1182
1268
|
});
|
|
1183
1269
|
const seenGroups = new Set();
|
|
1184
1270
|
cfg.groupBindings.forEach((g, i) => {
|
|
1185
1271
|
if (seenGroups.has(g.group))
|
|
1186
|
-
ctx.addIssue({ code:
|
|
1272
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate group binding "${g.group}"`, path: ["groupBindings", i, "group"] });
|
|
1187
1273
|
seenGroups.add(g.group);
|
|
1188
1274
|
if (g.tierId && !tierIds.has(g.tierId))
|
|
1189
|
-
ctx.addIssue({ code:
|
|
1275
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `group binding "${g.group}" → unknown tier "${g.tierId}"`, path: ["groupBindings", i, "tierId"] });
|
|
1190
1276
|
g.roles.forEach((r, j) => { if (!tierIds.has(r))
|
|
1191
|
-
ctx.addIssue({ code:
|
|
1277
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `group binding "${g.group}" → unknown role/tier "${r}"`, path: ["groupBindings", i, "roles", j] }); });
|
|
1192
1278
|
});
|
|
1193
1279
|
const seen = new Set();
|
|
1194
1280
|
cfg.subjects.forEach((s, i) => {
|
|
1195
1281
|
if (seen.has(s.principal))
|
|
1196
|
-
ctx.addIssue({ code:
|
|
1282
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate subject principal "${s.principal}"`, path: ["subjects", i, "principal"] });
|
|
1197
1283
|
seen.add(s.principal);
|
|
1198
1284
|
if (s.tierId && !tierIds.has(s.tierId))
|
|
1199
|
-
ctx.addIssue({ code:
|
|
1285
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `subject "${s.principal}" → unknown tier "${s.tierId}"`, path: ["subjects", i, "tierId"] });
|
|
1200
1286
|
s.roles.forEach((r, j) => { if (!tierIds.has(r))
|
|
1201
|
-
ctx.addIssue({ code:
|
|
1287
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `subject "${s.principal}" → unknown role/tier "${r}"`, path: ["subjects", i, "roles", j] }); });
|
|
1202
1288
|
s.overrides.forEach((o, j) => { if (o.principal !== s.principal)
|
|
1203
|
-
ctx.addIssue({ code:
|
|
1289
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `override.principal "${o.principal}" must equal subject "${s.principal}"`, path: ["subjects", i, "overrides", j, "principal"] }); });
|
|
1204
1290
|
});
|
|
1205
1291
|
});
|
|
1206
|
-
/** design/170 件A:`?principal=` caps wire 上的 org 记忆解析段(
|
|
1207
|
-
* 段级三值语义:**键整体缺席**=
|
|
1292
|
+
/** design/170 件A:`?principal=` caps wire 上的 org 记忆解析段(web-admin 铸、server 消费,两侧共此同源锚)。
|
|
1293
|
+
* 段级三值语义:**键整体缺席**=web-admin 不支持该段(能力握手,消费方按「取不到」瞬时处理,不判终局);
|
|
1208
1294
|
* **`scopes: {}` 空表**=取数成功的负结果(该 principal 零 org 授权,终局);**有表**=逐条目判。
|
|
1209
1295
|
* `gen`=单调世代号(publish 模式=globalRevision,live 模式=store version;消费方拒退挂此,防线为
|
|
1210
|
-
* 进程生命周期内的 LB 陈旧副本——重启后无高水位=接受
|
|
1296
|
+
* 进程生命周期内的 LB 陈旧副本——重启后无高水位=接受 web-admin 当刻值,web-admin 是真源)。 */
|
|
1211
1297
|
export const PrincipalOrgMemoryWire = z
|
|
1212
1298
|
.object({
|
|
1213
|
-
gen:
|
|
1299
|
+
gen: INTEGER.nonnegative(),
|
|
1214
1300
|
scopes: z.record(z.string().regex(/^org:\S+$/, "orgMemory scope must be an `org:` scope string"), z.object({ write: z.boolean().optional() }).strict()),
|
|
1215
1301
|
})
|
|
1216
1302
|
.strict();
|
|
1217
|
-
/** `?principal=` caps wire **整体**的读边界 schema(
|
|
1303
|
+
/** `?principal=` caps wire **整体**的读边界 schema(web-admin 铸、server 消费同源锚;产品令 2026-08-05:
|
|
1218
1304
|
* 网络边界读入必 schema,禁裸 as-cast)。**逐面容错极性是裁定过的语义**,严格化会翻转极性的面在此
|
|
1219
1305
|
* 显式保持宽读(每面注明所依裁定),不许硬转严:
|
|
1220
|
-
* - `runtimeCaps`/`budget`(治理面,fail-closed):严格校验已知键 + **passthrough 未知键**——新
|
|
1306
|
+
* - `runtimeCaps`/`budget`(治理面,fail-closed):严格校验已知键 + **passthrough 未知键**——新 web-admin
|
|
1221
1307
|
* 的新 cap 键必须原样透传给 core(老 server 剥键=静默放松治理);值漂移 ⇒ 整体 parse 失败 ⇒ 消费端
|
|
1222
1308
|
* fail-close(「outage 绝不静默 GRANT」的既裁方向)。
|
|
1223
1309
|
* - `scenario`/`allowlist`/`execution`(能力面,fail-open,[565]/[580] 裁定):保持 `unknown` 宽读,
|
|
@@ -1268,18 +1354,15 @@ export const ActorAssertionWire = z
|
|
|
1268
1354
|
/** sessionMirror TARGET url 的底线传输校验(0.10.1;0.10.2 加 trim 前置):**https,或 loopback http**
|
|
1269
1355
|
* (本机第二引擎/dev)。与壳 `evaluateEngineRouting`(shell-host engineTarget.ts,S1 路由闸)语义对齐:
|
|
1270
1356
|
* 镜像上行带会话内容+Bearer 凭证,非 loopback 明文 http = 凭证与会话数据裸奔——**写侧 schema 层就拒**
|
|
1271
|
-
* (fail-loud 比读侧执法更早;
|
|
1272
|
-
* {@link NON_SECRET_URL}(禁 userinfo 凭证/禁 query/fragment 夹带 secret-shaped 值)。loopback
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1357
|
+
* (fail-loud 比读侧执法更早;web-admin 编辑面存不进去,壳读侧闸只兜手改存储的漏网)。基线复用
|
|
1358
|
+
* {@link NON_SECRET_URL}(禁 userinfo 凭证/禁 query/fragment 夹带 secret-shaped 值)。loopback 判定 = 鉴权放宽那一只
|
|
1359
|
+
* {@link isLoopbackAuthHost}(11.0.0 起;明文 http 带凭据是放宽,窄是安全方向 —— 壳的同一判定改读同一只)。
|
|
1360
|
+
* **trim 前置(0.10.2,附B 三单合评审 M-1)**:前导/尾随空白先归一再验且存储值即 trim
|
|
1275
1361
|
* 后值——否则 " https://…" 写侧收(new URL 容忍前导空白)而壳读侧逐字符比对拒 = 全员镜像静默 degraded。 */
|
|
1276
1362
|
export const SESSION_MIRROR_ENGINE_URL = z.string().trim().pipe(NON_SECRET_URL).refine((s) => {
|
|
1277
1363
|
try {
|
|
1278
1364
|
const u = new URL(s);
|
|
1279
|
-
|
|
1280
|
-
return true;
|
|
1281
|
-
const h = u.hostname;
|
|
1282
|
-
return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]";
|
|
1365
|
+
return u.protocol === "https:" || isLoopbackAuthHost(u.hostname);
|
|
1283
1366
|
}
|
|
1284
1367
|
catch {
|
|
1285
1368
|
return true; // already passed NON_SECRET_URL; unreachable in practice
|
|
@@ -1298,7 +1381,7 @@ export const SESSION_MIRROR_ENGINE_URL = z.string().trim().pipe(NON_SECRET_URL).
|
|
|
1298
1381
|
* - `required:true` = **org 治理强制**:会话镜像默认开且不可关,且**本地覆盖不生效——既不能改目标
|
|
1299
1382
|
* 也不能关闭(org 压一切;附B 三单合评审 MAJOR-1 勘误:治理态下本地 --target 完全被忽略)**
|
|
1300
1383
|
* (审计场景;壳复用 CC managed-settings 锁定报错串——S3 execution 沙箱锁完全同构先例)。
|
|
1301
|
-
* 整个 sessionMirror **optional,缺省 INERT**(无下发=老
|
|
1384
|
+
* 整个 sessionMirror **optional,缺省 INERT**(无下发=老 web-admin/老壳/0.8-0.10 存量 execution 文档
|
|
1302
1385
|
* 零行为差、零迁移)。⚠️ ENFORCEMENT HONESTY 同域头注:required 的锁今天也是 CLIENT-SIDE 执法。
|
|
1303
1386
|
* 开放演化:v2 可能加 audit 字段类(保留窗/脱敏面等)——本对象刻意**不 .strict()**(zod 默认 strip
|
|
1304
1387
|
* 未知键:v2 文档被 v1 消费者读到=未知键剥除,forward-compatible;加字段恒 additive patch)。 */
|
|
@@ -1309,7 +1392,7 @@ export const SessionMirrorPolicy = z.object({
|
|
|
1309
1392
|
required: z.boolean().default(false),
|
|
1310
1393
|
});
|
|
1311
1394
|
/** 执行域 sealed-box 加密公钥登记(0.10.5,密钥托管线;挂 execution 域=「执行环境的属性」循
|
|
1312
|
-
* sessionMirror 收编先例,零新域)。
|
|
1395
|
+
* sessionMirror 收编先例,零新域)。web-admin **只登记公钥**——私钥由执行侧(service worker/引擎)
|
|
1313
1396
|
* 生成并持有,永不上行;浏览器端用这里的公钥 crypto_box_seal 加密模型 key(见 {@link SealedApiKey})。
|
|
1314
1397
|
* X25519 公钥恒 32 字节 → base64 恒 43 字符 + "=",regex 钉死(错长度=根本不是 X25519 公钥,写侧
|
|
1315
1398
|
* fail-loud 比执行侧解密失败早得多)。 */
|
|
@@ -1318,11 +1401,11 @@ export const ExecutionPublicKey = z.object({
|
|
|
1318
1401
|
publicKeyId: z.string().min(1).max(128),
|
|
1319
1402
|
/** X25519 公钥,base64(32 字节 → 恒 44 字符含 padding)。 */
|
|
1320
1403
|
publicKey: z.string().regex(/^[A-Za-z0-9+/]{43}=$/, "publicKey must be a base64 X25519 public key (32 bytes)"),
|
|
1321
|
-
/** 登记时刻(ISO)。
|
|
1404
|
+
/** 登记时刻(ISO)。web-admin 写面 server 端盖章(projects.registeredAt 同款纪律)。 */
|
|
1322
1405
|
createdAt: z.string().min(1),
|
|
1323
1406
|
notes: z.string().optional(),
|
|
1324
1407
|
/** 归属 worker 标识(0.10.9,service 合意)。**取值 = 上报方 worker 的
|
|
1325
|
-
* `
|
|
1408
|
+
* `SEMA_WEB_ADMIN_WORKER` env**(service 上报载荷带 `workerId`;该 env 未配 = 单 worker 部署,
|
|
1326
1409
|
* 上报省略本字段)。多 worker 部署下 seal 侧凭它按**目标 worker** 选钥(A worker 的密文只有
|
|
1327
1410
|
* A 的私钥能解——选错钥 = 执行侧解密失败,晚且难查);ABSENT = 未绑定,seal 侧 fallback
|
|
1328
1411
|
* newest(仅适合单 worker 部署)。optional additive:存量登记条目零迁移。 */
|
|
@@ -1336,8 +1419,8 @@ export const ExecutionPolicy = z.object({
|
|
|
1336
1419
|
* must not be a BREAKING schema change (promised to the service). The closed set of lanes a worker can
|
|
1337
1420
|
* declare is `EXECUTION_LANE_WORDS` (`execution-lanes.ts`, 7.0.0) — read it there, not from a copy in this
|
|
1338
1421
|
* comment. Unknown strings are consumer-defined. */
|
|
1339
|
-
allowedLanes: z.array(z.string().min(1)).
|
|
1340
|
-
/** Org 默认会话镜像目标(0.10.1,附B 两层)。ABSENT = INERT(
|
|
1422
|
+
allowedLanes: z.array(z.string().min(1)).prefault(["host"]),
|
|
1423
|
+
/** Org 默认会话镜像目标(0.10.1,附B 两层)。ABSENT = INERT(web-admin 未管镜像,壳解析序里本腿为空)。
|
|
1341
1424
|
* 读损档随域(DOMAIN_READ_FALLBACK.execution="throw" 已覆盖:损坏绝不静默退默认放权)。 */
|
|
1342
1425
|
sessionMirror: SessionMirrorPolicy.optional(),
|
|
1343
1426
|
/** 执行域 sealed-box 公钥登记表(0.10.5)。**optional 无 default,ABSENT = 未登记**(0.8–0.10 存量
|
|
@@ -1349,7 +1432,7 @@ export const ExecutionPolicy = z.object({
|
|
|
1349
1432
|
const seen = new Set();
|
|
1350
1433
|
(p.publicKeys ?? []).forEach((k, i) => {
|
|
1351
1434
|
if (seen.has(k.publicKeyId))
|
|
1352
|
-
ctx.addIssue({ code:
|
|
1435
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate publicKeyId "${k.publicKeyId}"`, path: ["publicKeys", i, "publicKeyId"] });
|
|
1353
1436
|
seen.add(k.publicKeyId);
|
|
1354
1437
|
});
|
|
1355
1438
|
});
|
|
@@ -1358,11 +1441,11 @@ export const ExecutionPolicy = z.object({
|
|
|
1358
1441
|
// 登记簿(索引/展示/审计)+ 无仓 project 的 projectId 铸造权威。铸造离线自由、使用时注册执法
|
|
1359
1442
|
// 佐证:执法位在 service worker(派生 `proj:*` 可读 scope 前查 `(tenant,projectId)`
|
|
1360
1443
|
// 已注册),本域只是它查的那张表——登记簿**不读 marker 文件、不镜像 marker schema**(已确认)。
|
|
1361
|
-
// 复合键 `(tenant, projectId)` 的 tenant 半边由
|
|
1444
|
+
// 复合键 `(tenant, projectId)` 的 tenant 半边由 web-admin 的 SCOPE 轴承载(api/scopes.ts:每 scope 一份
|
|
1362
1445
|
// 独立域文档),域内 Record 键=projectId only;fork 后同一 projectId 合法存在于两个 scope,互不相见。
|
|
1363
1446
|
/** projectId 形状:lowercase 规范 UUID。**宽读严写**(core 口径):读面/claim 收 generic
|
|
1364
1447
|
* UUID(外部/历史铸造的 v4 也认——marker 是身份不是格式声明);铸造侧(core formatProjectMarker /
|
|
1365
|
-
*
|
|
1448
|
+
* web-admin mint 端点)统一产 **uuidv7**。36 字符,天然满足 design/142 §1.2 段编码 64 上限。
|
|
1366
1449
|
* SINGLE-SOURCE 纪律:本常量是引擎那条正则的**自持镜像**(本仓不带引擎运行时依赖轴),对拍走 dev-only
|
|
1367
1450
|
* conformance(never a runtime import,EffectiveKey/coreTypes 先例)。
|
|
1368
1451
|
* 2.0.0:drift-lock 已建(`test/upstream-drift.test.ts` ③)—— pattern 必须与引擎逐字相同,唯一成文的差是
|
|
@@ -1382,23 +1465,23 @@ export const ProjectRegistration = z.object({
|
|
|
1382
1465
|
.max(512)
|
|
1383
1466
|
.refine((s) => !/\s/.test(s) && !s.includes("://"), "must be a NORMALIZED remote (host/path — no scheme, no whitespace)"))
|
|
1384
1467
|
.max(16)
|
|
1385
|
-
.
|
|
1468
|
+
.prefault([]),
|
|
1386
1469
|
/** 该项目会话的默认记忆 scope 词表。OPEN value domain — z.string(),不 enum(纪律:加词
|
|
1387
1470
|
* 不得 BREAKING)。方向(S4 细化):消费方=service memoryScope 派生(作 TaskSpec.
|
|
1388
1471
|
* memory.scopes 种子),形态=v2 scope 键字符串(过 core parseScopeKey 校验;known 词根:`proj` /
|
|
1389
1472
|
* `userproj`)。未知串=消费者自定义;空数组=不下发默认。本域只登记不解释,零耦合。 */
|
|
1390
|
-
defaultScopes: z.array(z.string().min(1)).
|
|
1391
|
-
/** 登记人 principal(审计锚;
|
|
1473
|
+
defaultScopes: z.array(z.string().min(1)).prefault([]),
|
|
1474
|
+
/** 登记人 principal(审计锚;web-admin 写面自动填 caller,不信客户端提交)。 */
|
|
1392
1475
|
registeredBy: z.string().min(1),
|
|
1393
|
-
/** 登记时刻(ISO)。
|
|
1476
|
+
/** 登记时刻(ISO)。web-admin 写面 server 端盖章。 */
|
|
1394
1477
|
registeredAt: z.string().min(1),
|
|
1395
1478
|
notes: z.string().optional(),
|
|
1396
1479
|
});
|
|
1397
1480
|
export const ProjectsConfig = z
|
|
1398
1481
|
.object({
|
|
1399
|
-
/** 键=projectId({@link PROJECT_ID_REGEX},generic lowercase UUID——宽读);tenant 半边=
|
|
1482
|
+
/** 键=projectId({@link PROJECT_ID_REGEX},generic lowercase UUID——宽读);tenant 半边=web-admin
|
|
1400
1483
|
* scope 轴(不进域文档,见域头注)。 */
|
|
1401
|
-
projects: z.record(z.string().regex(PROJECT_ID_REGEX, "projectId must be a lowercase canonical UUID"), ProjectRegistration).
|
|
1484
|
+
projects: z.record(z.string().regex(PROJECT_ID_REGEX, "projectId must be a lowercase canonical UUID"), ProjectRegistration).prefault({}),
|
|
1402
1485
|
})
|
|
1403
1486
|
.superRefine((cfg, ctx) => {
|
|
1404
1487
|
// 同 scope 内一条 remote 只许属于一个 project——「remote → projectId」找回阶梯(design/142
|
|
@@ -1409,7 +1492,7 @@ export const ProjectsConfig = z
|
|
|
1409
1492
|
for (const [i, r] of reg.gitRemotes.entries()) {
|
|
1410
1493
|
const prev = seen.get(r);
|
|
1411
1494
|
if (prev !== undefined && prev !== pid) {
|
|
1412
|
-
ctx.addIssue({ code:
|
|
1495
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `remote "${r}" already registered to project "${prev}"`, path: ["projects", pid, "gitRemotes", i] });
|
|
1413
1496
|
}
|
|
1414
1497
|
seen.set(r, pid);
|
|
1415
1498
|
}
|
|
@@ -1420,7 +1503,7 @@ export const ProjectsConfig = z
|
|
|
1420
1503
|
// Unified shape = slots × topology × rounds × finalizer. COMPLETELY separate from rosters (拍板): a slot
|
|
1421
1504
|
// member's `modelRef` points at the models CATALOG (`ModelEntry.name`, the key — NOT the gateway modelId,
|
|
1422
1505
|
// NOT a roster); roster churn never touches a template. Execution lives service-side ("collab spec →
|
|
1423
|
-
// workflow/team run" translation layer);
|
|
1506
|
+
// workflow/team run" translation layer); web-admin owns the template CONFIG only.
|
|
1424
1507
|
export const CollabTopology = z.enum(["star", "ring", "layered", "pair", "blackboard"]);
|
|
1425
1508
|
export const CollabFinalizerType = z.enum(["judge", "vote", "fuse", "verify", "summarize", "none"]);
|
|
1426
1509
|
export const CollabEarlyStop = z.enum(["consensus", "stability", "judge_call", "verify_pass"]);
|
|
@@ -1444,7 +1527,7 @@ const CollabSlot = z.object({
|
|
|
1444
1527
|
rolePrompt: z.string().max(2000).optional(),
|
|
1445
1528
|
/** 1 = exactly one member; "N" = the slot accepts multiple members (异构评审团). */
|
|
1446
1529
|
cardinality: z.union([z.literal(1), z.literal("N")]).default(1),
|
|
1447
|
-
members: z.array(CollabMember).
|
|
1530
|
+
members: z.array(CollabMember).prefault([]),
|
|
1448
1531
|
/** Tool names available to this slot's members (the old team `scenario` binding migrates here). */
|
|
1449
1532
|
tools: z.array(z.string().min(1)).optional(),
|
|
1450
1533
|
});
|
|
@@ -1457,7 +1540,7 @@ const CollabTemplate = z.object({
|
|
|
1457
1540
|
slots: z.array(CollabSlot).min(1),
|
|
1458
1541
|
/** `max` is ALWAYS the safety cap (blackboard topology: event/round cap — required, 防跑飞);
|
|
1459
1542
|
* `earlyStop: "verify_pass"` carries the architect pair's "verified → stop" loop. */
|
|
1460
|
-
rounds: z.object({ max:
|
|
1543
|
+
rounds: z.object({ max: INTEGER.positive(), earlyStop: CollabEarlyStop.optional() }),
|
|
1461
1544
|
finalizer: z.object({
|
|
1462
1545
|
type: CollabFinalizerType,
|
|
1463
1546
|
/** Slot id the finalizer anchors to (e.g. the judge slot). Meaningless for type "none". */
|
|
@@ -1468,10 +1551,10 @@ const CollabTemplate = z.object({
|
|
|
1468
1551
|
kind: z.enum(["transcript", "blackboard"]).default("transcript"),
|
|
1469
1552
|
persistence: CollabPersistence.default("session"),
|
|
1470
1553
|
})
|
|
1471
|
-
.
|
|
1554
|
+
.prefault({}),
|
|
1472
1555
|
/** 多模型烧钱必须有闸 (拍板): the budget object is REQUIRED and at least one axis must be set. */
|
|
1473
1556
|
budget: z.object({
|
|
1474
|
-
maxTokens:
|
|
1557
|
+
maxTokens: INTEGER.positive().optional(),
|
|
1475
1558
|
maxCost: z.number().finite().positive().optional(),
|
|
1476
1559
|
}),
|
|
1477
1560
|
enabled: z.boolean().default(true),
|
|
@@ -1480,34 +1563,34 @@ const CollabTemplate = z.object({
|
|
|
1480
1563
|
const slotIds = new Set();
|
|
1481
1564
|
t.slots.forEach((s, i) => {
|
|
1482
1565
|
if (slotIds.has(s.id))
|
|
1483
|
-
ctx.addIssue({ code:
|
|
1566
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate slot id "${s.id}"`, path: ["slots", i, "id"] });
|
|
1484
1567
|
slotIds.add(s.id);
|
|
1485
1568
|
if (s.cardinality === 1 && s.members.length > 1)
|
|
1486
|
-
ctx.addIssue({ code:
|
|
1569
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `slot "${s.id}" has cardinality 1 but ${s.members.length} members`, path: ["slots", i, "members"] });
|
|
1487
1570
|
});
|
|
1488
1571
|
if (t.finalizer.type === "none") {
|
|
1489
1572
|
if (t.finalizer.slot !== undefined)
|
|
1490
|
-
ctx.addIssue({ code:
|
|
1573
|
+
ctx.addIssue({ code: "custom", message: `finalizer type "none" must not anchor a slot`, path: ["finalizer", "slot"] });
|
|
1491
1574
|
}
|
|
1492
1575
|
else if (t.finalizer.slot !== undefined && !slotIds.has(t.finalizer.slot)) {
|
|
1493
|
-
ctx.addIssue({ code:
|
|
1576
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `finalizer.slot "${t.finalizer.slot}" is not a slot id of this template`, path: ["finalizer", "slot"] });
|
|
1494
1577
|
}
|
|
1495
1578
|
if (t.budget.maxTokens === undefined && t.budget.maxCost === undefined) {
|
|
1496
|
-
ctx.addIssue({ code:
|
|
1579
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `template "${t.id}": budget needs at least one of maxTokens/maxCost (多模型烧钱必须有闸)`, path: ["budget"] });
|
|
1497
1580
|
}
|
|
1498
1581
|
});
|
|
1499
|
-
export const CollabConfig = z.object({ templates: z.array(CollabTemplate).
|
|
1582
|
+
export const CollabConfig = z.object({ templates: z.array(CollabTemplate).prefault([]) }).superRefine((cfg, ctx) => {
|
|
1500
1583
|
// `id` is the template's reference key — dedup like every other keyed domain (two same-id templates
|
|
1501
1584
|
// shipping → the resolver silently takes the first; same failure class the old teams dedup fixed).
|
|
1502
1585
|
const seen = new Set();
|
|
1503
1586
|
cfg.templates.forEach((t, i) => {
|
|
1504
1587
|
if (seen.has(t.id))
|
|
1505
|
-
ctx.addIssue({ code:
|
|
1588
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate collab template "${t.id}" — the template key must be unique`, path: ["templates", i, "id"] });
|
|
1506
1589
|
seen.add(t.id);
|
|
1507
1590
|
});
|
|
1508
1591
|
});
|
|
1509
1592
|
// ── Domain: workers (声明式编排 — service design/22, the orchestrator design note) ──────
|
|
1510
|
-
// 每个 worker = 一个 worker 服务实例的声明(一份 env profile)。
|
|
1593
|
+
// 每个 worker = 一个 worker 服务实例的声明(一份 env profile)。web-admin 存 DESIRED(逻辑参数
|
|
1511
1594
|
// + secret 的 env-NAME 引用,**绝不存 secret 值**);一个 per-host reconciler 拉取后在宿主机本地解析
|
|
1512
1595
|
// secret → 渲染 docker compose → up/down 收敛。worker spec ↔ service env 的精确映射见 worker-env.ts。
|
|
1513
1596
|
//
|
|
@@ -1520,7 +1603,7 @@ const DURATION = z.string().regex(/^\d+(s|m|h|d)$/, 'duration like "30m", "2h",
|
|
|
1520
1603
|
// 生命周期 (design/25 §3 决策 3/4)。owner/purpose 用于配额 + 级联清理 + 审计;dependsOn 防级联雪崩。
|
|
1521
1604
|
export const WorkerLifecycle = z.object({
|
|
1522
1605
|
kind: z.enum(["persistent", "ephemeral"]).default("persistent"),
|
|
1523
|
-
/** ephemeral: 绝对存活时长,自
|
|
1606
|
+
/** ephemeral: 绝对存活时长,自 web-admin 首次见到起算(防时钟漂移),到期硬移除。 */
|
|
1524
1607
|
ttl: DURATION.optional(),
|
|
1525
1608
|
/** 无活动多久 → 告警(MVP 只告警,不自动 disable)。数据源 = 反代 metrics。 */
|
|
1526
1609
|
idleTimeout: DURATION.optional(),
|
|
@@ -1530,6 +1613,9 @@ export const WorkerLifecycle = z.object({
|
|
|
1530
1613
|
/** 依赖的其它 worker name —— sweeper 移除前查依赖,不孤儿化仍被依赖的 worker。 */
|
|
1531
1614
|
dependsOn: z.array(z.string().min(1)).optional(),
|
|
1532
1615
|
});
|
|
1616
|
+
/** 7.0.0 删除的 worker 字段(6.x 存储形)→ 它的新位。墓碑字段写在每个旧键原来所在的对象里({@link retiredKey}):在场即拒、拒句点名
|
|
1617
|
+
* 一次性迁移 SS-M4 —— 不让一行旧数据以「什么都没声明」的样子过闸(zod 会静默剥掉不认识的键)。 */
|
|
1618
|
+
const sixXField = (now) => ({ retiredIn: "7.0.0", now, migration: "SS-M4" });
|
|
1533
1619
|
const WorkerSecretRefs = z.object({
|
|
1534
1620
|
/** NAME of the host env var holding the model API key → service MODEL_API_KEY (或 anthropic 时 ANTHROPIC_API_KEY). */
|
|
1535
1621
|
apiKeyEnv: ENV_NAME,
|
|
@@ -1540,6 +1626,7 @@ const WorkerSecretRefs = z.object({
|
|
|
1540
1626
|
/** 7.0.0:SQL 店的口令 → `MYSQL_PASSWORD` / `PG_PASSWORD`(渲染器按 `runtime.store.backend` 选名族)。
|
|
1541
1627
|
* 在场 ⇔ `runtime.store.backend` ∈ {mysql, pg}(WorkerSpec 层判,与 `runtime.store.db` 同一条规则)。取代 6.x 的 `tidbPasswordEnv`。 */
|
|
1542
1628
|
dbPasswordEnv: ENV_NAME.optional(),
|
|
1629
|
+
tidbPasswordEnv: retiredKey("secretRefs.tidbPasswordEnv", sixXField("secretRefs.dbPasswordEnv")),
|
|
1543
1630
|
});
|
|
1544
1631
|
/**
|
|
1545
1632
|
* 每个**保留** env 名 → WorkerSpec 里产出它的那个结构字段(唯一写位)。`env` / `secretEnv` 逃生口拒这些名,拒因指路到这里写的字段
|
|
@@ -1552,12 +1639,26 @@ const WorkerSecretRefs = z.object({
|
|
|
1552
1639
|
* 7.0.0 增删(M-R2 结构块):
|
|
1553
1640
|
* · **加** `REMOTE_EXEC` / `REQUIRE_EXECUTION_LANE` + 车道表({@link EXECUTION_LANES})里**全部**车道 env 名 —— 车道有了结构位
|
|
1554
1641
|
* `runtime.execution`,逃生口再能设车道就是两把尺。这是对 0.1.30「执行基座键不保留」的**反转**:当年不保留是因为没有结构位
|
|
1555
|
-
* (活舰队 direct = k8s、canary = e2b 只能在 `worker.env` 里设);存量行由一次性迁移
|
|
1642
|
+
* (活舰队 direct = k8s、canary = e2b 只能在 `worker.env` 里设);存量行由一次性迁移 SS-M4 搬进结构块。
|
|
1556
1643
|
* 别的域也读的名(`MINIO_*`、`LOG_LEVEL`、`REMOTE_EXEC_HOME_DIR`)不在车道表里,照旧不保留。
|
|
1557
1644
|
* · **加** `DB_BACKEND` / `MEMORY_ENGINE` / `MEMORY_ENGINE_BACKEND` —— `runtime.store` 产出它们。
|
|
1558
|
-
* · **删** `MEMORY_BACKEND`(引擎从不读的死名,渲染器不再产出)与 `TIDB_*` 族(引擎的墓碑名,见之拒启;结构字段不再产出它们
|
|
1559
|
-
*
|
|
1645
|
+
* · **删** `MEMORY_BACKEND`(引擎从不读的死名,渲染器不再产出)与 `TIDB_*` 族(引擎的墓碑名,见之拒启;结构字段不再产出它们)。
|
|
1646
|
+
* MYSQL_* / PG_* 两族归 `runtime.store.db` + `secretRefs.dbPasswordEnv`。
|
|
1647
|
+
*
|
|
1648
|
+
* `env` / `secretEnv` 的写闸问**两张表**,各答一个问题:本表答「这个现名归哪个结构字段」(拒句指路到那个字段);墓碑表 `RETIRED_ENVS`
|
|
1649
|
+
* (`./retired-envs`)答「这个旧名已退役」(拒句就是墓碑句 `retiredEnvRefusal` 本身,引擎起服、壳、web-admin 读同一张表同一句)。墓碑名不进本表。
|
|
1650
|
+
*
|
|
1651
|
+
* 11.0.0:本表与 `RETIRED_ENVS` 的旧名集合零交集 —— `ANTHROPIC_BASEURL`(墓碑名)换成现名 `ANTHROPIC_BASE_URL`;渲染器写的 web-admin 连接三键
|
|
1652
|
+
* 入表,同 fleet 成员身份两键的「渲染器写、不可配」。**规则一条:渲染器写的名字是保留名**(运维不可在 `env` / `secretEnv` 里自写)。改名窗里渲染器
|
|
1653
|
+
* 按目标引擎版本写名,对还读上一代名的引擎写的是 `SEMA_REGISTRY_URL` / `SEMA_REGISTRY_WORKER` / `SEMA_REGISTRY_TOKEN`,所以这三名与现名三键同一组、
|
|
1654
|
+
* 同一句拒(指路同一处)。老三名在引擎换读现名的那一版随之搬去墓碑表(同一提交);保留表与墓碑表零交集的钉会逼这一步。
|
|
1560
1655
|
*/
|
|
1656
|
+
/** web-admin 连接三键的唯一写位(渲染器;没有 WorkerSpec 字段)—— 现名与上一代名指路同一句。 */
|
|
1657
|
+
const WEB_ADMIN_CONNECTION_SEAT = {
|
|
1658
|
+
url: "(set by the config renderer from the web-admin base URL — not configurable)",
|
|
1659
|
+
worker: "(set by the config renderer from the worker's name — not configurable)",
|
|
1660
|
+
token: "(set by the config renderer from the worker's pull token — not configurable)",
|
|
1661
|
+
};
|
|
1561
1662
|
const RESERVED_ENV_FIELD = Object.freeze({
|
|
1562
1663
|
PORT: "(fixed at 8090 — not configurable)", MODEL_PROVIDER: "model.provider", MODEL_ID: "model.modelId",
|
|
1563
1664
|
MODEL_REASONING: "model.reasoning", MODEL_CHEAP_ID: "model.cheapId", MODEL_MAX_TOKENS: "model.maxTokens",
|
|
@@ -1565,7 +1666,7 @@ const RESERVED_ENV_FIELD = Object.freeze({
|
|
|
1565
1666
|
MODEL_COST_CACHE_READ: "model.cost.cacheRead", MODEL_COST_CACHE_WRITE: "model.cost.cacheWrite",
|
|
1566
1667
|
MAX_PRINCIPAL_COST_USD: "runtime.maxPrincipalCostUsd", REQUIRE_PRINCIPAL: "runtime.requirePrincipal", RATE_LIMIT_RPM: "runtime.rateLimitRpm",
|
|
1567
1668
|
DEFAULT_SCENARIO: "runtime.scenario", GIT_API_BASEURL: "coords.gitApiBaseUrl", MODEL_GATEWAY_BASEURL: "model.gatewayBaseUrl",
|
|
1568
|
-
|
|
1669
|
+
ANTHROPIC_BASE_URL: "model.gatewayBaseUrl", MODEL_API_KEY: "secretRefs.apiKeyEnv", ANTHROPIC_API_KEY: "secretRefs.apiKeyEnv",
|
|
1569
1670
|
SERVICE_AUTH_TOKEN: "secretRefs.serviceAuthTokenEnv", GIT_API_TOKEN: "secretRefs.gitTokenEnv",
|
|
1570
1671
|
// ── runtime.store(7.0.0)
|
|
1571
1672
|
DB_BACKEND: "runtime.store.backend", SESSION_BACKEND: "runtime.store.session",
|
|
@@ -1579,6 +1680,11 @@ const RESERVED_ENV_FIELD = Object.freeze({
|
|
|
1579
1680
|
// ── fleet 成员身份两键:渲染器按该 worker 的代际写 generation(引擎读回并回声成 EngineSignal.generation)、按该 worker 令牌绑定的 scope 写 scope(引擎读作自身身份的 scope)—— 没有 WorkerSpec 字段能写它们
|
|
1580
1681
|
[FLEET_GENERATION_ENV]: "(set by the config renderer from the worker's generation — not configurable)",
|
|
1581
1682
|
[FLEET_SCOPE_ENV]: "(set by the config renderer from the worker's scope — not configurable)",
|
|
1683
|
+
// ── web-admin 连接三键:渲染器给每台 worker 写 web-admin 根 URL、worker 名与该 worker 的拉取令牌 —— 没有 WorkerSpec 字段能写它们;
|
|
1684
|
+
// 现名与渲染器对老引擎写的上一代名同一组(见表头注)
|
|
1685
|
+
[WEB_ADMIN_ENV.url]: WEB_ADMIN_CONNECTION_SEAT.url, SEMA_REGISTRY_URL: WEB_ADMIN_CONNECTION_SEAT.url,
|
|
1686
|
+
[WEB_ADMIN_ENV.worker]: WEB_ADMIN_CONNECTION_SEAT.worker, SEMA_REGISTRY_WORKER: WEB_ADMIN_CONNECTION_SEAT.worker,
|
|
1687
|
+
[WEB_ADMIN_ENV.token]: WEB_ADMIN_CONNECTION_SEAT.token, SEMA_REGISTRY_TOKEN: WEB_ADMIN_CONNECTION_SEAT.token,
|
|
1582
1688
|
REQUIRE_EXECUTION_LANE: "runtime.execution.lane (the renderer emits REQUIRE_EXECUTION_LANE=true for every declared lane)",
|
|
1583
1689
|
...Object.fromEntries([...LANE_ENV_OWNER].map(([name, owner]) => [name, laneEnvSeat(name, owner)])),
|
|
1584
1690
|
});
|
|
@@ -1588,14 +1694,20 @@ export const RESERVED_WORKER_ENV = new Set(Object.keys(RESERVED_ENV_FIELD));
|
|
|
1588
1694
|
/**
|
|
1589
1695
|
* 保留名被 `env` / `secretEnv` 逃生口设置时的拒因:点名键 + 它的唯一写位。句尾恒是「set it via <字段>」(下游按它抽归属字段,不动)。
|
|
1590
1696
|
* **迁移指路只有一条规则(7.0.1)**:这个键「旧形 ⇒ 迁移能搬」({@link isMigratableEnvKey},与迁移自己识别的键集同一只谓词)就在键名后
|
|
1591
|
-
*
|
|
1697
|
+
* 带同一句迁移指路(一次性迁移 SS-M4,`migrationSignpost`);搬不了的保留名(`PORT` / `MODEL_*` / `MYSQL_*` …)不带 —— 指一条跑了也没用的迁移比不指更糟。
|
|
1592
1698
|
*/
|
|
1593
|
-
function
|
|
1594
|
-
const signpost = isMigratableEnvKey(key) ?
|
|
1595
|
-
return `${slot} cannot set reserved key "${key}"${signpost} — set it via ${RESERVED_ENV_FIELD[key]}`;
|
|
1699
|
+
function reservedKeyIssue(slot, key) {
|
|
1700
|
+
const signpost = isMigratableEnvKey(key) ? " (" + migrationSignpost("SS-M4", "7.0.0") + ")" : "";
|
|
1701
|
+
return buildQuotedIssue `${fixed(slot)} cannot set reserved key "${key}"${fixed(signpost)} — set it via ${fixed(RESERVED_ENV_FIELD[key])}`;
|
|
1702
|
+
}
|
|
1703
|
+
/** 逃生口里有墓碑表的旧名 ⇒ 一条 issue,句子是墓碑句本身(`retiredEnvRefusal` —— 唯一铸点,本包不另拼;句里只有表里的名,不回显值)。 */
|
|
1704
|
+
function retiredEnvIssue(rec, ctx) {
|
|
1705
|
+
const refusal = retiredEnvRefusal(rec);
|
|
1706
|
+
if (refusal !== undefined)
|
|
1707
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `${fixed(refusal)}` });
|
|
1596
1708
|
}
|
|
1597
1709
|
// ── 7.0.0 M-R2:`runtime.execution` / `runtime.store` 两个结构块 ─────────────────────────────────────────────
|
|
1598
|
-
// 词表只有一个正本(`execution-lanes.ts` 的导出表):本包写闸(`WorkerSpec.safeParse`)
|
|
1710
|
+
// 词表只有一个正本(`execution-lanes.ts` 的导出表):本包写闸(`WorkerSpec.safeParse`)、web-admin 渲染器、引擎起服三处读同一张表。
|
|
1599
1711
|
// 拒因都在 zod issue 里(`path` 指到要改的字段、`message` 带出路)—— schema 本身是唯一判据,不另导出拒因函数。
|
|
1600
1712
|
/** 一个非密字面值(车道坐标 / SQL 坐标):≤2048,不像密钥(与 `env` 逃生口同一条判据 —— 密钥走 env-NAME 引用)。 */
|
|
1601
1713
|
const nonSecretLiteral = (minLength) => z
|
|
@@ -1613,47 +1725,47 @@ const nonSecretLiteral = (minLength) => z
|
|
|
1613
1725
|
*/
|
|
1614
1726
|
const ExecutionBlock = z
|
|
1615
1727
|
.object({
|
|
1616
|
-
lane: z.enum(EXECUTION_LANE_WORDS),
|
|
1617
|
-
coords: z.record(ENV_NAME, nonSecretLiteral(0)).
|
|
1618
|
-
secretRefs: z.record(ENV_NAME, ENV_NAME).
|
|
1728
|
+
lane: z.enum(EXECUTION_LANE_WORDS, { error: (iss) => retiredLaneWordRefusal(iss.input) }), // a retired lane word gets its way out
|
|
1729
|
+
coords: z.record(ENV_NAME, nonSecretLiteral(0)).prefault({}),
|
|
1730
|
+
secretRefs: z.record(ENV_NAME, ENV_NAME).prefault({}),
|
|
1619
1731
|
})
|
|
1620
1732
|
.superRefine((x, ctx) => {
|
|
1621
1733
|
const row = laneRowOf(x.lane);
|
|
1622
|
-
const issue = (slot, key,
|
|
1734
|
+
const issue = (slot, key, q) => ctx.addIssue({ code: "custom", path: [slot, key], ...q });
|
|
1623
1735
|
const owned = (secret) => (secret ? [...row.requiredSecretEnv, ...row.optionalSecretEnv] : [...row.requiredEnv, ...row.optionalEnv]);
|
|
1624
1736
|
const judgeKey = (slot, key) => {
|
|
1625
1737
|
const wantSecret = slot === "secretRefs";
|
|
1626
1738
|
const owner = LANE_ENV_OWNER.get(key);
|
|
1627
1739
|
if (owner === undefined) {
|
|
1628
1740
|
const allowed = owned(wantSecret);
|
|
1629
|
-
issue(slot, key, `${key} is not ${wantSecret ? "a secret" : "a coordinate"} of any execution lane — lane "${x.lane}" takes ${slot} ${allowed.length > 0 ? allowed.join(", ") : "(none)"}`);
|
|
1741
|
+
issue(slot, key, buildQuotedIssue `${key} is not ${fixed(wantSecret ? "a secret" : "a coordinate")} of any execution lane — lane "${x.lane}" takes ${fixed(slot)} ${fixed(allowed.length > 0 ? allowed.join(", ") : "(none)")}`);
|
|
1630
1742
|
return false;
|
|
1631
1743
|
}
|
|
1632
1744
|
if (owner.lane !== x.lane) {
|
|
1633
|
-
issue(slot, key, `${key} belongs to lane "${owner.lane}", not "${x.lane}" — remove it, or set runtime.execution.lane to "${owner.lane}"`);
|
|
1745
|
+
issue(slot, key, buildQuotedIssue `${key} belongs to lane "${fixed(owner.lane)}", not "${x.lane}" — remove it, or set runtime.execution.lane to "${fixed(owner.lane)}"`);
|
|
1634
1746
|
return false;
|
|
1635
1747
|
}
|
|
1636
1748
|
if (owner.secret !== wantSecret) {
|
|
1637
1749
|
issue(slot, key, owner.secret
|
|
1638
|
-
? `${key} is a secret of lane "${x.lane}" — put it in runtime.execution.secretRefs as an env-NAME (never a value in coords)`
|
|
1639
|
-
: `${key} is a non-secret coordinate of lane "${x.lane}" — write its value in runtime.execution.coords`);
|
|
1750
|
+
? buildQuotedIssue `${key} is a secret of lane "${x.lane}" — put it in runtime.execution.secretRefs as an env-NAME (never a value in coords)`
|
|
1751
|
+
: buildQuotedIssue `${key} is a non-secret coordinate of lane "${x.lane}" — write its value in runtime.execution.coords`);
|
|
1640
1752
|
return false;
|
|
1641
1753
|
}
|
|
1642
1754
|
return true;
|
|
1643
1755
|
};
|
|
1644
1756
|
for (const [key, value] of Object.entries(x.coords)) {
|
|
1645
1757
|
if (judgeKey("coords", key) && value === "" && LANE_ENV_OWNER.get(key)?.required === true) {
|
|
1646
|
-
issue("coords", key, `required coordinate ${key} of lane "${x.lane}" is empty — the engine reads an empty value as unset and refuses to start`);
|
|
1758
|
+
issue("coords", key, buildQuotedIssue `required coordinate ${key} of lane "${x.lane}" is empty — the engine reads an empty value as unset and refuses to start`);
|
|
1647
1759
|
}
|
|
1648
1760
|
}
|
|
1649
1761
|
for (const key of Object.keys(x.secretRefs))
|
|
1650
1762
|
judgeKey("secretRefs", key);
|
|
1651
1763
|
for (const key of row.requiredEnv)
|
|
1652
1764
|
if (!Object.hasOwn(x.coords, key))
|
|
1653
|
-
issue("coords", key, `lane "${x.lane}" requires coords.${key} (the engine refuses to start without it)`);
|
|
1765
|
+
issue("coords", key, buildQuotedIssue `lane "${x.lane}" requires coords.${key} (the engine refuses to start without it)`);
|
|
1654
1766
|
for (const key of row.requiredSecretEnv)
|
|
1655
1767
|
if (!Object.hasOwn(x.secretRefs, key))
|
|
1656
|
-
issue("secretRefs", key, `lane "${x.lane}" requires secretRefs.${key} — the host env-NAME holding it`);
|
|
1768
|
+
issue("secretRefs", key, buildQuotedIssue `lane "${x.lane}" requires secretRefs.${key} — the host env-NAME holding it`);
|
|
1657
1769
|
});
|
|
1658
1770
|
/** `MEMORY_ENGINE` 的两个词(引擎目录行的 `enumValues`;引擎还认 true/false/1/0… 两族同义词,结构位只收规范词)。 */
|
|
1659
1771
|
const MEMORY_ENGINE_SWITCH = ["on", "off"];
|
|
@@ -1675,7 +1787,7 @@ const StoreBlock = z.object({
|
|
|
1675
1787
|
engine: z.enum(MEMORY_ENGINE_SWITCH).default("on"),
|
|
1676
1788
|
backend: z.enum(MEMORY_ENGINE_BACKENDS).default("file"),
|
|
1677
1789
|
})
|
|
1678
|
-
.
|
|
1790
|
+
.prefault({}),
|
|
1679
1791
|
/** SQL 服务器坐标(中立,一份;名族由 `backend` 决定)。 */
|
|
1680
1792
|
db: z
|
|
1681
1793
|
.object({
|
|
@@ -1686,40 +1798,8 @@ const StoreBlock = z.object({
|
|
|
1686
1798
|
})
|
|
1687
1799
|
.optional(),
|
|
1688
1800
|
});
|
|
1689
|
-
/**
|
|
1690
|
-
*
|
|
1691
|
-
const RETIRED_WORKER_FIELDS = [
|
|
1692
|
-
{ parent: "runtime", key: "sessionBackend", now: "runtime.store.session" },
|
|
1693
|
-
{ parent: "runtime", key: "memoryBackend", now: "runtime.store.memory — the old field never reached the engine" },
|
|
1694
|
-
{ parent: "runtime", key: "db", now: "runtime.store.db.database" },
|
|
1695
|
-
{ parent: "coords", key: "tidbHost", now: "runtime.store.db.host" },
|
|
1696
|
-
{ parent: "coords", key: "tidbPort", now: "runtime.store.db.port" },
|
|
1697
|
-
{ parent: "coords", key: "tidbUser", now: "runtime.store.db.user" },
|
|
1698
|
-
{ parent: "secretRefs", key: "tidbPasswordEnv", now: "secretRefs.dbPasswordEnv" },
|
|
1699
|
-
];
|
|
1700
|
-
/** 一个对象的**自有数据属性**值(不触发访问器;不是普通对象 ⇒ undefined)。 */
|
|
1701
|
-
function ownDataValue(obj, key) {
|
|
1702
|
-
if (obj === null || typeof obj !== "object" || Array.isArray(obj))
|
|
1703
|
-
return undefined;
|
|
1704
|
-
const d = Object.getOwnPropertyDescriptor(obj, key);
|
|
1705
|
-
return d !== undefined && "value" in d ? d.value : undefined;
|
|
1706
|
-
}
|
|
1707
|
-
function retiredWorkerFieldIssues(raw, ctx) {
|
|
1708
|
-
for (const { parent, key, now } of RETIRED_WORKER_FIELDS) {
|
|
1709
|
-
const container = ownDataValue(raw, parent);
|
|
1710
|
-
if (container !== null && typeof container === "object" && !Array.isArray(container) && Object.hasOwn(container, key)) {
|
|
1711
|
-
ctx.addIssue({
|
|
1712
|
-
code: z.ZodIssueCode.custom,
|
|
1713
|
-
path: [parent, key],
|
|
1714
|
-
message: `${parent}.${key} was removed in 7.0.0 (now ${now}) — ${MIGRATION_SIGNPOST}`,
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
return raw;
|
|
1719
|
-
}
|
|
1720
|
-
/** 一台 worker 的名字(唯一 → 容器名 / compose project / 缺省路由键)—— **唯一**的名字判据。中心在租约 / 用量 /
|
|
1721
|
-
* discovery `?worker=` 等面上校验外部传来的 worker 名时读它(不抄窄正则)。7.0.0 起单独导出:`WorkerSpec` 不再是普通
|
|
1722
|
-
* 对象 schema(预处理 + 跨字段判据),`WorkerSpec.shape.name` 不复存在。 */
|
|
1801
|
+
/** 一台 worker 的名字(唯一 → 容器名 / compose project / 缺省路由键)—— **唯一**的名字判据。web-admin 在租约 / 用量 /
|
|
1802
|
+
* discovery `?worker=` 等面上校验外部传来的 worker 名时读它(不抄窄正则)。 */
|
|
1723
1803
|
export const WORKER_NAME = z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces");
|
|
1724
1804
|
const WorkerSpecObject = z.object({
|
|
1725
1805
|
/** 唯一 → 容器名 / compose project. */
|
|
@@ -1739,7 +1819,7 @@ const WorkerSpecObject = z.object({
|
|
|
1739
1819
|
* Hash-INCLUDED → a change re-applies the Deployment (k8s scales IN PLACE via SSA, no pod destruction). The
|
|
1740
1820
|
* docker backend is single-container: it warns and runs 1 if this is >1. Capped at 100 (resource-exhaustion
|
|
1741
1821
|
* guard — a typo'd 1e6 mustn't try to schedule a million pods; raise the cap if a real workload needs it). */
|
|
1742
|
-
replicas:
|
|
1822
|
+
replicas: INTEGER.positive().max(100).optional(),
|
|
1743
1823
|
/** AUTOSCALING (k8s backend, HPA) — declare bounds and let the cluster's HorizontalPodAutoscaler own the
|
|
1744
1824
|
* replica count (autoscaling/v2, CPU utilization; prove-before-scale 解禁 2026-06-13). Mutually exclusive
|
|
1745
1825
|
* with `replicas` (WorkersConfig rejects both — two owners of the same knob WILL fight). The orchestrator
|
|
@@ -1750,10 +1830,10 @@ const WorkerSpecObject = z.object({
|
|
|
1750
1830
|
* single-container, warns + ignores. Hash-INCLUDED; absent → no HPA, today's behaviour (zero churn). */
|
|
1751
1831
|
autoscaling: z
|
|
1752
1832
|
.object({
|
|
1753
|
-
minReplicas:
|
|
1754
|
-
maxReplicas:
|
|
1833
|
+
minReplicas: INTEGER.positive().max(100),
|
|
1834
|
+
maxReplicas: INTEGER.positive().max(100),
|
|
1755
1835
|
/** Target average CPU utilization %, vs resources.requests.cpu. Absent → backend default 80. */
|
|
1756
|
-
targetCpuPercent:
|
|
1836
|
+
targetCpuPercent: INTEGER.min(10).max(95).optional(),
|
|
1757
1837
|
})
|
|
1758
1838
|
.refine((a) => a.maxReplicas >= a.minReplicas, { message: "maxReplicas must be >= minReplicas" })
|
|
1759
1839
|
.optional(),
|
|
@@ -1770,19 +1850,19 @@ const WorkerSpecObject = z.object({
|
|
|
1770
1850
|
* hash-INCLUDED like every spec field — absent → contract defaults (zero churn on existing fleets). */
|
|
1771
1851
|
fleet: z
|
|
1772
1852
|
.object({
|
|
1773
|
-
/** Per-worker dispatch policy consumed by
|
|
1853
|
+
/** Per-worker dispatch policy consumed by web-admin `/api/dispatch` (D3: the gateway hop is optional;
|
|
1774
1854
|
* only affects traffic routed through it). Absent → contract defaults (round-robin, affinity on). */
|
|
1775
1855
|
dispatch: DispatchPolicy.optional(),
|
|
1776
1856
|
/** D1 per-worker drain-grace override → delivered to the container as env `DRAIN_GRACE_MS`
|
|
1777
1857
|
* (worker-side default 600s, floor 10s). */
|
|
1778
|
-
drainGraceMs:
|
|
1858
|
+
drainGraceMs: INTEGER.positive().optional(),
|
|
1779
1859
|
})
|
|
1780
1860
|
.optional(),
|
|
1781
1861
|
// `model` DELETED in 0.7.0 (EXPERT-REDESIGN §3 拍板 + E3 迁移闭环 — a2 存量 5 worker 已迁移目录+roster
|
|
1782
1862
|
// 标记,E3):模型/网关配置单一真源=models 目录,worker 经 `roster` 引用;主/cheap boot env
|
|
1783
1863
|
// 归宿=roster.primaryModel/cheapModel 标记(0.6.3,service 已承接)。pre-0.7 存量 JSON 里的
|
|
1784
1864
|
// `model` 键被 parse 静默剥除(zod strips unknown)→ workerSpecHash 变化=一次性重建;迁移工具输入=
|
|
1785
|
-
//
|
|
1865
|
+
// 冻结的旧 worker.model 形(0.5 → 0.7 迁移工具,11.0.0 随 `./migrate` 出口删除),必须在升级 0.7 前跑完(a2 已闭环,E3)。
|
|
1786
1866
|
/** 运行时 (非 secret). 映射到 service REQUIRE_PRINCIPAL / RATE_LIMIT_RPM / … 与两个结构块(7.0.0)。 */
|
|
1787
1867
|
runtime: z.object({
|
|
1788
1868
|
/** 7.0.0 执行车道块(见 `ExecutionBlock`)。enabled 的 worker 必填(WorkerSpec 层判);停用的可缺。 */
|
|
@@ -1791,52 +1871,61 @@ const WorkerSpecObject = z.object({
|
|
|
1791
1871
|
store: StoreBlock.optional(),
|
|
1792
1872
|
scenario: z.string().optional(), // DEFAULT_SCENARIO
|
|
1793
1873
|
requirePrincipal: z.boolean().default(false),
|
|
1794
|
-
rateLimitRpm:
|
|
1874
|
+
rateLimitRpm: INTEGER.nonnegative().default(0),
|
|
1795
1875
|
/** Per-principal cumulative cost CAP (USD) → MAX_PRINCIPAL_COST_USD → the service's cost gate ENFORCEMENT
|
|
1796
1876
|
* (over-cap → 429). Pairs with model.cost (which only ATTRIBUTES cost). ABSENT → no env → the service
|
|
1797
1877
|
* defaults to 0 = off (today's behaviour); EXPLICIT 0 emits "0" = block-all and is HASH-DISTINCT from
|
|
1798
1878
|
* absent (so the guard is `!= null`, not `> 0` — the two carry different caller intent). Optional + NO
|
|
1799
1879
|
* default → absent never materialises → no spec-hash churn. Hash-included (read at boot → cap change recreates). */
|
|
1800
1880
|
maxPrincipalCostUsd: z.number().finite().nonnegative().optional(),
|
|
1881
|
+
sessionBackend: retiredKey("runtime.sessionBackend", sixXField("runtime.store.session")),
|
|
1882
|
+
memoryBackend: retiredKey("runtime.memoryBackend", sixXField("runtime.store.memory — the old field never reached the engine")),
|
|
1883
|
+
db: retiredKey("runtime.db", sixXField("runtime.store.db.database")),
|
|
1801
1884
|
}),
|
|
1802
|
-
/** 物理坐标 (非 secret). 映射到 GIT_API_BASEURL。7.0.0 起 SQL 坐标在 `runtime.store.db`(`tidb*`
|
|
1885
|
+
/** 物理坐标 (非 secret). 映射到 GIT_API_BASEURL。7.0.0 起 SQL 坐标在 `runtime.store.db`(`tidb*` 三键删除,墓碑在下)。 */
|
|
1803
1886
|
coords: z
|
|
1804
1887
|
.object({
|
|
1805
1888
|
gitApiBaseUrl: NON_SECRET_URL.optional().or(z.literal("")),
|
|
1889
|
+
tidbHost: retiredKey("coords.tidbHost", sixXField("runtime.store.db.host")),
|
|
1890
|
+
tidbPort: retiredKey("coords.tidbPort", sixXField("runtime.store.db.port")),
|
|
1891
|
+
tidbUser: retiredKey("coords.tidbUser", sixXField("runtime.store.db.user")),
|
|
1806
1892
|
})
|
|
1807
|
-
.
|
|
1893
|
+
.prefault({}),
|
|
1808
1894
|
secretRefs: WorkerSecretRefs,
|
|
1809
1895
|
/**
|
|
1810
1896
|
* 通用 env 透传(逃生口)——给**没有专属结构化字段**的 service env 用,让任意 worker 声明自己的 app 专属变量
|
|
1811
|
-
*
|
|
1897
|
+
* (如某个应用的 `MY_APP_BASEURL`),不必为每个变量改契约。值是**字面非密值**(≤2048,且拒绝明显的密钥串——密钥走
|
|
1812
1898
|
* `secretEnv`)。**只能加结构化字段没产生的键**:`RESERVED_WORKER_ENV` 里的键(PORT、MODEL_x、gateway、auth、
|
|
1813
|
-
* secret 名…)被拒,防 env 改路由/关鉴权/遮蔽密钥(council)
|
|
1899
|
+
* secret 名…)被拒,防 env 改路由/关鉴权/遮蔽密钥(council);墓碑表 `RETIRED_ENVS` 里的旧名被拒(引擎见之拒启;空串 = 未设,同引擎)。
|
|
1900
|
+
* 可覆盖非保留键(如 LOG_LEVEL)。hash-included(改它重建)。
|
|
1814
1901
|
*/
|
|
1815
1902
|
env: z
|
|
1816
1903
|
.record(ENV_NAME, z.string().max(2048))
|
|
1817
1904
|
.superRefine((rec, ctx) => {
|
|
1905
|
+
retiredEnvIssue(rec, ctx);
|
|
1818
1906
|
for (const [k, v] of Object.entries(rec)) {
|
|
1819
1907
|
if (RESERVED_WORKER_ENV.has(k))
|
|
1820
|
-
ctx.addIssue({ code: "custom",
|
|
1908
|
+
ctx.addIssue({ code: "custom", ...reservedKeyIssue("env", k) });
|
|
1821
1909
|
// 0.1.32: NO name exemption — a PUBLIC key is allowed because SECRET_LITERAL_RE now only flags PRIVATE keys
|
|
1822
1910
|
// (root fix), so a literal private key / token in env (ANY name, incl `*_PUBKEY`) is still rejected.
|
|
1823
1911
|
if (containsSecretToken(v))
|
|
1824
|
-
ctx.addIssue({ code: "custom",
|
|
1912
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `env["${k}"] looks like a literal secret — put secrets in secretEnv (an env-NAME), never a value in env` }); // tokenized (mid-string too). R8
|
|
1825
1913
|
}
|
|
1826
1914
|
})
|
|
1827
1915
|
.optional(),
|
|
1828
1916
|
/**
|
|
1829
1917
|
* 通用**密钥** env 透传:`{ service 的环境变量名 → 宿主机 env-NAME }`。编排器把宿主机 env-NAME 从它的 secret 源
|
|
1830
|
-
* 解析成真值、经 secretKeyRef 注入——**
|
|
1831
|
-
* 故无法粘贴真值)。给 app 专属令牌用(如 `
|
|
1832
|
-
* 故密钥永不被 `env` 同名键遮蔽。`RESERVED_WORKER_ENV` 里的键被拒(那些结构化 secret 走 `secretRefs
|
|
1918
|
+
* 解析成真值、经 secretKeyRef 注入——**web-admin 只存名、绝不存值**(同 `secretRefs` 边界,值也是 `ENV_NAME`
|
|
1919
|
+
* 故无法粘贴真值)。给 app 专属令牌用(如 `MY_APP_TOKEN` → 某 env-名)。在 reconciler 里于 `env` 之后解析,
|
|
1920
|
+
* 故密钥永不被 `env` 同名键遮蔽。`RESERVED_WORKER_ENV` 里的键被拒(那些结构化 secret 走 `secretRefs`);墓碑表 `RETIRED_ENVS` 里的旧名被拒。
|
|
1833
1921
|
*/
|
|
1834
1922
|
secretEnv: z
|
|
1835
1923
|
.record(ENV_NAME, ENV_NAME)
|
|
1836
1924
|
.superRefine((rec, ctx) => {
|
|
1925
|
+
retiredEnvIssue(rec, ctx);
|
|
1837
1926
|
for (const k of Object.keys(rec))
|
|
1838
1927
|
if (RESERVED_WORKER_ENV.has(k))
|
|
1839
|
-
ctx.addIssue({ code: "custom",
|
|
1928
|
+
ctx.addIssue({ code: "custom", ...reservedKeyIssue("secretEnv", k) });
|
|
1840
1929
|
})
|
|
1841
1930
|
.optional(),
|
|
1842
1931
|
/** Opaque recreate trigger (hash-INCLUDED): bump it to force the reconciler to recreate the worker — e.g. to
|
|
@@ -1845,19 +1934,19 @@ const WorkerSpecObject = z.object({
|
|
|
1845
1934
|
recreateNonce: z.string().max(64).optional(),
|
|
1846
1935
|
/**
|
|
1847
1936
|
* 生命周期 (v2②, design/25 §3)。可选——不填 = `persistent`(默认安全,绝不被 GC 误杀)。
|
|
1848
|
-
* **reconciler 不读这个字段**(保持纯收敛);TTL/idle 决策全在
|
|
1937
|
+
* **reconciler 不读这个字段**(保持纯收敛);TTL/idle 决策全在 web-admin 侧 sweeper:到期就把 worker
|
|
1849
1938
|
* 从 desired 移除,reconciler 声明式自然 teardown。`ttl`/`idleTimeout` 用时长串("30m"/"2h"/"7d")。
|
|
1850
1939
|
*/
|
|
1851
1940
|
lifecycle: WorkerLifecycle.optional(),
|
|
1852
1941
|
/**
|
|
1853
|
-
* 多主机调度
|
|
1854
|
-
*
|
|
1855
|
-
*
|
|
1942
|
+
* 多主机调度(可选)。`placement.auto` 判 `host` 是**钉**还是**提示**:`auto:false`(缺省)⇒ `host` 是钉 —— 运维指定的那一台,
|
|
1943
|
+
* 只在那台起;`auto:true` ⇒ web-admin 的排布按容量 / 标签约束挑一台写回 `host`,那是提示,排布可改挑。`requires` = host 必须具备的
|
|
1944
|
+
* 全部标签(hosts 域声明)。不加单独的「钉」字段:钉 / 提示已由 `placement.auto` 编码,再加一个字段 = 同一事实两处写。
|
|
1856
1945
|
*/
|
|
1857
1946
|
placement: z
|
|
1858
1947
|
.object({
|
|
1859
1948
|
auto: z.boolean().default(false),
|
|
1860
|
-
requires: z.array(z.string().min(1)).
|
|
1949
|
+
requires: z.array(z.string().min(1)).prefault([]),
|
|
1861
1950
|
/** SOFT anti-affinity group: auto workers sharing this key are spread across hosts when possible
|
|
1862
1951
|
* (preferred, never blocks placement — the scheduling design note). 不填 = 无 spread 约束。 */
|
|
1863
1952
|
spread: z.string().min(1).optional(),
|
|
@@ -1865,7 +1954,7 @@ const WorkerSpecObject = z.object({
|
|
|
1865
1954
|
.optional(),
|
|
1866
1955
|
/**
|
|
1867
1956
|
* per-worker roster 引用 (the multi-roster design note)。指向 `rosters` 域里一个命名 roster;该 worker 的 service 拉
|
|
1868
|
-
* `GET /api/config/effective?worker=<name>` 时,
|
|
1957
|
+
* `GET /api/config/effective?worker=<name>` 时,web-admin 用这个 roster 解析它专属的 models/roles/@白名单。
|
|
1869
1958
|
* 不填 → 该 worker 拿全局 models(向后兼容)。**不进 `workerSpecHash`**(改 roster 只让 service 重拉、不重建容器)。
|
|
1870
1959
|
*/
|
|
1871
1960
|
roster: z.string().min(1).optional(),
|
|
@@ -1875,29 +1964,26 @@ const WorkerSpecObject = z.object({
|
|
|
1875
1964
|
});
|
|
1876
1965
|
/** 跨字段判据(G1 / G2 / host∧多副本 / store 四条)。每条一个 issue,`path` 指到要改的字段。 */
|
|
1877
1966
|
function workerSpecIssues(w, ctx) {
|
|
1878
|
-
const issue = (path,
|
|
1967
|
+
const issue = (path, q) => ctx.addIssue({ code: "custom", path, ...q });
|
|
1879
1968
|
const { execution, store, requirePrincipal } = w.runtime;
|
|
1880
1969
|
// G1:enabled 的 worker 必须声明车道与店(停用的不渲染,可缺)。
|
|
1881
1970
|
if (w.enabled && execution === undefined) {
|
|
1882
|
-
issue(["runtime", "execution", "lane"], `an enabled worker must declare its execution lane — runtime.execution.lane: ${EXECUTION_LANE_WORDS.join(" | ")}. `
|
|
1883
|
-
`Without one it would boot with no hands and still report tasks completed; there is no "no hands" lane — disable the worker instead`);
|
|
1971
|
+
issue(["runtime", "execution", "lane"], buildQuotedIssue `an enabled worker must declare its execution lane — runtime.execution.lane: ${fixed(EXECUTION_LANE_WORDS.join(" | "))}. Without one it would boot with no hands and still report tasks completed; there is no "no hands" lane — disable the worker instead`);
|
|
1884
1972
|
}
|
|
1885
1973
|
if (w.enabled && store === undefined) {
|
|
1886
|
-
issue(["runtime", "store", "backend"], `an enabled worker must declare its store — runtime.store.backend: ${STORE_BACKENDS.join(" | ")}`);
|
|
1974
|
+
issue(["runtime", "store", "backend"], buildQuotedIssue `an enabled worker must declare its store — runtime.store.backend: ${fixed(STORE_BACKENDS.join(" | "))}`);
|
|
1887
1975
|
}
|
|
1888
1976
|
if (execution !== undefined) {
|
|
1889
1977
|
// G2:多租户姿态不许跑不分租户的车道;出路按表现算(不手写第五份词表)。
|
|
1890
1978
|
if (requirePrincipal && laneRowOf(execution.lane).tenantSeparation === "single-user-only") {
|
|
1891
1979
|
const outlets = EXECUTION_LANES.filter((r) => r.tenantSeparation === "supported").map((r) => r.lane);
|
|
1892
|
-
issue(["runtime", "execution", "lane"], `runtime.requirePrincipal=true (multi-tenant posture) with the "${execution.lane}" execution lane runs every tenant's Bash on one shared target — `
|
|
1893
|
-
`this lane does not separate tenants; set runtime.execution.lane to ${outlets.join(" / ")}, or set runtime.requirePrincipal=false for a single-user worker`);
|
|
1980
|
+
issue(["runtime", "execution", "lane"], buildQuotedIssue `runtime.requirePrincipal=true (multi-tenant posture) with the "${execution.lane}" execution lane runs every tenant's Bash on one shared target — this lane does not separate tenants; set runtime.execution.lane to ${fixed(outlets.join(" / "))}, or set runtime.requirePrincipal=false for a single-user worker`);
|
|
1894
1981
|
}
|
|
1895
1982
|
// host ∧ 多副本:副本数有两个旋钮(replicas / autoscaling.maxReplicas,二者互斥),一条规则看「最多几个副本」。
|
|
1896
1983
|
if (execution.lane === "host") {
|
|
1897
1984
|
const [path, replicas] = w.autoscaling !== undefined ? [["autoscaling", "maxReplicas"], w.autoscaling.maxReplicas] : [["replicas"], w.replicas ?? 1];
|
|
1898
1985
|
if (replicas > 1) {
|
|
1899
|
-
issue(path, `the "host" execution lane runs tools on each replica's own filesystem — ${replicas} replicas would hold ${replicas} different workspaces behind one route, `
|
|
1900
|
-
`so a session's next turn can land where its files are not. Keep a single replica, or choose a lane other than "host"`);
|
|
1986
|
+
issue(path, buildQuotedIssue `the "host" execution lane runs tools on each replica's own filesystem — ${replicas} replicas would hold ${replicas} different workspaces behind one route, so a session's next turn can land where its files are not. Keep a single replica, or choose a lane other than "host"`);
|
|
1901
1987
|
}
|
|
1902
1988
|
}
|
|
1903
1989
|
}
|
|
@@ -1913,46 +1999,42 @@ function workerSpecIssues(w, ctx) {
|
|
|
1913
1999
|
// 写闸判不了 —— 那一条只在起服拒。
|
|
1914
2000
|
if (execution?.lane === "device" && store !== undefined && !sqlPoolOpens) {
|
|
1915
2001
|
if (!sql) {
|
|
1916
|
-
issue(["runtime", "store", "backend"], `the "device" execution lane keeps its device registry, session↔device binding and audit trail in SQL — store.backend "${store.backend}" has none `
|
|
1917
|
-
`(the engine refuses to start: device-durable-store); set store.backend to mysql | pg`);
|
|
2002
|
+
issue(["runtime", "store", "backend"], buildQuotedIssue `the "device" execution lane keeps its device registry, session↔device binding and audit trail in SQL — store.backend "${store.backend}" has none (the engine refuses to start: device-durable-store); set store.backend to mysql | pg`);
|
|
1918
2003
|
}
|
|
1919
2004
|
else {
|
|
1920
|
-
issue(["runtime", "store", "session"], `the "device" execution lane needs the SQL store actually built — store.session "memory" builds no SQL coordinates `
|
|
1921
|
-
`(the engine refuses to start: device-durable-store); set store.session to auto | mysql`);
|
|
2005
|
+
issue(["runtime", "store", "session"], buildQuotedIssue `the "device" execution lane needs the SQL store actually built — store.session "memory" builds no SQL coordinates (the engine refuses to start: device-durable-store); set store.session to auto | mysql`);
|
|
1922
2006
|
}
|
|
1923
2007
|
}
|
|
1924
2008
|
if (store !== undefined) {
|
|
1925
2009
|
// ⑧ session mysql 要一个 durable 会话店,memory 后端一只都不建(`plugins/session-store.ts:17`「a DB session backend requires a store backend」)。
|
|
1926
2010
|
if (store.session === "mysql" && store.backend === "memory") {
|
|
1927
|
-
issue(["runtime", "store", "session"], `store.session "mysql" asks for a durable session store, but store.backend "memory" builds none (the engine refuses to start: a DB session backend requires a store backend); `
|
|
1928
|
-
`set store.session to auto | memory, or store.backend to mysql | pg | local`);
|
|
2011
|
+
issue(["runtime", "store", "session"], buildQuotedIssue `store.session "mysql" asks for a durable session store, but store.backend "memory" builds none (the engine refuses to start: a DB session backend requires a store backend); set store.session to auto | memory, or store.backend to mysql | pg | local`);
|
|
1929
2012
|
}
|
|
1930
2013
|
// ⑨ 记忆引擎开 ∧ 方言 pg / tidb ⇒ 引擎绑 SQL 池(`boot/stores.ts:209` / `:232` / `:300`);session memory 时池不开 ⇒ 起服拒。
|
|
1931
2014
|
if (store.memory.engine === "on" && MEMORY_BACKEND_NEEDS_STORE[store.memory.backend] !== undefined && sql && !sqlPoolOpens) {
|
|
1932
|
-
issue(["runtime", "store", "session"], `store.memory.backend "${store.memory.backend}" binds the SQL pool, but store.session "memory" never opens it (the engine refuses to start half-configured); `
|
|
1933
|
-
`set store.session to auto | mysql, or store.memory.backend to file, or store.memory.engine to off`);
|
|
2015
|
+
issue(["runtime", "store", "session"], buildQuotedIssue `store.memory.backend "${store.memory.backend}" binds the SQL pool, but store.session "memory" never opens it (the engine refuses to start half-configured); set store.session to auto | mysql, or store.memory.backend to file, or store.memory.engine to off`);
|
|
1934
2016
|
}
|
|
1935
2017
|
}
|
|
1936
2018
|
if (store !== undefined) {
|
|
1937
2019
|
if (sql && store.db === undefined)
|
|
1938
|
-
issue(["runtime", "store", "db"], `store.backend "${store.backend}" needs runtime.store.db {host, port, user, database}`);
|
|
2020
|
+
issue(["runtime", "store", "db"], buildQuotedIssue `store.backend "${store.backend}" needs runtime.store.db {host, port, user, database}`);
|
|
1939
2021
|
if (!sql && store.db !== undefined)
|
|
1940
|
-
issue(["runtime", "store", "db"], `store.backend "${store.backend}" uses no SQL server — remove runtime.store.db, or set store.backend to mysql | pg`);
|
|
2022
|
+
issue(["runtime", "store", "db"], buildQuotedIssue `store.backend "${store.backend}" uses no SQL server — remove runtime.store.db, or set store.backend to mysql | pg`);
|
|
1941
2023
|
const needs = MEMORY_BACKEND_NEEDS_STORE[store.memory.backend];
|
|
1942
2024
|
if (needs !== undefined && store.backend !== needs) {
|
|
1943
|
-
issue(["runtime", "store", "memory", "backend"], `store.memory.backend "${store.memory.backend}" keeps memory in the ${needs} store — it needs store.backend "${needs}" (got "${store.backend}")`);
|
|
2025
|
+
issue(["runtime", "store", "memory", "backend"], buildQuotedIssue `store.memory.backend "${store.memory.backend}" keeps memory in the ${fixed(needs)} store — it needs store.backend "${fixed(needs)}" (got "${store.backend}")`);
|
|
1944
2026
|
}
|
|
1945
2027
|
if (requirePrincipal && !sql) {
|
|
1946
|
-
issue(["runtime", "store", "backend"], `runtime.requirePrincipal=true (multi-tenant posture) needs a SQL store — store.backend "${store.backend}" has no tenant isolation; set store.backend to mysql | pg`);
|
|
2028
|
+
issue(["runtime", "store", "backend"], buildQuotedIssue `runtime.requirePrincipal=true (multi-tenant posture) needs a SQL store — store.backend "${store.backend}" has no tenant isolation; set store.backend to mysql | pg`);
|
|
1947
2029
|
}
|
|
1948
2030
|
}
|
|
1949
2031
|
if (sql && w.secretRefs.dbPasswordEnv === undefined)
|
|
1950
|
-
issue(["secretRefs", "dbPasswordEnv"], `a SQL store (runtime.store.backend mysql | pg) needs secretRefs.dbPasswordEnv — the host env-NAME holding the database password`);
|
|
2032
|
+
issue(["secretRefs", "dbPasswordEnv"], buildQuotedIssue `a SQL store (runtime.store.backend mysql | pg) needs secretRefs.dbPasswordEnv — the host env-NAME holding the database password`);
|
|
1951
2033
|
if (!sql && w.secretRefs.dbPasswordEnv !== undefined)
|
|
1952
|
-
issue(["secretRefs", "dbPasswordEnv"], `secretRefs.dbPasswordEnv is set but there is no SQL store (runtime.store.backend mysql | pg) — remove it`);
|
|
2034
|
+
issue(["secretRefs", "dbPasswordEnv"], buildQuotedIssue `secretRefs.dbPasswordEnv is set but there is no SQL store (runtime.store.backend mysql | pg) — remove it`);
|
|
1953
2035
|
}
|
|
1954
2036
|
/** 一台 worker 的声明。写闸 = `WorkerSpec.safeParse`(7.0.0 删除项读到即拒 + 两个结构块 + 跨字段判据)。 */
|
|
1955
|
-
export const WorkerSpec =
|
|
2037
|
+
export const WorkerSpec = WorkerSpecObject.superRefine(workerSpecIssues);
|
|
1956
2038
|
/** Effective route key for a worker = explicit routePrefix, else its name. */
|
|
1957
2039
|
export function workerRouteKey(w) {
|
|
1958
2040
|
return w.routePrefix ?? w.name;
|
|
@@ -1967,27 +2049,27 @@ export const WORKER_RESOURCE_PRESETS = {
|
|
|
1967
2049
|
L: { requests: { cpu: "1", memory: "2Gi" }, limits: { cpu: "4", memory: "4Gi" } },
|
|
1968
2050
|
};
|
|
1969
2051
|
export const WorkersConfig = z
|
|
1970
|
-
.object({ workers: z.array(WorkerSpec).
|
|
2052
|
+
.object({ workers: z.array(WorkerSpec).prefault([]) })
|
|
1971
2053
|
.superRefine((cfg, ctx) => {
|
|
1972
2054
|
// worker `name` and effective routePrefix must each be unique across the fleet (design/25 决策7:
|
|
1973
|
-
// routePrefix 唯一性
|
|
2055
|
+
// routePrefix 唯一性 web-admin 提交时校验). Dup name → container-name clash; dup route → ambiguous
|
|
1974
2056
|
// reverse-proxy routing.
|
|
1975
2057
|
const names = new Set();
|
|
1976
2058
|
const routes = new Set();
|
|
1977
2059
|
cfg.workers.forEach((w, i) => {
|
|
1978
2060
|
if (names.has(w.name))
|
|
1979
|
-
ctx.addIssue({ code:
|
|
2061
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate worker name "${w.name}"`, path: ["workers", i, "name"] });
|
|
1980
2062
|
names.add(w.name);
|
|
1981
2063
|
const route = workerRouteKey(w);
|
|
1982
2064
|
// point the error at the field that actually set the route (explicit routePrefix, else the name fallback).
|
|
1983
2065
|
const routeField = w.routePrefix !== undefined ? "routePrefix" : "name";
|
|
1984
2066
|
if (routes.has(route))
|
|
1985
|
-
ctx.addIssue({ code:
|
|
2067
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate routePrefix "${route}" (collides with another worker's routePrefix/name)`, path: ["workers", i, routeField] });
|
|
1986
2068
|
routes.add(route);
|
|
1987
2069
|
// replicas + autoscaling = two owners of the same knob (the orchestrator's SSA vs the HPA controller
|
|
1988
2070
|
// would endlessly fight over spec.replicas) → reject at save, not at reconcile.
|
|
1989
2071
|
if (w.replicas !== undefined && w.autoscaling) {
|
|
1990
|
-
ctx.addIssue({ code:
|
|
2072
|
+
ctx.addIssue({ code: "custom", message: "replicas and autoscaling are mutually exclusive — the HPA owns the replica count when autoscaling is set", path: ["workers", i, "autoscaling"] });
|
|
1991
2073
|
}
|
|
1992
2074
|
});
|
|
1993
2075
|
});
|
|
@@ -2004,9 +2086,9 @@ export const WorkerStatus = z.object({
|
|
|
2004
2086
|
name: z.string().min(1),
|
|
2005
2087
|
host: z.string().min(1), // 哪个 reconciler 上报的
|
|
2006
2088
|
phase: z.enum(["Pending", "Running", "Failed", "Unknown"]),
|
|
2007
|
-
conditions: z.array(WorkerCondition).
|
|
2089
|
+
conditions: z.array(WorkerCondition).prefault([]),
|
|
2008
2090
|
/** reconciler 处理时的 effective `version` — admin 改 spec 后 version 变,据此判断 status 是否陈旧. */
|
|
2009
|
-
observedVersion:
|
|
2091
|
+
observedVersion: INTEGER.nonnegative(),
|
|
2010
2092
|
/** reconciler 算的逻辑 spec hash (漂移检测 + 防振荡, design/22 失败模式#1). */
|
|
2011
2093
|
observedSpecHash: z.string().optional(),
|
|
2012
2094
|
message: z.string().optional(),
|
|
@@ -2018,20 +2100,22 @@ export const WorkerStatus = z.object({
|
|
|
2018
2100
|
// - `capacity` DELETED —— 可放性改由探测值折算(HostStatus.probes − Σ已排 worker requests;计数制→资源制
|
|
2019
2101
|
// 是探测数据齐备后的**独立开关**,E2 placement 承接,切换前沿用计数制兜底、不按票数)。
|
|
2020
2102
|
// - `approved` NEW —— 默认 false = 待批(不参与自动排布;enforcement 随 E2 placement 生效,E1 只落契约)。
|
|
2021
|
-
// 存量迁移 = grandfathering 批量回填 approved:true(migrate
|
|
2022
|
-
/** DNS-label bound on a host NAME (0.8.0 tightening; single source — schema
|
|
2023
|
-
*
|
|
2103
|
+
// 存量迁移 = grandfathering 批量回填 approved:true(0.5 → 0.6 迁移工具,11.0.0 随 `./migrate` 出口删除),存量机器不出现排布空窗(§11-9)。
|
|
2104
|
+
/** DNS-label bound on a host NAME (0.8.0 tightening; single source — the schema and the one-time migration SS-M2 that
|
|
2105
|
+
* rewrites 0.7-era stored data over it both read this constant). */
|
|
2024
2106
|
export const HOST_NAME_MAX = 63;
|
|
2025
2107
|
/** Ceiling on the hosts ARRAY (0.8.0 tightening; same single-source rule as {@link HOST_NAME_MAX}). */
|
|
2026
2108
|
export const HOSTS_ARRAY_MAX = 1024;
|
|
2109
|
+
/** 两条上界的拒句尾:0.8.0 之前存下的超界数据由一次性迁移 SS-M2 改写(11.0.0 起读侧不再夹紧)。 */
|
|
2110
|
+
const HOSTS_BOUND_SIGNPOST = migrationSignpost("SS-M2", "0.8.0");
|
|
2027
2111
|
export const HostEntry = z.object({
|
|
2028
|
-
// .max(HOST_NAME_MAX=63) = DNS-label bound (E2 F3③ 挂账收账, 0.8.0):
|
|
2029
|
-
name: z.string().min(1).max(HOST_NAME_MAX).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
2112
|
+
// .max(HOST_NAME_MAX=63) = DNS-label bound (E2 F3③ 挂账收账, 0.8.0): web-admin-side probe clamp stays as双保险.
|
|
2113
|
+
name: z.string().min(1).max(HOST_NAME_MAX, `host name longer than ${HOST_NAME_MAX} characters (a DNS label) — ${HOSTS_BOUND_SIGNPOST}`).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
2030
2114
|
/** 待批准入编状态机(§2-3):自注册/新登记默认 false = 待批,不参与自动排布;admin 批准(走 publish)→
|
|
2031
2115
|
* true 参排(enforcement 随 E2)。既有 worker 不受影响(reconciler 仍按具体 host 认领)。 */
|
|
2032
2116
|
approved: z.boolean().default(false),
|
|
2033
2117
|
/** 调度标签(如 "gpu"/"region-us"),worker 的 `placement.requires` 据此匹配。 */
|
|
2034
|
-
labels: z.array(z.string().min(1)).
|
|
2118
|
+
labels: z.array(z.string().min(1)).prefault([]),
|
|
2035
2119
|
/** 关闭 = 完全不可调度(既有 worker 仍由 reconciler 管,但 placement 视它不可用)。 */
|
|
2036
2120
|
enabled: z.boolean().default(true),
|
|
2037
2121
|
/** drain = 不再往这台**新**放 worker(既有的留着),用于下线前腾空。 */
|
|
@@ -2039,16 +2123,15 @@ export const HostEntry = z.object({
|
|
|
2039
2123
|
notes: z.string().optional(),
|
|
2040
2124
|
});
|
|
2041
2125
|
export const HostsConfig = z
|
|
2042
|
-
// .max(HOSTS_ARRAY_MAX=1024) hosts (E2 F3③ 挂账收账, 0.8.0) — a fleet registry, not a datacenter inventory;
|
|
2043
|
-
// an absurdly long array is a poisoned publish, fail-loud at save time.
|
|
2044
|
-
//
|
|
2045
|
-
|
|
2046
|
-
.object({ hosts: z.array(HostEntry).max(HOSTS_ARRAY_MAX).default([]) })
|
|
2126
|
+
// .max(HOSTS_ARRAY_MAX=1024) hosts (E2 F3③ 挂账收账, 0.8.0) — a fleet host registry, not a datacenter inventory;
|
|
2127
|
+
// an absurdly long array is a poisoned publish, fail-loud at save time. The read path judges with the same schema
|
|
2128
|
+
// (11.0.0: the 0.8.1 read-side clamp is gone; 0.7-era stored data over these bounds is rewritten once by SS-M2).
|
|
2129
|
+
.object({ hosts: z.array(HostEntry).max(HOSTS_ARRAY_MAX, `more than ${HOSTS_ARRAY_MAX} hosts — ${HOSTS_BOUND_SIGNPOST}`).prefault([]) })
|
|
2047
2130
|
.superRefine((cfg, ctx) => {
|
|
2048
2131
|
const seen = new Set();
|
|
2049
2132
|
cfg.hosts.forEach((h, i) => {
|
|
2050
2133
|
if (seen.has(h.name))
|
|
2051
|
-
ctx.addIssue({ code:
|
|
2134
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate host "${h.name}"`, path: ["hosts", i, "name"] });
|
|
2052
2135
|
seen.add(h.name);
|
|
2053
2136
|
});
|
|
2054
2137
|
});
|
|
@@ -2058,28 +2141,28 @@ export const HostsConfig = z
|
|
|
2058
2141
|
// (仅身份字段 {name, approved:false},幂等一条,来源 token 记审计——EXPERT-REDESIGN §2-1/§11-4)。E2
|
|
2059
2142
|
// placement 的资源制折算消费它(可分配 = probes − Σ已排 worker requests);从未上报的机器不参与自动排布。
|
|
2060
2143
|
// Sanity ceilings (E2 F3③ 挂账收账, 0.8.0): a forged/absurd probe (cpuCores=1e12) would suck every auto
|
|
2061
|
-
// worker onto one host. Ceilings MIRROR
|
|
2062
|
-
// PROBE_MEM_BYTES_CEILING=2^50=1PiB) — schema now fail-louds at report time;
|
|
2144
|
+
// worker onto one host. Ceilings MIRROR web-admin's probeCapacities clamp (PROBE_CPU_CORES_CEILING=4096 /
|
|
2145
|
+
// PROBE_MEM_BYTES_CEILING=2^50=1PiB) — schema now fail-louds at report time; web-admin's clamp stays 双保险.
|
|
2063
2146
|
export const PROBE_CPU_CORES_MAX = 4096;
|
|
2064
2147
|
export const PROBE_MEM_BYTES_MAX = 2 ** 50; // 1 PiB
|
|
2065
2148
|
export const HostProbes = z.object({
|
|
2066
2149
|
/** 探测到的 CPU 核数(docker info NCPU / k8s node allocatable cpu)。可为小数(k8s allocatable 如 3.9)。 */
|
|
2067
2150
|
cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX),
|
|
2068
2151
|
/** 探测到的内存字节数(docker info MemTotal / k8s node allocatable memory)。 */
|
|
2069
|
-
memBytes:
|
|
2152
|
+
memBytes: INTEGER.positive().max(PROBE_MEM_BYTES_MAX),
|
|
2070
2153
|
/** 当前跑着的容器数(docker info ContainersRunning / k8s node 上本 fleet 的 pod 数)。 */
|
|
2071
|
-
containersRunning:
|
|
2154
|
+
containersRunning: INTEGER.nonnegative(),
|
|
2072
2155
|
/** k8s backend 补充:node 名 + allocatable(与顶层同单位——cpuCores 核数 / memBytes 字节)。docker 无此段。 */
|
|
2073
2156
|
k8sNode: z
|
|
2074
2157
|
.object({
|
|
2075
2158
|
name: z.string().min(1),
|
|
2076
|
-
allocatable: z.object({ cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX).optional(), memBytes:
|
|
2159
|
+
allocatable: z.object({ cpuCores: z.number().finite().positive().max(PROBE_CPU_CORES_MAX).optional(), memBytes: INTEGER.positive().max(PROBE_MEM_BYTES_MAX).optional() }).optional(),
|
|
2077
2160
|
})
|
|
2078
2161
|
.optional(),
|
|
2079
2162
|
});
|
|
2080
2163
|
export const HostStatus = z.object({
|
|
2081
2164
|
/** host name(hosts 域 HostEntry.name / ORCH_HOST_ID 同字符集,同 .max(HOST_NAME_MAX=63) DNS-label bound)。
|
|
2082
|
-
* status 上报是 WRITE 路径 → fail-loud 保持(有意设计,S3 M-1 订正注记:
|
|
2165
|
+
* status 上报是 WRITE 路径 → fail-loud 保持(有意设计,S3 M-1 订正注记:web-admin hosts/status PUT 无独立
|
|
2083
2166
|
* probe 上限,schema 即闸;读路径 grandfather 只涉 config-hosts 域,不涉此处)。 */
|
|
2084
2167
|
host: z.string().min(1).max(HOST_NAME_MAX).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters/digits/-, no spaces"),
|
|
2085
2168
|
backend: z.enum(["docker", "k8s"]),
|
|
@@ -2090,7 +2173,7 @@ export const HostStatus = z.object({
|
|
|
2090
2173
|
// ── Domain: skills (中心化 skill 分发 — the mcp/skills design note, Roadmap B1) ─────────
|
|
2091
2174
|
// A skill is a prompt-level capability: frontmatter (name/description/scenarios) + markdown `content`
|
|
2092
2175
|
// the service feeds into the agent's model context when the skill is invoked (core progressive disclosure
|
|
2093
|
-
// — NOT the base system prompt). Still centrally-delivered agent instructions.
|
|
2176
|
+
// — NOT the base system prompt). Still centrally-delivered agent instructions. Web-admin stores the FULL skill
|
|
2094
2177
|
// (incl. content, admin-edited); /effective ships a MANIFEST (content → contentHash) and the service
|
|
2095
2178
|
// lazily pulls the body by hash (slice 2). Targeting is by `scenarios[]`, filtered service-side exactly
|
|
2096
2179
|
// like the in-image `skillsForScenario` (the mcp/skills design note §3.1, §6 Q2). Applied at service boot/reconcile,
|
|
@@ -2098,27 +2181,27 @@ export const HostStatus = z.object({
|
|
|
2098
2181
|
// (injected into the model on invoke) → writing this domain is gated to `publisher` + the publish gate
|
|
2099
2182
|
// (§3.3, DOMAIN_WRITE_ROLE in store.ts).
|
|
2100
2183
|
export const SkillSpec = z.object({
|
|
2101
|
-
/** Catalog key — must match the in-image SKILLS_DIR name to OVERRIDE it (§6 Q4
|
|
2184
|
+
/** Catalog key — must match the in-image SKILLS_DIR name to OVERRIDE it (§6 Q4 web-admin 按名覆盖). */
|
|
2102
2185
|
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
2103
2186
|
/** One-line summary (frontmatter `description`) — surfaced to the model for skill selection. */
|
|
2104
2187
|
description: z.string().default(""),
|
|
2105
2188
|
/** Scenarios this skill applies to; empty = all scenarios (service-side filter, same as skillsForScenario). */
|
|
2106
|
-
scenarios: z.array(z.string().min(1)).
|
|
2189
|
+
scenarios: z.array(z.string().min(1)).prefault([]),
|
|
2107
2190
|
/** Per-roster targeting (v2, the mcp/skills design note §6 Q2): roster NAMEs this skill is for. Empty = all rosters.
|
|
2108
2191
|
* Filtered at GET /effective?worker=<name> by the worker's roster; the unscoped global pull ships all. */
|
|
2109
|
-
rosters: z.array(z.string().min(1)).
|
|
2192
|
+
rosters: z.array(z.string().min(1)).prefault([]),
|
|
2110
2193
|
/** The markdown body injected into the model context on invoke (progressive disclosure). Stored in full here; shipped as a hash in /effective. */
|
|
2111
2194
|
content: z.string().default(""),
|
|
2112
2195
|
enabled: z.boolean().default(true),
|
|
2113
2196
|
notes: z.string().optional(),
|
|
2114
2197
|
});
|
|
2115
2198
|
export const SkillsConfig = z
|
|
2116
|
-
.object({ skills: z.array(SkillSpec).
|
|
2199
|
+
.object({ skills: z.array(SkillSpec).prefault([]) })
|
|
2117
2200
|
.superRefine((cfg, ctx) => {
|
|
2118
2201
|
const seen = new Set();
|
|
2119
2202
|
cfg.skills.forEach((s, i) => {
|
|
2120
2203
|
if (seen.has(s.name))
|
|
2121
|
-
ctx.addIssue({ code:
|
|
2204
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate skill "${s.name}"`, path: ["skills", i, "name"] });
|
|
2122
2205
|
seen.add(s.name);
|
|
2123
2206
|
});
|
|
2124
2207
|
});
|
|
@@ -2126,15 +2209,15 @@ export const SkillsConfig = z
|
|
|
2126
2209
|
* (`readEffectiveWire`). Derived from {@link SkillSpec} exactly like {@link SkillManifestEntry} is (omit
|
|
2127
2210
|
* `content` / require `contentHash`) so schema, entry type and interface can never drift apart. Only
|
|
2128
2211
|
* `min(1)` on contentHash: the hash is the service's PULL KEY for the content endpoint, its exact format
|
|
2129
|
-
* is
|
|
2212
|
+
* is web-admin's writing concern — re-pinning it here would make an old reader reject a newer web-admin's
|
|
2130
2213
|
* legal hash scheme. */
|
|
2131
2214
|
export const SkillsManifestSchema = z.object({
|
|
2132
|
-
skills: z.array(SkillSpec.omit({ content: true }).extend({ contentHash: z.string().min(1) })).
|
|
2215
|
+
skills: z.array(SkillSpec.omit({ content: true }).extend({ contentHash: z.string().min(1) })).prefault([]),
|
|
2133
2216
|
});
|
|
2134
2217
|
// ── Domain: mcp (MCP server 注册表 — the mcp/skills design note, Roadmap B1) ────────────
|
|
2135
|
-
//
|
|
2218
|
+
// web-admin = an MCP-server REGISTRY: a scenario mounts a subset by `scenarios[]`. Maps onto core's
|
|
2136
2219
|
// `McpServerSpec = { name, transport: {kind:"stdio",command,args?,env?} | {kind:"http",url,headers?}, allowTools?, contentOrigin?, source? }`.
|
|
2137
|
-
// SECRET BOUNDARY (§3.1, design/15): a transport's env/header VALUES may be secret →
|
|
2220
|
+
// SECRET BOUNDARY (§3.1, design/15): a transport's env/header VALUES may be secret → web-admin stores only
|
|
2138
2221
|
// env-NAME references (`envRefs`/`headerRefs`: target-key → an ENV_NAME in the SERVICE's env). The service's
|
|
2139
2222
|
// `resolveSpec` reads the real values from its own environment — this DB never holds a credential. Like
|
|
2140
2223
|
// skills, MCP is applied at boot/reconcile (not hot) and writing is gated to `publisher` + the publish gate.
|
|
@@ -2184,12 +2267,10 @@ export const McpTransport = z.discriminatedUnion("kind", [
|
|
|
2184
2267
|
}),
|
|
2185
2268
|
]);
|
|
2186
2269
|
/** The closed word table for {@link McpServerSpec}.contentOrigin — a MIRROR literal of the engine's
|
|
2187
|
-
* `ToolContentOrigin`. This package
|
|
2188
|
-
* PROMPT_ARTIFACT_LIMITS a mirror constant)
|
|
2189
|
-
* compile
|
|
2190
|
-
*
|
|
2191
|
-
* dependencies. Inside this repo the table is pinned as a literal only (a drift surfaces at the consumer's
|
|
2192
|
-
* pickup gate, never silently on the wire — an out-of-table word is rejected at parse on both sides). */
|
|
2270
|
+
* `ToolContentOrigin`. This package carries no engine RUNTIME dependency (the same ruling that made
|
|
2271
|
+
* PROMPT_ARTIFACT_LIMITS a mirror constant); the mirror is pinned HERE, by its owner: the upstream drift gate
|
|
2272
|
+
* (group ⑥) asserts at compile time that this union and the engine type are the same in both directions, against the
|
|
2273
|
+
* engine the devDependency pins. Consumers import this table — they keep no copy of their own. */
|
|
2193
2274
|
export const MCP_CONTENT_ORIGINS = ["local", "execution", "external"];
|
|
2194
2275
|
export const McpServerSpec = z.object({
|
|
2195
2276
|
name: z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "letters/digits/._- , no spaces"),
|
|
@@ -2197,10 +2278,10 @@ export const McpServerSpec = z.object({
|
|
|
2197
2278
|
/** Allowlist of tool names exposed from this server (core `allowTools`). Empty/undefined = all. */
|
|
2198
2279
|
allowTools: z.array(z.string().min(1)).optional(),
|
|
2199
2280
|
/** Scenarios this server is mounted in; empty = all (service-side filter, same as skills). */
|
|
2200
|
-
scenarios: z.array(z.string().min(1)).
|
|
2281
|
+
scenarios: z.array(z.string().min(1)).prefault([]),
|
|
2201
2282
|
/** Per-roster targeting (v2): roster NAMEs this server is for. Empty = all rosters. Filtered at
|
|
2202
2283
|
* GET /effective?worker=<name> by the worker's roster; the unscoped global pull ships all. */
|
|
2203
|
-
rosters: z.array(z.string().min(1)).
|
|
2284
|
+
rosters: z.array(z.string().min(1)).prefault([]),
|
|
2204
2285
|
enabled: z.boolean().default(true),
|
|
2205
2286
|
/** Per-server opt-in for INBOUND MCP elicitation (core `McpServerSpec.elicitation`, design/99 §E23). Default
|
|
2206
2287
|
* OFF = fail-closed: only an opted-in server may prompt the user mid-tool-call. Service forwards this into the
|
|
@@ -2245,19 +2326,19 @@ export const McpServerSpec = z.object({
|
|
|
2245
2326
|
notes: z.string().optional(),
|
|
2246
2327
|
});
|
|
2247
2328
|
export const McpConfig = z
|
|
2248
|
-
.object({ servers: z.array(McpServerSpec).
|
|
2329
|
+
.object({ servers: z.array(McpServerSpec).prefault([]) })
|
|
2249
2330
|
.superRefine((cfg, ctx) => {
|
|
2250
2331
|
const seen = new Set();
|
|
2251
2332
|
cfg.servers.forEach((s, i) => {
|
|
2252
2333
|
if (seen.has(s.name))
|
|
2253
|
-
ctx.addIssue({ code:
|
|
2334
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate mcp server "${s.name}"`, path: ["servers", i, "name"] });
|
|
2254
2335
|
seen.add(s.name);
|
|
2255
2336
|
});
|
|
2256
2337
|
});
|
|
2257
2338
|
// ── Domain: plugins (plugin SOURCE REFERENCES — C2 plugins 上云, additive 0.5.0) ──────────────────────────
|
|
2258
2339
|
// The cloud stores WHERE a plugin comes from, NEVER its content (产品拍板): {name, source, version/sha pin}.
|
|
2259
2340
|
// The consuming shell materializes the plugin locally from the ref (git clone / marketplace install) — the
|
|
2260
|
-
//
|
|
2341
|
+
// web-admin never hosts executable plugin bytes, so there is nothing here to content-scan or lazily serve.
|
|
2261
2342
|
// Plugins are an EXECUTABLE surface (commands/hooks/MCP ride in) → write-gated to `publisher` like skills/
|
|
2262
2343
|
// mcp (DOMAIN_WRITE_ROLE) and capped per-principal by entitlement `visiblePlugins` (deny-wins, same
|
|
2263
2344
|
// mechanics as visibleMcp/visibleSkills — B1: a personal plugin ref is a net-new capability).
|
|
@@ -2274,18 +2355,18 @@ export const PLUGIN_ORIGIN = z
|
|
|
2274
2355
|
.min(1)
|
|
2275
2356
|
.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")
|
|
2276
2357
|
.refine((s) => !hasNul(s), "plugin source must not contain a NUL byte")
|
|
2277
|
-
.
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
return
|
|
2358
|
+
.superRefine((s, ctx) => {
|
|
2359
|
+
// The shape is THE URL judge's ({@link httpUrlFlaws}); this seat adds two rules of its own: https only, and spelled in full
|
|
2360
|
+
// (`https://…` as written — git reads the raw string, and WHATWG's repair of `https:host/x` into `https://host/x` is not git's).
|
|
2361
|
+
const flaws = httpUrlFlaws(s, ["userinfo"]);
|
|
2362
|
+
if (flaws.includes("not-http-url") || new URL(s).protocol !== "https:" || s.slice(0, 8).toLowerCase() !== "https://") {
|
|
2363
|
+
ctx.addIssue({ code: "custom", message: "plugin source must be an https:// URL (contract: https-only; ssh/scp/git/file/plain-http and owner/repo shorthand are not accepted — domestic mirrors ride an https mirror domain via PLUGINS_ALLOW_HOSTS)" });
|
|
2364
|
+
return;
|
|
2284
2365
|
}
|
|
2285
|
-
|
|
2286
|
-
|
|
2366
|
+
if (flaws.includes("userinfo") || urlQueryFragmentHasSecret(new URL(s))) {
|
|
2367
|
+
ctx.addIssue({ code: "custom", message: "an https plugin source must not embed credentials (userinfo) or a secret-bearing query/fragment" });
|
|
2287
2368
|
}
|
|
2288
|
-
}
|
|
2369
|
+
});
|
|
2289
2370
|
/** A full 40-hex git commit SHA — the ONLY pin form the supply-side treats as 钉死 (a branch/tag `version`
|
|
2290
2371
|
* is a moving ref; only a commit pin makes the materialized bytes reproducible). */
|
|
2291
2372
|
export const GIT_COMMIT_SHA = z.string().regex(/^[a-f0-9]{40}$/, "must be a full 40-character lowercase git commit SHA");
|
|
@@ -2316,37 +2397,40 @@ export const PluginSpec = z.object({
|
|
|
2316
2397
|
notes: z.string().optional(),
|
|
2317
2398
|
});
|
|
2318
2399
|
export const PluginsConfig = z
|
|
2319
|
-
.object({ plugins: z.array(PluginSpec).
|
|
2400
|
+
.object({ plugins: z.array(PluginSpec).prefault([]) })
|
|
2320
2401
|
.superRefine((cfg, ctx) => {
|
|
2321
2402
|
const seen = new Set();
|
|
2322
2403
|
cfg.plugins.forEach((p, i) => {
|
|
2323
2404
|
if (seen.has(p.name))
|
|
2324
|
-
ctx.addIssue({ code:
|
|
2405
|
+
ctx.addIssue({ code: "custom", ...buildQuotedIssue `duplicate plugin "${p.name}"`, path: ["plugins", i, "name"] });
|
|
2325
2406
|
seen.add(p.name);
|
|
2326
2407
|
});
|
|
2327
2408
|
});
|
|
2328
2409
|
// ── The domain registry ───────────────────────────────────────────────────────
|
|
2329
|
-
// ═══ prompts 域(
|
|
2410
|
+
// ═══ prompts 域(web-admin prompts 战役 v1;[ref] 五定夺/[1051] 产品终稿/[1057] M2 下发形)═══
|
|
2330
2411
|
// schema=编译目标非用户界面([1051]⓪):三卡(角色卡/场景说明/行为守则)是 web UI 编译层心智,
|
|
2331
|
-
// 本契约只承载编译产物(typed 声明)与 binding。单真源纪律:存储/解析在
|
|
2412
|
+
// 本契约只承载编译产物(typed 声明)与 binding。单真源纪律:存储/解析在 web-admin(sema-web),
|
|
2332
2413
|
// 消费投影在 server(声明数组逐字喂 core typed stableBlocks 钩子),schema 恒在本仓(零第二份)。
|
|
2414
|
+
/** 提示声明的槽位闭集(core `PromptTextDeclaration.slot` 同形):identity = 角色卡、scenario = 场景说明、behavior = 行为守则。
|
|
2415
|
+
* declaration 轴与 catalog 轴({@link PromptArtifactSection})共用这一只。 */
|
|
2416
|
+
export const PromptSlot = z.enum(["identity", "scenario", "behavior"]);
|
|
2333
2417
|
/** core `PromptTextDeclaration` 的契约层同形(core prompts/default.d.ts 逐字:id/slot 闭集/text)。
|
|
2334
2418
|
* slot ↔ 三卡:identity=角色卡、scenario=场景说明、behavior=行为守则([1051]①)。 */
|
|
2335
2419
|
export const PromptTextDeclaration = z.object({
|
|
2336
2420
|
id: z.string().min(1).max(128),
|
|
2337
|
-
slot:
|
|
2421
|
+
slot: PromptSlot,
|
|
2338
2422
|
text: z.string().max(65536),
|
|
2339
2423
|
/** core 1.315 opt-in 逐 section 对账锚([1062]①):声明方对 `text` 铸 sha256,core 校验后逐字
|
|
2340
2424
|
* 透传进 manifest `contentHash`(错值丢弃+warn,core 永不自算非盐化 digest)。缺省=退化为
|
|
2341
2425
|
* 整包 contentDigest 对账。契约层显式声明防非 strict parse strip 断锚([1062]③)。 */
|
|
2342
|
-
contentHash:
|
|
2426
|
+
contentHash: Sha256Digest.optional(),
|
|
2343
2427
|
});
|
|
2344
2428
|
/** 三维 binding([ref]①a:v1=global/worker/scenario,砍 model 维——per-model 微调走
|
|
2345
|
-
* ModelEntry.promptGuidance 专用通道)。covering 语义([1050]①b,解析只在
|
|
2429
|
+
* ModelEntry.promptGuidance 专用通道)。covering 语义([1050]①b,解析只在 web-admin 一处):
|
|
2346
2430
|
* 同 slot **层间整体替换**(胜出层该 slot 声明数组整体替换低层同 slot 全部条目;层内数组保序,
|
|
2347
2431
|
* 与 core assemble「declaration order preserved」无缝);层序 scenario > worker > global。
|
|
2348
2432
|
* 发布即物化([1051]②a):declarations 是 publish 时物化的不可变声明(意图层/编译器版本归
|
|
2349
|
-
*
|
|
2433
|
+
* web-admin 编辑面存储,非跨仓契约面)。 */
|
|
2350
2434
|
export const PromptBinding = z.object({
|
|
2351
2435
|
/** 绑定层。 */
|
|
2352
2436
|
level: z.enum(["global", "worker", "scenario"]),
|
|
@@ -2372,8 +2456,8 @@ export const PromptBinding = z.object({
|
|
|
2372
2456
|
// (overlays/profiles/modelPolicies/bindings)是后续 additive 切片,本轴到时 additive 扩,零迁移。
|
|
2373
2457
|
// 单真源纪律:server 候选门用 core 导出的 `verifyPromptArtifact`(digest 复算+上限+range 全管线),
|
|
2374
2458
|
// 本 zod 只是**传输门**(防 DoS/坏形早拒);上限=[[镜像常量]],见 PROMPT_ARTIFACT_LIMITS。
|
|
2375
|
-
/** core `ARTIFACT_LIMITS` 的镜像常量(
|
|
2376
|
-
*
|
|
2459
|
+
/** core `ARTIFACT_LIMITS` 的镜像常量(本包不加 core 运行期依赖轴 ⇒ 镜像)。对表钉住在**镜像的属主**这里:上游漂移门 ⑤ 逐键
|
|
2460
|
+
* === devDependency 钉住的那只 core 的导出 —— 改上限必须同版动两处、过钉。 */
|
|
2377
2461
|
export const PROMPT_ARTIFACT_LIMITS = {
|
|
2378
2462
|
maxSections: 64,
|
|
2379
2463
|
maxSectionBytes: 32_000,
|
|
@@ -2386,9 +2470,9 @@ export const PROMPT_ARTIFACT_LIMITS = {
|
|
|
2386
2470
|
* 为 UTF-16 码元数,core verify 按 UTF-8 字节复核——传输门宽、语义门准)。 */
|
|
2387
2471
|
export const PromptArtifactSection = z.object({
|
|
2388
2472
|
id: z.string().min(1).max(PROMPT_ARTIFACT_LIMITS.maxIdLength).regex(/^[a-z0-9][a-z0-9/._-]*$/),
|
|
2389
|
-
slot:
|
|
2473
|
+
slot: PromptSlot,
|
|
2390
2474
|
text: z.string().min(1).max(PROMPT_ARTIFACT_LIMITS.maxSectionBytes),
|
|
2391
|
-
contentHash:
|
|
2475
|
+
contentHash: Sha256Digest,
|
|
2392
2476
|
});
|
|
2393
2477
|
/** core `PromptCatalogSnapshot` 同形。packId=逻辑名(如 `center:acme@3`),历史身份恒在 digest。 */
|
|
2394
2478
|
export const PromptCatalogSnapshot = z.object({
|
|
@@ -2401,7 +2485,7 @@ export const PromptCatalogSnapshot = z.object({
|
|
|
2401
2485
|
export const PromptEpochArtifactPayload = z.object({
|
|
2402
2486
|
schemaVersion: z.literal(1),
|
|
2403
2487
|
assemblyApi: z.literal(1),
|
|
2404
|
-
catalogDigest:
|
|
2488
|
+
catalogDigest: Sha256Digest,
|
|
2405
2489
|
compatibleCore: z
|
|
2406
2490
|
.object({
|
|
2407
2491
|
minCore: z.string().regex(/^\d+\.\d+\.\d+$/).optional(),
|
|
@@ -2418,16 +2502,16 @@ export const PromptEpochArtifactPayload = z.object({
|
|
|
2418
2502
|
export const PublishedPromptArtifactEnvelope = z.object({
|
|
2419
2503
|
artifact: z.object({
|
|
2420
2504
|
payload: PromptEpochArtifactPayload,
|
|
2421
|
-
artifactDigest:
|
|
2505
|
+
artifactDigest: Sha256Digest,
|
|
2422
2506
|
}),
|
|
2423
2507
|
sourceRevision: z.string().min(1).max(256),
|
|
2424
2508
|
});
|
|
2425
|
-
/** prompts 域存储形 v1(
|
|
2509
|
+
/** prompts 域存储形 v1(web-admin 存储真形;[1043] PromptCatalogPayload 的 v1 最小面)。
|
|
2426
2510
|
* overlays/profiles/modelPolicies = 协议 §5.1 的后续 additive 面,留位不预设形(等 core S6
|
|
2427
2511
|
* catalog schema 班车,additive 补键零迁移)。 */
|
|
2428
2512
|
export const PromptsConfig = z.object({
|
|
2429
|
-
bindings: z.array(PromptBinding).max(256).
|
|
2430
|
-
/** catalog 轴存储位(0.10.20,[985]a):
|
|
2513
|
+
bindings: z.array(PromptBinding).max(256).prefault([]),
|
|
2514
|
+
/** catalog 轴存储位(0.10.20,[985]a):web-admin 发布门铸好的 immutable envelope 原样存(发布即
|
|
2431
2515
|
* 物化,店内零解析);local FileConfigStore lane 的 config.d/prompts.json 也可直接携带——catalog
|
|
2432
2516
|
* 免 covering 解析,两 lane 自此同形(结束 local lane prompts 不透传的过渡态,[1234]① 位)。 */
|
|
2433
2517
|
catalog: PublishedPromptArtifactEnvelope.optional(),
|
|
@@ -2438,17 +2522,22 @@ export const PromptsConfig = z.object({
|
|
|
2438
2522
|
* `promptsContentDigest`);sections=covering 已走完的基线终形(worker/core 零解析义务);
|
|
2439
2523
|
* scenarioOverrides[s]=该场景完整重算终形(整包替换非增量),无键=用基线;fail-closed=
|
|
2440
2524
|
* publish 闸关时整个 key 不出现。 */
|
|
2525
|
+
/** prompts 包身份 `packId` 的前缀(铸点 `promptsContentDigest` 与判官 {@link PromptPackId} 都引它)。值落在引擎的会话记录里、是
|
|
2526
|
+
* epoch 不误重 pin 的依据,所以值本身不随叫法归一改(换前缀 = 每条活会话的提示包身份翻一次,要引擎先读 epoch 判据)。 */
|
|
2527
|
+
export const PROMPT_PACK_ID_PREFIX = "center:";
|
|
2528
|
+
/** prompts 包身份的形:前缀 + 内容摘要的前 12 位小写 hex(纯内容寻址:同内容重发布 = 同 packId)。判它的每一处都引这一只。 */
|
|
2529
|
+
export const PromptPackId = z.string().regex(new RegExp(`^${PROMPT_PACK_ID_PREFIX}[0-9a-f]{12}$`));
|
|
2441
2530
|
export const EffectivePrompts = z.object({
|
|
2442
|
-
packId:
|
|
2443
|
-
contentDigest:
|
|
2531
|
+
packId: PromptPackId,
|
|
2532
|
+
contentDigest: Sha256Digest,
|
|
2444
2533
|
sections: z.array(PromptTextDeclaration),
|
|
2445
2534
|
scenarioOverrides: z.record(z.string(), z.array(PromptTextDeclaration)).optional(),
|
|
2446
2535
|
});
|
|
2447
2536
|
/** effective 下发形 v2(双轴,[985]a §1/[1242] Q1 裁:单 domain 单键、两轴是过渡窗内的两种表达)。
|
|
2448
2537
|
* declarations=现行 A 形(centerPromptProvider 继续消费);catalog=B 形(core S1 verify 管线的输入,
|
|
2449
2538
|
* server 原样交 core 不解释)。至少一轴必须在场(空对象=坏形);催收面语义照旧=publish 闸关时整个
|
|
2450
|
-
* `prompts` key 消失(fail-closed)
|
|
2451
|
-
* (
|
|
2539
|
+
* `prompts` key 消失(fail-closed)。引擎的候选门**只认本形**:没有 `schemaVersion` 的平铺 {@link EffectivePrompts} 体被响亮拒
|
|
2540
|
+
* (整个 prompts 面不采用,落最后一份好配置 / 内置)。 */
|
|
2452
2541
|
export const EffectivePromptsWire = z
|
|
2453
2542
|
.object({
|
|
2454
2543
|
schemaVersion: z.literal(1),
|
|
@@ -2467,18 +2556,17 @@ export const DOMAIN_SCHEMAS = {
|
|
|
2467
2556
|
collab: CollabConfig, // 0.7.0: replaces `teams` (EXPERT-REDESIGN §8/§9 同批换刀 — no editing gap)
|
|
2468
2557
|
workers: WorkersConfig,
|
|
2469
2558
|
hosts: HostsConfig,
|
|
2470
|
-
runtime: RuntimeConfig,
|
|
2471
2559
|
governance: GovernanceConfig,
|
|
2472
2560
|
entitlement: EntitlementConfig,
|
|
2473
2561
|
execution: ExecutionPolicy, // 0.8.0: S3 execution-lane policy (scope 级; client-advisory until service gate)
|
|
2474
2562
|
projects: ProjectsConfig, // 0.10.0: 142-S3 project identity ledger + 无仓铸造权威 (scope=tenant 轴; NOT portable — 租户身份数据)
|
|
2475
|
-
prompts: PromptsConfig, // 0.10.18:
|
|
2563
|
+
prompts: PromptsConfig, // 0.10.18: web-admin prompts 战役 v1([ref] 五定夺/产品终稿/M2 下发形;写门/store 携带解锁=[1064]②)
|
|
2476
2564
|
limits: LimitsConfig, // 0.19.0: cost/限流族热更新发布位(消费方 #322 批1;本地腿闭集缺口的源头修复)
|
|
2477
2565
|
// integrations + access: DELETED in 0.6.0 (EXPERT-REDESIGN §9) — parseDomain/effective/路由随之 404。
|
|
2478
2566
|
};
|
|
2479
2567
|
// ── Effective-config PROVENANCE (the unified shape TOC `sema config --effective` and TOB `/effective?principal=`
|
|
2480
2568
|
// step-5 trace both emit, so "why is this value what it is" renders identically across both seats). Settled
|
|
2481
|
-
// via
|
|
2569
|
+
// via web-admin control-plane design gate③ (see web-admin control-plane design note §R2-T/§10). A pure
|
|
2482
2570
|
// CONTRACT type — no runtime dep on the engine; the axis VALUES (enforcement/lockBehavior) mirror @sema-agent/
|
|
2483
2571
|
// core's safety vocabulary, validated by a dev-only conformance test (never a runtime import).
|
|
2484
2572
|
/** One provenance source in a key's resolution trail. Two orthogonal axes: the TOC file LAYER, or the TOB
|
|
@@ -2497,7 +2585,7 @@ export const SettingSource = z.discriminatedUnion("axis", [
|
|
|
2497
2585
|
* `lockBehavior` (BEHAVIOR / what the managed layer does) is orthogonal, only meaningful under managed enforcement. */
|
|
2498
2586
|
export const EffectiveKey = z.object({
|
|
2499
2587
|
key: z.string(),
|
|
2500
|
-
value: z.unknown(),
|
|
2588
|
+
value: z.unknown().optional(),
|
|
2501
2589
|
winningSource: SettingSource,
|
|
2502
2590
|
locked: z.boolean(),
|
|
2503
2591
|
enforcement: z.enum(["gate", "server", "resolver", "client"]),
|