@b00dea/cockpit 0.0.0-stage → 0.1.1
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 +474 -0
- package/LICENSE +21 -0
- package/README.md +25 -2
- package/bin/cockpit.mjs +33 -0
- package/cockpit.schema.json +2644 -0
- package/dist/autonomy.d.ts +45 -0
- package/dist/autonomy.js +173 -0
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.js +42 -0
- package/dist/browser/commands.d.ts +117 -0
- package/dist/browser/commands.js +453 -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 +118 -0
- package/dist/browser/runtime.js +186 -0
- package/dist/browser/surfaces.d.ts +141 -0
- package/dist/browser/surfaces.js +561 -0
- package/dist/browser/ui-gate.d.ts +15 -0
- package/dist/browser/ui-gate.js +18 -0
- package/dist/canonical-json.d.ts +2 -0
- package/dist/canonical-json.js +5 -0
- package/dist/cli-guard.d.ts +5 -0
- package/dist/cli-guard.js +71 -0
- package/dist/cli-main.d.ts +176 -0
- package/dist/cli-main.js +1584 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +16 -0
- package/dist/command-table.d.ts +121 -0
- package/dist/command-table.js +664 -0
- package/dist/config/load.d.ts +183 -0
- package/dist/config/load.js +367 -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/unconfirmed-toml.d.ts +9 -0
- package/dist/config/unconfirmed-toml.js +29 -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 +344 -0
- package/dist/docs/check.d.ts +16 -0
- package/dist/docs/check.js +197 -0
- package/dist/docs/decision-record.d.ts +34 -0
- package/dist/docs/decision-record.js +135 -0
- package/dist/docs/decisions.d.ts +16 -0
- package/dist/docs/decisions.js +103 -0
- package/dist/docs/markdown.d.ts +36 -0
- package/dist/docs/markdown.js +88 -0
- package/dist/docs/references.d.ts +32 -0
- package/dist/docs/references.js +148 -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/confirm.d.ts +35 -0
- package/dist/environment/application/confirm.js +122 -0
- package/dist/environment/application/destructive.d.ts +6 -0
- package/dist/environment/application/destructive.js +429 -0
- package/dist/environment/application/doctor.d.ts +94 -0
- package/dist/environment/application/doctor.js +247 -0
- package/dist/environment/application/guard.d.ts +49 -0
- package/dist/environment/application/guard.js +677 -0
- package/dist/environment/application/health.d.ts +143 -0
- package/dist/environment/application/health.js +385 -0
- package/dist/environment/application/lifecycle-context.d.ts +235 -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 +202 -0
- package/dist/environment/application/lifecycle.d.ts +18 -0
- package/dist/environment/application/lifecycle.js +737 -0
- package/dist/environment/application/logs.d.ts +45 -0
- package/dist/environment/application/logs.js +127 -0
- package/dist/environment/application/services.d.ts +60 -0
- package/dist/environment/application/services.js +89 -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/application/siblings.d.ts +43 -0
- package/dist/environment/application/siblings.js +269 -0
- package/dist/environment/claims.d.ts +389 -0
- package/dist/environment/claims.js +1701 -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 +125 -0
- package/dist/environment/compose-topology.js +490 -0
- package/dist/environment/contract.d.ts +522 -0
- package/dist/environment/contract.js +794 -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 +38 -0
- package/dist/environment/executable.js +190 -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 +191 -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 +278 -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 +492 -0
- package/dist/environment/generated-application/shared.js +354 -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 +400 -0
- package/dist/environment/generated-application/surfaces.d.ts +3 -0
- package/dist/environment/generated-application/surfaces.js +704 -0
- package/dist/environment/generated-application/sync.d.ts +6 -0
- package/dist/environment/generated-application/sync.js +124 -0
- package/dist/environment/git-observation.d.ts +202 -0
- package/dist/environment/git-observation.js +553 -0
- package/dist/environment/health-composition.d.ts +12 -0
- package/dist/environment/health-composition.js +645 -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 +333 -0
- package/dist/environment/init-application.js +726 -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 +31 -0
- package/dist/environment/lifecycle-composition.js +742 -0
- package/dist/environment/lifecycle-stage.d.ts +120 -0
- package/dist/environment/lifecycle-stage.js +408 -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/map-limit.d.ts +2 -0
- package/dist/environment/map-limit.js +13 -0
- package/dist/environment/mcp-observation.d.ts +9 -0
- package/dist/environment/mcp-observation.js +46 -0
- package/dist/environment/observations.d.ts +46 -0
- package/dist/environment/observations.js +346 -0
- package/dist/environment/operator-application.d.ts +79 -0
- package/dist/environment/operator-application.js +197 -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/presence.d.ts +4 -0
- package/dist/environment/presence.js +26 -0
- package/dist/environment/read-composition.d.ts +32 -0
- package/dist/environment/read-composition.js +397 -0
- package/dist/environment/readiness-composition.d.ts +57 -0
- package/dist/environment/readiness-composition.js +151 -0
- package/dist/environment/reclaim.d.ts +370 -0
- package/dist/environment/reclaim.js +1940 -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 +551 -0
- package/dist/environment/runtime.js +2405 -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/served-checkout-grants.d.ts +53 -0
- package/dist/environment/served-checkout-grants.js +182 -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-checkouts.d.ts +119 -0
- package/dist/environment/sibling-checkouts.js +254 -0
- package/dist/environment/sibling-instructions.d.ts +33 -0
- package/dist/environment/sibling-instructions.js +224 -0
- package/dist/environment/sibling-observation.d.ts +27 -0
- package/dist/environment/sibling-observation.js +45 -0
- package/dist/environment/sibling-pull.d.ts +23 -0
- package/dist/environment/sibling-pull.js +95 -0
- package/dist/environment/sibling-registration.d.ts +45 -0
- package/dist/environment/sibling-registration.js +538 -0
- package/dist/environment/stack-inputs.d.ts +18 -0
- package/dist/environment/stack-inputs.js +210 -0
- package/dist/environment/stack-inventory-prune.d.ts +31 -0
- package/dist/environment/stack-inventory-prune.js +129 -0
- package/dist/environment/stack-inventory-report.d.ts +44 -0
- package/dist/environment/stack-inventory-report.js +78 -0
- package/dist/environment/stack-inventory.d.ts +94 -0
- package/dist/environment/stack-inventory.js +418 -0
- package/dist/environment/state.d.ts +410 -0
- package/dist/environment/state.js +2153 -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 +92 -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 +105 -0
- package/dist/environment-exec/execute.js +1179 -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 +271 -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/answer-confirmation.d.ts +41 -0
- package/dist/guards/answer-confirmation.js +109 -0
- package/dist/guards/evaluator.d.ts +130 -0
- package/dist/guards/evaluator.js +1258 -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 +44 -0
- package/dist/guards/local-identity.js +129 -0
- package/dist/guards/policy-change.d.ts +43 -0
- package/dist/guards/policy-change.js +159 -0
- package/dist/guards/policy-pin.d.ts +13 -0
- package/dist/guards/policy-pin.js +177 -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 +41 -0
- package/dist/init/command.js +330 -0
- package/dist/init/derive.d.ts +53 -0
- package/dist/init/derive.js +819 -0
- package/dist/init/draft.d.ts +140 -0
- package/dist/init/draft.js +1228 -0
- package/dist/init/render.d.ts +47 -0
- package/dist/init/render.js +104 -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/repository-path.d.ts +9 -0
- package/dist/repository-path.js +16 -0
- package/dist/schema-json.d.ts +1 -0
- package/dist/schema-json.js +177 -0
- package/dist/schema.d.ts +1289 -0
- package/dist/schema.js +985 -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 +260 -0
- package/dist/surfaces/harness-registry.js +146 -0
- package/dist/surfaces/mcp.d.ts +145 -0
- package/dist/surfaces/mcp.js +349 -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 +716 -0
- package/dist/surfaces/render.d.ts +79 -0
- package/dist/surfaces/render.js +636 -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 +513 -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/test-protection.d.ts +34 -0
- package/dist/testing/test-protection.js +137 -0
- package/dist/testing/verdict.d.ts +29 -0
- package/dist/testing/verdict.js +62 -0
- package/dist/testing/weakening.d.ts +15 -0
- package/dist/testing/weakening.js +30 -0
- package/dist/work-orders/application-closure.d.ts +7 -0
- package/dist/work-orders/application-closure.js +76 -0
- package/dist/work-orders/application-context.d.ts +162 -0
- package/dist/work-orders/application-context.js +137 -0
- package/dist/work-orders/application-decisions.d.ts +27 -0
- package/dist/work-orders/application-decisions.js +121 -0
- package/dist/work-orders/application-grading.d.ts +11 -0
- package/dist/work-orders/application-grading.js +212 -0
- package/dist/work-orders/application.d.ts +6 -0
- package/dist/work-orders/application.js +223 -0
- package/dist/work-orders/composition.d.ts +101 -0
- package/dist/work-orders/composition.js +1202 -0
- package/dist/work-orders/contract.d.ts +77 -0
- package/dist/work-orders/contract.js +140 -0
- package/dist/work-orders/decisions.d.ts +23 -0
- package/dist/work-orders/decisions.js +55 -0
- package/dist/work-orders/store.d.ts +207 -0
- package/dist/work-orders/store.js +291 -0
- package/docs/README.md +68 -0
- package/docs/architecture.md +136 -0
- package/docs/capability-matrix.md +58 -0
- package/docs/cli-reference.md +807 -0
- package/docs/command-list.md +62 -0
- package/docs/compose-patterns.md +99 -0
- package/docs/concepts.md +173 -0
- package/docs/configuration-essentials.md +177 -0
- package/docs/configuration-reference.md +636 -0
- package/docs/docs-check.md +76 -0
- package/docs/environment.md +948 -0
- package/docs/extending.md +43 -0
- package/docs/faq.md +113 -0
- package/docs/getting-started.md +692 -0
- package/docs/harness-surfaces.md +500 -0
- package/docs/joining-a-team.md +76 -0
- package/docs/release-policy.md +49 -0
- package/docs/safety-and-autonomy.md +463 -0
- package/docs/troubleshooting.md +382 -0
- package/docs/tutorial.md +171 -0
- package/docs/work-and-browser.md +420 -0
- package/libexec/browser-operation.cjs +270 -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 +59 -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 +66 -0
- package/surfaces/skills/procedures/fix.md +95 -0
- package/surfaces/skills/procedures/setup.md +452 -0
- package/surfaces/skills/procedures/work.md +134 -0
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { unconfirmedFix } from '../guards/answer-confirmation.js';
|
|
3
|
+
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
|
4
|
+
import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { initCommand } from '../fix-catalogue.js';
|
|
7
|
+
import { canonicalJson } from '../canonical-json.js';
|
|
8
|
+
import { readBoundedInput, schemaIssueSummary, STRUCTURED_INPUT_BYTE_LIMIT } from '../config/load.js';
|
|
9
|
+
import { errorCause, snapshotJson, RefusalError, tryParseJson, hasErrorCode } from '../environment/contract.js';
|
|
10
|
+
import { judgePostureDeclaration } from '../environment/posture-observation.js';
|
|
11
|
+
import { isSensitiveField, redactSensitiveText } from '../environment/sensitive.js';
|
|
12
|
+
import { CockpitConfig, Guards, InstallPolicy, MachineFloor, Testing, configurationDefaults } from '../schema.js';
|
|
13
|
+
import { toSiblingRows } from '../emit.js';
|
|
14
|
+
import { gaps, parseRegistry } from '../registry-resolve/registry.js';
|
|
15
|
+
import { ANSWER_PROVENANCES, confirmedBy, renderCompleteDocument, renderIncompleteDocument, unconfirmedReason, } from './render.js';
|
|
16
|
+
import { COMPOSE_NAMES, NODE_LOCKFILE_NAMES, deriveInferredFacts, readDockerfileFacts } from './derive.js';
|
|
17
|
+
import { productPackageManifest } from '../team-metadata.js';
|
|
18
|
+
import { refusalOutcome as refusal } from '../refusal.js';
|
|
19
|
+
export function isAnswerProvenance(value) {
|
|
20
|
+
return ANSWER_PROVENANCES.includes(value);
|
|
21
|
+
}
|
|
22
|
+
const defaultReason = (field, source) => source === 'registry'
|
|
23
|
+
? `the registry dialect does not record ${field}; materialised from the CockpitConfig schema default`
|
|
24
|
+
: `no observed repository file declares ${field}; materialised from the CockpitConfig schema default`;
|
|
25
|
+
// cockpit.toml is committed beside the cockpit's own install, so the directive names that install.
|
|
26
|
+
// A path to the package that ran this command would point into one machine's temporary or product tree.
|
|
27
|
+
const SCHEMA_DIRECTIVE = './node_modules/@b00dea/cockpit/cockpit.schema.json';
|
|
28
|
+
const SCHEMA_ISSUES_SHOWN = 10;
|
|
29
|
+
function issueSummary(error) {
|
|
30
|
+
return schemaIssueSummary(error.issues, SCHEMA_ISSUES_SHOWN);
|
|
31
|
+
}
|
|
32
|
+
function estateRefusal(message, target = 'estate', fix) {
|
|
33
|
+
return { kind: 'refusal', status: 2, result: null, diagnostics: [{
|
|
34
|
+
severity: 'error',
|
|
35
|
+
code: 'DOCUMENT_ESTATE_OBSERVATION_INVALID',
|
|
36
|
+
message,
|
|
37
|
+
target,
|
|
38
|
+
...(fix === undefined ? {} : { fix }),
|
|
39
|
+
}] };
|
|
40
|
+
}
|
|
41
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
42
|
+
const SAFE_NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
43
|
+
const SAFE_RELATIVE = /^(?!.*(?:^|\/)\.\.(?:\/|$))[a-zA-Z0-9._-]+(?:\/[a-zA-Z0-9._-]+)*$/;
|
|
44
|
+
const MAX_ESTATE_EVIDENCE_BYTES = 1024 * 1024;
|
|
45
|
+
const MAX_ESTATE_REASON_LENGTH = 512;
|
|
46
|
+
const MAX_CONFIGURATION_ANSWER_BYTES = 262_144;
|
|
47
|
+
const LANGUAGE_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'Gemfile'];
|
|
48
|
+
const PUBLIC_RUNNABLE_SOURCES = [...LANGUAGE_MANIFESTS, 'Dockerfile', ...COMPOSE_NAMES];
|
|
49
|
+
const PACKAGE_RUN_SCRIPTS = ['dev', 'start', 'serve'];
|
|
50
|
+
class EstateSourceObservationError extends RefusalError {
|
|
51
|
+
target;
|
|
52
|
+
fix;
|
|
53
|
+
constructor(target, message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.target = target;
|
|
56
|
+
this.fix = `replace it with one readable regular file under the repository, then rerun ${initCommand()} with the root, target and options you ran it with`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function sourceObservationError(repository, label, source, problem) {
|
|
60
|
+
const displayed = label === '.' ? source : `${label}/${source}`;
|
|
61
|
+
return new EstateSourceObservationError(resolve(repository, source), `public runnable source ${displayed} ${problem}; init cannot trust product classification without bounded current source identity`);
|
|
62
|
+
}
|
|
63
|
+
function boundedReasons(reasons) {
|
|
64
|
+
const complete = reasons.join('; ');
|
|
65
|
+
if (complete.length <= MAX_ESTATE_REASON_LENGTH)
|
|
66
|
+
return complete;
|
|
67
|
+
const included = [];
|
|
68
|
+
for (const reason of reasons) {
|
|
69
|
+
const omitted = reasons.length - included.length - 1;
|
|
70
|
+
const suffix = omitted === 0 ? '' : `; ${omitted} additional source issue${omitted === 1 ? '' : 's'} omitted`;
|
|
71
|
+
if ([...included, reason].join('; ').length + suffix.length > MAX_ESTATE_REASON_LENGTH)
|
|
72
|
+
break;
|
|
73
|
+
included.push(reason);
|
|
74
|
+
}
|
|
75
|
+
const omitted = reasons.length - included.length;
|
|
76
|
+
return `${included.join('; ')}${included.length === 0 ? '' : '; '}${omitted} additional source issue${omitted === 1 ? '' : 's'} omitted`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The digest a source is bound by, or undefined when it is cockpit output rather than product
|
|
80
|
+
* evidence. Sync writes the cockpit's package pin and scripts into the cockpit root's package.json,
|
|
81
|
+
* which for an in-repo cockpit is the product's own manifest; bound as raw bytes, that write made
|
|
82
|
+
* every resumed in-repo init refuse its accepted answers as a changed estate. So a package.json is
|
|
83
|
+
* bound by its parsed content without the entries sync owns, and one holding nothing else is not
|
|
84
|
+
* observed at all. Any other change to a source still changes its digest.
|
|
85
|
+
*/
|
|
86
|
+
function sourceDigest(manifest, bytes) {
|
|
87
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
88
|
+
if (manifest !== 'package.json')
|
|
89
|
+
return digest(bytes);
|
|
90
|
+
// A package.json that is not JSON has no product entries to separate, so its raw bytes bind it.
|
|
91
|
+
const parsed = tryParseJson(bytes.toString('utf8'));
|
|
92
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
93
|
+
return digest(bytes);
|
|
94
|
+
const product = productPackageManifest(parsed);
|
|
95
|
+
return Object.keys(product).length === 0 ? undefined : digest(JSON.stringify(product));
|
|
96
|
+
}
|
|
97
|
+
function composeDeclaresServices(text) {
|
|
98
|
+
try {
|
|
99
|
+
const document = parseYaml(text);
|
|
100
|
+
const services = document && typeof document === 'object' && !Array.isArray(document)
|
|
101
|
+
? document.services : undefined;
|
|
102
|
+
return !!services && typeof services === 'object' && !Array.isArray(services)
|
|
103
|
+
&& Object.keys(services).length > 0
|
|
104
|
+
&& Object.values(services).every(service => !!service && typeof service === 'object' && !Array.isArray(service));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
} // The caller's reason names invalid Compose YAML; the parser message would quote file content.
|
|
109
|
+
}
|
|
110
|
+
/** Reads fixed public runnable sources and reports whether one declares a product process. */
|
|
111
|
+
async function observeRepositorySources(repository, label) {
|
|
112
|
+
const sources = [];
|
|
113
|
+
let runnable = 'absent';
|
|
114
|
+
const reasons = [];
|
|
115
|
+
// Reasons from several repositories are joined into one why, so each names its own file.
|
|
116
|
+
const named = (source) => label === '.' ? source : `${label}/${source}`;
|
|
117
|
+
for (const manifest of PUBLIC_RUNNABLE_SOURCES) {
|
|
118
|
+
const path = resolve(repository, manifest);
|
|
119
|
+
let bytes;
|
|
120
|
+
try {
|
|
121
|
+
const stat = await lstat(path);
|
|
122
|
+
if (stat.isSymbolicLink())
|
|
123
|
+
throw sourceObservationError(repository, label, manifest, 'is a symbolic link');
|
|
124
|
+
if (!stat.isFile())
|
|
125
|
+
throw sourceObservationError(repository, label, manifest, 'is not a regular file');
|
|
126
|
+
if (stat.nlink !== 1)
|
|
127
|
+
throw sourceObservationError(repository, label, manifest, 'has multiple hard links');
|
|
128
|
+
if (stat.size > MAX_ESTATE_EVIDENCE_BYTES) {
|
|
129
|
+
throw sourceObservationError(repository, label, manifest, 'exceeds the bounded observation size');
|
|
130
|
+
}
|
|
131
|
+
bytes = await readFile(path);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof EstateSourceObservationError)
|
|
135
|
+
throw error;
|
|
136
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
137
|
+
continue;
|
|
138
|
+
throw sourceObservationError(repository, label, manifest, 'could not be inspected and read safely');
|
|
139
|
+
}
|
|
140
|
+
if (bytes.byteLength > MAX_ESTATE_EVIDENCE_BYTES) {
|
|
141
|
+
throw sourceObservationError(repository, label, manifest, 'grew beyond the bounded observation size while it was read');
|
|
142
|
+
}
|
|
143
|
+
const sha256 = sourceDigest(manifest, bytes);
|
|
144
|
+
if (sha256 === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
sources.push({ path: manifest, sha256 });
|
|
147
|
+
if (manifest === 'Dockerfile') {
|
|
148
|
+
let text;
|
|
149
|
+
try {
|
|
150
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
151
|
+
}
|
|
152
|
+
catch { // Fatal decoding throws only for invalid UTF-8, which the reason names.
|
|
153
|
+
if (runnable !== 'confirmed')
|
|
154
|
+
runnable = 'unknown';
|
|
155
|
+
reasons.push(`${named('Dockerfile')} is not valid UTF-8`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (readDockerfileFacts(text).command)
|
|
159
|
+
runnable = 'confirmed';
|
|
160
|
+
else {
|
|
161
|
+
if (runnable !== 'confirmed')
|
|
162
|
+
runnable = 'unknown';
|
|
163
|
+
reasons.push(`${named('Dockerfile')} final stage declares no CMD or ENTRYPOINT`);
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (COMPOSE_NAMES.includes(manifest)) {
|
|
168
|
+
let text;
|
|
169
|
+
try {
|
|
170
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
171
|
+
}
|
|
172
|
+
catch { // Fatal decoding throws only for invalid UTF-8, which the reason names.
|
|
173
|
+
if (runnable !== 'confirmed')
|
|
174
|
+
runnable = 'unknown';
|
|
175
|
+
reasons.push(`${named(manifest)} is not valid UTF-8`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (composeDeclaresServices(text))
|
|
179
|
+
runnable = 'confirmed';
|
|
180
|
+
else {
|
|
181
|
+
if (runnable !== 'confirmed')
|
|
182
|
+
runnable = 'unknown';
|
|
183
|
+
reasons.push(`${named(manifest)} is not valid Compose YAML with at least one service`);
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (manifest !== 'package.json') {
|
|
188
|
+
if (runnable !== 'confirmed')
|
|
189
|
+
runnable = 'unknown';
|
|
190
|
+
reasons.push(`${named(manifest)} does not declare a supported runnable command`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(bytes.toString('utf8'));
|
|
196
|
+
}
|
|
197
|
+
catch { // The reason names invalid JSON; the parser message would quote file content.
|
|
198
|
+
if (runnable !== 'confirmed')
|
|
199
|
+
runnable = 'unknown';
|
|
200
|
+
reasons.push(`${named('package.json')} is not valid JSON`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const scripts = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
204
|
+
? parsed.scripts : undefined;
|
|
205
|
+
const confirmed = scripts && typeof scripts === 'object' && !Array.isArray(scripts)
|
|
206
|
+
&& PACKAGE_RUN_SCRIPTS.some(name => typeof scripts[name] === 'string'
|
|
207
|
+
&& scripts[name].trim().length > 0);
|
|
208
|
+
if (confirmed)
|
|
209
|
+
runnable = 'confirmed';
|
|
210
|
+
else
|
|
211
|
+
reasons.push(`${named('package.json')} declares no non-empty dev, start or serve script`);
|
|
212
|
+
}
|
|
213
|
+
return { runnable, reasons, sources };
|
|
214
|
+
}
|
|
215
|
+
function observationInput(input) {
|
|
216
|
+
if (!input || typeof input !== 'object' || typeof input.root !== 'string'
|
|
217
|
+
|| !isAbsolute(input.root) || resolve(input.root) !== input.root)
|
|
218
|
+
return undefined;
|
|
219
|
+
const keys = Object.keys(input).sort().join(',');
|
|
220
|
+
if (keys === 'root')
|
|
221
|
+
return { root: input.root };
|
|
222
|
+
if (keys === 'root,rootIsRepository')
|
|
223
|
+
return input.rootIsRepository === true ? { root: input.root, rootIsRepository: true } : undefined;
|
|
224
|
+
if (keys !== 'repositories,root' || !Array.isArray(input.repositories) || input.repositories.length < 1
|
|
225
|
+
|| input.repositories.length > 64 || input.repositories.some(name => typeof name !== 'string' || !SAFE_NAME.test(name))
|
|
226
|
+
|| new Set(input.repositories).size !== input.repositories.length)
|
|
227
|
+
return undefined;
|
|
228
|
+
return { root: input.root, repositories: [...input.repositories].sort() };
|
|
229
|
+
}
|
|
230
|
+
export async function observePublicEstate(input) {
|
|
231
|
+
const captured = observationInput(input);
|
|
232
|
+
if (!captured)
|
|
233
|
+
throw new RefusalError('public estate observation requires one canonical absolute root and optionally named repositories');
|
|
234
|
+
const root = await realpath(captured.root);
|
|
235
|
+
if (root !== captured.root)
|
|
236
|
+
throw new RefusalError('public estate root is linked or non-canonical');
|
|
237
|
+
const entries = captured.rootIsRepository ? [] : await estateDirectories(root, captured.repositories);
|
|
238
|
+
const repositories = [];
|
|
239
|
+
if (captured.rootIsRepository) {
|
|
240
|
+
const slug = basename(root);
|
|
241
|
+
if (!SAFE_NAME.test(slug))
|
|
242
|
+
throw new RefusalError(`the repository directory name ${JSON.stringify(slug)} is not a valid sibling slug`);
|
|
243
|
+
const { runnable, reasons, sources } = await observeRepositorySources(root, '.');
|
|
244
|
+
const source = sources[0];
|
|
245
|
+
if (source)
|
|
246
|
+
repositories.push({ slug, path: '.', evidencePath: source.path, runnable,
|
|
247
|
+
...(runnable === 'confirmed' ? {} : { why: boundedReasons(reasons) }), source,
|
|
248
|
+
...(sources.length > 1 ? { sources } : {}) });
|
|
249
|
+
}
|
|
250
|
+
for (const entry of entries) {
|
|
251
|
+
const { runnable, reasons, sources } = await observeRepositorySources(resolve(root, entry.name), entry.name);
|
|
252
|
+
const source = sources[0];
|
|
253
|
+
if (!source)
|
|
254
|
+
continue;
|
|
255
|
+
repositories.push({ slug: entry.name, path: entry.name, evidencePath: source.path, runnable,
|
|
256
|
+
...(runnable === 'confirmed' ? {} : { why: boundedReasons(reasons) }), source,
|
|
257
|
+
...(sources.length > 1 ? { sources } : {}) });
|
|
258
|
+
}
|
|
259
|
+
return { root, observedAt: new Date().toISOString(), repositories };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* A product repository is a git checkout whose own root carries a public manifest. Running init
|
|
263
|
+
* there must ask where the cockpit lives, because the answer decides where init writes; an empty,
|
|
264
|
+
* documents-only or not-yet-versioned directory simply is the cockpit.
|
|
265
|
+
*/
|
|
266
|
+
export async function observeCockpitRoot(input) {
|
|
267
|
+
let git = false;
|
|
268
|
+
try {
|
|
269
|
+
await lstat(resolve(input.root, '.git'));
|
|
270
|
+
git = true;
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (!hasErrorCode(error, 'ENOENT'))
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
if (!git)
|
|
277
|
+
return 'cockpit-root';
|
|
278
|
+
const { sources } = await observeRepositorySources(input.root, '.');
|
|
279
|
+
return sources.length > 0 ? 'product-repository' : 'cockpit-root';
|
|
280
|
+
}
|
|
281
|
+
async function estateDirectories(root, only) {
|
|
282
|
+
const entries = (await readdir(root, { withFileTypes: true }))
|
|
283
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && SAFE_NAME.test(entry.name)
|
|
284
|
+
&& (only === undefined || only.includes(entry.name)))
|
|
285
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
286
|
+
if (only !== undefined && entries.length !== only.length) {
|
|
287
|
+
throw new RefusalError(`a named estate repository is missing or not a plain directory: ${only.join(', ')}`);
|
|
288
|
+
}
|
|
289
|
+
if (entries.length > 64)
|
|
290
|
+
throw new RefusalError('public estate contains more than 64 candidate repositories');
|
|
291
|
+
return Promise.all(entries.map(async (entry) => ({ name: entry.name, stat: await lstat(resolve(root, entry.name)) })));
|
|
292
|
+
}
|
|
293
|
+
function sourceObservationSha256(root, rows) {
|
|
294
|
+
return createHash('sha256').update(JSON.stringify({
|
|
295
|
+
root,
|
|
296
|
+
repositories: rows.map(row => ({
|
|
297
|
+
slug: row.slug,
|
|
298
|
+
path: row.path,
|
|
299
|
+
runnable: row.runnable,
|
|
300
|
+
why: row.why ?? null,
|
|
301
|
+
source: { path: row.source.path, sha256: row.source.sha256 },
|
|
302
|
+
...(row.sources ? { sources: row.sources } : {}),
|
|
303
|
+
})),
|
|
304
|
+
})).digest('hex');
|
|
305
|
+
}
|
|
306
|
+
function sameIdentity(left, right) {
|
|
307
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
|
|
308
|
+
}
|
|
309
|
+
function sameStat(left, right) {
|
|
310
|
+
return sameIdentity(left, right)
|
|
311
|
+
&& left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
312
|
+
}
|
|
313
|
+
function estateCandidate(value, root) {
|
|
314
|
+
const candidate = snapshotJson(value);
|
|
315
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
|
|
316
|
+
return undefined;
|
|
317
|
+
const record = candidate;
|
|
318
|
+
const observedAt = typeof record.observedAt === 'string' ? new Date(record.observedAt) : undefined;
|
|
319
|
+
if (Object.keys(record).sort().join(',') !== 'observedAt,repositories,root'
|
|
320
|
+
|| record.root !== root || typeof record.observedAt !== 'string'
|
|
321
|
+
|| !observedAt || Number.isNaN(observedAt.valueOf()) || observedAt.toISOString() !== record.observedAt
|
|
322
|
+
// No repository is a valid observation: an empty directory or one holding only documents has
|
|
323
|
+
// nothing runnable, and that is answered with a surfaces-only cockpit rather than a refusal.
|
|
324
|
+
// currentEstateRows still refuses an empty list while any candidate directory has a manifest.
|
|
325
|
+
|| !Array.isArray(record.repositories) || record.repositories.length > 64)
|
|
326
|
+
return undefined;
|
|
327
|
+
const rows = [];
|
|
328
|
+
for (const value of record.repositories) {
|
|
329
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
330
|
+
return undefined;
|
|
331
|
+
const row = value;
|
|
332
|
+
const runnable = row.runnable;
|
|
333
|
+
const keys = ['evidencePath', 'path', 'runnable', 'slug', 'source',
|
|
334
|
+
...(row.sources === undefined ? [] : ['sources']), ...(runnable === 'confirmed' ? [] : ['why'])].sort().join(',');
|
|
335
|
+
if (Object.keys(row).sort().join(',') !== keys || typeof row.slug !== 'string' || !SAFE_NAME.test(row.slug)
|
|
336
|
+
|| typeof row.path !== 'string' || !SAFE_RELATIVE.test(row.path)
|
|
337
|
+
|| typeof row.evidencePath !== 'string' || !SAFE_RELATIVE.test(row.evidencePath)
|
|
338
|
+
|| runnable !== 'confirmed' && runnable !== 'absent' && runnable !== 'unknown'
|
|
339
|
+
|| runnable === 'confirmed' && row.why !== undefined
|
|
340
|
+
|| runnable !== 'confirmed' && (typeof row.why !== 'string' || row.why.trim() !== row.why || row.why.length < 1 || row.why.length > MAX_ESTATE_REASON_LENGTH)
|
|
341
|
+
|| !row.source || typeof row.source !== 'object' || Array.isArray(row.source)
|
|
342
|
+
|| Object.keys(row.source).sort().join(',') !== 'path,sha256')
|
|
343
|
+
return undefined;
|
|
344
|
+
const source = row.source;
|
|
345
|
+
if (source.path !== row.evidencePath || typeof source.sha256 !== 'string' || !SHA256.test(source.sha256)
|
|
346
|
+
|| typeof row.why === 'string' && redactSensitiveText(row.why) !== row.why)
|
|
347
|
+
return undefined;
|
|
348
|
+
const sources = row.sources;
|
|
349
|
+
if (sources !== undefined && (!Array.isArray(sources) || sources.length < 2 || sources.length > PUBLIC_RUNNABLE_SOURCES.length
|
|
350
|
+
|| sources.some(value => !value || typeof value !== 'object' || Array.isArray(value)
|
|
351
|
+
|| Object.keys(value).sort().join(',') !== 'path,sha256'
|
|
352
|
+
|| typeof value.path !== 'string' || !PUBLIC_RUNNABLE_SOURCES.includes(value.path)
|
|
353
|
+
|| typeof value.sha256 !== 'string' || !SHA256.test(value.sha256))
|
|
354
|
+
|| new Set(sources.map(value => value.path)).size !== sources.length
|
|
355
|
+
|| sources[0].path !== source.path || sources[0].sha256 !== source.sha256))
|
|
356
|
+
return undefined;
|
|
357
|
+
rows.push({ slug: row.slug, path: row.path, evidencePath: row.evidencePath, runnable,
|
|
358
|
+
...(runnable === 'confirmed' ? {} : { why: row.why }),
|
|
359
|
+
source: { path: source.path, sha256: source.sha256 },
|
|
360
|
+
...(sources === undefined ? {} : { sources: sources.map(value => ({ path: value.path, sha256: value.sha256 })) }) });
|
|
361
|
+
}
|
|
362
|
+
if (new Set(rows.map(row => row.slug)).size !== rows.length
|
|
363
|
+
|| new Set(rows.map(row => row.path)).size !== rows.length)
|
|
364
|
+
return undefined;
|
|
365
|
+
return rows;
|
|
366
|
+
}
|
|
367
|
+
async function currentEstateRows(root, rows, scope) {
|
|
368
|
+
try {
|
|
369
|
+
const candidates = scope.rootIsRepository ? [{ name: '.' }] : await estateDirectories(root, scope.repositories);
|
|
370
|
+
for (const directory of candidates) {
|
|
371
|
+
const manifests = [];
|
|
372
|
+
for (const manifest of PUBLIC_RUNNABLE_SOURCES) {
|
|
373
|
+
try {
|
|
374
|
+
const path = resolve(root, directory.name, manifest);
|
|
375
|
+
const stat = await lstat(path);
|
|
376
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1
|
|
377
|
+
|| stat.size > MAX_ESTATE_EVIDENCE_BYTES)
|
|
378
|
+
return false;
|
|
379
|
+
if (manifest === 'package.json') {
|
|
380
|
+
const bytes = await readFile(path);
|
|
381
|
+
if (bytes.byteLength > MAX_ESTATE_EVIDENCE_BYTES)
|
|
382
|
+
return false;
|
|
383
|
+
if (sourceDigest(manifest, bytes) === undefined)
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
manifests.push(manifest);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (!hasErrorCode(error, 'ENOENT'))
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (manifests.length === 0)
|
|
394
|
+
continue;
|
|
395
|
+
const row = rows.find(row => row.path === directory.name);
|
|
396
|
+
const observed = row && (row.sources ?? [row.source]).map(source => source.path);
|
|
397
|
+
if (!observed || observed.length !== manifests.length
|
|
398
|
+
|| observed.some((path, index) => path !== manifests[index]))
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
for (const row of rows) {
|
|
402
|
+
const repository = resolve(root, row.path);
|
|
403
|
+
const repositoryRelative = relative(root, repository);
|
|
404
|
+
const ownRoot = row.path === '.' && scope.rootIsRepository === true;
|
|
405
|
+
if (!repositoryRelative && !ownRoot || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)
|
|
406
|
+
|| await realpath(repository) !== repository)
|
|
407
|
+
return false;
|
|
408
|
+
const repositoryBefore = await lstat(repository);
|
|
409
|
+
if (!repositoryBefore.isDirectory() || repositoryBefore.isSymbolicLink())
|
|
410
|
+
return false;
|
|
411
|
+
for (const source of row.sources ?? [row.source]) {
|
|
412
|
+
const evidence = resolve(repository, source.path);
|
|
413
|
+
const evidenceRelative = relative(repository, evidence);
|
|
414
|
+
if (!evidenceRelative || evidenceRelative.startsWith(`..${sep}`) || isAbsolute(evidenceRelative)
|
|
415
|
+
|| await realpath(evidence) !== evidence)
|
|
416
|
+
return false;
|
|
417
|
+
const before = await lstat(evidence);
|
|
418
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1
|
|
419
|
+
|| before.size > MAX_ESTATE_EVIDENCE_BYTES)
|
|
420
|
+
return false;
|
|
421
|
+
const bytes = await readFile(evidence);
|
|
422
|
+
if (bytes.byteLength > MAX_ESTATE_EVIDENCE_BYTES)
|
|
423
|
+
return false;
|
|
424
|
+
const after = await lstat(evidence);
|
|
425
|
+
if (!sameStat(before, after)
|
|
426
|
+
|| sourceDigest(source.path, bytes) !== source.sha256)
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
if (!(ownRoot ? sameIdentity : sameStat)(repositoryBefore, await lstat(repository)))
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
return { cause: errorCause(error) };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
export async function classifyEstateObservation(input, adapters) {
|
|
439
|
+
let root;
|
|
440
|
+
let before;
|
|
441
|
+
let directories;
|
|
442
|
+
const captured = observationInput(input);
|
|
443
|
+
if (!captured)
|
|
444
|
+
return estateRefusal('estate classification requires one canonical absolute root and optionally named repositories');
|
|
445
|
+
const only = captured.repositories;
|
|
446
|
+
try {
|
|
447
|
+
root = await realpath(captured.root);
|
|
448
|
+
before = await lstat(root);
|
|
449
|
+
if (root !== captured.root || !before.isDirectory() || before.isSymbolicLink()) {
|
|
450
|
+
return estateRefusal('estate root is linked, missing or non-canonical', captured.root);
|
|
451
|
+
}
|
|
452
|
+
directories = captured.rootIsRepository ? [] : await estateDirectories(root, only);
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
return estateRefusal((only === undefined ? 'estate root is linked, missing or non-canonical'
|
|
456
|
+
: `estate root is linked, missing or non-canonical, or does not hold ${only.join(', ')} as a plain directory`) + `: ${errorCause(error)}`, captured.root);
|
|
457
|
+
}
|
|
458
|
+
let observed;
|
|
459
|
+
try {
|
|
460
|
+
observed = await adapters.observe({ root, ...(only === undefined ? {} : { repositories: only }),
|
|
461
|
+
...(captured.rootIsRepository ? { rootIsRepository: true } : {}) });
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
return error instanceof EstateSourceObservationError
|
|
465
|
+
? estateRefusal(error.message, error.target, error.fix)
|
|
466
|
+
: estateRefusal('estate observation owner failed', root);
|
|
467
|
+
}
|
|
468
|
+
const rows = estateCandidate(observed, root);
|
|
469
|
+
if (!rows)
|
|
470
|
+
return estateRefusal('estate observation is malformed, ambiguous, stale or unbound to current source bytes', root);
|
|
471
|
+
const current = await currentEstateRows(root, rows, captured);
|
|
472
|
+
if (current !== true) {
|
|
473
|
+
return estateRefusal(`estate observation is malformed, ambiguous, stale or unbound to current source bytes${typeof current === 'object' ? `: ${current.cause}` : ''}`, root);
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
const currentDirectories = captured.rootIsRepository ? [] : await estateDirectories(root, only);
|
|
477
|
+
if (directories.length !== currentDirectories.length || directories.some((entry, index) => entry.name !== currentDirectories[index].name || !sameStat(entry.stat, currentDirectories[index].stat))) {
|
|
478
|
+
return estateRefusal('estate candidate directories changed during observation', root);
|
|
479
|
+
}
|
|
480
|
+
const after = await lstat(root);
|
|
481
|
+
if (!(captured.rootIsRepository ? sameIdentity : sameStat)(before, after))
|
|
482
|
+
return estateRefusal('estate root changed during observation', root);
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
return estateRefusal(`estate root changed during observation: ${errorCause(error)}`, root);
|
|
486
|
+
}
|
|
487
|
+
const confirmed = rows.filter(row => row.runnable === 'confirmed');
|
|
488
|
+
const unknown = rows.filter(row => row.runnable === 'unknown');
|
|
489
|
+
const multi = rows.length >= 2;
|
|
490
|
+
const classification = confirmed.length === 0 && unknown.length === 0
|
|
491
|
+
? 'no-runnable-product' : !multi ? 'one-sibling'
|
|
492
|
+
: unknown.length > 0 ? 'incomplete-multi-repository' : 'ready-multi-repository';
|
|
493
|
+
const gaps = classification === 'no-runnable-product'
|
|
494
|
+
? [rows.length === 0
|
|
495
|
+
? { field: 'runnable-product', source: '.', why: 'no directory under the root carries a supported language manifest, Dockerfile or Compose file' }
|
|
496
|
+
: { field: 'runnable-product', source: rows.map(row => `${row.slug}/${row.source.path}`).join(', '),
|
|
497
|
+
why: rows.map(row => row.why).filter(Boolean).join('; ') }]
|
|
498
|
+
: unknown.map(row => ({ field: `${row.slug}.runnable`, source: `${row.slug}/${row.source.path}`, why: row.why }));
|
|
499
|
+
const result = {
|
|
500
|
+
classification,
|
|
501
|
+
sourceObservationSha256: sourceObservationSha256(root, rows),
|
|
502
|
+
notice: classification === 'one-sibling'
|
|
503
|
+
? 'one repository gets the full cockpit: agent instructions, guards, harness settings and its stack; grow it with the sibling add command'
|
|
504
|
+
: classification === 'no-runnable-product'
|
|
505
|
+
? 'build enough product to run before scaffolding a cockpit environment'
|
|
506
|
+
: classification === 'incomplete-multi-repository'
|
|
507
|
+
? 'the estate observation is incomplete and must be resumed from its named gaps'
|
|
508
|
+
: 'the observed multi-repository estate is ready for harness discovery',
|
|
509
|
+
gaps,
|
|
510
|
+
runnable: rows.map(row => ({
|
|
511
|
+
slug: row.slug, path: row.path, disposition: row.runnable,
|
|
512
|
+
})),
|
|
513
|
+
};
|
|
514
|
+
return { kind: 'complete', status: 0, diagnostics: [], result };
|
|
515
|
+
}
|
|
516
|
+
function buildAttributions(model, root, source) {
|
|
517
|
+
const attributions = [];
|
|
518
|
+
const defaults = model.defaults;
|
|
519
|
+
if (model.name && root) {
|
|
520
|
+
attributions.push({
|
|
521
|
+
target: 'cockpit.name',
|
|
522
|
+
value: model.name,
|
|
523
|
+
from: 'inferred',
|
|
524
|
+
because: 'the positional cockpit root basename was supplied by the operator',
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
for (const [target, value, field] of [
|
|
528
|
+
['cockpit.siblings_root', defaults.cockpit.siblings_root, 'the sibling checkout root'],
|
|
529
|
+
['cockpit.guide_cap', defaults.cockpit.guide_cap, 'the root guide line cap'],
|
|
530
|
+
['ports.step', defaults.ports.step, 'the stack port step'],
|
|
531
|
+
['ports.count', defaults.ports.count, 'the available stack count'],
|
|
532
|
+
// The guard hosts are not here: written, even as [], they record a decision that no
|
|
533
|
+
// non-local host exists, and doctor stops warning GUARD_HOSTS_UNDECLARED.
|
|
534
|
+
['guards.protected_branches', defaults.guards.protected_branches, 'protected branches'],
|
|
535
|
+
['guards.allow', defaults.guards.allow, 'allowed commands'],
|
|
536
|
+
['guards.deny', defaults.guards.deny, 'denied commands'],
|
|
537
|
+
['mcp.approved', defaults.mcp.approved, 'approved MCP servers'],
|
|
538
|
+
['work.attempt_cap', defaults.work.attempt_cap, 'the work-order attempt cap'],
|
|
539
|
+
['work.wip_cap', defaults.work.wip_cap, 'the work-order WIP cap'],
|
|
540
|
+
]) {
|
|
541
|
+
attributions.push({ target, value, from: 'default', because: defaultReason(field, source) });
|
|
542
|
+
}
|
|
543
|
+
for (const sibling of model.siblings) {
|
|
544
|
+
if (sibling.mount.from === 'inferred') {
|
|
545
|
+
attributions.push({
|
|
546
|
+
target: `sibling.${sibling.slug}.mount`,
|
|
547
|
+
value: sibling.mount.value,
|
|
548
|
+
from: 'inferred',
|
|
549
|
+
because: sibling.mount.because ?? 'the registry dialect does not record mount directly',
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
if (sibling.acquire.from === 'inferred') {
|
|
553
|
+
attributions.push({
|
|
554
|
+
target: `sibling.${sibling.slug}.acquire`,
|
|
555
|
+
value: sibling.acquire.value,
|
|
556
|
+
from: 'inferred',
|
|
557
|
+
because: sibling.acquire.because ?? 'the registry dialect does not record acquisition directly',
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
attributions.push({
|
|
561
|
+
target: `sibling.${sibling.slug}.toolchain`,
|
|
562
|
+
value: defaults.sibling.toolchain,
|
|
563
|
+
from: 'default',
|
|
564
|
+
because: defaultReason('a sibling toolchain', source),
|
|
565
|
+
});
|
|
566
|
+
attributions.push({
|
|
567
|
+
target: `sibling.${sibling.slug}.guides`,
|
|
568
|
+
value: defaults.sibling.guides,
|
|
569
|
+
from: 'default',
|
|
570
|
+
because: defaultReason('guide filenames', source),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return attributions;
|
|
574
|
+
}
|
|
575
|
+
function diagnosticsFor(attributions, unresolved) {
|
|
576
|
+
return [
|
|
577
|
+
...attributions.map((attribution) => ({
|
|
578
|
+
severity: 'info',
|
|
579
|
+
code: attribution.from === 'default' ? 'DOCUMENT_DEFAULT' : 'DOCUMENT_INFERRED',
|
|
580
|
+
message: attribution.because,
|
|
581
|
+
target: attribution.target,
|
|
582
|
+
})),
|
|
583
|
+
...unresolved.map((gap) => ({
|
|
584
|
+
severity: gap.blocking ? 'error' : 'warning',
|
|
585
|
+
code: 'DOCUMENT_GAP',
|
|
586
|
+
message: gap.why,
|
|
587
|
+
target: `${gap.slug}.${gap.field}`,
|
|
588
|
+
fix: gap.fix ?? (gap.blocking ? 'supply this value before treating the draft as valid' : 'confirm or replace the attributed default'),
|
|
589
|
+
})),
|
|
590
|
+
];
|
|
591
|
+
}
|
|
592
|
+
function cockpitName(root) {
|
|
593
|
+
const name = root ? basename(root) : undefined;
|
|
594
|
+
return name && /^[a-z0-9][a-z0-9-]*$/.test(name) ? name : undefined;
|
|
595
|
+
}
|
|
596
|
+
const inferredValue = (inference, target) => inference?.facts.find(fact => fact.target === target);
|
|
597
|
+
function unresolvedGaps(siblings, name) {
|
|
598
|
+
return [
|
|
599
|
+
...(!name
|
|
600
|
+
? [{
|
|
601
|
+
slug: 'cockpit',
|
|
602
|
+
field: 'name',
|
|
603
|
+
why: 'the positional cockpit root basename is not a valid cockpit name',
|
|
604
|
+
blocking: true,
|
|
605
|
+
fix: 'rename the cockpit root to lowercase letters, digits, and hyphens, or supply the name in the draft',
|
|
606
|
+
}]
|
|
607
|
+
: []),
|
|
608
|
+
{ slug: 'ports', field: 'bases', why: 'no registry dialect records the product port map', blocking: true },
|
|
609
|
+
...['hosted_db_hosts', 'vendor_endpoint_hosts'].map(field => ({ slug: 'guards', field, blocking: false,
|
|
610
|
+
why: 'the registry dialect does not record the non-local hosts the guard denies, so the key is left unwritten and doctor warns GUARD_HOSTS_UNDECLARED',
|
|
611
|
+
fix: 'list the database and live vendor API hosts of every non-local environment under [guards], or write both hosted_db_hosts = [] and vendor_endpoint_hosts = [] to record that none exists' })),
|
|
612
|
+
...gaps(siblings),
|
|
613
|
+
];
|
|
614
|
+
}
|
|
615
|
+
function configurationInput(value) {
|
|
616
|
+
const candidate = snapshotJson(value);
|
|
617
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
|
|
618
|
+
return undefined;
|
|
619
|
+
const record = candidate;
|
|
620
|
+
const keys = Object.keys(record).filter(key => key !== 'tools').sort().join(',');
|
|
621
|
+
if (keys !== 'answers,sourceObservationSha256' && keys !== 'answers,classification,sourceObservationSha256'
|
|
622
|
+
|| record.tools !== undefined && (!Array.isArray(record.tools) || !record.tools.every(tool => typeof tool === 'string'))
|
|
623
|
+
|| typeof record.sourceObservationSha256 !== 'string' || !SHA256.test(record.sourceObservationSha256)
|
|
624
|
+
|| record.classification !== undefined && !['one-sibling', 'incomplete-multi-repository',
|
|
625
|
+
'no-runnable-product', 'ready-multi-repository'].includes(record.classification)
|
|
626
|
+
|| !Array.isArray(record.answers) || record.answers.length > 12)
|
|
627
|
+
return undefined;
|
|
628
|
+
const answers = [];
|
|
629
|
+
for (const value of record.answers) {
|
|
630
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
631
|
+
return undefined;
|
|
632
|
+
const answer = value;
|
|
633
|
+
if (Object.keys(answer).sort().join(',') !== 'bytes,provenance,questionId,sha256'
|
|
634
|
+
|| typeof answer.questionId !== 'string' || !/^question:[a-f0-9]{64}$/.test(answer.questionId)
|
|
635
|
+
|| typeof answer.bytes !== 'string' || Buffer.byteLength(answer.bytes) > MAX_CONFIGURATION_ANSWER_BYTES
|
|
636
|
+
|| answer.bytes.includes('\0') || redactSensitiveText(answer.bytes) !== answer.bytes
|
|
637
|
+
|| typeof answer.sha256 !== 'string' || !SHA256.test(answer.sha256)
|
|
638
|
+
|| createHash('sha256').update(answer.bytes).digest('hex') !== answer.sha256
|
|
639
|
+
|| !isAnswerProvenance(answer.provenance))
|
|
640
|
+
return undefined;
|
|
641
|
+
answers.push({ questionId: answer.questionId, bytes: answer.bytes,
|
|
642
|
+
sha256: answer.sha256, provenance: answer.provenance });
|
|
643
|
+
}
|
|
644
|
+
return { sourceObservationSha256: record.sourceObservationSha256,
|
|
645
|
+
...(record.classification === undefined ? {} : { classification: record.classification }),
|
|
646
|
+
...(record.tools === undefined ? {} : { tools: [...record.tools] }),
|
|
647
|
+
answers };
|
|
648
|
+
}
|
|
649
|
+
const QUESTION_WHY = {
|
|
650
|
+
name: 'the source does not establish a schema-valid cockpit name',
|
|
651
|
+
nameProposed: 'name the cockpit: the name scopes its Compose projects, state and generated files, and renaming it after the first sync means bringing the stack down and syncing again; the cockpit root directory name is proposed',
|
|
652
|
+
siblingEstate: 'the source does not establish schema-shaped sibling declarations',
|
|
653
|
+
siblingRegistry: 'the registry leaves required sibling configuration unresolved',
|
|
654
|
+
ports: 'no source establishes the host-published base port map; an explicit empty map means none',
|
|
655
|
+
policies: 'declare one row per toolchain and lockfile with any build or test command the guard should allow',
|
|
656
|
+
testData: 'answer with exactly { seeded_data: boolean, signed_in_users: boolean } to state whether this local setup needs seeded data and signed-in users; each false field records a precise not-declared decision, while true keeps the product-owned declaration work visible without claiming that data or authentication exists',
|
|
657
|
+
composeFiles: 'list the Compose files, relative to the cockpit root, that run the product, such as ["compose.yml"]; cockpit runs them as written. A small app with no Compose file gets a starting one, which it then owns, from npx --no cockpit init <cockpit root> --scaffold-compose <app dir> --port <port>',
|
|
658
|
+
machine: 'declare the machine floor the product needs from the container runtime: runtime_memory_mb, runtime_cpus, free_disk_mb and cold_start_seconds; init proposes memory and CPUs from the Compose file\'s service limits, or from this machine\'s measured runtime where no limit records them; nothing records free disk or the cold start time, so a person states those, or the setup agent marks its choice "agent-default"',
|
|
659
|
+
posture: `preflight refuses a runtime until its posture is declared: addressing is "port" (localhost:<port>) or "hostname"; authentication is "none" (no sign-in), "bypassed" (sign-in turned off locally) or "real"; posture_requirements names what depends on them, each tagged with the posture it needs. For a plain localhost stack with no sign-in, answer ${JSON.stringify({ addressing: 'port', authentication: 'none', posture_requirements: [{ requirement: 'leased-localhost-ports', addressing: 'port' }] })}`,
|
|
660
|
+
testing: 'declare the product\'s test commands as [testing] stages and gates: a targeted gate agents run while iterating and a full gate they run before handing back shared changes; init proposes them from test entry points it finds on disk. Answer "none" when the product has no tests yet: no work can then be graded PASS, and doctor warns until gates are declared',
|
|
661
|
+
guards: 'name the database hosts and live vendor API hosts of every non-local environment the product has (production, staging, shared dev or QA): cockpit runs only locally, so the guard denies agent commands that reach them; answer { "hosted_db_hosts": [], "vendor_endpoint_hosts": [] } to record that none exists',
|
|
662
|
+
defaults: 'a person confirms the values the draft lists under its person heading (protected branches, guard allow and deny lists, approved MCP servers, resettable volumes, generated host paths, and anything a registry left to a guess); expectedSha256 covers only those, and the setup agent accepts the schema defaults and the cockpit\'s own layout, which bring-up proves',
|
|
663
|
+
};
|
|
664
|
+
/** How a decision comment attributes an answer, so a value the agent chose never reads as one a person stated. */
|
|
665
|
+
const STATED_BY = {
|
|
666
|
+
'operator-confirmed': 'the operator stated that',
|
|
667
|
+
'agent-default': 'the setup agent assumed, as a default, that',
|
|
668
|
+
unattributed: 'the answers file, without saying who chose it, states that',
|
|
669
|
+
derived: 'init read from the product files that',
|
|
670
|
+
};
|
|
671
|
+
const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
672
|
+
const DERIVED_FIX = 'answer the value init proposes for this field under interview.outstanding, whose provenance kind is "derived"; otherwise mark the answer "human" once a person states it, or "agent-default" when you chose it';
|
|
673
|
+
const noGuardHostsDecision = (provenance) => `${STATED_BY[provenance]} no non-local environment (production, staging, shared) has a database or live vendor host for the guard to deny`;
|
|
674
|
+
const runnableWhy = (gaps) => `resolve the exact current runnable gaps: ${gaps.map(gap => `${gap.field} from ${gap.source}`).join('; ')}`;
|
|
675
|
+
/**
|
|
676
|
+
* The questions a greenfield estate interview asks, in order. Init lists them before any answer
|
|
677
|
+
* exists so an unattended caller can prepare every answer in one pass; the loop in
|
|
678
|
+
* completeConfigurationDocument asks the same questions in the same order.
|
|
679
|
+
*/
|
|
680
|
+
export function plannedGreenfieldQuestions(input) {
|
|
681
|
+
const surfacesOnly = input.classification === 'no-runnable-product';
|
|
682
|
+
return [
|
|
683
|
+
{ field: 'cockpit.name', why: cockpitName(input.root) ? QUESTION_WHY.nameProposed : QUESTION_WHY.name },
|
|
684
|
+
{ field: 'sibling', why: QUESTION_WHY.siblingEstate },
|
|
685
|
+
...(!surfacesOnly && input.gaps.length > 0 ? [{ field: 'estate.runnable', why: runnableWhy(input.gaps) }] : []),
|
|
686
|
+
{ field: 'ports.bases', why: QUESTION_WHY.ports },
|
|
687
|
+
...(surfacesOnly ? [] : [
|
|
688
|
+
{ field: 'environment.install_policies', why: QUESTION_WHY.policies },
|
|
689
|
+
{ field: 'environment.test_data', why: QUESTION_WHY.testData },
|
|
690
|
+
{ field: 'environment.compose_files', why: QUESTION_WHY.composeFiles },
|
|
691
|
+
{ field: 'environment.machine', why: QUESTION_WHY.machine },
|
|
692
|
+
{ field: 'environment.posture', why: QUESTION_WHY.posture },
|
|
693
|
+
{ field: 'testing', why: QUESTION_WHY.testing },
|
|
694
|
+
]),
|
|
695
|
+
// Asked even with nothing to run: the guard governs the agent's commands, not the runtime.
|
|
696
|
+
{ field: 'guards', why: QUESTION_WHY.guards },
|
|
697
|
+
{ field: 'configuration.defaults', why: QUESTION_WHY.defaults },
|
|
698
|
+
];
|
|
699
|
+
}
|
|
700
|
+
function configurationQuestion(context, field, why, expectedSha256) {
|
|
701
|
+
const digest = createHash('sha256').update(JSON.stringify({ ...context, field, expectedSha256: expectedSha256 ?? null })).digest('hex');
|
|
702
|
+
return { id: `question:${digest}`, field,
|
|
703
|
+
source: `configuration:${context.sourceObservationSha256}`,
|
|
704
|
+
why, ...(expectedSha256 === undefined ? {} : { expectedSha256 }) };
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* The answer to the current question. One marked derived must be exactly the value init read for
|
|
708
|
+
* the field from an exact source: the provenance is checked, never taken on the answers file's word.
|
|
709
|
+
*/
|
|
710
|
+
function readConfigurationAnswer(answers, index, question, derived) {
|
|
711
|
+
const answer = answers[index];
|
|
712
|
+
if (!answer)
|
|
713
|
+
return { kind: 'pending' };
|
|
714
|
+
if (answer.questionId !== question.id)
|
|
715
|
+
return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_STALE', 'the operator answer does not match the current source-bound question', question.field) };
|
|
716
|
+
try {
|
|
717
|
+
const value = JSON.parse(answer.bytes);
|
|
718
|
+
if (containsSensitiveField(value))
|
|
719
|
+
return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'operator answers cannot contain sensitive fields', question.field) };
|
|
720
|
+
if (answer.provenance !== 'derived')
|
|
721
|
+
return { kind: 'answer', value, provenance: answer.provenance };
|
|
722
|
+
if (derived === undefined)
|
|
723
|
+
return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_NOT_DERIVED', `the ${question.field} answer is marked derived, but init read no complete value for it from an exact source`, question.field, DERIVED_FIX) };
|
|
724
|
+
if (canonicalJson(value) !== canonicalJson(derived.value))
|
|
725
|
+
return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_NOT_DERIVED', `the ${question.field} answer is marked derived, but it is not ${JSON.stringify(derived.value)}, the value init read from ${derived.source}`, question.field, DERIVED_FIX) };
|
|
726
|
+
return { kind: 'answer', value, provenance: answer.provenance, source: derived.source };
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
return { kind: 'refusal', outcome: refusal(// The parser message would quote the operator's answer.
|
|
730
|
+
'DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the operator answer is not valid JSON', question.field) };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function containsSensitiveField(value) {
|
|
734
|
+
if (Array.isArray(value))
|
|
735
|
+
return value.some(containsSensitiveField);
|
|
736
|
+
if (!value || typeof value !== 'object')
|
|
737
|
+
return false;
|
|
738
|
+
return Object.entries(value)
|
|
739
|
+
.some(([key, nested]) => isSensitiveField(key) || containsSensitiveField(nested));
|
|
740
|
+
}
|
|
741
|
+
function questionGap(question) {
|
|
742
|
+
const [slug, ...field] = question.field.split('.');
|
|
743
|
+
return { slug: slug, field: field.join('.') || question.field, why: question.why, blocking: true };
|
|
744
|
+
}
|
|
745
|
+
function pendingConfiguration(model, destination, question, configuration) {
|
|
746
|
+
const unresolved = [questionGap(question)];
|
|
747
|
+
const artifact = renderIncompleteDocument({ ...model, gaps: unresolved }, SCHEMA_DIRECTIVE, configuration);
|
|
748
|
+
return {
|
|
749
|
+
kind: 'gap-draft', artifact, destination, diagnostics: diagnosticsFor(model.attributions, unresolved),
|
|
750
|
+
result: { document: artifact }, status: 2, question,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function sameDetectedSibling(answered, detected) {
|
|
754
|
+
return Object.entries(detected).every(([key, value]) => JSON.stringify(answered[key]) === JSON.stringify(value));
|
|
755
|
+
}
|
|
756
|
+
function inferGreenfieldSiblingRoot(attributions, siblingsRoot) {
|
|
757
|
+
if (siblingsRoot === undefined)
|
|
758
|
+
return attributions;
|
|
759
|
+
const index = attributions.findIndex(row => row.target === 'cockpit.siblings_root');
|
|
760
|
+
if (index >= 0)
|
|
761
|
+
attributions.splice(index, 1);
|
|
762
|
+
attributions.push({ target: 'cockpit.siblings_root', value: siblingsRoot, from: 'inferred',
|
|
763
|
+
because: siblingsRoot === '.'
|
|
764
|
+
? 'the authenticated greenfield estate repositories are direct children of the cockpit root'
|
|
765
|
+
: 'the cockpit was created beside the product repository, so the authenticated estate repositories are children of the cockpit root\'s parent' });
|
|
766
|
+
return attributions;
|
|
767
|
+
}
|
|
768
|
+
/** Why an estate sibling's toolchain is the schema default, naming the lockfile read when one was. */
|
|
769
|
+
function estateToolchainReason(slug, directory, inference) {
|
|
770
|
+
const fact = inferredValue(inference, `${slug}.toolchain`);
|
|
771
|
+
return fact
|
|
772
|
+
? `${fact.provenance.source} proposes the ${String(fact.value)} toolchain, but the sibling answer leaves toolchain unset; materialised from the CockpitConfig schema default`
|
|
773
|
+
: `no lockfile that maps to a toolchain (${NODE_LOCKFILE_NAMES.join(', ')}) was found in ${directory}, so no sibling toolchain is proposed; materialised from the CockpitConfig schema default`;
|
|
774
|
+
}
|
|
775
|
+
function defaultAttributions(defaults, siblings, detected, root, source, siblingsRoot = undefined, inference) {
|
|
776
|
+
// The name is always an answer, so it is never surfaced as an inferred value to confirm.
|
|
777
|
+
const attributions = buildAttributions({ siblings: detected, defaults }, root, source)
|
|
778
|
+
.filter(row => !row.target.startsWith('sibling.') || row.from !== 'default');
|
|
779
|
+
for (const [field, value] of Object.entries(defaults.environment)) {
|
|
780
|
+
if (field !== 'install_policies')
|
|
781
|
+
attributions.push({ target: `environment.${field}`, value, from: 'default',
|
|
782
|
+
because: defaultReason(`the environment ${field} declaration`, source) });
|
|
783
|
+
}
|
|
784
|
+
attributions.push({ target: 'surfaces', value: defaults.surfaces, from: 'default',
|
|
785
|
+
because: defaultReason('the surfaces declaration', source) });
|
|
786
|
+
for (const sibling of siblings) {
|
|
787
|
+
for (const field of ['toolchain', 'guides'])
|
|
788
|
+
if (!(field in sibling)) {
|
|
789
|
+
attributions.push({ target: `sibling.${String(sibling.slug)}.${field}`,
|
|
790
|
+
value: defaults.sibling[field], from: 'default', because: field === 'toolchain' && source === 'estate' && root
|
|
791
|
+
? estateToolchainReason(String(sibling.slug), relative(root, resolve(root, siblingsRoot ?? '.', String(sibling.dir))) || '.', inference)
|
|
792
|
+
: defaultReason(`a sibling ${field}`, source) });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return inferGreenfieldSiblingRoot(attributions, siblingsRoot);
|
|
796
|
+
}
|
|
797
|
+
function confirmationDigest(attributions) {
|
|
798
|
+
return createHash('sha256').update(JSON.stringify(attributions
|
|
799
|
+
.map(({ target, value, from }) => ({ target, value, from })))).digest('hex');
|
|
800
|
+
}
|
|
801
|
+
function documentedRows(rows) {
|
|
802
|
+
return rows.map(row => ({ slug: row.slug, dir: row.dir, ...(row.remote ? { remote: row.remote } : {}),
|
|
803
|
+
...(row.branch ? { branch: row.branch } : {}), mount: { value: row.mount, from: 'read' },
|
|
804
|
+
acquire: { value: row.acquire, from: 'read' }, var: row.var, purpose: row.purpose }));
|
|
805
|
+
}
|
|
806
|
+
async function completeConfigurationDocument(input, root, registrySha256, detected, estateGaps = [], estateRunnable = []) {
|
|
807
|
+
const configuration = configurationInput(input.configuration);
|
|
808
|
+
if (!configuration)
|
|
809
|
+
return refusal('DOCUMENT_CONFIGURATION_INVALID', 'configuration completion requires bounded source-bound operator-confirmed answers');
|
|
810
|
+
// Nothing runnable is no reason to withhold agent surfaces. Such a cockpit declares no runtime,
|
|
811
|
+
// so the interview skips every environment question and the result says that runtime is absent.
|
|
812
|
+
const surfacesOnly = configuration.classification === 'no-runnable-product';
|
|
813
|
+
if (configuration.classification === 'incomplete-multi-repository' && estateGaps.length === 0)
|
|
814
|
+
return refusal('DOCUMENT_ESTATE_INCOMPLETE', 'resolve every named repository runnable gap before scaffolding a cockpit configuration');
|
|
815
|
+
const context = { root, registrySha256,
|
|
816
|
+
sourceObservationSha256: configuration.sourceObservationSha256,
|
|
817
|
+
classification: configuration.classification ?? null };
|
|
818
|
+
const defaults = configurationDefaults();
|
|
819
|
+
const defaultSource = registrySha256 !== null ? 'registry' : 'estate';
|
|
820
|
+
const siblingsRoot = registrySha256 !== null ? undefined
|
|
821
|
+
: input.estate && root ? relative(root, input.estate.root) || '.' : '.';
|
|
822
|
+
const destination = input.output ? resolve(input.output) : null;
|
|
823
|
+
let answerIndex = 0;
|
|
824
|
+
let inference;
|
|
825
|
+
let siblingRows;
|
|
826
|
+
let siblingAnswerRows = [];
|
|
827
|
+
// Every answer a person did not state is listed in cockpit.toml, so it never reads as theirs; one
|
|
828
|
+
// accepted as read names the file it was read from instead.
|
|
829
|
+
const unconfirmed = [];
|
|
830
|
+
const derived = [];
|
|
831
|
+
const record = (field, read) => {
|
|
832
|
+
if (read.provenance === 'derived')
|
|
833
|
+
derived.push({ field, source: read.source });
|
|
834
|
+
else if (read.provenance !== 'operator-confirmed')
|
|
835
|
+
unconfirmed.push({ field, provenance: read.provenance });
|
|
836
|
+
};
|
|
837
|
+
const derivedFact = (target) => {
|
|
838
|
+
const fact = inference?.facts.find(row => row.target === target && row.provenance.kind === 'derived');
|
|
839
|
+
return fact && { value: fact.value, source: fact.provenance.source };
|
|
840
|
+
};
|
|
841
|
+
// Always asked, with the directory name as the proposal: the name scopes Compose projects, state
|
|
842
|
+
// and generated files, so renaming after sync is a migration, and setup agents that were never
|
|
843
|
+
// asked renamed the cockpit by hand afterwards.
|
|
844
|
+
const proposedName = cockpitName(root);
|
|
845
|
+
const nameQuestion = configurationQuestion(context, 'cockpit.name', proposedName ? QUESTION_WHY.nameProposed : QUESTION_WHY.name);
|
|
846
|
+
const nameRead = readConfigurationAnswer(configuration.answers, answerIndex, nameQuestion, proposedName === undefined ? undefined : { value: proposedName, source: 'the cockpit root directory name' });
|
|
847
|
+
if (nameRead.kind === 'pending')
|
|
848
|
+
return pendingConfiguration({ name: proposedName, defaults, siblings: detected,
|
|
849
|
+
attributions: inferGreenfieldSiblingRoot(buildAttributions({ name: proposedName, defaults, siblings: detected }, root, defaultSource), siblingsRoot), gaps: [] }, destination, nameQuestion);
|
|
850
|
+
if (nameRead.kind === 'refusal')
|
|
851
|
+
return nameRead.outcome;
|
|
852
|
+
const parsedName = CockpitConfig.shape.cockpit.safeParse({ ...defaults.cockpit, name: nameRead.value });
|
|
853
|
+
if (!parsedName.success)
|
|
854
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the cockpit name answer does not match the schema: ${issueSummary(parsedName.error)}`, nameQuestion.field);
|
|
855
|
+
const name = parsedName.data.name;
|
|
856
|
+
record('cockpit.name', nameRead);
|
|
857
|
+
answerIndex += 1;
|
|
858
|
+
const detectedRows = toSiblingRows(detected);
|
|
859
|
+
const needsSiblingAnswer = detected.length === 0 || gaps([...detected]).some(gap => gap.blocking);
|
|
860
|
+
if (needsSiblingAnswer) {
|
|
861
|
+
const question = configurationQuestion(context, 'sibling', detected.length === 0
|
|
862
|
+
? QUESTION_WHY.siblingEstate : QUESTION_WHY.siblingRegistry);
|
|
863
|
+
const read = readConfigurationAnswer(configuration.answers, answerIndex, question);
|
|
864
|
+
if (read.kind === 'pending')
|
|
865
|
+
return pendingConfiguration({ name, defaults, siblings: detected,
|
|
866
|
+
attributions: inferGreenfieldSiblingRoot(buildAttributions({ defaults, siblings: detected }, root, defaultSource), siblingsRoot), gaps: [] }, destination, question);
|
|
867
|
+
if (read.kind === 'refusal')
|
|
868
|
+
return read.outcome;
|
|
869
|
+
if (!Array.isArray(read.value) || read.value.some(row => !row || typeof row !== 'object' || Array.isArray(row))) {
|
|
870
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the operator-confirmed sibling answer must be an array of schema-shaped declarations', question.field);
|
|
871
|
+
}
|
|
872
|
+
siblingAnswerRows = read.value;
|
|
873
|
+
const parsed = CockpitConfig.shape.sibling.safeParse(read.value);
|
|
874
|
+
if (!parsed.success)
|
|
875
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed sibling declarations do not match the schema: ${issueSummary(parsed.error)}`, question.field);
|
|
876
|
+
if (detectedRows.some((row, index) => !siblingAnswerRows[index]
|
|
877
|
+
|| !sameDetectedSibling(siblingAnswerRows[index], row)) || detectedRows.length > siblingAnswerRows.length) {
|
|
878
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the operator-confirmed sibling declarations are invalid or change detected registry facts', question.field);
|
|
879
|
+
}
|
|
880
|
+
siblingRows = parsed.data;
|
|
881
|
+
record('sibling', read);
|
|
882
|
+
answerIndex += 1;
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
const parsed = CockpitConfig.shape.sibling.safeParse(detectedRows);
|
|
886
|
+
if (!parsed.success)
|
|
887
|
+
return refusal('DOCUMENT_CONFIGURATION_INVALID', `detected registry sibling facts do not satisfy the configuration schema: ${issueSummary(parsed.error)}`, 'sibling');
|
|
888
|
+
siblingRows = parsed.data;
|
|
889
|
+
siblingAnswerRows = detectedRows;
|
|
890
|
+
}
|
|
891
|
+
// Read with the same inference init proposed from, so a defaulted toolchain names the lockfile it overrode.
|
|
892
|
+
inference = defaultSource === 'estate' && root ? await deriveInferredFacts({ root: resolve(root, siblingsRoot ?? '.'),
|
|
893
|
+
configurationRoot: root, repositories: siblingRows.map(row => ({ slug: row.slug, path: row.dir })) }) : undefined;
|
|
894
|
+
if (estateRunnable.length > 0) {
|
|
895
|
+
const dispositionSlugs = estateRunnable.map(row => row.slug);
|
|
896
|
+
const siblingSlugs = siblingRows.map(row => row.slug);
|
|
897
|
+
if (new Set(dispositionSlugs).size !== dispositionSlugs.length
|
|
898
|
+
|| [...dispositionSlugs].sort().join(',') !== [...siblingSlugs].sort().join(',')
|
|
899
|
+
|| estateRunnable.some(disposition => siblingRows.find(row => row.slug === disposition.slug)?.dir !== disposition.path)) {
|
|
900
|
+
// Naming only the code left a setup agent that had declared every repository beside an
|
|
901
|
+
// in-repo cockpit with no way to tell which declaration init would not accept.
|
|
902
|
+
const observed = estateRunnable.map(row => `${row.slug} (dir ${JSON.stringify(row.path)})`);
|
|
903
|
+
const declared = siblingRows.map(row => `${row.slug} (dir ${JSON.stringify(row.dir)})`);
|
|
904
|
+
const unobserved = declared.filter(row => !observed.includes(row));
|
|
905
|
+
const undeclared = observed.filter(row => !declared.includes(row));
|
|
906
|
+
return refusal('DOCUMENT_ESTATE_TOPOLOGY_MISMATCH', `the sibling answer must declare exactly the repositories init observed (${observed.join(', ')})${unobserved.length === 0 ? '' : `; it declares ${unobserved.join(', ')}, which init did not observe`}${undeclared.length === 0 ? '' : `; it omits ${undeclared.join(', ')}`}${input.estate === undefined ? '' : '; a cockpit inside or beside a product repository starts with that repository alone'}`, 'sibling', 'declare one sibling for each repository the message says init observed, with that slug and dir, and no other; add any other repository after init with npx --no cockpit sibling add <slug> --config <your cockpit.toml>');
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
const runnableResolutions = new Map();
|
|
910
|
+
if (!surfacesOnly && estateGaps.length > 0) {
|
|
911
|
+
const slugs = estateGaps.map(gap => /^([a-z][a-z0-9-]{0,63})\.runnable$/.exec(gap.field)?.[1]);
|
|
912
|
+
if (slugs.some(slug => !slug) || new Set(slugs).size !== slugs.length)
|
|
913
|
+
return refusal('DOCUMENT_ESTATE_GAPS_INVALID', 'the current estate runnable gaps are malformed or duplicated');
|
|
914
|
+
const question = configurationQuestion(context, 'estate.runnable', runnableWhy(estateGaps));
|
|
915
|
+
const read = readConfigurationAnswer(configuration.answers, answerIndex, question);
|
|
916
|
+
if (read.kind === 'pending')
|
|
917
|
+
return pendingConfiguration({ name, defaults, siblings: documentedRows(siblingRows),
|
|
918
|
+
attributions: defaultAttributions(defaults, siblingAnswerRows, detected, root, defaultSource, siblingsRoot, inference), gaps: [] }, input.output ? resolve(input.output) : null, question);
|
|
919
|
+
if (read.kind === 'refusal')
|
|
920
|
+
return read.outcome;
|
|
921
|
+
if (!read.value || typeof read.value !== 'object' || Array.isArray(read.value)
|
|
922
|
+
|| Object.keys(read.value).sort().join(',') !== [...slugs].sort().join(','))
|
|
923
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the runnable answer must resolve every current gap exactly once', question.field);
|
|
924
|
+
for (const slug of slugs) {
|
|
925
|
+
const resolution = read.value[slug];
|
|
926
|
+
if (!resolution || typeof resolution !== 'object' || Array.isArray(resolution))
|
|
927
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'each runnable gap requires one schema-shaped disposition', question.field);
|
|
928
|
+
const row = resolution;
|
|
929
|
+
if (row.runnable === false && Object.keys(row).join(',') === 'runnable') {
|
|
930
|
+
runnableResolutions.set(slug, { runnable: false });
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (row.runnable !== true || Object.keys(row).sort().join(',') !== 'command,runnable,service'
|
|
934
|
+
|| typeof row.service !== 'string' || !SAFE_NAME.test(row.service)
|
|
935
|
+
|| !Array.isArray(row.command) || row.command.length < 1 || row.command.length > 32
|
|
936
|
+
|| row.command.some(part => typeof part !== 'string' || part.length < 1 || part.length > 4096))
|
|
937
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'runnable dispositions require an exact service identity and command argv', question.field);
|
|
938
|
+
runnableResolutions.set(slug, { runnable: true, service: row.service,
|
|
939
|
+
command: [...row.command] });
|
|
940
|
+
}
|
|
941
|
+
if (configuration.classification === 'one-sibling'
|
|
942
|
+
&& [...runnableResolutions.values()].every(row => !row.runnable))
|
|
943
|
+
return refusal('DOCUMENT_NO_RUNNABLE_PRODUCT', 'the operator-confirmed estate contains no runnable product', question.field);
|
|
944
|
+
record('estate.runnable', read);
|
|
945
|
+
answerIndex += 1;
|
|
946
|
+
}
|
|
947
|
+
const portQuestion = configurationQuestion(context, 'ports.bases', QUESTION_WHY.ports);
|
|
948
|
+
const portRead = readConfigurationAnswer(configuration.answers, answerIndex, portQuestion, derivedFact('ports.bases'));
|
|
949
|
+
const previewRows = documentedRows(siblingRows);
|
|
950
|
+
const attributions = defaultAttributions(defaults, siblingAnswerRows, detected, root, defaultSource, siblingsRoot, inference);
|
|
951
|
+
if (portRead.kind === 'pending')
|
|
952
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
953
|
+
attributions, gaps: [] }, destination, portQuestion);
|
|
954
|
+
if (portRead.kind === 'refusal')
|
|
955
|
+
return portRead.outcome;
|
|
956
|
+
const parsedPorts = CockpitConfig.shape.ports.safeParse({ ...defaults.ports, bases: portRead.value });
|
|
957
|
+
if (!parsedPorts.success)
|
|
958
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed port bases do not match the schema: ${issueSummary(parsedPorts.error)}`, portQuestion.field);
|
|
959
|
+
record('ports.bases', portRead);
|
|
960
|
+
answerIndex += 1;
|
|
961
|
+
let environment = defaults.environment;
|
|
962
|
+
let testData;
|
|
963
|
+
let testing;
|
|
964
|
+
let testingNone;
|
|
965
|
+
if (!surfacesOnly) {
|
|
966
|
+
const policyQuestion = configurationQuestion(context, 'environment.install_policies', QUESTION_WHY.policies);
|
|
967
|
+
const policyRead = readConfigurationAnswer(configuration.answers, answerIndex, policyQuestion);
|
|
968
|
+
if (policyRead.kind === 'pending')
|
|
969
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
970
|
+
attributions, gaps: [] }, destination, policyQuestion);
|
|
971
|
+
if (policyRead.kind === 'refusal')
|
|
972
|
+
return policyRead.outcome;
|
|
973
|
+
const parsedPolicies = InstallPolicy.array().max(256).safeParse(policyRead.value);
|
|
974
|
+
if (!parsedPolicies.success)
|
|
975
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed install policies do not match the schema: ${issueSummary(parsedPolicies.error)}`, policyQuestion.field);
|
|
976
|
+
record('environment.install_policies', policyRead);
|
|
977
|
+
answerIndex += 1;
|
|
978
|
+
if (registrySha256 === null) {
|
|
979
|
+
const testDataQuestion = configurationQuestion(context, 'environment.test_data', QUESTION_WHY.testData);
|
|
980
|
+
const testDataRead = readConfigurationAnswer(configuration.answers, answerIndex, testDataQuestion);
|
|
981
|
+
if (testDataRead.kind === 'pending')
|
|
982
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
983
|
+
attributions, gaps: [] }, destination, testDataQuestion);
|
|
984
|
+
if (testDataRead.kind === 'refusal')
|
|
985
|
+
return testDataRead.outcome;
|
|
986
|
+
const value = testDataRead.value;
|
|
987
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
988
|
+
|| Object.keys(value).sort().join(',') !== 'seeded_data,signed_in_users'
|
|
989
|
+
|| typeof value.seeded_data !== 'boolean'
|
|
990
|
+
|| typeof value.signed_in_users !== 'boolean')
|
|
991
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the local test-data answer must be exactly { seeded_data: boolean, signed_in_users: boolean }', testDataQuestion.field);
|
|
992
|
+
testData = { seeded_data: value.seeded_data,
|
|
993
|
+
signed_in_users: value.signed_in_users, provenance: testDataRead.provenance };
|
|
994
|
+
record('environment.test_data', testDataRead);
|
|
995
|
+
answerIndex += 1;
|
|
996
|
+
}
|
|
997
|
+
const composeQuestion = configurationQuestion(context, 'environment.compose_files', QUESTION_WHY.composeFiles);
|
|
998
|
+
const composeRead = readConfigurationAnswer(configuration.answers, answerIndex, composeQuestion, derivedFact('environment.compose_files'));
|
|
999
|
+
if (composeRead.kind === 'pending')
|
|
1000
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
1001
|
+
attributions, gaps: [] }, destination, composeQuestion);
|
|
1002
|
+
if (composeRead.kind === 'refusal')
|
|
1003
|
+
return composeRead.outcome;
|
|
1004
|
+
const parsedCompose = Array.isArray(composeRead.value) && composeRead.value.length > 0
|
|
1005
|
+
? CockpitConfig.shape.environment.safeParse({ ...defaults.environment,
|
|
1006
|
+
install_policies: parsedPolicies.data, compose_files: composeRead.value }) : undefined;
|
|
1007
|
+
if (!parsedCompose?.success)
|
|
1008
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed Compose files do not match the schema: ${parsedCompose
|
|
1009
|
+
? issueSummary(parsedCompose.error) : 'compose_files must list at least one path'}`, composeQuestion.field);
|
|
1010
|
+
environment = parsedCompose.data;
|
|
1011
|
+
const defaultIndex = attributions.findIndex(row => row.target === 'environment.compose_files');
|
|
1012
|
+
if (defaultIndex >= 0)
|
|
1013
|
+
attributions.splice(defaultIndex, 1);
|
|
1014
|
+
record('environment.compose_files', composeRead);
|
|
1015
|
+
answerIndex += 1;
|
|
1016
|
+
// Every runtime command refuses a declared runtime with no floor, so a configuration that
|
|
1017
|
+
// declares one is incomplete until it is answered. The interview proposes memory and CPUs,
|
|
1018
|
+
// but free disk and the cold start time have no source, so the answer is never derived.
|
|
1019
|
+
const machineQuestion = configurationQuestion(context, 'environment.machine', QUESTION_WHY.machine);
|
|
1020
|
+
const machineRead = readConfigurationAnswer(configuration.answers, answerIndex, machineQuestion);
|
|
1021
|
+
if (machineRead.kind === 'pending')
|
|
1022
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
1023
|
+
attributions, gaps: [] }, destination, machineQuestion);
|
|
1024
|
+
if (machineRead.kind === 'refusal')
|
|
1025
|
+
return machineRead.outcome;
|
|
1026
|
+
const parsedMachine = MachineFloor.safeParse(machineRead.value);
|
|
1027
|
+
if (!parsedMachine.success)
|
|
1028
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed machine floor does not match the schema: ${issueSummary(parsedMachine.error)}`, machineQuestion.field);
|
|
1029
|
+
environment = { ...environment, machine: parsedMachine.data };
|
|
1030
|
+
record('environment.machine', machineRead);
|
|
1031
|
+
answerIndex += 1;
|
|
1032
|
+
const postureQuestion = configurationQuestion(context, 'environment.posture', QUESTION_WHY.posture);
|
|
1033
|
+
const postureRead = readConfigurationAnswer(configuration.answers, answerIndex, postureQuestion);
|
|
1034
|
+
if (postureRead.kind === 'pending')
|
|
1035
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
1036
|
+
attributions, gaps: [] }, destination, postureQuestion);
|
|
1037
|
+
if (postureRead.kind === 'refusal')
|
|
1038
|
+
return postureRead.outcome;
|
|
1039
|
+
const postureAnswer = postureRead.value;
|
|
1040
|
+
const parsedPosture = postureAnswer && typeof postureAnswer === 'object' && !Array.isArray(postureAnswer)
|
|
1041
|
+
&& Object.keys(postureAnswer).sort().join(',') === 'addressing,authentication,posture_requirements'
|
|
1042
|
+
? CockpitConfig.shape.environment.safeParse({ ...environment, ...postureAnswer }) : undefined;
|
|
1043
|
+
if (!parsedPosture?.success)
|
|
1044
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed posture must be exactly { addressing, authentication, posture_requirements }${parsedPosture
|
|
1045
|
+
? `: ${issueSummary(parsedPosture.error)}` : ''}`, postureQuestion.field);
|
|
1046
|
+
// The same judgement preflight gates on, so a posture that could never pass is refused here.
|
|
1047
|
+
const postureFailures = judgePostureDeclaration({ addressing: parsedPosture.data.addressing ?? null,
|
|
1048
|
+
authentication: parsedPosture.data.authentication ?? null }, parsedPosture.data.posture_requirements ?? [])
|
|
1049
|
+
.filter(row => !row.pass);
|
|
1050
|
+
if (postureFailures.length > 0)
|
|
1051
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed posture would fail preflight: ${postureFailures.map(row => row.reason).join('; ')}`, postureQuestion.field);
|
|
1052
|
+
environment = parsedPosture.data;
|
|
1053
|
+
record('environment.posture', postureRead);
|
|
1054
|
+
answerIndex += 1;
|
|
1055
|
+
// Asked, never defaulted: a cockpit with no gate cannot grade work, and "none" is a decision
|
|
1056
|
+
// a person makes, not the absence of an answer.
|
|
1057
|
+
const testingQuestion = configurationQuestion(context, 'testing', QUESTION_WHY.testing);
|
|
1058
|
+
const testingRead = readConfigurationAnswer(configuration.answers, answerIndex, testingQuestion);
|
|
1059
|
+
if (testingRead.kind === 'pending')
|
|
1060
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
1061
|
+
attributions, gaps: [] }, destination, testingQuestion);
|
|
1062
|
+
if (testingRead.kind === 'refusal')
|
|
1063
|
+
return testingRead.outcome;
|
|
1064
|
+
if (testingRead.value === 'none')
|
|
1065
|
+
testingNone = testingRead.provenance;
|
|
1066
|
+
else {
|
|
1067
|
+
const answer = testingRead.value;
|
|
1068
|
+
const parsedTesting = answer && typeof answer === 'object' && !Array.isArray(answer)
|
|
1069
|
+
? Testing.safeParse({ native_hooks: { install: 'inspect' }, ...answer }) : undefined;
|
|
1070
|
+
if (!parsedTesting?.success)
|
|
1071
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the testing answer must be "none" or a [testing] table of stages and gates${parsedTesting
|
|
1072
|
+
? `: ${issueSummary(parsedTesting.error)}` : ''}`, testingQuestion.field);
|
|
1073
|
+
testing = parsedTesting.data;
|
|
1074
|
+
}
|
|
1075
|
+
record('testing', testingRead);
|
|
1076
|
+
answerIndex += 1;
|
|
1077
|
+
}
|
|
1078
|
+
// Left as schema defaults, both lists are empty and the guard denies nothing by host, which no
|
|
1079
|
+
// one would notice; so the hosts are an explicit answer, and an explicit empty one is a decision.
|
|
1080
|
+
const guardQuestion = configurationQuestion(context, 'guards', QUESTION_WHY.guards);
|
|
1081
|
+
const guardRead = readConfigurationAnswer(configuration.answers, answerIndex, guardQuestion);
|
|
1082
|
+
if (guardRead.kind === 'pending')
|
|
1083
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
1084
|
+
attributions, gaps: [] }, destination, guardQuestion);
|
|
1085
|
+
if (guardRead.kind === 'refusal')
|
|
1086
|
+
return guardRead.outcome;
|
|
1087
|
+
const guardAnswer = guardRead.value;
|
|
1088
|
+
const parsedGuards = guardAnswer && typeof guardAnswer === 'object' && !Array.isArray(guardAnswer)
|
|
1089
|
+
&& Object.keys(guardAnswer).sort().join(',') === 'hosted_db_hosts,vendor_endpoint_hosts'
|
|
1090
|
+
? Guards.safeParse({ ...defaults.guards, ...guardAnswer }) : undefined;
|
|
1091
|
+
if (!parsedGuards?.success)
|
|
1092
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed guard hosts must be exactly { hosted_db_hosts, vendor_endpoint_hosts }${parsedGuards
|
|
1093
|
+
? `: ${issueSummary(parsedGuards.error)}` : ''}`, guardQuestion.field);
|
|
1094
|
+
const noGuardHosts = parsedGuards.data.hosted_db_hosts.length === 0 && parsedGuards.data.vendor_endpoint_hosts.length === 0;
|
|
1095
|
+
record('guards', guardRead);
|
|
1096
|
+
answerIndex += 1;
|
|
1097
|
+
const candidate = CockpitConfig.safeParse({ ...(unconfirmed.length === 0 ? {} : { unconfirmed: unconfirmed.map(row => row.field) }),
|
|
1098
|
+
...defaults, cockpit: { ...defaults.cockpit, name,
|
|
1099
|
+
...(siblingsRoot === undefined ? {} : { siblings_root: siblingsRoot }) },
|
|
1100
|
+
sibling: siblingRows, ports: parsedPorts.data, guards: parsedGuards.data, environment, ...(testing ? { testing } : {}),
|
|
1101
|
+
...(configuration.tools === undefined ? {} : { surfaces: { ...defaults.surfaces, tools: configuration.tools } }) });
|
|
1102
|
+
if (!candidate.success)
|
|
1103
|
+
return refusal('DOCUMENT_CONFIGURATION_INVALID', `the completed operator-confirmed configuration does not match the schema: ${issueSummary(candidate.error)}`);
|
|
1104
|
+
if (configuration.classification === 'one-sibling' && candidate.data.sibling.length !== 1
|
|
1105
|
+
|| (configuration.classification === 'ready-multi-repository'
|
|
1106
|
+
|| configuration.classification === 'incomplete-multi-repository') && candidate.data.sibling.length < 2) {
|
|
1107
|
+
return refusal('DOCUMENT_CONFIGURATION_APPLICABILITY_INVALID', 'the sibling declarations do not match the current estate classification', 'sibling');
|
|
1108
|
+
}
|
|
1109
|
+
const expectedSha256 = confirmationDigest(attributions.filter(row => confirmedBy(row) === 'person'));
|
|
1110
|
+
const defaultsQuestion = configurationQuestion(context, 'configuration.defaults', QUESTION_WHY.defaults, expectedSha256);
|
|
1111
|
+
const defaultsRead = readConfigurationAnswer(configuration.answers, answerIndex, defaultsQuestion);
|
|
1112
|
+
if (defaultsRead.kind === 'pending')
|
|
1113
|
+
return pendingConfiguration({ name, defaults, siblings: previewRows,
|
|
1114
|
+
attributions, gaps: [], unconfirmed }, destination, defaultsQuestion, candidate.data);
|
|
1115
|
+
if (defaultsRead.kind === 'refusal')
|
|
1116
|
+
return defaultsRead.outcome;
|
|
1117
|
+
if (!defaultsRead.value || typeof defaultsRead.value !== 'object' || Array.isArray(defaultsRead.value)
|
|
1118
|
+
|| Object.keys(defaultsRead.value).sort().join(',') !== 'confirmed,sha256'
|
|
1119
|
+
|| defaultsRead.value.confirmed !== true
|
|
1120
|
+
|| defaultsRead.value.sha256 !== expectedSha256) {
|
|
1121
|
+
return refusal('DOCUMENT_DEFAULT_CONFIRMATION_INVALID', 'the operator did not confirm the exact surfaced inferred and schema-default values', defaultsQuestion.field);
|
|
1122
|
+
}
|
|
1123
|
+
answerIndex += 1;
|
|
1124
|
+
if (configuration.answers.length !== answerIndex)
|
|
1125
|
+
return refusal('DOCUMENT_CONFIGURATION_ANSWER_EXTRA', 'configuration completion received an answer for no current question');
|
|
1126
|
+
const decisions = [
|
|
1127
|
+
...(noGuardHosts ? [`guards: ${noGuardHostsDecision(guardRead.provenance)}; list those hosts under [guards] once one exists`] : []),
|
|
1128
|
+
...(testingNone ? [`testing: no test gate is declared because ${STATED_BY[testingNone]} the product has no tests to run yet; declare [testing] once it does`] : []),
|
|
1129
|
+
...(testData ? [testData.seeded_data
|
|
1130
|
+
? candidate.data.environment.seed
|
|
1131
|
+
? 'seeded data: declared by environment.seed'
|
|
1132
|
+
: 'seeded data: required but unresolved; declare environment.seed with the volumes it fills in environment.resettable_volumes before claiming a seeded local setup'
|
|
1133
|
+
: `seeded data: not declared because ${STATED_BY[testData.provenance]} this local setup does not need it`,
|
|
1134
|
+
testData.signed_in_users
|
|
1135
|
+
? candidate.data.browser?.authentication.states.length
|
|
1136
|
+
? 'signed-in users: declared by browser.authentication states backed by seeded personas'
|
|
1137
|
+
: 'signed-in users: required but unresolved; declare seeded personas and browser authentication states before claiming signed-in testing'
|
|
1138
|
+
: `signed-in users: not declared because ${STATED_BY[testData.provenance]} this local setup does not need them`] : []),
|
|
1139
|
+
];
|
|
1140
|
+
const artifact = renderCompleteDocument(candidate.data, SCHEMA_DIRECTIVE, decisions, unconfirmed, derived);
|
|
1141
|
+
const diagnostics = diagnosticsFor(attributions, []);
|
|
1142
|
+
for (const row of unconfirmed)
|
|
1143
|
+
diagnostics.push({ severity: 'warning', code: 'DOCUMENT_ANSWER_UNCONFIRMED', target: row.field,
|
|
1144
|
+
message: `${unconfirmedReason(row)}, so cockpit.toml lists it under unconfirmed and doctor warns until a person confirms it`,
|
|
1145
|
+
fix: unconfirmedFix([row.field]) });
|
|
1146
|
+
if (noGuardHosts)
|
|
1147
|
+
diagnostics.push({ severity: 'info', code: 'DOCUMENT_GUARD_HOSTS_NONE', target: 'guards',
|
|
1148
|
+
message: `${noGuardHostsDecision(guardRead.provenance)}, so the guard denies no command by host`,
|
|
1149
|
+
fix: 'list the hosts in guards.hosted_db_hosts and guards.vendor_endpoint_hosts and run npx --no cockpit sync --config <your cockpit.toml> once a non-local environment exists' });
|
|
1150
|
+
if (surfacesOnly)
|
|
1151
|
+
diagnostics.push({ severity: 'info', code: 'DOCUMENT_RUNTIME_NOT_DECLARED', target: 'environment',
|
|
1152
|
+
message: 'no runnable product was observed, so this configuration declares no runtime environment and no Compose file: sync publishes the harness surfaces only, and up, logs and the other runtime commands refuse',
|
|
1153
|
+
fix: 'once a product runs, declare environment.compose_files and run npx --no cockpit sync --config <your cockpit.toml>' });
|
|
1154
|
+
return { kind: 'complete', artifact, destination, diagnostics,
|
|
1155
|
+
result: { document: artifact, ...(unconfirmed.length === 0 ? {} : { unconfirmed }), ...(derived.length === 0 ? {} : { derived }) },
|
|
1156
|
+
status: 0, question: null };
|
|
1157
|
+
}
|
|
1158
|
+
export async function draftFromRegistry(input, estateGaps = [], estateRunnable = []) {
|
|
1159
|
+
const root = resolve(input.root);
|
|
1160
|
+
const registry = input.registry ? resolve(input.registry) : resolve(root, 'env/lib/instance.sh');
|
|
1161
|
+
let bytes;
|
|
1162
|
+
try {
|
|
1163
|
+
const read = await readBoundedInput(registry);
|
|
1164
|
+
if (read.kind === 'too-large')
|
|
1165
|
+
return refusal('REGISTRY_TOO_LARGE', `the registry file exceeds the ${STRUCTURED_INPUT_BYTE_LIMIT}-byte input limit`, registry);
|
|
1166
|
+
bytes = read.bytes;
|
|
1167
|
+
}
|
|
1168
|
+
catch (error) {
|
|
1169
|
+
const code = error.code;
|
|
1170
|
+
if (code === 'ENOENT' && input.registry === undefined) {
|
|
1171
|
+
return completeConfigurationDocument(input, root, null, [], estateGaps, estateRunnable);
|
|
1172
|
+
}
|
|
1173
|
+
if (code === 'ENOENT') {
|
|
1174
|
+
return refusal('REGISTRY_NOT_FOUND', 'the registry file does not exist; --registry names an existing shell-registry cockpit (env/lib/instance.sh)', registry, `for a new product run: ${initCommand()}; otherwise check the registry path`);
|
|
1175
|
+
}
|
|
1176
|
+
return refusal('REGISTRY_UNREADABLE', `the registry file could not be read (${code ?? 'unknown I/O error'})`, registry);
|
|
1177
|
+
}
|
|
1178
|
+
let source;
|
|
1179
|
+
try {
|
|
1180
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
1181
|
+
}
|
|
1182
|
+
catch { // Fatal decoding throws only for invalid UTF-8, which the reason names.
|
|
1183
|
+
return refusal('REGISTRY_ENCODING_INVALID', 'the registry file is not valid UTF-8', registry);
|
|
1184
|
+
}
|
|
1185
|
+
if (source.trim() === '')
|
|
1186
|
+
return refusal('REGISTRY_EMPTY', 'the registry file is empty', registry);
|
|
1187
|
+
const parsed = parseRegistry(source);
|
|
1188
|
+
if (parsed.skipped.length > 0) {
|
|
1189
|
+
const knownGaps = diagnosticsFor([], unresolvedGaps(parsed.siblings, cockpitName(root)));
|
|
1190
|
+
return {
|
|
1191
|
+
kind: 'skipped-scope',
|
|
1192
|
+
artifact: null,
|
|
1193
|
+
destination: null,
|
|
1194
|
+
diagnostics: [
|
|
1195
|
+
...parsed.skipped.map((row) => ({
|
|
1196
|
+
severity: 'error',
|
|
1197
|
+
code: row.code ?? 'REGISTRY_ROW_SKIPPED',
|
|
1198
|
+
message: row.reason,
|
|
1199
|
+
target: `registry line ${row.sourceLine}`,
|
|
1200
|
+
fix: 'repair every skipped row before documenting repository scope',
|
|
1201
|
+
})),
|
|
1202
|
+
...knownGaps,
|
|
1203
|
+
],
|
|
1204
|
+
result: null,
|
|
1205
|
+
status: 2,
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
if (!parsed.dialect || parsed.siblings.length === 0) {
|
|
1209
|
+
return refusal(parsed.found ? 'REGISTRY_EMPTY' : 'REGISTRY_NOT_FOUND', parsed.found ? 'the registry contains no usable rows'
|
|
1210
|
+
: 'the file contains no sibling registry; --registry names an existing shell-registry cockpit', registry, parsed.found ? undefined : `for a new product run: ${initCommand()}`);
|
|
1211
|
+
}
|
|
1212
|
+
return completeConfigurationDocument(input, root, createHash('sha256').update(bytes).digest('hex'), parsed.siblings, estateGaps, estateRunnable);
|
|
1213
|
+
}
|
|
1214
|
+
export async function draftFromEstate(input) {
|
|
1215
|
+
if (input.registry !== undefined)
|
|
1216
|
+
return draftFromRegistry(input);
|
|
1217
|
+
const classified = await classifyEstateObservation(input.estate ?? { root: resolve(input.root) }, { observe: observePublicEstate });
|
|
1218
|
+
if (classified.kind !== 'complete')
|
|
1219
|
+
return {
|
|
1220
|
+
kind: 'refusal', status: 2, artifact: null, destination: null, result: null,
|
|
1221
|
+
diagnostics: [...classified.diagnostics],
|
|
1222
|
+
};
|
|
1223
|
+
if (input.configuration.sourceObservationSha256 !== classified.result.sourceObservationSha256
|
|
1224
|
+
|| input.configuration.classification !== classified.result.classification) {
|
|
1225
|
+
return refusal('DOCUMENT_ESTATE_OBSERVATION_CHANGED', 'the configuration answers are not bound to the current public estate observation', input.root);
|
|
1226
|
+
}
|
|
1227
|
+
return draftFromRegistry(input, classified.result.gaps, classified.result.runnable);
|
|
1228
|
+
}
|