@b00dea/cockpit 0.0.0-stage → 0.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 +375 -0
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/bin/cockpit.mjs +33 -0
- package/cockpit.schema.json +2574 -0
- package/dist/autonomy.d.ts +44 -0
- package/dist/autonomy.js +156 -0
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.js +42 -0
- package/dist/browser/commands.d.ts +62 -0
- package/dist/browser/commands.js +349 -0
- package/dist/browser/package-paths.d.ts +1 -0
- package/dist/browser/package-paths.js +6 -0
- package/dist/browser/proxy.d.ts +22 -0
- package/dist/browser/proxy.js +37 -0
- package/dist/browser/runtime.d.ts +117 -0
- package/dist/browser/runtime.js +183 -0
- package/dist/browser/surfaces.d.ts +141 -0
- package/dist/browser/surfaces.js +556 -0
- package/dist/browser/ui-gate.d.ts +15 -0
- package/dist/browser/ui-gate.js +18 -0
- package/dist/cli-guard.d.ts +5 -0
- package/dist/cli-guard.js +71 -0
- package/dist/cli-main.d.ts +139 -0
- package/dist/cli-main.js +1405 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +16 -0
- package/dist/command-table.d.ts +113 -0
- package/dist/command-table.js +512 -0
- package/dist/config/load.d.ts +171 -0
- package/dist/config/load.js +345 -0
- package/dist/config/sibling-toml.d.ts +48 -0
- package/dist/config/sibling-toml.js +160 -0
- package/dist/config/surface-tools-toml.d.ts +9 -0
- package/dist/config/surface-tools-toml.js +37 -0
- package/dist/config/verified-write.d.ts +43 -0
- package/dist/config/verified-write.js +89 -0
- package/dist/diagnostics.d.ts +26 -0
- package/dist/diagnostics.js +335 -0
- package/dist/emit.d.ts +22 -0
- package/dist/emit.js +43 -0
- package/dist/entrypoint-kinds.d.ts +3 -0
- package/dist/entrypoint-kinds.js +11 -0
- package/dist/environment/application/destructive.d.ts +6 -0
- package/dist/environment/application/destructive.js +425 -0
- package/dist/environment/application/doctor.d.ts +89 -0
- package/dist/environment/application/doctor.js +235 -0
- package/dist/environment/application/guard.d.ts +43 -0
- package/dist/environment/application/guard.js +622 -0
- package/dist/environment/application/health.d.ts +134 -0
- package/dist/environment/application/health.js +379 -0
- package/dist/environment/application/lifecycle-context.d.ts +228 -0
- package/dist/environment/application/lifecycle-context.js +154 -0
- package/dist/environment/application/lifecycle-state.d.ts +25 -0
- package/dist/environment/application/lifecycle-state.js +201 -0
- package/dist/environment/application/lifecycle.d.ts +17 -0
- package/dist/environment/application/lifecycle.js +729 -0
- package/dist/environment/application/logs.d.ts +45 -0
- package/dist/environment/application/logs.js +127 -0
- package/dist/environment/application/setup.d.ts +129 -0
- package/dist/environment/application/setup.js +741 -0
- package/dist/environment/application/shared.d.ts +11 -0
- package/dist/environment/application/shared.js +61 -0
- package/dist/environment/claims.d.ts +389 -0
- package/dist/environment/claims.js +1709 -0
- package/dist/environment/codex-hook-trust.d.ts +11 -0
- package/dist/environment/codex-hook-trust.js +185 -0
- package/dist/environment/compose-environment.d.ts +1 -0
- package/dist/environment/compose-environment.js +6 -0
- package/dist/environment/compose-ownership.d.ts +21 -0
- package/dist/environment/compose-ownership.js +117 -0
- package/dist/environment/compose-topology.d.ts +117 -0
- package/dist/environment/compose-topology.js +413 -0
- package/dist/environment/contract.d.ts +489 -0
- package/dist/environment/contract.js +761 -0
- package/dist/environment/declaration.d.ts +14 -0
- package/dist/environment/declaration.js +39 -0
- package/dist/environment/destructive-composition.d.ts +29 -0
- package/dist/environment/destructive-composition.js +333 -0
- package/dist/environment/docker-daemon.d.ts +30 -0
- package/dist/environment/docker-daemon.js +78 -0
- package/dist/environment/docker-endpoint.d.ts +10 -0
- package/dist/environment/docker-endpoint.js +19 -0
- package/dist/environment/env-schema-observation.d.ts +46 -0
- package/dist/environment/env-schema-observation.js +241 -0
- package/dist/environment/evidence.d.ts +136 -0
- package/dist/environment/evidence.js +437 -0
- package/dist/environment/executable.d.ts +26 -0
- package/dist/environment/executable.js +167 -0
- package/dist/environment/generated-application/authorities.d.ts +5 -0
- package/dist/environment/generated-application/authorities.js +61 -0
- package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
- package/dist/environment/generated-application/bootstrap-skill.js +328 -0
- package/dist/environment/generated-application/check.d.ts +13 -0
- package/dist/environment/generated-application/check.js +189 -0
- package/dist/environment/generated-application/custom-surface.d.ts +26 -0
- package/dist/environment/generated-application/custom-surface.js +289 -0
- package/dist/environment/generated-application/prepare.d.ts +29 -0
- package/dist/environment/generated-application/prepare.js +276 -0
- package/dist/environment/generated-application/publication.d.ts +33 -0
- package/dist/environment/generated-application/publication.js +547 -0
- package/dist/environment/generated-application/shared.d.ts +484 -0
- package/dist/environment/generated-application/shared.js +349 -0
- package/dist/environment/generated-application/surface-observation.d.ts +4 -0
- package/dist/environment/generated-application/surface-observation.js +69 -0
- package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
- package/dist/environment/generated-application/surface-outputs.js +389 -0
- package/dist/environment/generated-application/surfaces.d.ts +3 -0
- package/dist/environment/generated-application/surfaces.js +699 -0
- package/dist/environment/generated-application/sync.d.ts +6 -0
- package/dist/environment/generated-application/sync.js +109 -0
- package/dist/environment/git-observation.d.ts +183 -0
- package/dist/environment/git-observation.js +544 -0
- package/dist/environment/health-composition.d.ts +11 -0
- package/dist/environment/health-composition.js +628 -0
- package/dist/environment/host-observation.d.ts +56 -0
- package/dist/environment/host-observation.js +52 -0
- package/dist/environment/identity.d.ts +48 -0
- package/dist/environment/identity.js +85 -0
- package/dist/environment/init-application.d.ts +308 -0
- package/dist/environment/init-application.js +697 -0
- package/dist/environment/install-policy-observation.d.ts +71 -0
- package/dist/environment/install-policy-observation.js +284 -0
- package/dist/environment/install-policy.d.ts +48 -0
- package/dist/environment/install-policy.js +96 -0
- package/dist/environment/instance.d.ts +33 -0
- package/dist/environment/instance.js +122 -0
- package/dist/environment/instruction-pointer.d.ts +19 -0
- package/dist/environment/instruction-pointer.js +144 -0
- package/dist/environment/lifecycle-composition.d.ts +29 -0
- package/dist/environment/lifecycle-composition.js +675 -0
- package/dist/environment/lifecycle-stage.d.ts +115 -0
- package/dist/environment/lifecycle-stage.js +398 -0
- package/dist/environment/lifecycle.d.ts +75 -0
- package/dist/environment/lifecycle.js +354 -0
- package/dist/environment/logs.d.ts +132 -0
- package/dist/environment/logs.js +519 -0
- package/dist/environment/observations.d.ts +46 -0
- package/dist/environment/observations.js +344 -0
- package/dist/environment/operator-application.d.ts +50 -0
- package/dist/environment/operator-application.js +155 -0
- package/dist/environment/ports.d.ts +182 -0
- package/dist/environment/ports.js +1205 -0
- package/dist/environment/posture-observation.d.ts +68 -0
- package/dist/environment/posture-observation.js +253 -0
- package/dist/environment/read-composition.d.ts +27 -0
- package/dist/environment/read-composition.js +352 -0
- package/dist/environment/readiness-composition.d.ts +38 -0
- package/dist/environment/readiness-composition.js +130 -0
- package/dist/environment/reclaim.d.ts +333 -0
- package/dist/environment/reclaim.js +1835 -0
- package/dist/environment/repository-identity.d.ts +13 -0
- package/dist/environment/repository-identity.js +108 -0
- package/dist/environment/runtime-measurement-record.d.ts +38 -0
- package/dist/environment/runtime-measurement-record.js +111 -0
- package/dist/environment/runtime-measurement.d.ts +27 -0
- package/dist/environment/runtime-measurement.js +224 -0
- package/dist/environment/runtime-probe-residue.d.ts +43 -0
- package/dist/environment/runtime-probe-residue.js +148 -0
- package/dist/environment/runtime-probe.d.ts +72 -0
- package/dist/environment/runtime-probe.js +725 -0
- package/dist/environment/runtime.d.ts +505 -0
- package/dist/environment/runtime.js +2335 -0
- package/dist/environment/seed.d.ts +16 -0
- package/dist/environment/seed.js +43 -0
- package/dist/environment/selftest-application.d.ts +86 -0
- package/dist/environment/selftest-application.js +713 -0
- package/dist/environment/sensitive.d.ts +3 -0
- package/dist/environment/sensitive.js +48 -0
- package/dist/environment/setup-observation.d.ts +62 -0
- package/dist/environment/setup-observation.js +1473 -0
- package/dist/environment/setup.d.ts +43 -0
- package/dist/environment/setup.js +176 -0
- package/dist/environment/sibling-instructions.d.ts +33 -0
- package/dist/environment/sibling-instructions.js +224 -0
- package/dist/environment/sibling-registration.d.ts +45 -0
- package/dist/environment/sibling-registration.js +538 -0
- package/dist/environment/state.d.ts +371 -0
- package/dist/environment/state.js +2013 -0
- package/dist/environment/templates.d.ts +140 -0
- package/dist/environment/templates.js +685 -0
- package/dist/environment/tool-registration.d.ts +9 -0
- package/dist/environment/tool-registration.js +96 -0
- package/dist/environment/upgrade-application.d.ts +19 -0
- package/dist/environment/upgrade-application.js +175 -0
- package/dist/environment-exec/execute.d.ts +102 -0
- package/dist/environment-exec/execute.js +1175 -0
- package/dist/environment-exec/libexec-program.d.ts +6 -0
- package/dist/environment-exec/libexec-program.js +22 -0
- package/dist/environment-exec/verified-digest.d.ts +4 -0
- package/dist/environment-exec/verified-digest.js +16 -0
- package/dist/exact-keys.d.ts +7 -0
- package/dist/exact-keys.js +15 -0
- package/dist/fix-catalogue.d.ts +32 -0
- package/dist/fix-catalogue.js +60 -0
- package/dist/freshness.d.ts +29 -0
- package/dist/freshness.js +75 -0
- package/dist/generated-environment/adopt.d.ts +16 -0
- package/dist/generated-environment/adopt.js +275 -0
- package/dist/generated-environment/command.d.ts +47 -0
- package/dist/generated-environment/command.js +75 -0
- package/dist/generated-environment/transaction.d.ts +186 -0
- package/dist/generated-environment/transaction.js +1770 -0
- package/dist/grading/evaluate.d.ts +71 -0
- package/dist/grading/evaluate.js +403 -0
- package/dist/grading/grade.d.ts +33 -0
- package/dist/grading/grade.js +57 -0
- package/dist/guards/evaluator.d.ts +113 -0
- package/dist/guards/evaluator.js +1226 -0
- package/dist/guards/harness-adapter.d.ts +17 -0
- package/dist/guards/harness-adapter.js +57 -0
- package/dist/guards/host.d.ts +8 -0
- package/dist/guards/host.js +10 -0
- package/dist/guards/invocation-parser.d.ts +66 -0
- package/dist/guards/invocation-parser.js +595 -0
- package/dist/guards/local-identity.d.ts +40 -0
- package/dist/guards/local-identity.js +123 -0
- package/dist/guards/policy-pin.d.ts +13 -0
- package/dist/guards/policy-pin.js +175 -0
- package/dist/guards/protected-locations.d.ts +62 -0
- package/dist/guards/protected-locations.js +461 -0
- package/dist/guards/read-only.d.ts +37 -0
- package/dist/guards/read-only.js +315 -0
- package/dist/init/command.d.ts +40 -0
- package/dist/init/command.js +310 -0
- package/dist/init/derive.d.ts +51 -0
- package/dist/init/derive.js +715 -0
- package/dist/init/draft.d.ts +138 -0
- package/dist/init/draft.js +1205 -0
- package/dist/init/render.d.ts +35 -0
- package/dist/init/render.js +82 -0
- package/dist/init/scaffold.d.ts +53 -0
- package/dist/init/scaffold.js +121 -0
- package/dist/invocation.d.ts +13 -0
- package/dist/invocation.js +20 -0
- package/dist/package-metadata.d.ts +9 -0
- package/dist/package-metadata.js +19 -0
- package/dist/readiness.d.ts +53 -0
- package/dist/readiness.js +88 -0
- package/dist/references/named.d.ts +24 -0
- package/dist/references/named.js +27 -0
- package/dist/refusal.d.ts +15 -0
- package/dist/refusal.js +7 -0
- package/dist/registry-derivation.d.ts +31 -0
- package/dist/registry-derivation.js +32 -0
- package/dist/registry-resolve/command.d.ts +47 -0
- package/dist/registry-resolve/command.js +226 -0
- package/dist/registry-resolve/registry.d.ts +68 -0
- package/dist/registry-resolve/registry.js +292 -0
- package/dist/registry-resolve/render-shell.d.ts +18 -0
- package/dist/registry-resolve/render-shell.js +36 -0
- package/dist/repair.d.ts +29 -0
- package/dist/repair.js +8 -0
- package/dist/schema-json.d.ts +1 -0
- package/dist/schema-json.js +177 -0
- package/dist/schema.d.ts +1322 -0
- package/dist/schema.js +934 -0
- package/dist/setup/command.d.ts +4 -0
- package/dist/setup/command.js +4 -0
- package/dist/sha256.d.ts +1 -0
- package/dist/sha256.js +4 -0
- package/dist/surfaces/adoption.d.ts +41 -0
- package/dist/surfaces/adoption.js +217 -0
- package/dist/surfaces/consumer-instructions.d.ts +20 -0
- package/dist/surfaces/consumer-instructions.js +43 -0
- package/dist/surfaces/contract.d.ts +42 -0
- package/dist/surfaces/contract.js +169 -0
- package/dist/surfaces/core-declarations.d.ts +24 -0
- package/dist/surfaces/core-declarations.js +79 -0
- package/dist/surfaces/development-markers.d.ts +1 -0
- package/dist/surfaces/development-markers.js +18 -0
- package/dist/surfaces/generator-adapter.d.ts +71 -0
- package/dist/surfaces/generator-adapter.js +809 -0
- package/dist/surfaces/harness-registry.d.ts +180 -0
- package/dist/surfaces/harness-registry.js +117 -0
- package/dist/surfaces/mcp.d.ts +95 -0
- package/dist/surfaces/mcp.js +186 -0
- package/dist/surfaces/merge.d.ts +30 -0
- package/dist/surfaces/merge.js +105 -0
- package/dist/surfaces/ownership.d.ts +190 -0
- package/dist/surfaces/ownership.js +795 -0
- package/dist/surfaces/projection.d.ts +136 -0
- package/dist/surfaces/projection.js +681 -0
- package/dist/surfaces/render.d.ts +118 -0
- package/dist/surfaces/render.js +633 -0
- package/dist/surfaces/restrictions.d.ts +130 -0
- package/dist/surfaces/restrictions.js +447 -0
- package/dist/surfaces/selftest.d.ts +162 -0
- package/dist/surfaces/selftest.js +393 -0
- package/dist/surfaces/skill-contract.d.ts +85 -0
- package/dist/surfaces/skill-contract.js +449 -0
- package/dist/surfaces/sync.d.ts +57 -0
- package/dist/surfaces/sync.js +164 -0
- package/dist/team-metadata.d.ts +41 -0
- package/dist/team-metadata.js +512 -0
- package/dist/testing/gates.d.ts +91 -0
- package/dist/testing/gates.js +182 -0
- package/dist/testing/hooks.d.ts +59 -0
- package/dist/testing/hooks.js +139 -0
- package/dist/testing/verdict.d.ts +29 -0
- package/dist/testing/verdict.js +62 -0
- package/dist/work-orders/application.d.ts +99 -0
- package/dist/work-orders/application.js +383 -0
- package/dist/work-orders/composition.d.ts +124 -0
- package/dist/work-orders/composition.js +1010 -0
- package/dist/work-orders/contract.d.ts +61 -0
- package/dist/work-orders/contract.js +122 -0
- package/dist/work-orders/store.d.ts +158 -0
- package/dist/work-orders/store.js +197 -0
- package/docs/README.md +65 -0
- package/docs/architecture.md +136 -0
- package/docs/capability-matrix.md +50 -0
- package/docs/cli-reference.md +599 -0
- package/docs/command-list.md +51 -0
- package/docs/compose-patterns.md +99 -0
- package/docs/concepts.md +171 -0
- package/docs/configuration-essentials.md +172 -0
- package/docs/configuration-reference.md +621 -0
- package/docs/environment.md +760 -0
- package/docs/extending.md +43 -0
- package/docs/faq.md +111 -0
- package/docs/getting-started.md +608 -0
- package/docs/harness-surfaces.md +433 -0
- package/docs/joining-a-team.md +76 -0
- package/docs/release-policy.md +49 -0
- package/docs/safety-and-autonomy.md +419 -0
- package/docs/troubleshooting.md +361 -0
- package/docs/tutorial.md +170 -0
- package/docs/work-and-browser.md +254 -0
- package/libexec/browser-operation.cjs +186 -0
- package/libexec/fence-marker.cjs +101 -0
- package/libexec/fence-marker.d.cts +24 -0
- package/libexec/fenced-group-runner.cjs +77 -0
- package/libexec/fenced-supervisor.cjs +137 -0
- package/libexec/generated-filesystem.cjs +731 -0
- package/libexec/install-filesystem.cjs +225 -0
- package/libexec/rulesync-generator.NOTICE.md +1112 -0
- package/libexec/rulesync-generator.cjs +743 -0
- package/libexec/surface-generator-worker.cjs +266 -0
- package/libexec/surface-output-reader.cjs +99 -0
- package/libexec/surface-scratch-cleanup.cjs +14 -0
- package/libexec/validate-harness-evidence.d.mts +183 -0
- package/libexec/validate-harness-evidence.mjs +1312 -0
- package/package.json +74 -4
- package/surfaces/README.md +33 -0
- package/surfaces/agents/evaluator.agent.md +76 -0
- package/surfaces/guards/bash-guard.sh +48 -0
- package/surfaces/guards/claude.adapter.json +25 -0
- package/surfaces/guards/codex.adapter.json +24 -0
- package/surfaces/guards/opencode.adapter.json +25 -0
- package/surfaces/references/browser.md +63 -0
- package/surfaces/references/test-data-and-personas.md +84 -0
- package/surfaces/references/testing-tiers.md +11 -0
- package/surfaces/skills/cockpit-browser.skill.md +54 -0
- package/surfaces/skills/cockpit-explain.skill.md +32 -0
- package/surfaces/skills/cockpit-fix.skill.md +37 -0
- package/surfaces/skills/cockpit-setup.skill.md +39 -0
- package/surfaces/skills/cockpit-work.skill.md +34 -0
- package/surfaces/skills/collection.json +5 -0
- package/surfaces/skills/procedures/explain.md +61 -0
- package/surfaces/skills/procedures/fix.md +81 -0
- package/surfaces/skills/procedures/setup.md +379 -0
- package/surfaces/skills/procedures/work.md +105 -0
package/dist/schema.js
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { parseDocument } from 'yaml';
|
|
5
|
+
import { browserProxyAuthorityMatches } from './browser/proxy.js';
|
|
6
|
+
import { ENTRYPOINT_KINDS } from './entrypoint-kinds.js';
|
|
7
|
+
import { GUARD_HOST_LIMITS, GUARD_HOST_PATTERN, normalizeGuardHost } from './guards/host.js';
|
|
8
|
+
import { ADOPTABLE_SURFACE_PATHS, ADOPTED_SURFACE_ROOT, SURFACE_BUNDLE_FORMATS, SURFACE_FEATURES, SURFACE_TARGETS, surfaceSourcesMatchFeature } from './surfaces/contract.js';
|
|
9
|
+
import { DEFAULT_SURFACE_TOOLS, GUARD_SURFACE_TARGETS } from './surfaces/harness-registry.js';
|
|
10
|
+
import { RefusalError, errorCause } from './environment/contract.js';
|
|
11
|
+
export const STRUCTURED_INPUT_ENTRY_LIMIT = 4_096;
|
|
12
|
+
// Zod cannot reflect a regex in a string type; successful parses satisfy this narrower output.
|
|
13
|
+
export const Mount = z
|
|
14
|
+
.enum(['always', 'host', 'none'])
|
|
15
|
+
.or(z.string().regex(/^profile:[a-z0-9-]+$/, 'expected `profile:<name>`'));
|
|
16
|
+
// `auto` clones on setup, `manual` expects a human (setup wires the extra remotes), `none`
|
|
17
|
+
// is not a repository at all — a path to local data, referenced and never cloned. A row with
|
|
18
|
+
// acquire `none` has no remote, which the refinement below enforces.
|
|
19
|
+
export const Acquire = z.enum(['auto', 'manual', 'none']);
|
|
20
|
+
export const AddressingPosture = z.enum(['hostname', 'port']);
|
|
21
|
+
// `real` signs in through the product's provider, `bypassed` turns an existing sign-in off locally,
|
|
22
|
+
// and `none` is a product with no sign-in at all, so nothing can depend on one.
|
|
23
|
+
export const AuthPosture = z.enum(['real', 'bypassed', 'none']);
|
|
24
|
+
// What installs and tests a sibling. The cockpit owns the environment; the toolchain owns
|
|
25
|
+
// the commands, and they are delegated rather than reimplemented. `none` is a real answer —
|
|
26
|
+
// a directory of data has no install step and must not acquire one by default.
|
|
27
|
+
const Toolchain = z.enum(['node', 'python', 'ruby', 'go', 'rust', 'dotnet', 'none']);
|
|
28
|
+
const SafeRelativePath = z
|
|
29
|
+
.string()
|
|
30
|
+
.regex(/^(?!\/)(?![A-Za-z][A-Za-z0-9+.-]*:)(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?!.*\\)(?:[^/\x00-\x1f\x7f]+\/)*[^/\x00-\x1f\x7f]+$/, 'expected a confined relative path without dot segments or backslashes');
|
|
31
|
+
const SurfacePath = SafeRelativePath.and(z.string().max(4096).regex(/^(?!.*(?:^|\/)(?:\.git|internal|node_modules)(?:\/|$)).+$/, 'surface paths cannot enter private or machine-owned directories'));
|
|
32
|
+
const LocalComposePath = SafeRelativePath.regex(/^[^:]+$/, 'expected a local compose file path');
|
|
33
|
+
const SafeUrlPath = z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(/^\/(?!\/)(?!\.{1,2}(?:\/|$))(?!.*\/\.{1,2}(?:\/|$))(?!.*[%\\?#\x00-\x20\x7f]).*$/, 'expected an absolute URL path without escapes, dot segments, controls, backslashes, query, or fragment');
|
|
36
|
+
export const EnvironmentFile = z
|
|
37
|
+
.object({
|
|
38
|
+
template: SafeRelativePath.describe('Tracked template file the instance is created from.'),
|
|
39
|
+
instance: SafeRelativePath.describe('Machine-local file created from the template and left out of version control.'),
|
|
40
|
+
})
|
|
41
|
+
.strict()
|
|
42
|
+
.refine((file) => file.template !== file.instance, {
|
|
43
|
+
message: 'template and instance must name different files',
|
|
44
|
+
path: ['instance'],
|
|
45
|
+
});
|
|
46
|
+
// `up` holds one authority window open while every service starts, and each start step runs for
|
|
47
|
+
// the declared budget. A budget beyond what that window can hold was silently cut to five minutes,
|
|
48
|
+
// so a stack that needed more was killed and reported unhealthy on every retry. An hour covers a
|
|
49
|
+
// first start that builds images and installs dependencies; a longer one is refused here.
|
|
50
|
+
export const MAX_COLD_START_SECONDS = 3_600;
|
|
51
|
+
export const MachineFloor = z
|
|
52
|
+
.object({
|
|
53
|
+
runtime_memory_mb: z.number().int().positive().describe('Least memory in megabytes the container runtime must offer before the stack starts.'),
|
|
54
|
+
runtime_cpus: z.number().positive().describe('Least number of CPUs the container runtime must offer before the stack starts.'),
|
|
55
|
+
free_disk_mb: z.number().int().positive().describe('Least free disk space in megabytes the stack needs before it starts.'),
|
|
56
|
+
cold_start_seconds: z.number().int().positive().max(MAX_COLD_START_SECONDS).describe('Seconds a first start may take; start fails and reports unhealthy beyond it. At most one hour.'),
|
|
57
|
+
})
|
|
58
|
+
.strict();
|
|
59
|
+
export const ResettableVolumeId = z.string().regex(/^[a-z0-9][a-z0-9_.-]*$/);
|
|
60
|
+
export const GeneratedHostPath = SafeRelativePath;
|
|
61
|
+
export function parseBrowserNetworkExposure(value, reason) {
|
|
62
|
+
const exposure = value ?? '<loopback>';
|
|
63
|
+
const rules = exposure.split(',');
|
|
64
|
+
const validRule = (candidate) => {
|
|
65
|
+
if (candidate === '<loopback>' || candidate === '*')
|
|
66
|
+
return true;
|
|
67
|
+
const ipv6 = /^\[([0-9a-f:]+)\](?::([1-9][0-9]{0,4}))?$/.exec(candidate);
|
|
68
|
+
if (ipv6)
|
|
69
|
+
return isIP(ipv6[1]) === 6 && (!ipv6[2] || Number(ipv6[2]) <= 65_535);
|
|
70
|
+
const authority = /^([^:]+)(?::([1-9][0-9]{0,4}))?$/.exec(candidate);
|
|
71
|
+
if (!authority || authority[2] && Number(authority[2]) > 65_535)
|
|
72
|
+
return false;
|
|
73
|
+
const wildcard = authority[1].startsWith('*.');
|
|
74
|
+
const hostname = wildcard ? authority[1].slice(2) : authority[1];
|
|
75
|
+
if (hostname.length > 253 || wildcard && isIP(hostname) !== 0)
|
|
76
|
+
return false;
|
|
77
|
+
return isIP(hostname) === 4
|
|
78
|
+
|| /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(hostname);
|
|
79
|
+
};
|
|
80
|
+
if (Buffer.byteLength(exposure) > 512 || rules.length === 0 || rules.length > 32
|
|
81
|
+
|| new Set(rules).size !== rules.length || rules.some((rule) => !rule || rule !== rule.trim() || !validRule(rule))) {
|
|
82
|
+
throw new RefusalError('browser network exposure must contain bounded Playwright hostname, IP, wildcard or loopback rules');
|
|
83
|
+
}
|
|
84
|
+
if (exposure === '<loopback>') {
|
|
85
|
+
if (reason !== undefined)
|
|
86
|
+
throw new RefusalError('browser network exposure reason is only valid for a wider decision');
|
|
87
|
+
return { value: exposure };
|
|
88
|
+
}
|
|
89
|
+
if (typeof reason !== 'string' || reason !== reason.trim() || reason.length === 0 || Buffer.byteLength(reason) > 512
|
|
90
|
+
|| /[\x00-\x1f\x7f]/.test(reason))
|
|
91
|
+
throw new RefusalError('wider browser network exposure requires a bounded explicit reason');
|
|
92
|
+
return { value: exposure, reason };
|
|
93
|
+
}
|
|
94
|
+
export const EntrypointDerivative = z
|
|
95
|
+
.object({
|
|
96
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).describe('Name that identifies this entrypoint in command output.'),
|
|
97
|
+
kind: z.enum(ENTRYPOINT_KINDS).describe('What the address is used for, such as an application origin or a callback URL.'),
|
|
98
|
+
port: z.string().regex(/^[A-Z][A-Z0-9_]*$/).describe('Port-base variable from [ports.bases] whose leased port this address uses.'),
|
|
99
|
+
path: SafeUrlPath.describe('Absolute URL path appended to the origin.'),
|
|
100
|
+
hostname: z.string().max(253).regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+localhost(?![\s\S])/).optional().describe('Named local URL host, a subdomain of .localhost; omit to use 127.0.0.1.'),
|
|
101
|
+
scheme: z.enum(['http', 'https']).optional().describe('URL scheme, http or https; omit to use http.'),
|
|
102
|
+
})
|
|
103
|
+
.strict();
|
|
104
|
+
const CommandArgv = z.array(z.string().min(1)).min(1);
|
|
105
|
+
// Unicode mode counts scalar values, excluding lone surrogate code units before UTF-8 encoding.
|
|
106
|
+
const GUARD_COMMAND_WORD_PATTERN = '^[^\\x00-\\x1f\\x7f-\\x9f\\uD800-\\uDFFF]{1,128}(?![\\s\\S])';
|
|
107
|
+
export const GuardCommandArgv = z.array(z.string().regex(new RegExp(GUARD_COMMAND_WORD_PATTERN, 'u'))).min(1).max(32);
|
|
108
|
+
export const InstallPolicy = z
|
|
109
|
+
.object({
|
|
110
|
+
toolchain: Toolchain.exclude(['none']).describe('Toolchain of the siblings this policy applies to.'),
|
|
111
|
+
lockfile: SafeRelativePath.describe('Lockfile, relative to the sibling, that selects this policy.'),
|
|
112
|
+
build: GuardCommandArgv.optional().describe('Build command, as an argument list, when the product owns one.'),
|
|
113
|
+
test: GuardCommandArgv.optional().describe('Test command, as an argument list, when the product owns one.'),
|
|
114
|
+
})
|
|
115
|
+
.strict()
|
|
116
|
+
// A row with neither admits nothing; it would only satisfy setup's lookup for the lockfile.
|
|
117
|
+
.refine(row => row.build !== undefined || row.test !== undefined, { message: 'an install policy declares a build command, a test command or both' });
|
|
118
|
+
function hasDuplicate(values) {
|
|
119
|
+
return new Set(values).size !== values.length;
|
|
120
|
+
}
|
|
121
|
+
const PostureRequirement = z.object({
|
|
122
|
+
requirement: z.string().min(1).max(64).regex(/^[a-z][a-z0-9-]*$/).describe('Name of the posture requirement the product relies on.'),
|
|
123
|
+
addressing: AddressingPosture.optional().describe('Addressing posture this requirement depends on; set at least one of addressing and authentication.'),
|
|
124
|
+
authentication: AuthPosture.optional().describe('Authentication posture this requirement depends on; set at least one of addressing and authentication.'),
|
|
125
|
+
}).strict().refine((requirement) => requirement.addressing !== undefined || requirement.authentication !== undefined, {
|
|
126
|
+
message: 'posture requirement must tag addressing or authentication',
|
|
127
|
+
});
|
|
128
|
+
const DeclarationReason = z.string().min(1).max(4096).regex(/\S/);
|
|
129
|
+
const EnvironmentName = z.string().regex(/^[A-Z][A-Z0-9_]*$/).max(128);
|
|
130
|
+
// Where a declared value came from. A date records when its source was looked at, so it is
|
|
131
|
+
// required wherever the source lies outside this configuration. The one exception is a value
|
|
132
|
+
// declared by the configuration file itself, which the loader checks by path because the
|
|
133
|
+
// schema cannot see the file name.
|
|
134
|
+
export const Provenance = z.object({
|
|
135
|
+
source: SafeRelativePath.max(4096).describe('File or place the value came from, relative to the repository.'),
|
|
136
|
+
observed_on: z.string().date().optional().describe('Date the source was looked at; required unless kind is declared.'),
|
|
137
|
+
kind: z.enum(['declared', 'measured', 'inferred']).describe('How the value was established: declared by this file, measured, or inferred.'),
|
|
138
|
+
reason: DeclarationReason.describe('Why the value is what it is.'),
|
|
139
|
+
confirmed: z.boolean().optional().describe('Set to true once a person has confirmed an inferred value; inferred values require it.'),
|
|
140
|
+
}).strict().refine((value) => value.kind !== 'inferred' || value.confirmed === true, {
|
|
141
|
+
path: ['confirmed'], message: 'inferred declarations require explicit confirmation',
|
|
142
|
+
}).refine((value) => value.kind === 'declared' || value.observed_on !== undefined, {
|
|
143
|
+
path: ['observed_on'], message: 'measured and inferred provenance require observed_on',
|
|
144
|
+
});
|
|
145
|
+
export const Environment = z
|
|
146
|
+
.object({
|
|
147
|
+
addressing: AddressingPosture.optional().describe('How the product is reached locally: by host name or by leased port.'),
|
|
148
|
+
authentication: AuthPosture.optional().describe('How the product signs in locally: real sign-in, bypassed, or none.'),
|
|
149
|
+
posture_requirements: z.array(PostureRequirement).min(1).max(128).optional().describe('Posture facts the product relies on, each tagged with the posture it depends on.'),
|
|
150
|
+
machine: MachineFloor.optional().describe('Least the container runtime must offer before the stack starts.'),
|
|
151
|
+
resettable_volumes: z.array(ResettableVolumeId).default([]).describe('Compose volumes that reset may delete and recreate, named by their Compose key.'),
|
|
152
|
+
generated_host_paths: z.array(GeneratedHostPath).default([]).describe('Host paths cockpit generates and owns, relative to the cockpit.'),
|
|
153
|
+
compose_files: z.array(LocalComposePath).default([]).describe('Compose files, relative to the cockpit, that make up the stack; cockpit runs them as written.'),
|
|
154
|
+
entrypoint_derivatives: z.array(EntrypointDerivative).default([]).describe('Addresses derived from leased ports, such as application origins and callback URLs.'),
|
|
155
|
+
install_policies: z.array(InstallPolicy).default([]).describe('The build and test commands the guard admits for each toolchain and lockfile.'),
|
|
156
|
+
// Product data lives in the resettable volumes, so the seed reruns exactly when one of them is
|
|
157
|
+
// new: after `reset`, a `docker volume rm`, or the first start.
|
|
158
|
+
seed: z.object({
|
|
159
|
+
argv: CommandArgv.max(256).describe('Command, as an argument list, that fills the resettable volumes; it runs on the host in the cockpit directory with the leased ports in its environment.'),
|
|
160
|
+
timeout_seconds: z.number().int().min(1).max(3600).describe('Seconds the seed may run before it is stopped.'),
|
|
161
|
+
}).strict().optional().describe('Command up runs once the stack is healthy, whenever a resettable volume was created since the last successful seed.'),
|
|
162
|
+
})
|
|
163
|
+
.strict()
|
|
164
|
+
.superRefine((environment, ctx) => {
|
|
165
|
+
const identities = {
|
|
166
|
+
resettable_volumes: environment.resettable_volumes,
|
|
167
|
+
generated_host_paths: environment.generated_host_paths,
|
|
168
|
+
compose_files: environment.compose_files,
|
|
169
|
+
entrypoint_derivatives: environment.entrypoint_derivatives.map((row) => row.name),
|
|
170
|
+
posture_requirements: environment.posture_requirements?.map((row) => row.requirement) ?? [],
|
|
171
|
+
};
|
|
172
|
+
for (const [field, values] of Object.entries(identities)) {
|
|
173
|
+
if (hasDuplicate(values)) {
|
|
174
|
+
ctx.addIssue({ code: 'custom', path: [field], message: `${field} contains a duplicate identity` });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// A seed reruns only when a resettable volume is new, so without one it would run once and
|
|
178
|
+
// never again while reading as a control.
|
|
179
|
+
if (environment.seed && environment.resettable_volumes.length === 0) {
|
|
180
|
+
ctx.addIssue({ code: 'custom', path: ['seed'], message: 'seed requires resettable_volumes: it reruns whenever one of those volumes is new' });
|
|
181
|
+
}
|
|
182
|
+
const selectors = environment.install_policies.map((row) => `${row.toolchain}\0${row.lockfile}`);
|
|
183
|
+
if (hasDuplicate(selectors)) {
|
|
184
|
+
ctx.addIssue({
|
|
185
|
+
code: 'custom',
|
|
186
|
+
path: ['install_policies'],
|
|
187
|
+
message: 'install_policies contains a duplicate toolchain and lockfile selector',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
const McpSourceReference = z.object({
|
|
192
|
+
// A JSON file with a top-level `mcpServers` object, the shape agent tools read from `.mcp.json`.
|
|
193
|
+
path: SafeRelativePath.max(4096).describe('JSON file in the sibling with a top-level mcpServers object, as agent tools read from .mcp.json.'),
|
|
194
|
+
provenance: Provenance.describe('Where this MCP source declaration came from.'),
|
|
195
|
+
}).strict();
|
|
196
|
+
const SiblingDerives = z.object({
|
|
197
|
+
mcp_sources: z.array(McpSourceReference).min(1).max(256).describe('MCP server declaration files this sibling publishes.'),
|
|
198
|
+
}).strict().superRefine((derives, ctx) => {
|
|
199
|
+
if (hasDuplicate(derives.mcp_sources.map((source) => source.path))) {
|
|
200
|
+
ctx.addIssue({ code: 'custom', path: ['mcp_sources'], message: 'MCP source references contain a duplicate path' });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
// A lane that touches live data or local hardware. Never in a default bring-up, never in CI,
|
|
204
|
+
// and it asks before it acts.
|
|
205
|
+
const Gate = z.enum(['operator']);
|
|
206
|
+
export const OPERATOR_GATE_RISK_PATTERN = String.raw `\b(?:[lL][iI][vV][eE]|[pP][rR][oO][dD][uU][cC][tT][iI][oO][nN]|[hH][aA][rR][dD][wW][aA][rR][eE]|[sS][iI][mM][uU][lL][aA][tT][oO][rR])\b`;
|
|
207
|
+
const OPERATOR_GATE_RISK_RE = new RegExp(OPERATOR_GATE_RISK_PATTERN);
|
|
208
|
+
export function requiresOperatorGate(sibling) {
|
|
209
|
+
return sibling.mount === 'host' || OPERATOR_GATE_RISK_RE.test(sibling.purpose);
|
|
210
|
+
}
|
|
211
|
+
// Runtime supplies sibling variables to Compose, so declarations must never overlap variables
|
|
212
|
+
// that can change Docker, Compose or the host process before cockpit's authenticated plan runs.
|
|
213
|
+
const SiblingVariable = z.string().regex(/^(?!(?:DOCKER_|COMPOSE_|LD_|DYLD_|LIBPATH$|SHLIB_PATH$|NODE_OPTIONS$|NODE_PATH$|BASH_ENV$|ENV$|PATH$|IFS$))[A-Z][A-Z0-9_]*$/, 'expected an uppercase environment variable that is not a Docker, Compose or host process control');
|
|
214
|
+
export function deriveSiblingVariable(slug) {
|
|
215
|
+
const candidate = `${slug.toUpperCase().replaceAll('-', '_')}_DIR`;
|
|
216
|
+
return SiblingVariable.safeParse(candidate).success ? candidate : `SIBLING_${candidate}`;
|
|
217
|
+
}
|
|
218
|
+
// Most repositories publish one or both of these, so an absent default guide is not an error; any
|
|
219
|
+
// other declared name is one the row chose, and an absent one is a typo that would copy nothing.
|
|
220
|
+
export const SIBLING_DEFAULT_GUIDES = Object.freeze(['CLAUDE.md', 'AGENTS.md']);
|
|
221
|
+
export const Sibling = z
|
|
222
|
+
.object({
|
|
223
|
+
// Recorded rather than derived: slug, directory and remote are routinely three different
|
|
224
|
+
// names for one repository, and every one of them is load-bearing somewhere.
|
|
225
|
+
slug: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).describe('Short name of the sibling; also how the root AGENTS.md labels its copied guides.'),
|
|
226
|
+
// Relative to the siblings root and resolved without further checks by several
|
|
227
|
+
// consumers, so the schema is where a hostile cockpit.toml is stopped. `.` is the
|
|
228
|
+
// siblings root itself: a single-repository stack whose one sibling is the cockpit root, or a
|
|
229
|
+
// cockpit started before any product repository exists, which grows with `sibling add`.
|
|
230
|
+
dir: z.union([z.literal('.'), SafeRelativePath]).describe('Checkout directory under the siblings root, or . for the siblings root itself.'),
|
|
231
|
+
// Absent exactly when nothing is cloned. Never derived from `dir`: a directory is
|
|
232
|
+
// named for local convenience, a remote for whatever the org called it.
|
|
233
|
+
remote: z.string().min(1).optional().describe('Git remote to clone from; required when acquire is auto or manual and not allowed when none.'),
|
|
234
|
+
branch: z.string().min(1).optional().describe('Branch to check out when cloning.'),
|
|
235
|
+
mount: Mount.describe('How the stack uses the checkout: always mounted, mounted under a profile, host only, or not mounted.'),
|
|
236
|
+
acquire: Acquire.describe('How the checkout reaches disk: cloned by setup, cloned by a person, or not a repository.'),
|
|
237
|
+
// The exported `*_DIR`. Recorded because it does not follow from the slug — a
|
|
238
|
+
// `renderer` sibling may export ENGINE_DIR, and deriving RENDERER_DIR instead would
|
|
239
|
+
// leave every consumer reading an unset variable.
|
|
240
|
+
var: SiblingVariable.describe('Name of the exported *_DIR environment variable that points at the checkout.'),
|
|
241
|
+
toolchain: Toolchain.default('none').describe('Toolchain that installs and tests the sibling; none for a directory of data.'),
|
|
242
|
+
gate: Gate.optional().describe('Set to operator for a lane that touches live data or local hardware; it never runs by default and asks first.'),
|
|
243
|
+
// Why this repository cannot join the stack. Required on a host lane, because that
|
|
244
|
+
// is a constraint someone had to discover and it will otherwise be rediscovered as a
|
|
245
|
+
// bug: a simulator that cannot be containerised, a renderer that lives in a vendor's
|
|
246
|
+
// cloud, a client that needs a display. Preflight prints this instead of reporting
|
|
247
|
+
// the sibling broken.
|
|
248
|
+
host_reason: z.string().min(1).optional().describe('Why the sibling cannot join the stack; required when mount is host.'),
|
|
249
|
+
// Which guide files this repository publishes. Projected into the cockpit as symlinks
|
|
250
|
+
// to the live files — never copies, which are stale the moment the sibling edits its
|
|
251
|
+
// own guide and report nothing. Defaulted rather than required: most repositories use
|
|
252
|
+
// one or both of these, and one that does not is a row that says so.
|
|
253
|
+
guides: z.array(SafeRelativePath).default([...SIBLING_DEFAULT_GUIDES]).describe('Guide files the sibling publishes, copied into the cockpit\'s root AGENTS.md by sync.'),
|
|
254
|
+
// Setup writes this repository only when the declaration and its command-line approval agree.
|
|
255
|
+
instruction_pointer: z.boolean().default(false).describe('Let setup add a pointer to the cockpit guide in this sibling, when the command line also approves it.'),
|
|
256
|
+
// Printed when the checkout is missing, so it says what the absence costs.
|
|
257
|
+
purpose: z.string().min(1).describe('What the sibling is for; printed when its checkout is missing.'),
|
|
258
|
+
environment_files: z.array(EnvironmentFile).optional().describe('Template and instance environment file pairs the sibling needs.'),
|
|
259
|
+
derives: SiblingDerives.optional().describe('Facts about the sibling that cockpit derives rows or MCP servers from.'),
|
|
260
|
+
})
|
|
261
|
+
.strict()
|
|
262
|
+
.refine((s) => (s.acquire === 'none' ? !s.remote : true), {
|
|
263
|
+
message: 'acquire "none" means nothing is ever cloned — remove `remote`',
|
|
264
|
+
path: ['remote'],
|
|
265
|
+
})
|
|
266
|
+
.refine((s) => (s.acquire !== 'none' ? !!s.remote : true), {
|
|
267
|
+
message: 'acquire "auto" or "manual" needs a `remote` to clone from',
|
|
268
|
+
path: ['remote'],
|
|
269
|
+
})
|
|
270
|
+
.refine((s) => (s.mount === 'host' ? !!s.host_reason : true), {
|
|
271
|
+
message: 'mount "host" needs a `host_reason` — record why it cannot join the stack, ' +
|
|
272
|
+
'or the next reader treats a hard constraint as a gap to close',
|
|
273
|
+
path: ['host_reason'],
|
|
274
|
+
})
|
|
275
|
+
.refine((s) => (!requiresOperatorGate(s) ? true : s.gate === 'operator'), {
|
|
276
|
+
message: 'a live, production, hardware, simulator, or host lane needs gate = "operator"',
|
|
277
|
+
path: ['gate'],
|
|
278
|
+
})
|
|
279
|
+
.refine((s) => !s.instruction_pointer || s.dir !== '.', {
|
|
280
|
+
message: 'the cockpit root already loads AGENTS.md and cannot point that file at itself',
|
|
281
|
+
path: ['instruction_pointer'],
|
|
282
|
+
})
|
|
283
|
+
.refine((s) => !s.environment_files || !hasDuplicate(s.environment_files.map((file) => file.instance)), { message: 'environment_files contains a duplicate instance path', path: ['environment_files'] });
|
|
284
|
+
// Only host-published ports and browser-facing URLs carry the offset. Container-internal
|
|
285
|
+
// DNS and ports never do — that invariant is what lets several stacks run side by side on
|
|
286
|
+
// byte-identical service config with only the published edge differing.
|
|
287
|
+
const Ports = z
|
|
288
|
+
.object({
|
|
289
|
+
step: z.number().int().positive().default(100).describe('Offset between consecutive stacks\' published ports.'),
|
|
290
|
+
count: z.number().int().positive().default(27).describe('Number of stacks that can run side by side.'),
|
|
291
|
+
bases: z.record(z.string().regex(/^[A-Z][A-Z0-9_]*$/), z.number().int().min(1).max(65535)).describe('Base host port for each port variable; stack n publishes base plus n times step.'),
|
|
292
|
+
})
|
|
293
|
+
.strict()
|
|
294
|
+
.superRefine((ports, context) => {
|
|
295
|
+
const memberCount = ports.count * Object.keys(ports.bases).length;
|
|
296
|
+
if (!Number.isSafeInteger(memberCount) || memberCount > STRUCTURED_INPUT_ENTRY_LIMIT) {
|
|
297
|
+
context.addIssue({
|
|
298
|
+
code: 'custom',
|
|
299
|
+
message: `ports.count multiplied by the number of bases must not exceed ${STRUCTURED_INPUT_ENTRY_LIMIT}`,
|
|
300
|
+
path: ['count'],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// Stack n publishes each base plus n * step, so two bases whose distance is a multiple of
|
|
304
|
+
// step below count * step hand one port to two stacks. Leasing only refuses that at `up`,
|
|
305
|
+
// after `check` passed; the config is wrong, so the config check names it.
|
|
306
|
+
const entries = Object.entries(ports.bases).sort(([, left], [, right]) => left - right);
|
|
307
|
+
const spread = entries.length === 0 ? 0 : entries.at(-1)[1] - entries[0][1];
|
|
308
|
+
for (const [index, [lower, lowerBase]] of entries.entries()) {
|
|
309
|
+
for (const [upper, upperBase] of entries.slice(index + 1)) {
|
|
310
|
+
const distance = upperBase - lowerBase;
|
|
311
|
+
if (distance % ports.step !== 0 || distance / ports.step >= ports.count)
|
|
312
|
+
continue;
|
|
313
|
+
const stacks = distance / ports.step;
|
|
314
|
+
context.addIssue({
|
|
315
|
+
code: 'custom',
|
|
316
|
+
path: ['bases', upper],
|
|
317
|
+
message: distance === 0
|
|
318
|
+
? `${lower} and ${upper} share base ${lowerBase}; give each variable its own base`
|
|
319
|
+
: `${lower} (${lowerBase}) and ${upper} (${upperBase}) overlap: stack ${stacks}'s ${lower} is stack 0's ${upper} `
|
|
320
|
+
+ `with step ${ports.step}; set [ports] step above the bases' spread (at least ${spread + 1}) `
|
|
321
|
+
+ 'or move a base so no two differ by a multiple of step',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
const GuardHosts = z.array(z.string().regex(GUARD_HOST_PATTERN)).max(GUARD_HOST_LIMITS.rows)
|
|
327
|
+
.superRefine((hosts, ctx) => {
|
|
328
|
+
const seen = new Set();
|
|
329
|
+
for (const [index, host] of hosts.entries()) {
|
|
330
|
+
const normalized = normalizeGuardHost(host);
|
|
331
|
+
if (seen.has(normalized))
|
|
332
|
+
ctx.addIssue({ code: 'custom', path: [index], message: 'guard hosts must be unique after removing one leading wildcard' });
|
|
333
|
+
seen.add(normalized);
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
.describe('Declared guard hosts; uniqueness after removing one leading wildcard is enforced at runtime.');
|
|
337
|
+
const GuardRuleText = z.string().min(1).max(512).regex(/\S/).regex(/^[^\x00-\x1f\x7f-\x9f]+$/u);
|
|
338
|
+
const GuardAllow = z.object({
|
|
339
|
+
argv: GuardCommandArgv.describe('Command prefix, as an argument list, that is allowed.'),
|
|
340
|
+
reason: GuardRuleText.describe('Why the command is allowed.'),
|
|
341
|
+
cwd: z.union([z.literal('.'), SafeRelativePath.max(4096)]).optional().describe('Allow only when run from this directory, relative to the cockpit.'),
|
|
342
|
+
}).strict();
|
|
343
|
+
const GuardDeny = z.union([
|
|
344
|
+
z.object({ prefix: GuardCommandArgv.describe('Command prefix, as an argument list, that is refused.'), reason: GuardRuleText.describe('Why the command is refused.'), fix: GuardRuleText.optional().describe('Safer command or step to suggest instead.') }).strict(),
|
|
345
|
+
z.object({ contains: GuardCommandArgv.describe('Argument sequence that is refused wherever it appears in a command.'), reason: GuardRuleText.describe('Why the command is refused.'), fix: GuardRuleText.optional().describe('Safer command or step to suggest instead.') }).strict(),
|
|
346
|
+
]);
|
|
347
|
+
export const Guards = z
|
|
348
|
+
.object({
|
|
349
|
+
hosted_db_hosts: GuardHosts.default([]).describe('Hosted database host names the guards keep agents away from.'),
|
|
350
|
+
vendor_endpoint_hosts: GuardHosts.default([]).describe('Real vendor endpoint host names the guards keep agents away from.'),
|
|
351
|
+
protected_branches: z.array(z.string()).default(['main', 'master']).describe('Branches the guards keep agents from pushing to or rewriting.'),
|
|
352
|
+
allow: z.array(GuardAllow).max(64).default([]).describe('Commands allowed in addition to the built-in rules.'),
|
|
353
|
+
deny: z.array(GuardDeny).max(64).default([]).describe('Commands refused in addition to the built-in rules.'),
|
|
354
|
+
})
|
|
355
|
+
.strict();
|
|
356
|
+
// THE approval list: each approved server name maps to the digest of the declaration a human
|
|
357
|
+
// reviewed, which `sync` reports for every server it keeps disabled. Approving a name alone let an
|
|
358
|
+
// agent rewrite the command of an approved server in a sibling's declaration file and the next
|
|
359
|
+
// human sync start it. A server absent from it, or changed since, is generated disabled on every
|
|
360
|
+
// harness, so the three cannot disagree about what may start.
|
|
361
|
+
const Mcp = z
|
|
362
|
+
.object({
|
|
363
|
+
approved: z.record(z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/).max(128), z.string().regex(/^[a-f0-9]{64}$/, 'an approval is the sha256 sync reports for the server'))
|
|
364
|
+
.refine((approved) => Object.keys(approved).length <= 256, 'at most 256 MCP servers can be approved').default({}).describe('Approved MCP servers, each mapped to the sha256 of the declaration a person reviewed; others stay disabled.'),
|
|
365
|
+
})
|
|
366
|
+
.strict();
|
|
367
|
+
// A whole product suite under a build tool runs for minutes, not seconds; the ceiling still bounds
|
|
368
|
+
// a hung stage so a gate always returns a verdict. Work-order verification runs the same suites.
|
|
369
|
+
const TESTING_STAGE_TIMEOUT_DEFAULT_SECONDS = 1_800;
|
|
370
|
+
export const TESTING_STAGE_TIMEOUT_MAX_SECONDS = 7_200;
|
|
371
|
+
// One independent grading run of the evaluator reads the change and its check output; minutes are normal.
|
|
372
|
+
const EVALUATOR_TIMEOUT_DEFAULT_SECONDS = 900;
|
|
373
|
+
// The agent tools that can run headless with a read-only mechanism of their own and return output a
|
|
374
|
+
// schema constrains, named as the harness registry names them, each with the command it runs as.
|
|
375
|
+
export const EVALUATOR_COMMANDS = Object.freeze({ codexcli: 'codex', claudecode: 'claude', opencode: 'opencode' });
|
|
376
|
+
export function runningEvaluatorTool(env) {
|
|
377
|
+
const claude = (env.CLAUDECODE ?? '') !== '';
|
|
378
|
+
const codex = (env.CODEX_THREAD_ID ?? '') !== '';
|
|
379
|
+
return claude === codex ? undefined : claude ? 'claudecode' : 'codexcli';
|
|
380
|
+
}
|
|
381
|
+
// Who may pass a decision point: the agent itself, a person typing the digest cockpit shows in a
|
|
382
|
+
// terminal, or cockpit running in CI, where the required `work verify` job behind a protected
|
|
383
|
+
// environment's reviewers is the gate. A local person gate is best-effort: cockpit cannot tell a
|
|
384
|
+
// person from a program driving a terminal.
|
|
385
|
+
export const DECIDERS = ['agent', 'person', 'ci'];
|
|
386
|
+
const Decider = z.enum(DECIDERS);
|
|
387
|
+
export const WORK_GRADING = ['independent', 'checks', 'self'];
|
|
388
|
+
const WorkGates = z
|
|
389
|
+
.object({
|
|
390
|
+
accept: Decider.optional().describe('Who accepts a draft work order: agent, person or ci. Default agent.'),
|
|
391
|
+
amend: Decider.optional().describe('Who accepts a changed contract. Default agent. Removing or rewriting an accepted criterion always needs a person or ci.'),
|
|
392
|
+
answer: Decider.optional().describe('Who answers an open question on an order. Default agent.'),
|
|
393
|
+
signoff: Decider.optional().describe('Who closes a graded order. Default agent.'),
|
|
394
|
+
})
|
|
395
|
+
.strict();
|
|
396
|
+
export const Work = z
|
|
397
|
+
.object({
|
|
398
|
+
grading: z.enum(WORK_GRADING).default('independent')
|
|
399
|
+
.describe("How an order earns done: independent (an evaluator grades it), checks (cockpit ran every criterion's check and all passed) or self (the agent reports met or unmet with a note)."),
|
|
400
|
+
// An ordered fallback list: the first that runs answers, and one that is missing, signed out or
|
|
401
|
+
// rate-limited is recorded and passed over. It never falls back to the agent grading itself.
|
|
402
|
+
evaluators: z.array(z.enum(['codexcli', 'claudecode', 'opencode'])).min(1).max(3)
|
|
403
|
+
.refine(tools => new Set(tools).size === tools.length, 'list each evaluator once').optional()
|
|
404
|
+
.describe('Agent tools that run the independent grader for work grade and cockpit grade, tried in order: codexcli, claudecode, opencode. Defaults to the tools surfaces.tools lists that can evaluate, in that order, with the tool running the command moved last when it is detected (Claude Code or Codex), so another tool grades first. A list given here is used exactly as written.'),
|
|
405
|
+
// Consumers may lower these framework maxima, but cannot raise them.
|
|
406
|
+
attempt_cap: z.number().int().min(1).max(2).default(2).describe('Failed checks or grades before an order is blocked; may be lowered but not raised.'),
|
|
407
|
+
wip_cap: z.number().int().min(1).max(3).default(3).describe('Accepted orders that may be unfinished at once; may be lowered but not raised.'),
|
|
408
|
+
verification_timeout_seconds: z.number().int().min(1).max(TESTING_STAGE_TIMEOUT_MAX_SECONDS).default(TESTING_STAGE_TIMEOUT_DEFAULT_SECONDS)
|
|
409
|
+
.describe("Seconds one criterion's check command may run before it is stopped and reported as not run."),
|
|
410
|
+
evaluator_timeout_seconds: z.number().int().min(1).max(TESTING_STAGE_TIMEOUT_MAX_SECONDS).default(EVALUATOR_TIMEOUT_DEFAULT_SECONDS)
|
|
411
|
+
.describe('Seconds one independent grading run may take before it is stopped.'),
|
|
412
|
+
gates: WorkGates.prefault({}).describe('Who passes each work-order decision.'),
|
|
413
|
+
})
|
|
414
|
+
.strict();
|
|
415
|
+
export const Autonomy = z
|
|
416
|
+
.object({
|
|
417
|
+
sync: Decider.optional().describe('Who may run a sync that writes: agent, person or ci. Default agent.'),
|
|
418
|
+
stale_delete: Decider.optional().describe('Who approves sync deleting files cockpit generated before and no longer generates. Default agent.'),
|
|
419
|
+
reset: Decider.optional().describe('Who may apply a reset, which deletes stack data. Default person.'),
|
|
420
|
+
// CI adds nothing here: an approval is a digest in [mcp.approved], which only a person edits.
|
|
421
|
+
mcp_approve: z.enum(['agent', 'person']).optional().describe('Who approves a declared MCP server: person records each digest in [mcp.approved]; agent lets sync enable every declared server as declared, so an agent that writes a sibling MCP source starts that process outside the guard. Default person.'),
|
|
422
|
+
})
|
|
423
|
+
.strict();
|
|
424
|
+
// An exact semantic version, pre-release included: an MCP image can bundle a Playwright alpha.
|
|
425
|
+
export const BROWSER_DRIVER_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/;
|
|
426
|
+
const BrowserDriverVersion = z.string().max(128).regex(BROWSER_DRIVER_VERSION);
|
|
427
|
+
/** Playwright refuses a client and server whose major.minor differ, so only that much must agree. */
|
|
428
|
+
export function browserDriversCompatible(left, right) {
|
|
429
|
+
const release = (version) => version.split('-', 1)[0].split('.').slice(0, 2).join('.');
|
|
430
|
+
return release(left) === release(right);
|
|
431
|
+
}
|
|
432
|
+
const BrowserImage = z.object({
|
|
433
|
+
reference: z.string().regex(/^[a-z0-9][a-z0-9./:_-]*@sha256:[a-f0-9]{64}$/).describe('Image reference pinned by sha256 digest.'),
|
|
434
|
+
driver_version: BrowserDriverVersion.describe('Playwright driver version the image carries.'),
|
|
435
|
+
}).strict();
|
|
436
|
+
const BrowserArtifactRoots = z.object({
|
|
437
|
+
mcp: GeneratedHostPath.describe('Generated host path for the browser MCP server\'s files.'),
|
|
438
|
+
cli: GeneratedHostPath.describe('Generated host path for command-line browser output.'),
|
|
439
|
+
auth: GeneratedHostPath.describe('Generated host path for saved sign-in state.'),
|
|
440
|
+
}).strict().superRefine((artifacts, ctx) => {
|
|
441
|
+
const paths = Object.values(artifacts);
|
|
442
|
+
for (const [index, path] of paths.entries()) {
|
|
443
|
+
if (paths.some((other, otherIndex) => index !== otherIndex && (path === other || path.startsWith(`${other}/`)))) {
|
|
444
|
+
ctx.addIssue({ code: 'custom', message: 'browser artifact roots must be distinct and non-overlapping' });
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
export const Browser = z.object({
|
|
450
|
+
engine: z.enum(['chromium', 'firefox', 'webkit']).default('chromium').describe('Browser engine to drive.'),
|
|
451
|
+
network_exposure: z.string().min(1).max(512).regex(/^[a-z0-9.*:<>\[\],-]+$/).refine((value) => {
|
|
452
|
+
const rules = value.split(',');
|
|
453
|
+
return rules.length <= 32 && rules.every((rule) => rule.length > 0) && new Set(rules).size === rules.length;
|
|
454
|
+
}, 'browser network exposure requires bounded unique rules').default('<loopback>').describe('Network rules the browser may reach beyond loopback; the default is loopback only.'),
|
|
455
|
+
network_exposure_reason: z.string().min(1).max(512).refine((value) => value === value.trim() && !/[\x00-\x1f\x7f]/.test(value), 'browser exposure reason must be bounded plain text').optional().describe('Why the browser needs wider network access; required when network_exposure is wider than loopback.'),
|
|
456
|
+
harnesses: z.array(z.object({
|
|
457
|
+
target: z.enum(GUARD_SURFACE_TARGETS).describe('Agent tool the browser is wired to.'),
|
|
458
|
+
version: z.string().regex(/^\d+\.\d+\.\d+$/).describe('Version of the agent tool the wiring was checked against.'),
|
|
459
|
+
}).strict()).min(1).max(GUARD_SURFACE_TARGETS.length).refine((rows) => !hasDuplicate(rows.map((row) => row.target)), 'browser harness targets must be unique').describe('Agent tools, with versions, that are wired to the browser.'),
|
|
460
|
+
images: z.object({ mcp: BrowserImage.describe('Image that serves the browser MCP server.') }).strict().describe('Pinned container images for the browser.'),
|
|
461
|
+
project: z.object({
|
|
462
|
+
manifest: GeneratedHostPath.optional().describe('package.json for the project; omit to use the one beside the lockfile.'),
|
|
463
|
+
manifest_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional().describe('SHA-256 of the manifest; required when manifest is set.'),
|
|
464
|
+
lockfile: GeneratedHostPath.describe('Lockfile that pins the Playwright driver the browser commands load.'),
|
|
465
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/).describe('SHA-256 of the lockfile.'),
|
|
466
|
+
driver_version: BrowserDriverVersion.describe('Playwright driver version the lockfile pins.'),
|
|
467
|
+
}).strict().describe('Node project the browser commands load their Playwright driver from.'),
|
|
468
|
+
endpoint: z.string().url().optional().describe('WebSocket address of the browser server, using an allowed loopback host.'),
|
|
469
|
+
max_clients: z.number().int().min(1).max(64).describe('Most browser clients that may connect at once.'),
|
|
470
|
+
proxy: z.object({
|
|
471
|
+
host_variable: EnvironmentName.optional().describe('Port variable in [ports.bases] that leases the proxy port.'),
|
|
472
|
+
server_port: z.number().int().min(1).max(65535).describe('Port the browser server listens on.'),
|
|
473
|
+
proxy_port: z.number().int().min(1).max(65535).describe('Port the proxy listens on; differs from server_port.'),
|
|
474
|
+
allowed_hosts: z.array(z.string().min(1).max(260)).min(1).max(64).describe('Host and port pairs the proxy allows.'),
|
|
475
|
+
bind: z.enum(['127.0.0.1', '::1']).default('127.0.0.1').describe('Loopback address the proxy binds to.'),
|
|
476
|
+
}).strict().describe('Proxy the browser reaches the application through.'),
|
|
477
|
+
resolver: z.array(z.object({
|
|
478
|
+
hostname: z.string().regex(/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*$/).describe('Host name to resolve.'),
|
|
479
|
+
address: z.union([z.ipv4(), z.ipv6()]).describe('Address the host name resolves to.'),
|
|
480
|
+
}).strict()).max(64).default([]).describe('Fixed host name to address mappings the browser uses.'),
|
|
481
|
+
authentication: z.object({
|
|
482
|
+
policy: z.enum(['none', 'serialized', 'per-worker']).describe('Whether sessions share no sign-in, take turns on one, or each keep their own.'),
|
|
483
|
+
states: z.array(z.object({
|
|
484
|
+
worker: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/).describe('Name of the worker the state belongs to.'),
|
|
485
|
+
path: GeneratedHostPath.describe('Saved sign-in state file under the auth artifact root.'),
|
|
486
|
+
}).strict()).max(64).default([]).describe('Saved sign-in states, one per worker.'),
|
|
487
|
+
}).strict().describe('How browser sessions sign in to the application.'),
|
|
488
|
+
// `isolated` and `reaches_production` are the person's statement about the data the browser
|
|
489
|
+
// touches; cockpit cannot observe either. They are required rather than defaulted so that
|
|
490
|
+
// request_preapproval, which lets browser tools run without prompts, is never granted on a
|
|
491
|
+
// posture nobody wrote down.
|
|
492
|
+
data: z.object({
|
|
493
|
+
mode: z.enum(['empty', 'seeded', 'restored']).describe('What the data is: empty, seeded by the product, or restored from real data.'),
|
|
494
|
+
isolated: z.boolean().describe('True when the data is separate from anyone else\'s.'),
|
|
495
|
+
reaches_production: z.boolean().describe('True when browser actions can affect production.'),
|
|
496
|
+
}).strict().describe('Statement about the data the browser touches; cockpit cannot observe it.'),
|
|
497
|
+
request_preapproval: z.boolean().default(false).describe('Run browser tools without prompts; allowed only on isolated, non-production, non-restored data.'),
|
|
498
|
+
artifacts: BrowserArtifactRoots.describe('Generated host paths for browser output.'),
|
|
499
|
+
}).strict().superRefine((browser, ctx) => {
|
|
500
|
+
try {
|
|
501
|
+
parseBrowserNetworkExposure(browser.network_exposure, browser.network_exposure_reason);
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
ctx.addIssue({ code: 'custom', path: ['network_exposure'], message: errorCause(error) });
|
|
505
|
+
}
|
|
506
|
+
if ((browser.project.manifest === undefined) !== (browser.project.manifest_sha256 === undefined)) {
|
|
507
|
+
ctx.addIssue({ code: 'custom', path: ['project'], message: 'browser project manifest path and digest must be declared together' });
|
|
508
|
+
}
|
|
509
|
+
if (!browserDriversCompatible(browser.images.mcp.driver_version, browser.project.driver_version)) {
|
|
510
|
+
ctx.addIssue({ code: 'custom', path: ['images', 'mcp', 'driver_version'], message: 'browser MCP image driver must share major.minor with the project driver' });
|
|
511
|
+
}
|
|
512
|
+
if (browser.proxy.server_port === browser.proxy.proxy_port) {
|
|
513
|
+
ctx.addIssue({ code: 'custom', path: ['proxy', 'proxy_port'], message: 'browser server and proxy ports must differ' });
|
|
514
|
+
}
|
|
515
|
+
if (hasDuplicate(browser.proxy.allowed_hosts)) {
|
|
516
|
+
ctx.addIssue({ code: 'custom', path: ['proxy', 'allowed_hosts'], message: 'browser proxy hosts must be unique' });
|
|
517
|
+
}
|
|
518
|
+
if (browser.proxy.allowed_hosts.some((value) => !browserProxyAuthorityMatches(value, browser.proxy.proxy_port))) {
|
|
519
|
+
ctx.addIssue({ code: 'custom', path: ['proxy', 'allowed_hosts'], message: 'browser proxy hosts must be exact authorities on the proxy port' });
|
|
520
|
+
}
|
|
521
|
+
if (browser.endpoint) {
|
|
522
|
+
try {
|
|
523
|
+
const endpoint = new URL(browser.endpoint);
|
|
524
|
+
const spelling = /^ws:\/\/([^/?#]+)(\/[^?#]*)$/.exec(browser.endpoint);
|
|
525
|
+
if (!spelling || spelling[2] !== endpoint.pathname || endpoint.protocol !== 'ws:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash
|
|
526
|
+
|| !browser.proxy.allowed_hosts.includes(spelling[1]) || !['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname))
|
|
527
|
+
throw new TypeError();
|
|
528
|
+
}
|
|
529
|
+
catch { /* The issue names the rule; URL parser text adds nothing. */
|
|
530
|
+
ctx.addIssue({ code: 'custom', path: ['endpoint'], message: 'browser endpoint must use an allowed credential-free loopback ws:// authority; TLS termination is unavailable' });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const workers = browser.authentication.states.map((state) => state.worker);
|
|
534
|
+
const paths = browser.authentication.states.map((state) => state.path);
|
|
535
|
+
if (hasDuplicate(workers) || hasDuplicate(paths)) {
|
|
536
|
+
ctx.addIssue({ code: 'custom', path: ['authentication', 'states'], message: 'browser authentication workers and state paths must be unique' });
|
|
537
|
+
}
|
|
538
|
+
if (paths.some((path) => !path.startsWith(`${browser.artifacts.auth}/`))) {
|
|
539
|
+
ctx.addIssue({ code: 'custom', path: ['authentication', 'states'], message: 'browser authentication state must stay inside the auth artifact root' });
|
|
540
|
+
}
|
|
541
|
+
if (browser.request_preapproval && (browser.data.mode === 'restored' || !browser.data.isolated || browser.data.reaches_production)) {
|
|
542
|
+
ctx.addIssue({ code: 'custom', path: ['request_preapproval'], message: 'browser preapproval requires isolated empty or seeded data without production reach' });
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
export const TestingName = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/);
|
|
546
|
+
const TestingCommand = z.array(z.string().min(1).max(4096).refine((value) => !/[\x00-\x1f\x7f]/.test(value))).min(1).max(128);
|
|
547
|
+
// Stock runners reject an unknown option, so a selection reaches a stage only through arguments the
|
|
548
|
+
// stage declares in its tool's own spelling, such as ["-t", "{value}"] for vitest or ["-k", "{value}"] for pytest.
|
|
549
|
+
const TestingSelectArguments = z.array(z.string().min(1).max(4096).refine((value) => !/[\x00-\x1f\x7f]/.test(value))).min(1).max(32)
|
|
550
|
+
.refine((values) => values.some((value) => value.includes('{value}')), 'a selection must place {value} in at least one argument');
|
|
551
|
+
const TestingSelect = z.object({
|
|
552
|
+
spec: TestingSelectArguments.optional().describe('Arguments that select tests by spec; {value} becomes the selection.'),
|
|
553
|
+
file: TestingSelectArguments.optional().describe('Arguments that select tests by file; {value} becomes the selection.'),
|
|
554
|
+
test: TestingSelectArguments.optional().describe('Arguments that select tests by test name; {value} becomes the selection.'),
|
|
555
|
+
route: TestingSelectArguments.optional().describe('Arguments that select tests by route; {value} becomes the selection.'),
|
|
556
|
+
}).strict();
|
|
557
|
+
export const Testing = z.object({
|
|
558
|
+
stages: z.array(z.object({
|
|
559
|
+
id: TestingName.describe('Name of the stage.'),
|
|
560
|
+
command: TestingCommand.describe('Command to run, as an argument list.'),
|
|
561
|
+
requires_stack: z.boolean().describe('True when the stage needs the stack running.'),
|
|
562
|
+
timeout_seconds: z.number().int().min(1).max(TESTING_STAGE_TIMEOUT_MAX_SECONDS).default(TESTING_STAGE_TIMEOUT_DEFAULT_SECONDS)
|
|
563
|
+
.describe('Seconds the stage may run before it is stopped and the gate reports DEGRADED.'),
|
|
564
|
+
select: TestingSelect.optional()
|
|
565
|
+
.describe('Arguments appended to the command when test-run --select names that kind; {value} becomes the selected value.'),
|
|
566
|
+
}).strict()).min(1).max(128).describe('Commands that make up the test gates.'),
|
|
567
|
+
gates: z.array(z.object({
|
|
568
|
+
id: TestingName.describe('Name of the gate.'),
|
|
569
|
+
tier: z.enum(['targeted', 'full']).describe('Whether the gate covers targeted changes or the full suite.'),
|
|
570
|
+
stages: z.array(TestingName).min(1).max(128).describe('Stages the gate runs, by id.'),
|
|
571
|
+
changes: z.string().min(1).max(4096).refine((value) => !/[\x00-\x1f\x7f]/.test(value)).describe('Kind of change that calls for this gate.'),
|
|
572
|
+
}).strict()).min(1).max(128).describe('Named gates that run stages before handing back changes.'),
|
|
573
|
+
native_hooks: z.object({
|
|
574
|
+
install: z.enum(['inspect', 'explicit-native']).default('inspect').describe('Inspect the repository\'s hooks, or install them as the policy file declares.'),
|
|
575
|
+
}).strict().default({ install: 'inspect' }).describe('How the repository\'s own git hooks are handled.'),
|
|
576
|
+
native_hook_policy: z.object({
|
|
577
|
+
path: SafeRelativePath.max(4096).describe('Policy file, relative to the cockpit.'),
|
|
578
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/).describe('SHA-256 of the policy file.'),
|
|
579
|
+
}).strict().optional().describe('Policy file for installing the repository\'s hooks; required for explicit-native.'),
|
|
580
|
+
}).strict().superRefine((testing, ctx) => {
|
|
581
|
+
const stageIds = testing.stages.map((stage) => stage.id);
|
|
582
|
+
const gateIds = testing.gates.map((gate) => gate.id);
|
|
583
|
+
if (hasDuplicate(stageIds))
|
|
584
|
+
ctx.addIssue({ code: 'custom', path: ['stages'], message: 'testing stage identities must be unique' });
|
|
585
|
+
if (hasDuplicate(gateIds))
|
|
586
|
+
ctx.addIssue({ code: 'custom', path: ['gates'], message: 'testing gate identities must be unique' });
|
|
587
|
+
for (const [index, gate] of testing.gates.entries()) {
|
|
588
|
+
if (hasDuplicate(gate.stages) || gate.stages.some((stage) => !stageIds.includes(stage))) {
|
|
589
|
+
ctx.addIssue({ code: 'custom', path: ['gates', index, 'stages'], message: 'testing gates require unique declared stages' });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (testing.native_hooks.install === 'explicit-native' && !testing.native_hook_policy
|
|
593
|
+
|| testing.native_hooks.install === 'inspect' && testing.native_hook_policy) {
|
|
594
|
+
ctx.addIssue({ code: 'custom', path: ['native_hook_policy'], message: 'native hook policy is required only for explicit native installation' });
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
const CockpitMeta = z
|
|
598
|
+
.object({
|
|
599
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).describe('Name of this cockpit.'),
|
|
600
|
+
// Resolved from the MAIN checkout, never from the cwd's parent — a nested linked
|
|
601
|
+
// checkout must still find the declared sibling root.
|
|
602
|
+
siblings_root: z.string().default('..').describe('Directory holding the sibling checkouts, relative to the cockpit.'),
|
|
603
|
+
// preflight FAILS over it. A cap that only warns is a cap that is always slightly
|
|
604
|
+
// exceeded; adherence falls as the guide grows.
|
|
605
|
+
guide_cap: z.number().int().positive().default(200).describe('Most lines of always-loaded guidance; preflight fails above it.'),
|
|
606
|
+
})
|
|
607
|
+
.strict();
|
|
608
|
+
const SurfaceName = z.string().regex(/^[a-z0-9][a-z0-9-]*$/).max(128);
|
|
609
|
+
const SurfaceSha256 = z.string().regex(/^[a-f0-9]{64}$/);
|
|
610
|
+
const SurfaceBytes = z.string().max(65_536).refine((bytes) => Buffer.byteLength(bytes) <= 65_536, {
|
|
611
|
+
message: 'surface source bytes exceed the 65536-byte scalar limit',
|
|
612
|
+
});
|
|
613
|
+
function hasYamlOnlyMarkdownFrontmatter(path, bytes) {
|
|
614
|
+
if (!path.endsWith('.md'))
|
|
615
|
+
return true;
|
|
616
|
+
const normalized = bytes.charCodeAt(0) === 0xFEFF ? bytes.slice(1) : bytes;
|
|
617
|
+
if (!normalized.startsWith('---'))
|
|
618
|
+
return true;
|
|
619
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(normalized);
|
|
620
|
+
if (!match)
|
|
621
|
+
return false;
|
|
622
|
+
try {
|
|
623
|
+
const document = parseDocument(match[1], { uniqueKeys: true });
|
|
624
|
+
if (document.errors.length > 0 || document.warnings.length > 0)
|
|
625
|
+
return false;
|
|
626
|
+
document.toJS({ maxAliasCount: 0 });
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
return false;
|
|
631
|
+
} // The caller reports front matter that is not plain YAML; parser text would quote the file.
|
|
632
|
+
}
|
|
633
|
+
const SurfaceSource = z.object({
|
|
634
|
+
path: SurfacePath.describe('Path of the source file.'),
|
|
635
|
+
bytes: SurfaceBytes.describe('Full text of the source.'),
|
|
636
|
+
}).strict().superRefine((source, ctx) => {
|
|
637
|
+
if (!hasYamlOnlyMarkdownFrontmatter(source.path, source.bytes)) {
|
|
638
|
+
ctx.addIssue({
|
|
639
|
+
code: 'custom',
|
|
640
|
+
path: ['bytes'],
|
|
641
|
+
message: 'Markdown frontmatter must use an unqualified YAML delimiter and valid bounded YAML',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
const SurfaceOverrideReference = z.object({
|
|
646
|
+
name: SurfaceName.describe('Name of the core declaration being replaced.'),
|
|
647
|
+
sha256: SurfaceSha256.describe('SHA-256 of the original core declaration, recorded so a later change to it is reported.'),
|
|
648
|
+
}).strict();
|
|
649
|
+
export const SurfaceDeclaration = z.object({
|
|
650
|
+
name: SurfaceName.describe('Name of the declaration; an overlay that reuses a core name replaces it.'),
|
|
651
|
+
feature: z.enum(SURFACE_FEATURES).describe('Kind of surface the declaration provides.'),
|
|
652
|
+
sources: z.array(SurfaceSource).min(1).max(256).describe('Files that make up the declaration.'),
|
|
653
|
+
source: SurfacePath.describe('Where the declaration was read from.'),
|
|
654
|
+
replaces: SurfaceOverrideReference.optional().describe('Core declaration this overlay replaces; required when the name matches a core one.'),
|
|
655
|
+
}).strict().superRefine((declaration, ctx) => {
|
|
656
|
+
if (!surfaceSourcesMatchFeature(declaration.sources, declaration.feature)) {
|
|
657
|
+
ctx.addIssue({ code: 'custom', path: ['sources'], message: 'surface sources must match their feature and include every skill definition' });
|
|
658
|
+
}
|
|
659
|
+
if (hasDuplicate(declaration.sources.map((source) => source.path))) {
|
|
660
|
+
ctx.addIssue({ code: 'custom', path: ['sources'], message: 'surface source paths must be unique' });
|
|
661
|
+
}
|
|
662
|
+
if (declaration.replaces && declaration.replaces.name !== declaration.name) {
|
|
663
|
+
ctx.addIssue({ code: 'custom', path: ['replaces', 'name'], message: 'an override must name its declaration identity' });
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
export const SurfaceOverlay = SurfaceDeclaration.safeExtend({
|
|
667
|
+
feature: z.enum(['rules', 'subagents', 'skills', 'hooks']).describe('Kind of surface the overlay provides.'),
|
|
668
|
+
});
|
|
669
|
+
// A team's own harness settings file that cockpit moved under cockpit/adopted/ and merges with its
|
|
670
|
+
// rendering. `sha256` records the bytes that were moved; it is a record for review, not a lock:
|
|
671
|
+
// the adopted copy is consumer-owned, and the next sync re-merges whatever it holds.
|
|
672
|
+
export const SurfaceAdoption = z.object({
|
|
673
|
+
path: z.enum(ADOPTABLE_SURFACE_PATHS).describe('Harness settings file that was adopted.'),
|
|
674
|
+
source: SurfacePath.describe('Where the adopted copy now lives, under cockpit/adopted/.'),
|
|
675
|
+
sha256: SurfaceSha256.describe('SHA-256 of the bytes that were moved, as a record for review.'),
|
|
676
|
+
adopted_on: z.string().date().describe('Date the file was adopted.'),
|
|
677
|
+
provenance: Provenance.describe('Where the adoption came from; its kind must be declared.'),
|
|
678
|
+
}).strict().superRefine((row, ctx) => {
|
|
679
|
+
if (row.source !== `${ADOPTED_SURFACE_ROOT}/${row.path}`) {
|
|
680
|
+
ctx.addIssue({ code: 'custom', path: ['source'], message: `an adopted surface is kept at ${ADOPTED_SURFACE_ROOT}/<path>` });
|
|
681
|
+
}
|
|
682
|
+
if (row.provenance.kind !== 'declared') {
|
|
683
|
+
ctx.addIssue({ code: 'custom', path: ['provenance', 'kind'], message: 'an adopted surface is declared by the team that owns it' });
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
const HarnessMechanism = z.object({
|
|
687
|
+
module: SurfacePath.describe('Module that implements the capability.'),
|
|
688
|
+
export: z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]*$/).max(128).describe('Export of the module that implements the capability.'),
|
|
689
|
+
}).strict();
|
|
690
|
+
const HarnessCapabilityBase = {
|
|
691
|
+
feature: z.enum(SURFACE_FEATURES).describe('Surface feature this row describes.'),
|
|
692
|
+
measured_on: z.string().date().describe('Date the capability was measured.'),
|
|
693
|
+
};
|
|
694
|
+
const HarnessEmittedCapability = {
|
|
695
|
+
...HarnessCapabilityBase,
|
|
696
|
+
spelling: SurfacePath.describe('File the harness reads for this feature.'),
|
|
697
|
+
control: SurfacePath.describe('Path of the deliberately broken control file the capability was measured against.'),
|
|
698
|
+
mechanism: HarnessMechanism.describe('Code that emits the surface.'),
|
|
699
|
+
};
|
|
700
|
+
const CustomHarnessCapability = z.discriminatedUnion('state', [
|
|
701
|
+
z.object({
|
|
702
|
+
...HarnessEmittedCapability,
|
|
703
|
+
state: z.literal('enforced').describe('Whether the harness enforces, only instructs, or lacks this capability.'),
|
|
704
|
+
evidence: z.object({ reference: SurfacePath.describe('Probe record or file, relative to the cockpit.'), sha256: SurfaceSha256.describe('SHA-256 of the evidence.') }).strict().describe('Evidence that the harness enforces the capability.'),
|
|
705
|
+
}).strict(),
|
|
706
|
+
z.object({
|
|
707
|
+
...HarnessEmittedCapability,
|
|
708
|
+
state: z.literal('instructed').describe('Whether the harness enforces, only instructs, or lacks this capability.'),
|
|
709
|
+
}).strict(),
|
|
710
|
+
z.object({
|
|
711
|
+
...HarnessCapabilityBase,
|
|
712
|
+
state: z.literal('unavailable').describe('Whether the harness enforces, only instructs, or lacks this capability.'),
|
|
713
|
+
reason: DeclarationReason.describe('Why the capability is unavailable.'),
|
|
714
|
+
}).strict(),
|
|
715
|
+
]);
|
|
716
|
+
export const CustomHarness = z.object({
|
|
717
|
+
name: SurfaceName.regex(new RegExp(`^(?!(?:${SURFACE_TARGETS.join('|')})$)`), 'custom harness names cannot replace shipped targets').describe('Name of the custom harness; cannot reuse a shipped one.'),
|
|
718
|
+
capabilities: z.array(CustomHarnessCapability).length(SURFACE_FEATURES.length).describe('One row per surface feature saying what the harness supports.'),
|
|
719
|
+
}).strict().superRefine((harness, ctx) => {
|
|
720
|
+
const features = harness.capabilities.map((capability) => capability.feature);
|
|
721
|
+
if (hasDuplicate(features) || SURFACE_FEATURES.some((feature) => !features.includes(feature))) {
|
|
722
|
+
ctx.addIssue({ code: 'custom', path: ['capabilities'], message: 'capabilities must hold exactly one row for each surface feature' });
|
|
723
|
+
}
|
|
724
|
+
const spellings = harness.capabilities.flatMap((capability) => capability.state === 'unavailable' ? [] : [capability.spelling]);
|
|
725
|
+
if (hasDuplicate(spellings)) {
|
|
726
|
+
ctx.addIssue({ code: 'custom', path: ['capabilities'], message: 'emitted capability spellings must be unique' });
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
const Surfaces = z.object({
|
|
730
|
+
// A list rather than per-tool switches so `tool add` and `tool remove` edit one line. A cockpit with
|
|
731
|
+
// no guarded tool would render instructions and skills and enforce nothing, so one must be listed.
|
|
732
|
+
tools: z.array(z.enum(SURFACE_TARGETS)).min(1).max(SURFACE_TARGETS.length).default([...DEFAULT_SURFACE_TOOLS])
|
|
733
|
+
.refine(tools => !hasDuplicate(tools), 'each tool is listed once')
|
|
734
|
+
.refine(tools => tools.some(tool => GUARD_SURFACE_TARGETS.includes(tool)), `list at least one tool cockpit has a guard for (${GUARD_SURFACE_TARGETS.join(', ')})`)
|
|
735
|
+
.describe(`Agent tools whose instructions, skills and settings sync writes and check compares. Defaults to the tools whose guard is measured (${DEFAULT_SURFACE_TOOLS.join(', ')}); the other shipped targets are written only when listed.`),
|
|
736
|
+
overlays: z.array(SurfaceOverlay).max(256).default([]).describe('Team-owned rules, subagents, skills and hooks added to the generated surfaces.'),
|
|
737
|
+
harnesses: z.array(CustomHarness).max(64).default([]).describe('Agent tools beyond the shipped ones, with measured capabilities.'),
|
|
738
|
+
bundles: z.array(z.enum(SURFACE_BUNDLE_FORMATS)).max(SURFACE_BUNDLE_FORMATS.length).default([]).describe('Bundle formats to publish, such as a Claude plugin bundle.'),
|
|
739
|
+
adopted: z.array(SurfaceAdoption).max(ADOPTABLE_SURFACE_PATHS.length).default([]).describe('Harness settings files cockpit merges with its own output.'),
|
|
740
|
+
}).strict().superRefine((surfaces, ctx) => {
|
|
741
|
+
if (hasDuplicate(surfaces.adopted.map((row) => row.path))) {
|
|
742
|
+
ctx.addIssue({ code: 'custom', path: ['adopted'], message: 'each harness settings file is adopted at most once' });
|
|
743
|
+
}
|
|
744
|
+
if (hasDuplicate(surfaces.overlays.map((overlay) => overlay.name))) {
|
|
745
|
+
ctx.addIssue({ code: 'custom', path: ['overlays'], message: 'overlay names must be unique' });
|
|
746
|
+
}
|
|
747
|
+
if (hasDuplicate(surfaces.harnesses.map((harness) => harness.name))) {
|
|
748
|
+
ctx.addIssue({ code: 'custom', path: ['harnesses'], message: 'custom harness names must be unique' });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
export function configurationDefaults() {
|
|
752
|
+
return {
|
|
753
|
+
cockpit: CockpitMeta.omit({ name: true }).parse({}),
|
|
754
|
+
sibling: {
|
|
755
|
+
toolchain: Sibling.shape.toolchain.parse(undefined),
|
|
756
|
+
guides: Sibling.shape.guides.parse(undefined),
|
|
757
|
+
instruction_pointer: Sibling.shape.instruction_pointer.parse(undefined),
|
|
758
|
+
},
|
|
759
|
+
ports: {
|
|
760
|
+
step: Ports.shape.step.parse(undefined),
|
|
761
|
+
count: Ports.shape.count.parse(undefined),
|
|
762
|
+
},
|
|
763
|
+
guards: Guards.parse({}),
|
|
764
|
+
mcp: Mcp.parse({}),
|
|
765
|
+
work: Work.parse({}),
|
|
766
|
+
autonomy: Autonomy.parse({}),
|
|
767
|
+
environment: Environment.parse({}),
|
|
768
|
+
surfaces: Surfaces.parse({}),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* The init interview answers a cockpit.toml records. `unconfirmed` lists those no person stated: the
|
|
773
|
+
* setup agent chose them as defaults, or the answers file did not say who chose them. doctor warns
|
|
774
|
+
* until a person confirms each one and deletes its entry, because a configuration whose values look
|
|
775
|
+
* stated but were guessed is the failure cockpit exists to prevent.
|
|
776
|
+
*/
|
|
777
|
+
export const UNCONFIRMABLE_ANSWERS = ['cockpit.name', 'sibling', 'estate.runnable', 'ports.bases', 'environment.install_policies',
|
|
778
|
+
'environment.test_data', 'environment.compose_files', 'environment.machine', 'environment.posture',
|
|
779
|
+
'testing', 'guards'];
|
|
780
|
+
export const CockpitConfig = z
|
|
781
|
+
.object({
|
|
782
|
+
unconfirmed: z.array(z.enum(UNCONFIRMABLE_ANSWERS)).min(1).max(UNCONFIRMABLE_ANSWERS.length)
|
|
783
|
+
.refine(rows => new Set(rows).size === rows.length, { message: 'unconfirmed lists an answer more than once' }).optional().describe('Answers no person has stated yet; init writes them and doctor reports them.'),
|
|
784
|
+
cockpit: CockpitMeta.describe('Identity and layout of this cockpit.'),
|
|
785
|
+
sibling: z.array(Sibling).min(1).describe('Product repositories the cockpit works across.'),
|
|
786
|
+
ports: Ports.describe('Port leasing for running stacks side by side.'),
|
|
787
|
+
guards: Guards.prefault({}).describe('Rules that restrict what agents may run.'),
|
|
788
|
+
mcp: Mcp.prefault({}).describe('Approval of MCP servers.'),
|
|
789
|
+
work: Work.prefault({}).describe('How work orders are accepted, graded and closed.'),
|
|
790
|
+
autonomy: Autonomy.prefault({}).describe('Which decisions an agent may take without a person.'),
|
|
791
|
+
browser: Browser.optional().describe('Browser automation for the stack.'),
|
|
792
|
+
testing: Testing.optional().describe('Test stages and gates.'),
|
|
793
|
+
environment: Environment.prefault({}).describe('How the stack is reached, signed in to, built and kept.'),
|
|
794
|
+
surfaces: Surfaces.prefault({}).describe('Instructions, skills and settings generated for agent tools.'),
|
|
795
|
+
})
|
|
796
|
+
.strict()
|
|
797
|
+
.superRefine((cfg, ctx) => {
|
|
798
|
+
const seen = new Map();
|
|
799
|
+
for (const [i, s] of cfg.sibling.entries()) {
|
|
800
|
+
for (const [field, value] of [
|
|
801
|
+
['slug', s.slug],
|
|
802
|
+
['dir', s.dir],
|
|
803
|
+
['var', s.var],
|
|
804
|
+
]) {
|
|
805
|
+
const key = `${field}:${value}`;
|
|
806
|
+
const first = seen.get(key);
|
|
807
|
+
if (first !== undefined) {
|
|
808
|
+
ctx.addIssue({
|
|
809
|
+
code: 'custom',
|
|
810
|
+
path: ['sibling', i, field],
|
|
811
|
+
message: `duplicate ${field} "${value}" — also on sibling "${first}"`,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
seen.set(key, s.slug);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const leasedPorts = new Set(Object.keys(cfg.ports.bases));
|
|
818
|
+
if (cfg.browser) {
|
|
819
|
+
// A browser wired to a tool sync does not render would be a declaration that does nothing.
|
|
820
|
+
for (const [index, row] of cfg.browser.harnesses.entries())
|
|
821
|
+
if (!cfg.surfaces.tools.includes(row.target)) {
|
|
822
|
+
ctx.addIssue({ code: 'custom', path: ['browser', 'harnesses', index, 'target'],
|
|
823
|
+
message: `browser harness ${row.target} is not in surfaces.tools; add it with npx --no cockpit tool add ${row.target} or remove this row` });
|
|
824
|
+
}
|
|
825
|
+
const proxyVariable = cfg.browser.proxy.host_variable;
|
|
826
|
+
if (proxyVariable !== undefined && cfg.ports.bases[proxyVariable] !== cfg.browser.proxy.proxy_port) {
|
|
827
|
+
ctx.addIssue({ code: 'custom', path: ['browser', 'proxy', 'host_variable'], message: 'browser proxy lease must name a configured base matching the proxy port' });
|
|
828
|
+
}
|
|
829
|
+
const artifacts = Object.values(cfg.browser.artifacts);
|
|
830
|
+
const declared = cfg.environment.generated_host_paths;
|
|
831
|
+
if (artifacts.length !== declared.length || artifacts.some((path) => !declared.includes(path))) {
|
|
832
|
+
ctx.addIssue({ code: 'custom', path: ['browser', 'artifacts'], message: 'browser artifact roots must exactly match generated host path ownership' });
|
|
833
|
+
}
|
|
834
|
+
if (!cfg.environment.addressing || !cfg.environment.authentication || !cfg.environment.posture_requirements) {
|
|
835
|
+
ctx.addIssue({ code: 'custom', path: ['browser'], message: 'browser declarations require explicit environment posture declarations' });
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
// A stack with no sign-in has no requirement that depends on one, so the row may leave it untagged.
|
|
839
|
+
if (!cfg.environment.posture_requirements.some((row) => row.addressing === cfg.environment.addressing
|
|
840
|
+
&& (row.authentication === cfg.environment.authentication
|
|
841
|
+
|| cfg.environment.authentication === 'none' && row.authentication === undefined))) {
|
|
842
|
+
ctx.addIssue({ code: 'custom', path: ['browser'], message: 'browser posture requirements must cover the selected environment declarations' });
|
|
843
|
+
}
|
|
844
|
+
if (cfg.environment.addressing === 'hostname' && cfg.browser.resolver.length === 0
|
|
845
|
+
|| cfg.environment.addressing === 'port' && cfg.browser.resolver.length !== 0) {
|
|
846
|
+
ctx.addIssue({ code: 'custom', path: ['browser', 'resolver'], message: 'browser resolver declarations must match the environment addressing posture' });
|
|
847
|
+
}
|
|
848
|
+
const { policy, states } = cfg.browser.authentication;
|
|
849
|
+
if (cfg.environment.authentication !== 'real' && (policy !== 'none' || states.length !== 0)
|
|
850
|
+
|| cfg.environment.authentication === 'real' && (policy === 'none'
|
|
851
|
+
|| states.length !== (policy === 'serialized' ? 1 : cfg.browser.max_clients))) {
|
|
852
|
+
ctx.addIssue({ code: 'custom', path: ['browser', 'authentication'], message: 'browser authentication declarations must match the environment posture and client cap' });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
for (const [index, derivative] of cfg.environment.entrypoint_derivatives.entries()) {
|
|
857
|
+
if (!leasedPorts.has(derivative.port)) {
|
|
858
|
+
ctx.addIssue({
|
|
859
|
+
code: 'custom',
|
|
860
|
+
path: ['environment', 'entrypoint_derivatives', index, 'port'],
|
|
861
|
+
message: `entrypoint derivative references undeclared port "${derivative.port}"`,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
// Reset matches each entry against a Compose volume key, so a name no volume declares
|
|
866
|
+
// would reset nothing while reading as a control. A stack's keys live in its Compose files
|
|
867
|
+
// and are checked where `sync`, `check` and `up` read them.
|
|
868
|
+
if (cfg.environment.compose_files.length === 0) {
|
|
869
|
+
cfg.environment.resettable_volumes.forEach((name, index) => {
|
|
870
|
+
ctx.addIssue({
|
|
871
|
+
code: 'custom', path: ['environment', 'resettable_volumes', index],
|
|
872
|
+
message: `resettable volume ${JSON.stringify(name)} names no volume: the environment declares no compose_files`,
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
// Deliberately NOT checked here: that a `profile:<name>` lane names a profile in
|
|
877
|
+
// `profiles.default`. A profile lane is opt-in by definition — it exists precisely to
|
|
878
|
+
// stay off until EXTRA_PROFILES turns it on — so absence from the default set is the
|
|
879
|
+
// normal case, not a defect. The spelling is defined by the compose file,
|
|
880
|
+
// which preflight reads and a schema cannot.
|
|
881
|
+
});
|
|
882
|
+
/** Rows the stack bind-mounts under the given profile set — the projection's import list. */
|
|
883
|
+
export function mounted(cfg, profiles) {
|
|
884
|
+
return cfg.sibling.filter((s) => s.mount === 'always' || (s.mount.startsWith('profile:') && profiles.includes(s.mount.slice(8))));
|
|
885
|
+
}
|
|
886
|
+
// How a sibling is described, fetched, gated for agents or projected into guides. None of it reaches
|
|
887
|
+
// a container, so editing it must leave a running stack alone.
|
|
888
|
+
const SIBLING_NON_RUNTIME_FIELDS = new Set(['purpose', 'guides', 'instruction_pointer', 'remote', 'branch', 'acquire', 'host_reason', 'gate']);
|
|
889
|
+
// What the host checks or reports around the containers: the machine floor and cold-start budget
|
|
890
|
+
// `up` and preflight hold the host to, the posture preflight reads, the volumes `reset` may delete,
|
|
891
|
+
// the host command that seeds them, and the URLs derived from leased ports. Hashing them made raising cold_start_seconds strand a
|
|
892
|
+
// healthy stack behind a demand to run `down`.
|
|
893
|
+
const ENVIRONMENT_NON_RUNTIME_FIELDS = new Set([
|
|
894
|
+
'machine', 'addressing', 'authentication', 'posture_requirements', 'resettable_volumes', 'entrypoint_derivatives', 'seed',
|
|
895
|
+
]);
|
|
896
|
+
// What a declaration says to its reader: why it is so, where it came from, what a probe proves, how
|
|
897
|
+
// long a start once took. Rendering keeps all of it out of the Compose file and the work a running
|
|
898
|
+
// stack is bound to, so rewording a health property or a provenance note left a healthy stack
|
|
899
|
+
// behind a demand to run `down`.
|
|
900
|
+
const DESCRIPTIVE_FIELDS = new Set(['provenance', 'reason', 'property', 'measurement', 'alternative']);
|
|
901
|
+
function canonicalJson(value) {
|
|
902
|
+
return JSON.stringify(value, (_key, item) => item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
903
|
+
? Object.fromEntries(Object.entries(item).filter(([key]) => !DESCRIPTIVE_FIELDS.has(key))
|
|
904
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))
|
|
905
|
+
: item);
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* The SHA-256 that identifies a running stack: ownership labels, the generation, saved progress and
|
|
909
|
+
* data history are keyed by it rather than by the bytes of cockpit.toml. Keying them by the file
|
|
910
|
+
* meant rewording a sibling's purpose stranded the running stack, so `up` and `sibling add` refused
|
|
911
|
+
* until it was torn down.
|
|
912
|
+
*
|
|
913
|
+
* It covers everything except what is known not to reach a container: comments and formatting,
|
|
914
|
+
* guards, MCP, work orders, surfaces, testing, the guide settings, the descriptive sibling fields,
|
|
915
|
+
* the host-only environment fields, siblings with mount "none", and descriptive prose and provenance
|
|
916
|
+
* anywhere. A new field is therefore counted
|
|
917
|
+
* as runtime until someone decides otherwise: the failure is a needless down, never a stack running
|
|
918
|
+
* an older declaration than the one on disk. The contents of `environment.compose_files` are
|
|
919
|
+
* outside it: only their paths are declared here, and `up` hands the files to Compose as they are.
|
|
920
|
+
*/
|
|
921
|
+
export function runtimeDeclarationSha256(cfg) {
|
|
922
|
+
const sibling = cfg.sibling
|
|
923
|
+
.filter((row) => row.mount !== 'none' || row.derives !== undefined || row.toolchain !== 'none')
|
|
924
|
+
.map((row) => Object.fromEntries(Object.entries(row).filter(([field]) => !SIBLING_NON_RUNTIME_FIELDS.has(field))))
|
|
925
|
+
.sort((left, right) => String(left.slug) < String(right.slug) ? -1 : 1);
|
|
926
|
+
const environment = Object.fromEntries(Object.entries(cfg.environment).filter(([field]) => !ENVIRONMENT_NON_RUNTIME_FIELDS.has(field)));
|
|
927
|
+
return createHash('sha256').update(canonicalJson({
|
|
928
|
+
cockpit: { name: cfg.cockpit.name, siblings_root: cfg.cockpit.siblings_root },
|
|
929
|
+
sibling,
|
|
930
|
+
ports: cfg.ports,
|
|
931
|
+
browser: cfg.browser ?? null,
|
|
932
|
+
environment,
|
|
933
|
+
})).digest('hex');
|
|
934
|
+
}
|