@b00dea/cockpit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +375 -0
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/bin/cockpit.mjs +33 -0
- package/cockpit.schema.json +2574 -0
- package/dist/autonomy.d.ts +44 -0
- package/dist/autonomy.js +156 -0
- package/dist/bounded-read.d.ts +14 -0
- package/dist/bounded-read.js +42 -0
- package/dist/browser/commands.d.ts +62 -0
- package/dist/browser/commands.js +349 -0
- package/dist/browser/package-paths.d.ts +1 -0
- package/dist/browser/package-paths.js +6 -0
- package/dist/browser/proxy.d.ts +22 -0
- package/dist/browser/proxy.js +37 -0
- package/dist/browser/runtime.d.ts +117 -0
- package/dist/browser/runtime.js +183 -0
- package/dist/browser/surfaces.d.ts +141 -0
- package/dist/browser/surfaces.js +556 -0
- package/dist/browser/ui-gate.d.ts +15 -0
- package/dist/browser/ui-gate.js +18 -0
- package/dist/cli-guard.d.ts +5 -0
- package/dist/cli-guard.js +71 -0
- package/dist/cli-main.d.ts +139 -0
- package/dist/cli-main.js +1405 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +16 -0
- package/dist/command-table.d.ts +113 -0
- package/dist/command-table.js +512 -0
- package/dist/config/load.d.ts +171 -0
- package/dist/config/load.js +345 -0
- package/dist/config/sibling-toml.d.ts +48 -0
- package/dist/config/sibling-toml.js +160 -0
- package/dist/config/surface-tools-toml.d.ts +9 -0
- package/dist/config/surface-tools-toml.js +37 -0
- package/dist/config/verified-write.d.ts +43 -0
- package/dist/config/verified-write.js +89 -0
- package/dist/diagnostics.d.ts +26 -0
- package/dist/diagnostics.js +335 -0
- package/dist/emit.d.ts +22 -0
- package/dist/emit.js +43 -0
- package/dist/entrypoint-kinds.d.ts +3 -0
- package/dist/entrypoint-kinds.js +11 -0
- package/dist/environment/application/destructive.d.ts +6 -0
- package/dist/environment/application/destructive.js +425 -0
- package/dist/environment/application/doctor.d.ts +89 -0
- package/dist/environment/application/doctor.js +235 -0
- package/dist/environment/application/guard.d.ts +43 -0
- package/dist/environment/application/guard.js +622 -0
- package/dist/environment/application/health.d.ts +134 -0
- package/dist/environment/application/health.js +379 -0
- package/dist/environment/application/lifecycle-context.d.ts +228 -0
- package/dist/environment/application/lifecycle-context.js +154 -0
- package/dist/environment/application/lifecycle-state.d.ts +25 -0
- package/dist/environment/application/lifecycle-state.js +201 -0
- package/dist/environment/application/lifecycle.d.ts +17 -0
- package/dist/environment/application/lifecycle.js +729 -0
- package/dist/environment/application/logs.d.ts +45 -0
- package/dist/environment/application/logs.js +127 -0
- package/dist/environment/application/setup.d.ts +129 -0
- package/dist/environment/application/setup.js +741 -0
- package/dist/environment/application/shared.d.ts +11 -0
- package/dist/environment/application/shared.js +61 -0
- package/dist/environment/claims.d.ts +389 -0
- package/dist/environment/claims.js +1709 -0
- package/dist/environment/codex-hook-trust.d.ts +11 -0
- package/dist/environment/codex-hook-trust.js +185 -0
- package/dist/environment/compose-environment.d.ts +1 -0
- package/dist/environment/compose-environment.js +6 -0
- package/dist/environment/compose-ownership.d.ts +21 -0
- package/dist/environment/compose-ownership.js +117 -0
- package/dist/environment/compose-topology.d.ts +117 -0
- package/dist/environment/compose-topology.js +413 -0
- package/dist/environment/contract.d.ts +489 -0
- package/dist/environment/contract.js +761 -0
- package/dist/environment/declaration.d.ts +14 -0
- package/dist/environment/declaration.js +39 -0
- package/dist/environment/destructive-composition.d.ts +29 -0
- package/dist/environment/destructive-composition.js +333 -0
- package/dist/environment/docker-daemon.d.ts +30 -0
- package/dist/environment/docker-daemon.js +78 -0
- package/dist/environment/docker-endpoint.d.ts +10 -0
- package/dist/environment/docker-endpoint.js +19 -0
- package/dist/environment/env-schema-observation.d.ts +46 -0
- package/dist/environment/env-schema-observation.js +241 -0
- package/dist/environment/evidence.d.ts +136 -0
- package/dist/environment/evidence.js +437 -0
- package/dist/environment/executable.d.ts +26 -0
- package/dist/environment/executable.js +167 -0
- package/dist/environment/generated-application/authorities.d.ts +5 -0
- package/dist/environment/generated-application/authorities.js +61 -0
- package/dist/environment/generated-application/bootstrap-skill.d.ts +16 -0
- package/dist/environment/generated-application/bootstrap-skill.js +328 -0
- package/dist/environment/generated-application/check.d.ts +13 -0
- package/dist/environment/generated-application/check.js +189 -0
- package/dist/environment/generated-application/custom-surface.d.ts +26 -0
- package/dist/environment/generated-application/custom-surface.js +289 -0
- package/dist/environment/generated-application/prepare.d.ts +29 -0
- package/dist/environment/generated-application/prepare.js +276 -0
- package/dist/environment/generated-application/publication.d.ts +33 -0
- package/dist/environment/generated-application/publication.js +547 -0
- package/dist/environment/generated-application/shared.d.ts +484 -0
- package/dist/environment/generated-application/shared.js +349 -0
- package/dist/environment/generated-application/surface-observation.d.ts +4 -0
- package/dist/environment/generated-application/surface-observation.js +69 -0
- package/dist/environment/generated-application/surface-outputs.d.ts +71 -0
- package/dist/environment/generated-application/surface-outputs.js +389 -0
- package/dist/environment/generated-application/surfaces.d.ts +3 -0
- package/dist/environment/generated-application/surfaces.js +699 -0
- package/dist/environment/generated-application/sync.d.ts +6 -0
- package/dist/environment/generated-application/sync.js +109 -0
- package/dist/environment/git-observation.d.ts +183 -0
- package/dist/environment/git-observation.js +544 -0
- package/dist/environment/health-composition.d.ts +11 -0
- package/dist/environment/health-composition.js +628 -0
- package/dist/environment/host-observation.d.ts +56 -0
- package/dist/environment/host-observation.js +52 -0
- package/dist/environment/identity.d.ts +48 -0
- package/dist/environment/identity.js +85 -0
- package/dist/environment/init-application.d.ts +308 -0
- package/dist/environment/init-application.js +697 -0
- package/dist/environment/install-policy-observation.d.ts +71 -0
- package/dist/environment/install-policy-observation.js +284 -0
- package/dist/environment/install-policy.d.ts +48 -0
- package/dist/environment/install-policy.js +96 -0
- package/dist/environment/instance.d.ts +33 -0
- package/dist/environment/instance.js +122 -0
- package/dist/environment/instruction-pointer.d.ts +19 -0
- package/dist/environment/instruction-pointer.js +144 -0
- package/dist/environment/lifecycle-composition.d.ts +29 -0
- package/dist/environment/lifecycle-composition.js +675 -0
- package/dist/environment/lifecycle-stage.d.ts +115 -0
- package/dist/environment/lifecycle-stage.js +398 -0
- package/dist/environment/lifecycle.d.ts +75 -0
- package/dist/environment/lifecycle.js +354 -0
- package/dist/environment/logs.d.ts +132 -0
- package/dist/environment/logs.js +519 -0
- package/dist/environment/observations.d.ts +46 -0
- package/dist/environment/observations.js +344 -0
- package/dist/environment/operator-application.d.ts +50 -0
- package/dist/environment/operator-application.js +155 -0
- package/dist/environment/ports.d.ts +182 -0
- package/dist/environment/ports.js +1205 -0
- package/dist/environment/posture-observation.d.ts +68 -0
- package/dist/environment/posture-observation.js +253 -0
- package/dist/environment/read-composition.d.ts +27 -0
- package/dist/environment/read-composition.js +352 -0
- package/dist/environment/readiness-composition.d.ts +38 -0
- package/dist/environment/readiness-composition.js +130 -0
- package/dist/environment/reclaim.d.ts +333 -0
- package/dist/environment/reclaim.js +1835 -0
- package/dist/environment/repository-identity.d.ts +13 -0
- package/dist/environment/repository-identity.js +108 -0
- package/dist/environment/runtime-measurement-record.d.ts +38 -0
- package/dist/environment/runtime-measurement-record.js +111 -0
- package/dist/environment/runtime-measurement.d.ts +27 -0
- package/dist/environment/runtime-measurement.js +224 -0
- package/dist/environment/runtime-probe-residue.d.ts +43 -0
- package/dist/environment/runtime-probe-residue.js +148 -0
- package/dist/environment/runtime-probe.d.ts +72 -0
- package/dist/environment/runtime-probe.js +725 -0
- package/dist/environment/runtime.d.ts +505 -0
- package/dist/environment/runtime.js +2335 -0
- package/dist/environment/seed.d.ts +16 -0
- package/dist/environment/seed.js +43 -0
- package/dist/environment/selftest-application.d.ts +86 -0
- package/dist/environment/selftest-application.js +713 -0
- package/dist/environment/sensitive.d.ts +3 -0
- package/dist/environment/sensitive.js +48 -0
- package/dist/environment/setup-observation.d.ts +62 -0
- package/dist/environment/setup-observation.js +1473 -0
- package/dist/environment/setup.d.ts +43 -0
- package/dist/environment/setup.js +176 -0
- package/dist/environment/sibling-instructions.d.ts +33 -0
- package/dist/environment/sibling-instructions.js +224 -0
- package/dist/environment/sibling-registration.d.ts +45 -0
- package/dist/environment/sibling-registration.js +538 -0
- package/dist/environment/state.d.ts +371 -0
- package/dist/environment/state.js +2013 -0
- package/dist/environment/templates.d.ts +140 -0
- package/dist/environment/templates.js +685 -0
- package/dist/environment/tool-registration.d.ts +9 -0
- package/dist/environment/tool-registration.js +96 -0
- package/dist/environment/upgrade-application.d.ts +19 -0
- package/dist/environment/upgrade-application.js +175 -0
- package/dist/environment-exec/execute.d.ts +102 -0
- package/dist/environment-exec/execute.js +1175 -0
- package/dist/environment-exec/libexec-program.d.ts +6 -0
- package/dist/environment-exec/libexec-program.js +22 -0
- package/dist/environment-exec/verified-digest.d.ts +4 -0
- package/dist/environment-exec/verified-digest.js +16 -0
- package/dist/exact-keys.d.ts +7 -0
- package/dist/exact-keys.js +15 -0
- package/dist/fix-catalogue.d.ts +32 -0
- package/dist/fix-catalogue.js +60 -0
- package/dist/freshness.d.ts +29 -0
- package/dist/freshness.js +75 -0
- package/dist/generated-environment/adopt.d.ts +16 -0
- package/dist/generated-environment/adopt.js +275 -0
- package/dist/generated-environment/command.d.ts +47 -0
- package/dist/generated-environment/command.js +75 -0
- package/dist/generated-environment/transaction.d.ts +186 -0
- package/dist/generated-environment/transaction.js +1770 -0
- package/dist/grading/evaluate.d.ts +71 -0
- package/dist/grading/evaluate.js +403 -0
- package/dist/grading/grade.d.ts +33 -0
- package/dist/grading/grade.js +57 -0
- package/dist/guards/evaluator.d.ts +113 -0
- package/dist/guards/evaluator.js +1226 -0
- package/dist/guards/harness-adapter.d.ts +17 -0
- package/dist/guards/harness-adapter.js +57 -0
- package/dist/guards/host.d.ts +8 -0
- package/dist/guards/host.js +10 -0
- package/dist/guards/invocation-parser.d.ts +66 -0
- package/dist/guards/invocation-parser.js +595 -0
- package/dist/guards/local-identity.d.ts +40 -0
- package/dist/guards/local-identity.js +123 -0
- package/dist/guards/policy-pin.d.ts +13 -0
- package/dist/guards/policy-pin.js +175 -0
- package/dist/guards/protected-locations.d.ts +62 -0
- package/dist/guards/protected-locations.js +461 -0
- package/dist/guards/read-only.d.ts +37 -0
- package/dist/guards/read-only.js +315 -0
- package/dist/init/command.d.ts +40 -0
- package/dist/init/command.js +310 -0
- package/dist/init/derive.d.ts +51 -0
- package/dist/init/derive.js +715 -0
- package/dist/init/draft.d.ts +138 -0
- package/dist/init/draft.js +1205 -0
- package/dist/init/render.d.ts +35 -0
- package/dist/init/render.js +82 -0
- package/dist/init/scaffold.d.ts +53 -0
- package/dist/init/scaffold.js +121 -0
- package/dist/invocation.d.ts +13 -0
- package/dist/invocation.js +20 -0
- package/dist/package-metadata.d.ts +9 -0
- package/dist/package-metadata.js +19 -0
- package/dist/readiness.d.ts +53 -0
- package/dist/readiness.js +88 -0
- package/dist/references/named.d.ts +24 -0
- package/dist/references/named.js +27 -0
- package/dist/refusal.d.ts +15 -0
- package/dist/refusal.js +7 -0
- package/dist/registry-derivation.d.ts +31 -0
- package/dist/registry-derivation.js +32 -0
- package/dist/registry-resolve/command.d.ts +47 -0
- package/dist/registry-resolve/command.js +226 -0
- package/dist/registry-resolve/registry.d.ts +68 -0
- package/dist/registry-resolve/registry.js +292 -0
- package/dist/registry-resolve/render-shell.d.ts +18 -0
- package/dist/registry-resolve/render-shell.js +36 -0
- package/dist/repair.d.ts +29 -0
- package/dist/repair.js +8 -0
- package/dist/schema-json.d.ts +1 -0
- package/dist/schema-json.js +177 -0
- package/dist/schema.d.ts +1322 -0
- package/dist/schema.js +934 -0
- package/dist/setup/command.d.ts +4 -0
- package/dist/setup/command.js +4 -0
- package/dist/sha256.d.ts +1 -0
- package/dist/sha256.js +4 -0
- package/dist/surfaces/adoption.d.ts +41 -0
- package/dist/surfaces/adoption.js +217 -0
- package/dist/surfaces/consumer-instructions.d.ts +20 -0
- package/dist/surfaces/consumer-instructions.js +43 -0
- package/dist/surfaces/contract.d.ts +42 -0
- package/dist/surfaces/contract.js +169 -0
- package/dist/surfaces/core-declarations.d.ts +24 -0
- package/dist/surfaces/core-declarations.js +79 -0
- package/dist/surfaces/development-markers.d.ts +1 -0
- package/dist/surfaces/development-markers.js +18 -0
- package/dist/surfaces/generator-adapter.d.ts +71 -0
- package/dist/surfaces/generator-adapter.js +809 -0
- package/dist/surfaces/harness-registry.d.ts +180 -0
- package/dist/surfaces/harness-registry.js +117 -0
- package/dist/surfaces/mcp.d.ts +95 -0
- package/dist/surfaces/mcp.js +186 -0
- package/dist/surfaces/merge.d.ts +30 -0
- package/dist/surfaces/merge.js +105 -0
- package/dist/surfaces/ownership.d.ts +190 -0
- package/dist/surfaces/ownership.js +795 -0
- package/dist/surfaces/projection.d.ts +136 -0
- package/dist/surfaces/projection.js +681 -0
- package/dist/surfaces/render.d.ts +118 -0
- package/dist/surfaces/render.js +633 -0
- package/dist/surfaces/restrictions.d.ts +130 -0
- package/dist/surfaces/restrictions.js +447 -0
- package/dist/surfaces/selftest.d.ts +162 -0
- package/dist/surfaces/selftest.js +393 -0
- package/dist/surfaces/skill-contract.d.ts +85 -0
- package/dist/surfaces/skill-contract.js +449 -0
- package/dist/surfaces/sync.d.ts +57 -0
- package/dist/surfaces/sync.js +164 -0
- package/dist/team-metadata.d.ts +41 -0
- package/dist/team-metadata.js +512 -0
- package/dist/testing/gates.d.ts +91 -0
- package/dist/testing/gates.js +182 -0
- package/dist/testing/hooks.d.ts +59 -0
- package/dist/testing/hooks.js +139 -0
- package/dist/testing/verdict.d.ts +29 -0
- package/dist/testing/verdict.js +62 -0
- package/dist/work-orders/application.d.ts +99 -0
- package/dist/work-orders/application.js +383 -0
- package/dist/work-orders/composition.d.ts +124 -0
- package/dist/work-orders/composition.js +1010 -0
- package/dist/work-orders/contract.d.ts +61 -0
- package/dist/work-orders/contract.js +122 -0
- package/dist/work-orders/store.d.ts +158 -0
- package/dist/work-orders/store.js +197 -0
- package/docs/README.md +65 -0
- package/docs/architecture.md +136 -0
- package/docs/capability-matrix.md +50 -0
- package/docs/cli-reference.md +599 -0
- package/docs/command-list.md +51 -0
- package/docs/compose-patterns.md +99 -0
- package/docs/concepts.md +171 -0
- package/docs/configuration-essentials.md +172 -0
- package/docs/configuration-reference.md +621 -0
- package/docs/environment.md +760 -0
- package/docs/extending.md +43 -0
- package/docs/faq.md +111 -0
- package/docs/getting-started.md +608 -0
- package/docs/harness-surfaces.md +433 -0
- package/docs/joining-a-team.md +76 -0
- package/docs/release-policy.md +49 -0
- package/docs/safety-and-autonomy.md +419 -0
- package/docs/troubleshooting.md +361 -0
- package/docs/tutorial.md +170 -0
- package/docs/work-and-browser.md +254 -0
- package/libexec/browser-operation.cjs +186 -0
- package/libexec/fence-marker.cjs +101 -0
- package/libexec/fence-marker.d.cts +24 -0
- package/libexec/fenced-group-runner.cjs +77 -0
- package/libexec/fenced-supervisor.cjs +137 -0
- package/libexec/generated-filesystem.cjs +731 -0
- package/libexec/install-filesystem.cjs +225 -0
- package/libexec/rulesync-generator.NOTICE.md +1112 -0
- package/libexec/rulesync-generator.cjs +743 -0
- package/libexec/surface-generator-worker.cjs +266 -0
- package/libexec/surface-output-reader.cjs +99 -0
- package/libexec/surface-scratch-cleanup.cjs +14 -0
- package/libexec/validate-harness-evidence.d.mts +183 -0
- package/libexec/validate-harness-evidence.mjs +1312 -0
- package/package.json +74 -4
- package/surfaces/README.md +33 -0
- package/surfaces/agents/evaluator.agent.md +76 -0
- package/surfaces/guards/bash-guard.sh +48 -0
- package/surfaces/guards/claude.adapter.json +25 -0
- package/surfaces/guards/codex.adapter.json +24 -0
- package/surfaces/guards/opencode.adapter.json +25 -0
- package/surfaces/references/browser.md +63 -0
- package/surfaces/references/test-data-and-personas.md +84 -0
- package/surfaces/references/testing-tiers.md +11 -0
- package/surfaces/skills/cockpit-browser.skill.md +54 -0
- package/surfaces/skills/cockpit-explain.skill.md +32 -0
- package/surfaces/skills/cockpit-fix.skill.md +37 -0
- package/surfaces/skills/cockpit-setup.skill.md +39 -0
- package/surfaces/skills/cockpit-work.skill.md +34 -0
- package/surfaces/skills/collection.json +5 -0
- package/surfaces/skills/procedures/explain.md +61 -0
- package/surfaces/skills/procedures/fix.md +81 -0
- package/surfaces/skills/procedures/setup.md +379 -0
- package/surfaces/skills/procedures/work.md +105 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { authenticatedExecutionOutcome } from '../environment-exec/execute.js';
|
|
3
|
+
import { formatShellCommand } from '../fix-catalogue.js';
|
|
4
|
+
import { RefusalError, ENVIRONMENT_CODES, OBSERVATION_REASON_BYTE_LIMIT, snapshotEnvironmentObservation, isCanonicalTimestamp, snapshotExecutionStep, } from './contract.js';
|
|
5
|
+
import { resolvedGitExecutable } from './executable.js';
|
|
6
|
+
import { repositoryIdentity } from './repository-identity.js';
|
|
7
|
+
import { stackId as deriveStackId } from './identity.js';
|
|
8
|
+
import { isRuntimeTopologyEvidence } from './runtime.js';
|
|
9
|
+
import { consumeSetupCheckoutProjection } from './setup-observation.js';
|
|
10
|
+
const OWNER = 'git-observation';
|
|
11
|
+
export const observationDeclarations = Object.freeze([
|
|
12
|
+
{ id: 'git-working-tree', owner: OWNER, postures: ['environment'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.GIT_STATE_UNHEALTHY, isIssued: isGitObservation, issuedAt: observationIssuedAt },
|
|
13
|
+
{ id: 'git-adjacency', owner: OWNER, postures: ['environment'], stages: [{ stage: 'bootstrap', disposition: 'required' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.CHECKOUT_ADJACENCY_UNHEALTHY, isIssued: isGitObservation, issuedAt: observationIssuedAt },
|
|
14
|
+
{ id: 'running-checkout', owner: OWNER, postures: ['running-stack'], stages: [{ stage: 'bootstrap', disposition: 'not-applicable', reason: 'no running checkout exists before the first seed' }, { stage: 'steady', disposition: 'required' }], code: ENVIRONMENT_CODES.RUNNING_CHECKOUT_MISMATCH, isIssued: isGitObservation, issuedAt: observationIssuedAt },
|
|
15
|
+
]);
|
|
16
|
+
const issuedObservations = new WeakMap();
|
|
17
|
+
function issueObservation(value) {
|
|
18
|
+
const snapshot = snapshotEnvironmentObservation(value);
|
|
19
|
+
if (!snapshot || snapshot.owner !== OWNER)
|
|
20
|
+
throw new RefusalError('Git observation is malformed');
|
|
21
|
+
issuedObservations.set(snapshot, Date.now());
|
|
22
|
+
return snapshot;
|
|
23
|
+
}
|
|
24
|
+
function isGitObservation(value) {
|
|
25
|
+
return value !== null && typeof value === 'object' && issuedObservations.has(value);
|
|
26
|
+
}
|
|
27
|
+
function observationIssuedAt(value) {
|
|
28
|
+
return value !== null && typeof value === 'object' ? issuedObservations.get(value) : undefined;
|
|
29
|
+
}
|
|
30
|
+
const OUTPUT_LIMIT = 65_536;
|
|
31
|
+
const issuedGitSteps = new WeakMap();
|
|
32
|
+
const issuedGitResults = new WeakMap();
|
|
33
|
+
function safeObservationEnvironment(env) {
|
|
34
|
+
const entries = Object.entries(env);
|
|
35
|
+
return entries.some(([name, value]) => !['LANG', 'LC_ALL', 'TZ'].includes(name)
|
|
36
|
+
|| typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > 1_024) ? undefined : entries;
|
|
37
|
+
}
|
|
38
|
+
export function createGitObservationStep(input) {
|
|
39
|
+
const executable = resolvedGitExecutable(input.executable);
|
|
40
|
+
const safeEnvironment = safeObservationEnvironment(input.env);
|
|
41
|
+
if (!executable || !Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > 30_000
|
|
42
|
+
|| !/^stack:[a-f0-9]{64}$/.test(input.stackId) || !/^checkout:[a-f0-9]{64}$/.test(input.checkoutId)
|
|
43
|
+
|| typeof input.source !== 'string' || Buffer.byteLength(`${input.source}:adjacency`) > 1_024 || /[\x00-\x1f\x7f]/.test(input.source)
|
|
44
|
+
|| !safeEnvironment)
|
|
45
|
+
return undefined;
|
|
46
|
+
const step = snapshotExecutionStep({
|
|
47
|
+
id: 'git-health',
|
|
48
|
+
argv: [executable.canonicalPath, '-C', input.checkoutRoot, 'status', '--porcelain=v2', '--branch', '--untracked-files=normal'],
|
|
49
|
+
argv0: executable.invocationPath,
|
|
50
|
+
cwd: input.checkoutRoot,
|
|
51
|
+
env: Object.fromEntries(safeEnvironment),
|
|
52
|
+
timeoutMs: input.timeoutMs,
|
|
53
|
+
stdoutLimitBytes: OUTPUT_LIMIT,
|
|
54
|
+
stderrLimitBytes: OUTPUT_LIMIT,
|
|
55
|
+
paths: [input.checkoutRoot],
|
|
56
|
+
ports: [],
|
|
57
|
+
labels: { stackId: input.stackId, source: input.source, checkoutId: input.checkoutId },
|
|
58
|
+
expected: [{ kind: 'path', target: input.checkoutRoot, state: 'repository' }],
|
|
59
|
+
consumedFiles: [{ path: executable.canonicalPath, sha256: executable.sha256, maxBytes: 536_870_912, argvIndexes: [0], executable: true, executionMode: executable.executionMode }],
|
|
60
|
+
filesystemChecks: [],
|
|
61
|
+
compensation: [],
|
|
62
|
+
});
|
|
63
|
+
if (step)
|
|
64
|
+
issuedGitSteps.set(step, Object.freeze({
|
|
65
|
+
stackId: input.stackId, source: input.source, checkoutId: input.checkoutId,
|
|
66
|
+
checkoutRoot: input.checkoutRoot, executable, env: Object.freeze(Object.fromEntries(safeEnvironment)), timeoutMs: input.timeoutMs,
|
|
67
|
+
}));
|
|
68
|
+
return step;
|
|
69
|
+
}
|
|
70
|
+
function unavailable(reason) {
|
|
71
|
+
return { kind: 'unavailable', reason };
|
|
72
|
+
}
|
|
73
|
+
export function decodeGitObservation(bytes, context) {
|
|
74
|
+
if (bytes.byteLength > OUTPUT_LIMIT || !/^stack:[a-f0-9]{64}$/.test(context.stackId)
|
|
75
|
+
|| !isCanonicalTimestamp(context.observedAt) || typeof context.source !== 'string'
|
|
76
|
+
|| context.source.length === 0 || Buffer.byteLength(`${context.source}:working-tree`) > 1_024
|
|
77
|
+
|| Buffer.byteLength(`${context.source}:adjacency`) > 1_024 || /[\x00-\x1f\x7f]/.test(context.source)
|
|
78
|
+
|| typeof context.checkoutRoot !== 'string' || context.checkoutRoot.length === 0 || /[\x00-\x1f\x7f]/.test(context.checkoutRoot)) {
|
|
79
|
+
return unavailable('Git evidence exceeds its declared boundary');
|
|
80
|
+
}
|
|
81
|
+
let source;
|
|
82
|
+
try {
|
|
83
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Invalid UTF-8 is the only throw and is what the message names.
|
|
87
|
+
return unavailable('Git evidence is not valid UTF-8');
|
|
88
|
+
}
|
|
89
|
+
let revision;
|
|
90
|
+
let branch;
|
|
91
|
+
let upstream = null;
|
|
92
|
+
let ahead = 0;
|
|
93
|
+
let behind = 0;
|
|
94
|
+
let trackedDirtyCount = 0;
|
|
95
|
+
let untrackedCount = 0;
|
|
96
|
+
const headers = new Set();
|
|
97
|
+
for (const line of source.split(/\r?\n/)) {
|
|
98
|
+
if (line === '')
|
|
99
|
+
continue;
|
|
100
|
+
if (line.startsWith('# branch.oid ')) {
|
|
101
|
+
if (headers.has('oid'))
|
|
102
|
+
return unavailable('Git evidence contains a duplicate branch header');
|
|
103
|
+
headers.add('oid');
|
|
104
|
+
revision = line.slice(13);
|
|
105
|
+
}
|
|
106
|
+
else if (line.startsWith('# branch.head ')) {
|
|
107
|
+
if (headers.has('head'))
|
|
108
|
+
return unavailable('Git evidence contains a duplicate branch header');
|
|
109
|
+
headers.add('head');
|
|
110
|
+
branch = line.slice(14);
|
|
111
|
+
}
|
|
112
|
+
else if (line.startsWith('# branch.upstream ')) {
|
|
113
|
+
if (headers.has('upstream'))
|
|
114
|
+
return unavailable('Git evidence contains a duplicate branch header');
|
|
115
|
+
headers.add('upstream');
|
|
116
|
+
upstream = line.slice(18);
|
|
117
|
+
}
|
|
118
|
+
else if (line.startsWith('# branch.ab ')) {
|
|
119
|
+
if (headers.has('adjacency'))
|
|
120
|
+
return unavailable('Git evidence contains a duplicate branch header');
|
|
121
|
+
headers.add('adjacency');
|
|
122
|
+
const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);
|
|
123
|
+
if (!match)
|
|
124
|
+
return unavailable('Git adjacency evidence is malformed');
|
|
125
|
+
ahead = Number(match[1]);
|
|
126
|
+
behind = Number(match[2]);
|
|
127
|
+
if (!Number.isSafeInteger(ahead) || !Number.isSafeInteger(behind))
|
|
128
|
+
return unavailable('Git adjacency evidence exceeds exact integer bounds');
|
|
129
|
+
}
|
|
130
|
+
else if (line.startsWith('? '))
|
|
131
|
+
untrackedCount += 1;
|
|
132
|
+
else if (/^[12u] /.test(line))
|
|
133
|
+
trackedDirtyCount += 1;
|
|
134
|
+
else if (!line.startsWith('! '))
|
|
135
|
+
return unavailable('Git status evidence contains an unknown record');
|
|
136
|
+
}
|
|
137
|
+
const validIdentity = (value) => Buffer.byteLength(value) <= 1_024 && !/[\x00-\x1f\x7f]/.test(value);
|
|
138
|
+
// `git init` leaves HEAD unborn, and Git reports that as `(initial)` rather than an object name.
|
|
139
|
+
const unborn = revision === '(initial)';
|
|
140
|
+
if (!revision || !branch || !unborn && !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(revision) || !validIdentity(branch)
|
|
141
|
+
|| upstream !== null && !validIdentity(upstream)
|
|
142
|
+
|| upstream === null && headers.has('adjacency')) {
|
|
143
|
+
return unavailable('Git evidence omitted or malformed repository identity');
|
|
144
|
+
}
|
|
145
|
+
const committed = unborn ? null : revision;
|
|
146
|
+
const workingTree = trackedDirtyCount > 0 && untrackedCount > 0 ? 'dirty-and-untracked'
|
|
147
|
+
: trackedDirtyCount > 0 ? 'dirty' : untrackedCount > 0 ? 'untracked' : 'clean';
|
|
148
|
+
// Git names an upstream without counting against it when the upstream ref no longer exists,
|
|
149
|
+
// which is what a merged and deleted remote branch leaves behind.
|
|
150
|
+
const adjacency = upstream === null ? 'untracked-upstream'
|
|
151
|
+
: !headers.has('adjacency') ? 'upstream-gone'
|
|
152
|
+
: ahead > 0 && behind > 0 ? 'diverged'
|
|
153
|
+
: ahead > 0 ? 'ahead' : behind > 0 ? 'behind' : 'aligned';
|
|
154
|
+
const evidence = { revision: committed, branch, upstream, ahead, behind, trackedDirtyCount, untrackedCount, workingTree, adjacency };
|
|
155
|
+
const git = (...args) => formatShellCommand(['git', '-C', context.checkoutRoot, ...args]);
|
|
156
|
+
const workingTreeRemedy = unborn
|
|
157
|
+
? `review the files with \`${git('status', '--short')}\`, then commit them with \`${git('add', '-A')} && ${git('commit', '-m', 'Add the cockpit')}\``
|
|
158
|
+
: `review the changes with \`${git('status', '--short')}\`, then commit them, ignore them in .gitignore, or discard them`;
|
|
159
|
+
const adjacencyRemedy = adjacency === 'upstream-gone'
|
|
160
|
+
? `push the branch to recreate its upstream ${upstream}, or stop tracking it with \`${git('branch', '--unset-upstream')}\``
|
|
161
|
+
: 'reconcile the checkout with its declared upstream';
|
|
162
|
+
if ([workingTreeRemedy, adjacencyRemedy].some((remedy) => Buffer.byteLength(remedy) > OBSERVATION_REASON_BYTE_LIMIT)) {
|
|
163
|
+
return unavailable('Git checkout path is too long to name in its remedy');
|
|
164
|
+
}
|
|
165
|
+
const common = {
|
|
166
|
+
owner: OWNER,
|
|
167
|
+
stackId: context.stackId,
|
|
168
|
+
observedAt: context.observedAt,
|
|
169
|
+
applicability: { state: 'required', posture: 'environment' },
|
|
170
|
+
freshness: { state: 'current' },
|
|
171
|
+
};
|
|
172
|
+
const workingTreeValue = { revision: committed, branch, trackedDirtyCount, untrackedCount };
|
|
173
|
+
const workingTreeExpected = { revision: committed, branch, trackedDirtyCount: 0, untrackedCount: 0 };
|
|
174
|
+
// Without a commit there is no revision for the working tree to be clean against, so the missing
|
|
175
|
+
// commit is the one cause reported rather than a count of files that are all simply uncommitted.
|
|
176
|
+
const workingTreeObserved = unborn
|
|
177
|
+
? { state: 'failed', reason: `the repository at ${context.checkoutRoot} has no commits yet (${trackedDirtyCount} staged, ${untrackedCount} untracked), so there is no committed revision for its working tree to match`, observedAt: common.observedAt }
|
|
178
|
+
: { state: 'available', value: workingTreeValue, observedAt: common.observedAt };
|
|
179
|
+
const adjacencyValue = { revision: committed, branch, upstream, ahead, behind, adjacency };
|
|
180
|
+
const adjacencyExpected = { revision: committed, branch, upstream, ahead: 0, behind: 0,
|
|
181
|
+
adjacency: upstream === null ? 'untracked-upstream' : 'aligned' };
|
|
182
|
+
const observations = [
|
|
183
|
+
{
|
|
184
|
+
code: ENVIRONMENT_CODES.GIT_STATE_UNHEALTHY,
|
|
185
|
+
owner: common.owner,
|
|
186
|
+
stackId: common.stackId,
|
|
187
|
+
source: `${context.source}:working-tree`,
|
|
188
|
+
observed: workingTreeObserved,
|
|
189
|
+
expectation: { kind: 'equal', value: workingTreeExpected },
|
|
190
|
+
applicability: common.applicability,
|
|
191
|
+
freshness: common.freshness,
|
|
192
|
+
remedy: workingTreeRemedy,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
code: ENVIRONMENT_CODES.CHECKOUT_ADJACENCY_UNHEALTHY,
|
|
196
|
+
owner: common.owner,
|
|
197
|
+
stackId: common.stackId,
|
|
198
|
+
source: `${context.source}:adjacency`,
|
|
199
|
+
observed: { state: 'available', value: adjacencyValue, observedAt: common.observedAt },
|
|
200
|
+
expectation: { kind: 'equal', value: adjacencyExpected },
|
|
201
|
+
applicability: common.applicability,
|
|
202
|
+
freshness: common.freshness,
|
|
203
|
+
remedy: adjacencyRemedy,
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
return { kind: 'complete', evidence, observations };
|
|
207
|
+
}
|
|
208
|
+
export function observeGit(step, outcome, context) {
|
|
209
|
+
const binding = issuedGitSteps.get(step);
|
|
210
|
+
if (!binding || binding.stackId !== context.stackId || binding.checkoutId !== context.checkoutId || binding.source !== context.source)
|
|
211
|
+
return unavailable('Git observation context does not match its issued plan');
|
|
212
|
+
const authenticated = authenticatedExecutionOutcome(outcome, step);
|
|
213
|
+
if (!authenticated || authenticated.kind !== 'complete')
|
|
214
|
+
return unavailable('Git execution evidence is unavailable or unauthenticated');
|
|
215
|
+
const decoded = decodeGitObservation(authenticated.stdout, { stackId: context.stackId, observedAt: authenticated.receipt.finishedAt,
|
|
216
|
+
source: context.source, checkoutRoot: binding.checkoutRoot });
|
|
217
|
+
if (decoded.kind !== 'complete')
|
|
218
|
+
return decoded;
|
|
219
|
+
const result = {
|
|
220
|
+
kind: 'complete',
|
|
221
|
+
evidence: decoded.evidence,
|
|
222
|
+
observations: decoded.observations.map(issueObservation),
|
|
223
|
+
};
|
|
224
|
+
issuedGitResults.set(result, Object.freeze({ ...binding, evidence: decoded.evidence }));
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
const issuedComparisonRefs = new WeakMap();
|
|
228
|
+
const issuedComparisonUrls = new WeakMap();
|
|
229
|
+
const issuedComparisonRevalidations = new WeakMap();
|
|
230
|
+
const COMPARISON_OUTPUT_LIMIT = 4_096;
|
|
231
|
+
const comparisonCode = ENVIRONMENT_CODES.GIT_COMPARISON_REMOTE_MISMATCH;
|
|
232
|
+
function comparisonUnavailable(reason, remoteName) {
|
|
233
|
+
return remoteName === undefined
|
|
234
|
+
? { kind: 'unavailable', code: comparisonCode, reason }
|
|
235
|
+
: { kind: 'unavailable', code: comparisonCode, reason, remoteName };
|
|
236
|
+
}
|
|
237
|
+
function safeSibling(value) {
|
|
238
|
+
return typeof value === 'string' && /^[a-z0-9](?:[a-z0-9-]{0,62})$/.test(value);
|
|
239
|
+
}
|
|
240
|
+
function safeRemoteName(value) {
|
|
241
|
+
return /^[A-Za-z0-9](?:[A-Za-z0-9._/-]{0,254})$/.test(value)
|
|
242
|
+
&& !value.includes('//') && !value.includes('..') && !value.endsWith('/');
|
|
243
|
+
}
|
|
244
|
+
function comparisonStep(input) {
|
|
245
|
+
const environment = safeObservationEnvironment(input.env);
|
|
246
|
+
if (!environment || !Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > 30_000)
|
|
247
|
+
return undefined;
|
|
248
|
+
return snapshotExecutionStep({
|
|
249
|
+
id: input.id, argv: input.argv, argv0: input.binding.executable.invocationPath,
|
|
250
|
+
cwd: input.binding.checkoutRoot, env: Object.fromEntries(environment), timeoutMs: input.timeoutMs,
|
|
251
|
+
stdoutLimitBytes: COMPARISON_OUTPUT_LIMIT, stderrLimitBytes: COMPARISON_OUTPUT_LIMIT,
|
|
252
|
+
paths: [input.binding.checkoutRoot], ports: [],
|
|
253
|
+
labels: { stackId: input.binding.stackId, source: input.binding.source, checkoutId: input.binding.checkoutId },
|
|
254
|
+
expected: [{ kind: 'path', target: input.binding.checkoutRoot, state: 'repository' }],
|
|
255
|
+
consumedFiles: [{
|
|
256
|
+
path: input.binding.executable.canonicalPath, sha256: input.binding.executable.sha256,
|
|
257
|
+
maxBytes: 536_870_912, argvIndexes: [0], executable: true,
|
|
258
|
+
executionMode: input.binding.executable.executionMode,
|
|
259
|
+
}],
|
|
260
|
+
filesystemChecks: [], compensation: [],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
export function createComparisonRemoteRefStep(input) {
|
|
264
|
+
if (!safeSibling(input.sibling))
|
|
265
|
+
return comparisonUnavailable('comparison sibling identity is unavailable');
|
|
266
|
+
if (input.declaredRemote === undefined) {
|
|
267
|
+
return input.acquire === 'none'
|
|
268
|
+
? { kind: 'not-applicable', code: comparisonCode, reason: `sibling ${input.sibling} has no declared comparison remote` }
|
|
269
|
+
: comparisonUnavailable(`declared repository identity is unavailable for sibling ${input.sibling}`);
|
|
270
|
+
}
|
|
271
|
+
const expected = repositoryIdentity(input.declaredRemote);
|
|
272
|
+
// git status and git remote -v never show why a declared remote has no identity, so the reason names it.
|
|
273
|
+
if (expected.kind !== 'known')
|
|
274
|
+
return comparisonUnavailable(`declared repository identity is unavailable for sibling ${input.sibling}: ${{
|
|
275
|
+
credentialed: 'its remote in cockpit.toml carries credentials; declare it without them',
|
|
276
|
+
unsupported: 'its remote in cockpit.toml is a local path or a URL scheme other than https or ssh, which has no repository identity to compare; declare its https or ssh remote, or remove remote and set acquire = "none"',
|
|
277
|
+
malformed: 'its remote in cockpit.toml is not a repository URL cockpit can read; declare its https or ssh remote',
|
|
278
|
+
unbounded: 'its remote in cockpit.toml is too long to compare; declare its https or ssh remote',
|
|
279
|
+
}[expected.reason]}`);
|
|
280
|
+
const git = input.git && issuedGitResults.get(input.git);
|
|
281
|
+
const executable = resolvedGitExecutable(input.executable);
|
|
282
|
+
if (!git || !executable || git.stackId !== input.stackId || git.checkoutId !== input.checkoutId
|
|
283
|
+
|| git.source !== input.source || git.checkoutRoot !== input.checkoutRoot
|
|
284
|
+
|| git.executable.canonicalPath !== executable.canonicalPath || git.executable.sha256 !== executable.sha256) {
|
|
285
|
+
return comparisonUnavailable(`Git status evidence is unavailable for sibling ${input.sibling}`);
|
|
286
|
+
}
|
|
287
|
+
const { branch, revision, upstream } = git.evidence;
|
|
288
|
+
if (upstream === null || revision === null || branch === '(detached)') {
|
|
289
|
+
return comparisonUnavailable(`comparison upstream is unavailable for sibling ${input.sibling}`);
|
|
290
|
+
}
|
|
291
|
+
if (git.evidence.adjacency !== 'aligned') {
|
|
292
|
+
return comparisonUnavailable(`comparison upstream is ${git.evidence.adjacency} for sibling ${input.sibling}`);
|
|
293
|
+
}
|
|
294
|
+
const binding = { ...git, sibling: input.sibling, expected: expected.identity, branch, revision, upstream };
|
|
295
|
+
const step = comparisonStep({
|
|
296
|
+
id: `git:${input.sibling}:comparison-ref`, binding, env: input.env, timeoutMs: input.timeoutMs,
|
|
297
|
+
argv: [executable.canonicalPath, '-C', input.checkoutRoot, 'for-each-ref',
|
|
298
|
+
'--format=%(objectname)%00%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream)',
|
|
299
|
+
'--count=1', '--', `refs/heads/${branch}`],
|
|
300
|
+
});
|
|
301
|
+
if (!step)
|
|
302
|
+
return comparisonUnavailable(`comparison ref probe is unavailable for sibling ${input.sibling}`);
|
|
303
|
+
const stage = { kind: 'probe', step };
|
|
304
|
+
issuedComparisonRefs.set(stage, binding);
|
|
305
|
+
return stage;
|
|
306
|
+
}
|
|
307
|
+
function decodeComparisonRef(bytes, binding) {
|
|
308
|
+
if (bytes.byteLength === 0 || bytes.byteLength > COMPARISON_OUTPUT_LIMIT)
|
|
309
|
+
return undefined;
|
|
310
|
+
let value;
|
|
311
|
+
try {
|
|
312
|
+
value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
313
|
+
}
|
|
314
|
+
catch { /* Invalid UTF-8 is the only throw; the caller treats the undefined ref as unavailable. */
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
const lines = value.replace(/\n$/, '').split(/\r?\n/);
|
|
318
|
+
if (lines.length !== 1)
|
|
319
|
+
return undefined;
|
|
320
|
+
const fields = lines[0].split('\0');
|
|
321
|
+
if (fields.length !== 5)
|
|
322
|
+
return undefined;
|
|
323
|
+
const [revision, ref, upstream, remoteName, upstreamRef] = fields;
|
|
324
|
+
if (revision !== binding.revision || ref !== `refs/heads/${binding.branch}` || upstream !== binding.upstream
|
|
325
|
+
|| !safeRemoteName(remoteName) || !/^refs\/(?:heads|remotes)\/[^\x00-\x20\x7f]+$/.test(upstreamRef)
|
|
326
|
+
|| Buffer.byteLength(upstreamRef) > 1_024)
|
|
327
|
+
return undefined;
|
|
328
|
+
return { remoteName, upstreamRef };
|
|
329
|
+
}
|
|
330
|
+
export function observeComparisonRemoteRef(stage, outcome) {
|
|
331
|
+
const binding = issuedComparisonRefs.get(stage);
|
|
332
|
+
if (!binding || stage.kind !== 'probe')
|
|
333
|
+
return comparisonUnavailable('comparison ref evidence is unavailable or unauthenticated');
|
|
334
|
+
const authenticated = authenticatedExecutionOutcome(outcome, stage.step);
|
|
335
|
+
if (!authenticated || authenticated.kind !== 'complete') {
|
|
336
|
+
return comparisonUnavailable(`comparison ref evidence is unavailable for sibling ${binding.sibling}`);
|
|
337
|
+
}
|
|
338
|
+
const decoded = decodeComparisonRef(authenticated.stdout, binding);
|
|
339
|
+
if (!decoded)
|
|
340
|
+
return comparisonUnavailable(`comparison upstream is unavailable for sibling ${binding.sibling}`);
|
|
341
|
+
const { remoteName, upstreamRef } = decoded;
|
|
342
|
+
const step = comparisonStep({
|
|
343
|
+
id: `git:${binding.sibling}:comparison-url`, binding, env: stage.step.env, timeoutMs: stage.step.timeoutMs,
|
|
344
|
+
argv: [binding.executable.canonicalPath, '-C', binding.checkoutRoot, 'remote', 'get-url', '--', remoteName],
|
|
345
|
+
});
|
|
346
|
+
if (!step)
|
|
347
|
+
return comparisonUnavailable(`comparison remote probe is unavailable for sibling ${binding.sibling}`, remoteName);
|
|
348
|
+
const next = { kind: 'probe', step, remoteName };
|
|
349
|
+
issuedComparisonUrls.set(next, Object.freeze({ ...binding, remoteName, upstreamRef }));
|
|
350
|
+
return next;
|
|
351
|
+
}
|
|
352
|
+
export function observeComparisonRemoteUrl(stage, outcome) {
|
|
353
|
+
const binding = issuedComparisonUrls.get(stage);
|
|
354
|
+
if (!binding || stage.kind !== 'probe')
|
|
355
|
+
return comparisonUnavailable('comparison remote evidence is unavailable or unauthenticated');
|
|
356
|
+
const authenticated = authenticatedExecutionOutcome(outcome, stage.step);
|
|
357
|
+
if (!authenticated || authenticated.kind !== 'complete') {
|
|
358
|
+
return comparisonUnavailable(`comparison remote evidence is unavailable for sibling ${binding.sibling}`, binding.remoteName);
|
|
359
|
+
}
|
|
360
|
+
if (authenticated.stdout.byteLength === 0 || authenticated.stdout.byteLength > COMPARISON_OUTPUT_LIMIT) {
|
|
361
|
+
return comparisonUnavailable(`comparison remote identity is unavailable for sibling ${binding.sibling}`, binding.remoteName);
|
|
362
|
+
}
|
|
363
|
+
let raw;
|
|
364
|
+
try {
|
|
365
|
+
raw = new TextDecoder('utf-8', { fatal: true }).decode(authenticated.stdout);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// Invalid UTF-8 is the only throw; the message says the remote identity is unavailable.
|
|
369
|
+
return comparisonUnavailable(`comparison remote identity is unavailable for sibling ${binding.sibling}`, binding.remoteName);
|
|
370
|
+
}
|
|
371
|
+
const lines = raw.replace(/\n$/, '').split(/\r?\n/);
|
|
372
|
+
const observed = lines.length === 1 ? repositoryIdentity(lines[0]) : undefined;
|
|
373
|
+
if (!observed || observed.kind !== 'known') {
|
|
374
|
+
return comparisonUnavailable(`comparison remote identity is unavailable for sibling ${binding.sibling}`, binding.remoteName);
|
|
375
|
+
}
|
|
376
|
+
const state = observed.identity.host === binding.expected.host
|
|
377
|
+
&& observed.identity.owner === binding.expected.owner
|
|
378
|
+
&& observed.identity.repository === binding.expected.repository ? 'match' : 'mismatch';
|
|
379
|
+
const step = comparisonStep({
|
|
380
|
+
id: `git:${binding.sibling}:comparison-revalidate`, binding, env: stage.step.env, timeoutMs: stage.step.timeoutMs,
|
|
381
|
+
argv: [binding.executable.canonicalPath, '-C', binding.checkoutRoot, 'for-each-ref',
|
|
382
|
+
'--format=%(objectname)%00%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream)',
|
|
383
|
+
'--', `refs/heads/${binding.branch}`, binding.upstreamRef],
|
|
384
|
+
});
|
|
385
|
+
if (!step)
|
|
386
|
+
return comparisonUnavailable(`comparison revalidation probe is unavailable for sibling ${binding.sibling}`, binding.remoteName);
|
|
387
|
+
const next = { kind: 'probe', step, remoteName: binding.remoteName };
|
|
388
|
+
issuedComparisonRevalidations.set(next, Object.freeze({ ...binding, state }));
|
|
389
|
+
return next;
|
|
390
|
+
}
|
|
391
|
+
export function observeComparisonRemoteRevalidation(stage, outcome) {
|
|
392
|
+
const binding = issuedComparisonRevalidations.get(stage);
|
|
393
|
+
if (!binding || stage.kind !== 'probe') {
|
|
394
|
+
return comparisonUnavailable('comparison revalidation evidence is unavailable or unauthenticated');
|
|
395
|
+
}
|
|
396
|
+
const authenticated = authenticatedExecutionOutcome(outcome, stage.step);
|
|
397
|
+
let stable = false;
|
|
398
|
+
if (authenticated?.kind === 'complete' && authenticated.stdout.byteLength <= COMPARISON_OUTPUT_LIMIT) {
|
|
399
|
+
let raw;
|
|
400
|
+
try {
|
|
401
|
+
raw = new TextDecoder('utf-8', { fatal: true }).decode(authenticated.stdout);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Invalid UTF-8 is the only throw; the message says the upstream could not be authenticated.
|
|
405
|
+
return comparisonUnavailable(`comparison upstream changed while authenticating sibling ${binding.sibling}`, binding.remoteName);
|
|
406
|
+
}
|
|
407
|
+
const lines = raw.replace(/\n$/, '').split(/\r?\n/);
|
|
408
|
+
const branch = lines.filter(line => line.split('\0')[1] === `refs/heads/${binding.branch}`);
|
|
409
|
+
const upstream = lines.filter(line => line.split('\0')[1] === binding.upstreamRef);
|
|
410
|
+
const current = branch.length === 1 ? decodeComparisonRef(Buffer.from(branch[0]), binding) : undefined;
|
|
411
|
+
stable = lines.length === 2 && upstream.length === 1
|
|
412
|
+
&& upstream[0].split('\0').length === 5 && upstream[0].split('\0')[0] === binding.revision
|
|
413
|
+
&& current?.remoteName === binding.remoteName && current.upstreamRef === binding.upstreamRef;
|
|
414
|
+
}
|
|
415
|
+
if (!stable) {
|
|
416
|
+
return comparisonUnavailable(`comparison upstream changed while authenticating sibling ${binding.sibling}`, binding.remoteName);
|
|
417
|
+
}
|
|
418
|
+
return binding.state === 'match'
|
|
419
|
+
? { kind: 'complete', code: comparisonCode, state: binding.state, remoteName: binding.remoteName }
|
|
420
|
+
: {
|
|
421
|
+
kind: 'complete', code: comparisonCode, state: binding.state, remoteName: binding.remoteName,
|
|
422
|
+
reason: `comparison remote ${binding.remoteName} does not match the declared repository for sibling ${binding.sibling}`,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
export function observeRunningCheckout(topology, checkoutProjection, runtimeSha256, observedAt) {
|
|
426
|
+
const checkout = consumeSetupCheckoutProjection(checkoutProjection);
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
const observed = Date.parse(observedAt);
|
|
429
|
+
if (!checkout || !isRuntimeTopologyEvidence(topology) || !isCanonicalTimestamp(observedAt)
|
|
430
|
+
|| observed > now || observed < Date.parse(checkout.observedAt)
|
|
431
|
+
|| now > Date.parse(checkout.validThrough)
|
|
432
|
+
|| observed < Date.parse(topology.observationWindow.startedAt)
|
|
433
|
+
|| observed > Date.parse(topology.observationWindow.expiresAt)) {
|
|
434
|
+
return { kind: 'unavailable', reason: 'running checkout needs current authenticated topology and checkout evidence' };
|
|
435
|
+
}
|
|
436
|
+
const expectedStack = deriveStackId({ cockpitId: checkout.cockpitId, checkoutId: checkout.checkoutId, variant: { kind: 'shared' } });
|
|
437
|
+
const measured = { stackId: topology.stackId, configSha256: topology.configSha256,
|
|
438
|
+
checkoutMatches: topology.cwd === dirname(checkout.source.path) };
|
|
439
|
+
const expected = { stackId: expectedStack, configSha256: runtimeSha256, checkoutMatches: true };
|
|
440
|
+
const observation = {
|
|
441
|
+
code: ENVIRONMENT_CODES.RUNNING_CHECKOUT_MISMATCH, owner: OWNER, stackId: expectedStack,
|
|
442
|
+
source: `${checkout.source.path}:running-checkout`, observed: { state: 'available', value: measured, observedAt },
|
|
443
|
+
expectation: { kind: 'equal', value: expected }, applicability: { state: 'required', posture: 'running-stack' },
|
|
444
|
+
freshness: { state: 'current' }, remedy: 'restart the stack from the current checkout and configuration',
|
|
445
|
+
};
|
|
446
|
+
return { kind: 'complete', observation: issueObservation(observation) };
|
|
447
|
+
}
|
|
448
|
+
// Git exits 128 both outside any repository and inside one it refuses to read; only its message
|
|
449
|
+
// tells them apart (git 2.54.0, 2026-11-03). Each step runs with no LANG, so Git does not translate
|
|
450
|
+
// it, and a message this does not recognise is a failure, never an absent repository.
|
|
451
|
+
const NOT_A_REPOSITORY = /^fatal: not a git repository\b/;
|
|
452
|
+
const DUBIOUS_OWNERSHIP = /^fatal: detected dubious ownership in repository at '(.+)'$/;
|
|
453
|
+
/**
|
|
454
|
+
* Why a Git step that exited non-zero failed: the directory is in no repository, or Git refused to
|
|
455
|
+
* read the repository. A refusal names the cause and its remedy; ownership is a trust decision about
|
|
456
|
+
* another user's files, so its remedy goes through a person.
|
|
457
|
+
*/
|
|
458
|
+
export function gitRepositoryRefusal(stderr) {
|
|
459
|
+
// Git may warn before the line that ends it; that `fatal:` line states the cause.
|
|
460
|
+
const lines = stderr.toString('utf8').split(/\r?\n/);
|
|
461
|
+
const line = (lines.find((candidate) => candidate.startsWith('fatal: ')) ?? lines[0])
|
|
462
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').replace(/(\w+:\/\/)[^/@\s]+@/g, '$1[redacted]@').slice(0, 512);
|
|
463
|
+
if (NOT_A_REPOSITORY.test(line))
|
|
464
|
+
return { kind: 'not-a-repository' };
|
|
465
|
+
const owned = DUBIOUS_OWNERSHIP.exec(line)?.[1];
|
|
466
|
+
if (owned !== undefined)
|
|
467
|
+
return { kind: 'refused',
|
|
468
|
+
reason: `Git refuses to read ${owned} because another user owns it (dubious ownership)`,
|
|
469
|
+
fix: `ask the person who owns this machine whether to trust ${owned}; only if they agree, run ${formatShellCommand(['git', 'config', '--global', '--add', 'safe.directory', owned])}, then rerun this command` };
|
|
470
|
+
return { kind: 'refused', reason: line === '' ? 'Git failed without a message' : `Git failed: ${line}`,
|
|
471
|
+
fix: 'fix what Git reports, then rerun this command' };
|
|
472
|
+
}
|
|
473
|
+
// The names the guard denies agents from reading: `.env` and `.env.<suffix>`, except the templates
|
|
474
|
+
// meant to be committed. Values are never read; only Git's index is asked which names it tracks.
|
|
475
|
+
const SECRET_FILE = /^\.env(?:\.[^/]+)?$/;
|
|
476
|
+
const SECRET_TEMPLATE = /\.(?:example|sample|template|dist)$/;
|
|
477
|
+
const TRACKED_SECRET_LIMIT = 65_536;
|
|
478
|
+
/**
|
|
479
|
+
* Secret-shaped files a repository commits. The guard keeps agents from reading them, which
|
|
480
|
+
* protects the session but not the history: every clone already holds the values, and nothing
|
|
481
|
+
* told the person so. Reports paths only.
|
|
482
|
+
*/
|
|
483
|
+
export async function inspectTrackedSecretFiles(input, execute) {
|
|
484
|
+
const executable = resolvedGitExecutable(input.executable);
|
|
485
|
+
if (!executable)
|
|
486
|
+
throw new RefusalError('tracked secret inspection needs a resolved Git executable');
|
|
487
|
+
// One independent read-only Git call per repository; the findings are joined in repository order.
|
|
488
|
+
const perRepository = await Promise.all(input.repositories.map(async (repository) => {
|
|
489
|
+
const step = snapshotExecutionStep({
|
|
490
|
+
id: `git:tracked-secrets:${repository.slug}`,
|
|
491
|
+
argv: [executable.canonicalPath, '-C', repository.root, 'ls-files', '-z', '--', '.env', '*/.env', '.env.*', '*/.env.*'],
|
|
492
|
+
argv0: executable.invocationPath, cwd: repository.root, env: {}, timeoutMs: input.timeoutMs,
|
|
493
|
+
stdoutLimitBytes: TRACKED_SECRET_LIMIT, stderrLimitBytes: COMPARISON_OUTPUT_LIMIT,
|
|
494
|
+
paths: [repository.root], ports: [], labels: { sibling: repository.slug },
|
|
495
|
+
expected: [{ kind: 'path', target: repository.root, state: 'repository' }],
|
|
496
|
+
consumedFiles: [{ path: executable.canonicalPath, sha256: executable.sha256, maxBytes: 536_870_912,
|
|
497
|
+
argvIndexes: [0], executable: true, executionMode: executable.executionMode }],
|
|
498
|
+
filesystemChecks: [], compensation: [],
|
|
499
|
+
});
|
|
500
|
+
if (!step)
|
|
501
|
+
throw new RefusalError(`tracked secret inspection could not be planned for ${repository.slug}`);
|
|
502
|
+
const outcome = authenticatedExecutionOutcome(await execute(step), step);
|
|
503
|
+
// A directory that is not a repository tracks nothing; that gap is reported where Git state is.
|
|
504
|
+
// A repository Git refuses to read may track anything, so it is no clean result.
|
|
505
|
+
const refused = outcome?.kind === 'refusal' && outcome.code === ENVIRONMENT_CODES.EXECUTION_STEP_FAILED
|
|
506
|
+
? gitRepositoryRefusal(outcome.stderr) : undefined;
|
|
507
|
+
if (refused?.kind === 'not-a-repository')
|
|
508
|
+
return [];
|
|
509
|
+
if (refused) {
|
|
510
|
+
return [{
|
|
511
|
+
severity: 'warning',
|
|
512
|
+
code: ENVIRONMENT_CODES.EVIDENCE_UNAVAILABLE,
|
|
513
|
+
message: `whether sibling ${repository.slug} commits secret-shaped files could not be checked: ${refused.reason}`,
|
|
514
|
+
target: `sibling:${repository.slug}`,
|
|
515
|
+
fix: refused.fix,
|
|
516
|
+
}];
|
|
517
|
+
}
|
|
518
|
+
if (!outcome || outcome.kind !== 'complete') {
|
|
519
|
+
return [{
|
|
520
|
+
severity: 'warning',
|
|
521
|
+
code: ENVIRONMENT_CODES.EVIDENCE_UNAVAILABLE,
|
|
522
|
+
message: `whether sibling ${repository.slug} commits secret-shaped files could not be checked (${outcome?.code ?? 'unauthenticated Git outcome'})`,
|
|
523
|
+
target: `sibling:${repository.slug}`,
|
|
524
|
+
fix: `run git -C ${repository.root} ls-files to see why Git cannot list the repository, fix that, then rerun this command`,
|
|
525
|
+
}];
|
|
526
|
+
}
|
|
527
|
+
const tracked = outcome.stdout.toString('utf8').split('\0').filter((path) => {
|
|
528
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
529
|
+
return path !== '' && SECRET_FILE.test(name) && !SECRET_TEMPLATE.test(name);
|
|
530
|
+
});
|
|
531
|
+
if (tracked.length === 0)
|
|
532
|
+
return [];
|
|
533
|
+
const shown = tracked.slice(0, 8).join(', ');
|
|
534
|
+
return [{
|
|
535
|
+
severity: 'warning',
|
|
536
|
+
// A literal, so the diagnostic inventory captures the fix beside the code.
|
|
537
|
+
code: 'SECRET_FILE_TRACKED',
|
|
538
|
+
message: `sibling ${repository.slug} commits ${tracked.length === 1 ? 'a secret-shaped file' : `${tracked.length} secret-shaped files`} (${shown}${tracked.length > 8 ? ', ...' : ''}); the guard keeps agents from reading ${tracked.length === 1 ? 'it' : 'them'}, but every clone of the repository already holds the values. cockpit did not read ${tracked.length === 1 ? 'it' : 'them'}`,
|
|
539
|
+
target: `sibling:${repository.slug}`,
|
|
540
|
+
fix: 'a person decides with the repository owner: stop tracking each file with git rm --cached <file>, add it to .gitignore, commit, and rotate any credential it held; commit a .env.example with the variable names instead',
|
|
541
|
+
}];
|
|
542
|
+
}));
|
|
543
|
+
return perRepository.flat();
|
|
544
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExecuteProbeOutcome } from '../environment-exec/execute.js';
|
|
2
|
+
import { type DoctorCommandAdapters } from './application/doctor.js';
|
|
3
|
+
import { type PreflightCommandAdapters } from './application/health.js';
|
|
4
|
+
import { type ExecutionStep } from './contract.js';
|
|
5
|
+
export interface HealthCommandCompositionInput {
|
|
6
|
+
readonly configPath: string;
|
|
7
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
8
|
+
readonly timeoutMs: number;
|
|
9
|
+
}
|
|
10
|
+
export type HealthCommandAdapterComposition = PreflightCommandAdapters & DoctorCommandAdapters;
|
|
11
|
+
export declare function createHealthCommandAdapters(rawInput: HealthCommandCompositionInput, execute: (step: ExecutionStep) => Promise<ExecuteProbeOutcome>): HealthCommandAdapterComposition;
|