@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,270 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Drives one browser operation for packages/cockpit/src/browser/surfaces.ts through the project's own
|
|
4
|
+
// Playwright module: navigate to a surface, then report it or save a screenshot into the artifact
|
|
5
|
+
// root. Runs from a staged snapshot, so it must stay self-contained.
|
|
6
|
+
//
|
|
7
|
+
// argv: named flags (--operation, --engine, --connection, --url, --origin, --timeout, --module,
|
|
8
|
+
// --module-proof, --control-authorities, --control-ports, --screenshot-limit, and the screenshot
|
|
9
|
+
// artifact flags); see operationStep in surfaces.ts for the full set.
|
|
10
|
+
// stdout on success: one JSON 'cockpit-browser-observation/v1' object. A drive observation also carries
|
|
11
|
+
// per-route diagnostics: counts of console errors, uncaught page errors and failed or >= 400 requests to
|
|
12
|
+
// the application origin, a count of warnings, aborted requests and third-party traffic, and a capped
|
|
13
|
+
// list of entries. Entries hold only identifiers: a request's method, URL path (no query) and status or
|
|
14
|
+
// network error code; a console error's script path and line; an uncaught error's class name. Bodies,
|
|
15
|
+
// headers and cookies are never read. Message text is read only when cockpit passes --messages, which it
|
|
16
|
+
// does only for a data posture that is isolated, not restored and not reaching production; the drive
|
|
17
|
+
// observation then carries a capped `messages` list of console error and uncaught error text.
|
|
18
|
+
// stderr on failure: one JSON 'cockpit-browser-failure/v1' object naming the phase and its category,
|
|
19
|
+
// and, when one is known, a fixed-vocabulary reason (a browser network error code such as
|
|
20
|
+
// ERR_CONNECTION_REFUSED, HTTP_<status> or TIMEOUT), exit code 1. The failure deliberately carries no
|
|
21
|
+
// error text: page content, URLs and storage state must not leak into cockpit's output.
|
|
22
|
+
//
|
|
23
|
+
// Requests to cockpit's own browser-control authorities and nested top-level navigations are blocked
|
|
24
|
+
// so the page under test cannot reach the control plane that drives it. Requests to a loopback or
|
|
25
|
+
// resolver-mapped host outside the application origin are blocked too: such an address is the host
|
|
26
|
+
// (through the remote runner's network exposure, or directly for a local browser), and the operation
|
|
27
|
+
// was given only the application. Playwright does not route a request the browser follows as a
|
|
28
|
+
// redirect, so a redirect to another loopback port still reaches it; this blocks direct requests only.
|
|
29
|
+
const fs = require('node:fs')
|
|
30
|
+
const crypto = require('node:crypto')
|
|
31
|
+
const path = require('node:path')
|
|
32
|
+
const args = process.argv.slice(2)
|
|
33
|
+
const one = name => { const index = args.indexOf(name); if (index < 0 || index + 1 >= args.length) throw new Error('missing argument'); return args[index + 1] }
|
|
34
|
+
const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex')
|
|
35
|
+
const MAX_ENTRIES = 20
|
|
36
|
+
const MAX_PATH = 200
|
|
37
|
+
const MAX_MESSAGES = 5
|
|
38
|
+
const MAX_MESSAGE_BYTES = 240
|
|
39
|
+
const SETTLE_MS = 1000
|
|
40
|
+
// Records what the page did wrong; what it said only with withMessages: see the stdout contract above.
|
|
41
|
+
function collectDiagnostics(page, origin, withMessages) {
|
|
42
|
+
const counts = { consoleErrors: 0, consoleWarnings: 0, pageErrors: 0, failedRequests: 0, abortedRequests: 0 }
|
|
43
|
+
const thirdParty = { requests: 0, failed: 0 }
|
|
44
|
+
const entries = []
|
|
45
|
+
let omitted = 0
|
|
46
|
+
const messages = []
|
|
47
|
+
// Control characters are replaced so message text stays one line of plain text; the cap is in bytes.
|
|
48
|
+
const excerpt = value => {
|
|
49
|
+
// A character is at least one byte, so the first MAX_MESSAGE_BYTES characters hold the longest prefix within the cap.
|
|
50
|
+
let text = String(value).replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, MAX_MESSAGE_BYTES)
|
|
51
|
+
while (Buffer.byteLength(text) > MAX_MESSAGE_BYTES) text = text.slice(0, -1)
|
|
52
|
+
return text
|
|
53
|
+
}
|
|
54
|
+
const keep = (kind, value) => { if (withMessages && messages.length < MAX_MESSAGES) { const text = excerpt(value); if (text) messages.push({ kind, text }) } }
|
|
55
|
+
const add = entry => { if (entries.length < MAX_ENTRIES) entries.push(entry); else omitted += 1 }
|
|
56
|
+
// `new URL` throws TypeError for an address it cannot parse; anything else is a real fault.
|
|
57
|
+
const parse = value => {
|
|
58
|
+
let parsed
|
|
59
|
+
try { parsed = new URL(value) } catch (error) { if (error instanceof TypeError) return undefined; throw error }
|
|
60
|
+
return ['http:', 'https:'].includes(parsed.protocol) ? parsed : undefined
|
|
61
|
+
}
|
|
62
|
+
const pathOf = parsed => parsed.pathname.slice(0, MAX_PATH)
|
|
63
|
+
page.on('console', message => {
|
|
64
|
+
if (message.type() === 'warning') counts.consoleWarnings += 1
|
|
65
|
+
if (message.type() !== 'error') return
|
|
66
|
+
counts.consoleErrors += 1
|
|
67
|
+
keep('console', message.text())
|
|
68
|
+
const location = message.location()
|
|
69
|
+
const parsed = location && typeof location.url === 'string' ? parse(location.url) : undefined
|
|
70
|
+
const own = parsed && parsed.origin === origin
|
|
71
|
+
add({ kind: 'console', ...(own ? { path: pathOf(parsed) } : {}),
|
|
72
|
+
...(own && Number.isInteger(location.lineNumber) && location.lineNumber >= 0 ? { line: location.lineNumber } : {}) })
|
|
73
|
+
})
|
|
74
|
+
page.on('pageerror', error => {
|
|
75
|
+
counts.pageErrors += 1
|
|
76
|
+
keep('pageerror', error && typeof error.message === 'string' ? error.message : 'uncaught error')
|
|
77
|
+
add({ kind: 'pageerror', name: error && typeof error.name === 'string' && /^[A-Za-z0-9_$.]{1,64}$/.test(error.name) ? error.name : 'Error' })
|
|
78
|
+
})
|
|
79
|
+
page.on('request', request => { const parsed = parse(request.url()); if (parsed && parsed.origin !== origin) thirdParty.requests += 1 })
|
|
80
|
+
page.on('requestfailed', request => {
|
|
81
|
+
const parsed = parse(request.url())
|
|
82
|
+
if (!parsed) return
|
|
83
|
+
const failure = request.failure()
|
|
84
|
+
const code = failure && typeof failure.errorText === 'string' ? /net::(ERR_[A-Z0-9_]{1,64})\b/.exec(failure.errorText) : null
|
|
85
|
+
// A request the browser cancelled itself, such as one left behind by a navigation, is not a failure.
|
|
86
|
+
if (code && code[1] === 'ERR_ABORTED') { counts.abortedRequests += 1; return }
|
|
87
|
+
if (parsed.origin !== origin) { thirdParty.failed += 1; return }
|
|
88
|
+
counts.failedRequests += 1
|
|
89
|
+
add({ kind: 'request', method: request.method(), path: pathOf(parsed), failure: code ? code[1] : 'ERR_UNKNOWN' })
|
|
90
|
+
})
|
|
91
|
+
page.on('response', response => {
|
|
92
|
+
const status = response.status()
|
|
93
|
+
if (status < 400) return
|
|
94
|
+
const request = response.request()
|
|
95
|
+
// A main-document failure already refuses the drive, so it is not reported twice.
|
|
96
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) return
|
|
97
|
+
const parsed = parse(response.url())
|
|
98
|
+
if (!parsed) return
|
|
99
|
+
if (parsed.origin !== origin) { thirdParty.failed += 1; return }
|
|
100
|
+
counts.failedRequests += 1
|
|
101
|
+
add({ kind: 'request', method: request.method(), path: pathOf(parsed), status })
|
|
102
|
+
})
|
|
103
|
+
return () => ({ ...counts, thirdParty, entries, omitted, ...(withMessages ? { messages } : {}) })
|
|
104
|
+
}
|
|
105
|
+
async function main() {
|
|
106
|
+
const moduleProof = fs.readFileSync(one('--module-proof'))
|
|
107
|
+
const modulePath = one('--module')
|
|
108
|
+
const moduleBytes = fs.readFileSync(modulePath)
|
|
109
|
+
if (digest(moduleProof) !== digest(moduleBytes)) throw new Error('module changed')
|
|
110
|
+
const api = require(modulePath)
|
|
111
|
+
const engine = one('--engine')
|
|
112
|
+
const browserType = api && api[engine]
|
|
113
|
+
const connection = one('--connection')
|
|
114
|
+
if (!['chromium', 'firefox', 'webkit'].includes(engine) || !browserType
|
|
115
|
+
|| connection === 'remote' && typeof browserType.connect !== 'function'
|
|
116
|
+
|| connection === 'local' && typeof browserType.launch !== 'function') throw new Error('browser module lacks selected engine operation')
|
|
117
|
+
const operation = one('--operation')
|
|
118
|
+
const url = one('--url')
|
|
119
|
+
const timeout = Number(one('--timeout'))
|
|
120
|
+
const deadline = Date.now() + timeout
|
|
121
|
+
const remaining = () => { const value = deadline - Date.now(); if (value <= 0) throw new Error('browser operation deadline expired'); return value }
|
|
122
|
+
const resolverRule = args.includes('--resolver-rule') ? one('--resolver-rule') : undefined
|
|
123
|
+
const categories = { connect: 'availability', context: 'setup', navigation: 'navigation',
|
|
124
|
+
screenshot: 'observation', artifact: 'artifact', cleanup: 'cleanup' }
|
|
125
|
+
let browser, context, page, observation, failurePhase, failureReason, httpStatus, cleanupFailed = false, phase = 'connect'
|
|
126
|
+
try {
|
|
127
|
+
browser = connection === 'remote'
|
|
128
|
+
? await browserType.connect(one('--endpoint'), { timeout: remaining(), exposeNetwork: one('--expose-network') })
|
|
129
|
+
: await browserType.launch({ headless: true, timeout: remaining(),
|
|
130
|
+
...(resolverRule ? { args: ['--host-resolver-rules=' + resolverRule] } : {}) })
|
|
131
|
+
phase = 'context'
|
|
132
|
+
const state = process.env.COCKPIT_BROWSER_STORAGE_STATE
|
|
133
|
+
const stateProof = state ? fs.readFileSync(one('--state-proof')) : undefined
|
|
134
|
+
if (state && digest(fs.readFileSync(state)) !== digest(stateProof)) throw new Error('storage state changed')
|
|
135
|
+
const storageState = stateProof ? JSON.parse(stateProof.toString('utf8')) : undefined
|
|
136
|
+
context = await browser.newContext({ ...(storageState ? { storageState } : {}), serviceWorkers: 'block' })
|
|
137
|
+
const controlAuthorities = new Set(JSON.parse(one('--control-authorities')))
|
|
138
|
+
const controlPorts = new Set(JSON.parse(one('--control-ports')).map(String))
|
|
139
|
+
// An address that does not parse cannot be shown to be safe, so both guards treat it as blocked
|
|
140
|
+
// rather than naming the error, which would quote the address.
|
|
141
|
+
const isControlAuthority = value => { try {
|
|
142
|
+
const parsed = new URL(value)
|
|
143
|
+
const port = parsed.port || (parsed.protocol === 'http:' || parsed.protocol === 'ws:' ? '80'
|
|
144
|
+
: parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? '443' : '')
|
|
145
|
+
return controlAuthorities.has(parsed.hostname + ':' + port) || controlPorts.has(port)
|
|
146
|
+
} catch { return true } }
|
|
147
|
+
const origin = one('--origin')
|
|
148
|
+
const mappedHosts = new Set((resolverRule || '').split(',').map(rule => rule.trim().split(/\s+/))
|
|
149
|
+
.filter(parts => parts[0] === 'MAP' && parts[1]).map(parts => parts[1].toLowerCase()))
|
|
150
|
+
const isForeignHost = value => { try {
|
|
151
|
+
const parsed = new URL(value)
|
|
152
|
+
const scheme = { 'ws:': 'http:', 'wss:': 'https:' }[parsed.protocol] || parsed.protocol
|
|
153
|
+
if (!['http:', 'https:'].includes(scheme) || new URL(scheme + '//' + parsed.host).origin === origin) return false
|
|
154
|
+
const hostname = parsed.hostname
|
|
155
|
+
return hostname === 'localhost' || hostname.endsWith('.localhost') || /^127\./.test(hostname) || hostname === '0.0.0.0'
|
|
156
|
+
|| hostname === '[::1]' || hostname.startsWith('[::ffff:7f') || mappedHosts.has(hostname)
|
|
157
|
+
} catch { return true } }
|
|
158
|
+
let mainNavigationAttempts = 0
|
|
159
|
+
await context.route('**/*', async (route, request) => {
|
|
160
|
+
if (isControlAuthority(request.url()) || isForeignHost(request.url())) return route.abort('blockedbyclient')
|
|
161
|
+
const frame = request.frame()
|
|
162
|
+
const mainNavigation = request.isNavigationRequest() && page && frame === page.mainFrame()
|
|
163
|
+
if (request.isNavigationRequest() && !mainNavigation
|
|
164
|
+
&& (!frame || typeof frame.parentFrame !== 'function' || frame.parentFrame() === null)) return route.abort('blockedbyclient')
|
|
165
|
+
if (mainNavigation) mainNavigationAttempts += 1
|
|
166
|
+
await route.continue()
|
|
167
|
+
})
|
|
168
|
+
if (typeof context.routeWebSocket !== 'function') throw new Error('browser module lacks WebSocket routing')
|
|
169
|
+
await context.routeWebSocket(() => true, webSocket => {
|
|
170
|
+
if (isControlAuthority(webSocket.url()) || isForeignHost(webSocket.url())) return webSocket.close({ code: 1008, reason: 'blocked by the cockpit browser operation' })
|
|
171
|
+
webSocket.connectToServer()
|
|
172
|
+
})
|
|
173
|
+
phase = 'navigation'
|
|
174
|
+
page = await context.newPage()
|
|
175
|
+
const diagnostics = operation === 'drive' ? collectDiagnostics(page, origin, args.includes('--messages')) : undefined
|
|
176
|
+
let documentEpoch = 0
|
|
177
|
+
let documentResponse
|
|
178
|
+
page.on('framenavigated', frame => { if (frame === page.mainFrame()) documentEpoch += 1 })
|
|
179
|
+
page.on('response', response => {
|
|
180
|
+
const request = response.request()
|
|
181
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) documentResponse = response
|
|
182
|
+
})
|
|
183
|
+
const currentDocument = () => {
|
|
184
|
+
const displayed = new URL(page.url())
|
|
185
|
+
if (displayed.origin !== origin || displayed.username || displayed.password || displayed.hash) throw new Error('navigation left application origin')
|
|
186
|
+
if (!documentResponse || typeof documentResponse.ok !== 'function' || !documentResponse.ok()
|
|
187
|
+
|| typeof documentResponse.url !== 'function' || documentResponse.url() !== displayed.href) {
|
|
188
|
+
throw new Error('displayed document lacks a successful terminal HTTP response')
|
|
189
|
+
}
|
|
190
|
+
return { url: displayed.href, epoch: documentEpoch, attempts: mainNavigationAttempts }
|
|
191
|
+
}
|
|
192
|
+
const response = await page.goto(url, { timeout: remaining() })
|
|
193
|
+
if (response && typeof response.status === 'function') httpStatus = response.status()
|
|
194
|
+
if (!response || typeof response.ok !== 'function' || !response.ok()) throw new Error('navigation did not return a successful HTTP response')
|
|
195
|
+
documentResponse = response
|
|
196
|
+
const initialDocument = currentDocument()
|
|
197
|
+
if (operation === 'drive') {
|
|
198
|
+
// Errors raised just after load are findings too. An application that polls never goes idle,
|
|
199
|
+
// so running out the short wait is not a failure; any other error still propagates.
|
|
200
|
+
try { await page.waitForLoadState('networkidle', { timeout: Math.min(remaining(), SETTLE_MS) }) }
|
|
201
|
+
catch (error) { if (!error || error.name !== 'TimeoutError') throw error }
|
|
202
|
+
observation = { schema: 'cockpit-browser-observation/v1', operation, finalUrl: initialDocument.url, diagnostics: diagnostics() }
|
|
203
|
+
}
|
|
204
|
+
else if (operation === 'screenshot') {
|
|
205
|
+
phase = 'screenshot'
|
|
206
|
+
const displayedBefore = currentDocument()
|
|
207
|
+
const bytes = await page.screenshot()
|
|
208
|
+
if (!Buffer.isBuffer(bytes) || bytes.byteLength > Number(one('--screenshot-limit'))) throw new Error('screenshot observation exceeds bound')
|
|
209
|
+
const displayedAfter = currentDocument()
|
|
210
|
+
if (displayedAfter.url !== displayedBefore.url || displayedAfter.epoch !== displayedBefore.epoch
|
|
211
|
+
|| displayedAfter.attempts !== displayedBefore.attempts) throw new Error('navigation changed during screenshot observation')
|
|
212
|
+
const output = one('--output')
|
|
213
|
+
const root = one('--artifact-root')
|
|
214
|
+
const expectedDev = Number(one('--artifact-dev'))
|
|
215
|
+
const expectedIno = Number(one('--artifact-ino'))
|
|
216
|
+
const expectedBirthtimeNs = one('--artifact-birthtime-ns')
|
|
217
|
+
phase = 'artifact'
|
|
218
|
+
if (path.dirname(output) !== root || path.basename(output) !== one('--output-name')) throw new Error('screenshot output escaped root')
|
|
219
|
+
let rootDescriptor, dotDescriptor, descriptor
|
|
220
|
+
try {
|
|
221
|
+
rootDescriptor = fs.openSync(root, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW)
|
|
222
|
+
const held = fs.fstatSync(rootDescriptor)
|
|
223
|
+
const named = fs.lstatSync(root)
|
|
224
|
+
// ext4 reuses a freed inode for the next directory created, so a root recreated since
|
|
225
|
+
// planning can carry the planned device and inode; birth time tells the two apart.
|
|
226
|
+
if (!held.isDirectory() || named.isSymbolicLink() || held.dev !== expectedDev || held.ino !== expectedIno
|
|
227
|
+
|| String(fs.fstatSync(rootDescriptor, { bigint: true }).birthtimeNs) !== expectedBirthtimeNs
|
|
228
|
+
|| named.dev !== held.dev || named.ino !== held.ino) throw new Error('artifact root changed')
|
|
229
|
+
process.chdir(root)
|
|
230
|
+
dotDescriptor = fs.openSync('.', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW)
|
|
231
|
+
const anchored = fs.fstatSync(dotDescriptor)
|
|
232
|
+
if (anchored.dev !== held.dev || anchored.ino !== held.ino) throw new Error('artifact root changed')
|
|
233
|
+
descriptor = fs.openSync(one('--output-name'), fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600)
|
|
234
|
+
fs.fchmodSync(descriptor, 0o600); fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor)
|
|
235
|
+
const after = fs.fstatSync(dotDescriptor)
|
|
236
|
+
if (after.dev !== held.dev || after.ino !== held.ino) throw new Error('artifact root changed')
|
|
237
|
+
} finally {
|
|
238
|
+
if (descriptor !== undefined) fs.closeSync(descriptor)
|
|
239
|
+
if (dotDescriptor !== undefined) fs.closeSync(dotDescriptor)
|
|
240
|
+
if (rootDescriptor !== undefined) fs.closeSync(rootDescriptor)
|
|
241
|
+
}
|
|
242
|
+
observation = { schema: 'cockpit-browser-observation/v1', operation, finalUrl: displayedAfter.url, evidence: { sha256: digest(bytes), byteLength: bytes.byteLength } }
|
|
243
|
+
} else throw new Error('unsupported operation')
|
|
244
|
+
} catch (error) {
|
|
245
|
+
failurePhase = phase
|
|
246
|
+
// Only identifiers leave this process: Chromium's net error code, the HTTP status or a timeout.
|
|
247
|
+
const message = error && typeof error.message === 'string' ? error.message : ''
|
|
248
|
+
const network = /net::(ERR_[A-Z0-9_]{1,64})\b/.exec(message)
|
|
249
|
+
failureReason = network ? network[1]
|
|
250
|
+
: error && error.name === 'TimeoutError' ? 'TIMEOUT'
|
|
251
|
+
: phase === 'navigation' && Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599
|
|
252
|
+
&& (httpStatus < 200 || httpStatus > 299) ? 'HTTP_' + httpStatus
|
|
253
|
+
: undefined
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
for (const value of [page, context, browser]) if (value && typeof value.close === 'function') {
|
|
257
|
+
// The failure record carries a cleanup flag and never a message, so the error is not kept.
|
|
258
|
+
try { await value.close() } catch { cleanupFailed = true }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (failurePhase || cleanupFailed || !observation) {
|
|
262
|
+
const failedAt = failurePhase || 'cleanup'
|
|
263
|
+
process.stderr.write(JSON.stringify({ schema: 'cockpit-browser-failure/v1', phase: failedAt,
|
|
264
|
+
category: categories[failedAt], cleanup: cleanupFailed, ...(failurePhase && failureReason ? { reason: failureReason } : {}) }))
|
|
265
|
+
process.exitCode = 1
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
process.stdout.write(JSON.stringify(observation))
|
|
269
|
+
}
|
|
270
|
+
main().catch(() => { process.stderr.write('{"schema":"cockpit-browser-failure/v1","phase":"context","category":"setup","cleanup":false}'); process.exitCode = 1 })
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Reads and replaces the executor's fence marker: the small JSON file that records whether a fenced
|
|
4
|
+
// attempt is active, terminating, terminated or cancelled. The executor
|
|
5
|
+
// (packages/cockpit/src/environment-exec/execute.ts), libexec/fenced-supervisor.cjs and
|
|
6
|
+
// libexec/fenced-group-runner.cjs all read it here so they agree on what an authentic marker is.
|
|
7
|
+
//
|
|
8
|
+
// A marker is accepted only when the bytes came from the file that is named now: it is opened without
|
|
9
|
+
// following links, read whole within a small bound, and the descriptor, the named entry and the parent
|
|
10
|
+
// directory must still agree afterwards. Each marker records its own device and inode, so a copy or a
|
|
11
|
+
// marker renamed into place from elsewhere does not match the file it claims to be. This module
|
|
12
|
+
// decides nothing about status; callers check the fields they need.
|
|
13
|
+
const fs = require('node:fs')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
|
|
16
|
+
const FENCE_MARKER_MAX_BYTES = 1024n
|
|
17
|
+
|
|
18
|
+
function readFenceMarkerFile(markerPath) {
|
|
19
|
+
let fd
|
|
20
|
+
try {
|
|
21
|
+
fd = fs.openSync(markerPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
|
|
22
|
+
const before = fs.fstatSync(fd, { bigint: true })
|
|
23
|
+
if (!before.isFile() || before.size > FENCE_MARKER_MAX_BYTES) return undefined
|
|
24
|
+
const bytes = Buffer.alloc(Number(before.size) + 1)
|
|
25
|
+
const count = fs.readSync(fd, bytes, 0, bytes.length, 0)
|
|
26
|
+
const after = fs.fstatSync(fd, { bigint: true })
|
|
27
|
+
const named = fs.lstatSync(markerPath, { bigint: true })
|
|
28
|
+
const parent = fs.lstatSync(path.dirname(markerPath), { bigint: true })
|
|
29
|
+
if (count !== Number(before.size) || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
|
|
30
|
+
|| !named.isFile() || named.dev !== before.dev || named.ino !== before.ino
|
|
31
|
+
|| !parent.isDirectory() || parent.isSymbolicLink()) return undefined
|
|
32
|
+
return {
|
|
33
|
+
value: JSON.parse(bytes.subarray(0, count).toString('utf8')),
|
|
34
|
+
identity: {
|
|
35
|
+
markerDev: String(before.dev), markerIno: String(before.ino),
|
|
36
|
+
parentDev: String(parent.dev), parentIno: String(parent.ino),
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Absent, unreadable and malformed markers all mean "no authentic marker"; every caller refuses
|
|
41
|
+
// on that answer, and the fence protocol has no channel that could carry the cause further.
|
|
42
|
+
return undefined
|
|
43
|
+
} finally {
|
|
44
|
+
if (fd !== undefined) try { fs.closeSync(fd) } catch { /* the answer above does not depend on the descriptor */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The marker the executor published for this supervisor, unchanged since the executor named it in
|
|
49
|
+
// its release line. `approved` carries supervisorPid plus the four identity fields from that line.
|
|
50
|
+
function readApprovedActiveMarker(fence, approved) {
|
|
51
|
+
const read = readFenceMarkerFile(fence.markerPath)
|
|
52
|
+
if (!read) return undefined
|
|
53
|
+
const { value: marker, identity } = read
|
|
54
|
+
if (marker === null || typeof marker !== 'object'
|
|
55
|
+
|| marker.format !== 'cockpit-executor/v1' || marker.attemptId !== fence.attemptId
|
|
56
|
+
|| marker.supervisorPid !== approved.supervisorPid || marker.status !== 'active'
|
|
57
|
+
|| marker.markerDev !== approved.markerDev || marker.markerIno !== approved.markerIno
|
|
58
|
+
|| marker.parentDev !== approved.parentDev || marker.parentIno !== approved.parentIno
|
|
59
|
+
|| marker.markerDev !== identity.markerDev || marker.markerIno !== identity.markerIno
|
|
60
|
+
|| marker.parentDev !== identity.parentDev || marker.parentIno !== identity.parentIno) return undefined
|
|
61
|
+
return marker
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function syncDirectory(directory) {
|
|
65
|
+
const fd = fs.openSync(directory, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW)
|
|
66
|
+
try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Replaces the approved active marker with one carrying `fields`, durably. The replacement is written
|
|
70
|
+
// beside the marker under a name owned by `writer` and this process, and renamed over the marker only
|
|
71
|
+
// if the active marker is still the approved one, so a concurrent repair or cancel is never overwritten.
|
|
72
|
+
function replaceApprovedActiveMarker(fence, approved, writer, fields) {
|
|
73
|
+
const current = readApprovedActiveMarker(fence, approved)
|
|
74
|
+
if (!current) return false
|
|
75
|
+
const temporary = `${fence.markerPath}.${writer}-${process.pid}`
|
|
76
|
+
let fd
|
|
77
|
+
try {
|
|
78
|
+
fd = fs.openSync(temporary, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600)
|
|
79
|
+
const identity = fs.fstatSync(fd, { bigint: true })
|
|
80
|
+
fs.writeFileSync(fd, `${JSON.stringify({ ...current, markerDev: String(identity.dev), markerIno: String(identity.ino), ...fields })}\n`)
|
|
81
|
+
fs.fsyncSync(fd)
|
|
82
|
+
fs.closeSync(fd)
|
|
83
|
+
fd = undefined
|
|
84
|
+
if (!readApprovedActiveMarker(fence, approved)) return false
|
|
85
|
+
fs.renameSync(temporary, fence.markerPath)
|
|
86
|
+
syncDirectory(path.dirname(fence.markerPath))
|
|
87
|
+
return true
|
|
88
|
+
} catch {
|
|
89
|
+
// Before the rename, nothing landed: the active marker stays in place, which repair reads as
|
|
90
|
+
// "attempt may still run". If only the directory sync failed, the rename has happened and the
|
|
91
|
+
// replacement is what a reader now sees, but a crash may still bring the old active marker
|
|
92
|
+
// back. Either way the new status is not known durable, so the caller gets false and must not
|
|
93
|
+
// report it published; repair then reads whichever marker survives and both are safe to act on.
|
|
94
|
+
return false
|
|
95
|
+
} finally {
|
|
96
|
+
if (fd !== undefined) try { fs.closeSync(fd) } catch { /* the replacement already failed */ }
|
|
97
|
+
try { fs.unlinkSync(temporary) } catch { /* renamed into place, or never created */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { readFenceMarkerFile, replaceApprovedActiveMarker, syncDirectory }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface FenceMarkerIdentity {
|
|
2
|
+
readonly markerDev: string
|
|
3
|
+
readonly markerIno: string
|
|
4
|
+
readonly parentDev: string
|
|
5
|
+
readonly parentIno: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FenceMarkerLocation {
|
|
9
|
+
readonly attemptId: string
|
|
10
|
+
readonly markerPath: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ApprovedFenceMarker extends FenceMarkerIdentity {
|
|
14
|
+
readonly supervisorPid: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readFenceMarkerFile(markerPath: string): { readonly value: unknown; readonly identity: FenceMarkerIdentity } | undefined
|
|
18
|
+
export function replaceApprovedActiveMarker(
|
|
19
|
+
fence: FenceMarkerLocation,
|
|
20
|
+
approved: ApprovedFenceMarker,
|
|
21
|
+
writer: string,
|
|
22
|
+
fields: Readonly<Record<string, unknown>>,
|
|
23
|
+
): boolean
|
|
24
|
+
export function syncDirectory(directory: string): void
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Runs one fenced action as the leader of its own process group, under libexec/fenced-supervisor.cjs.
|
|
4
|
+
//
|
|
5
|
+
// argv: <config>, base64url JSON { command, args, argv0, cwd, spawnNotAfter, fence, approved }, where
|
|
6
|
+
// `approved` is the active marker the executor published ({ supervisorPid, markerDev, markerIno,
|
|
7
|
+
// parentDev, parentIno }).
|
|
8
|
+
// fd 3 to the supervisor: 'EXPIRED\n' when the spawn deadline had passed (the action never starts),
|
|
9
|
+
// otherwise exactly one 'DONE <exit code>\n' when the action closes.
|
|
10
|
+
// stdin from the supervisor: 'STOP\n' stops the group. Any other input, or stdin closing without it,
|
|
11
|
+
// means the supervisor is gone: the runner then records a 'terminating' marker naming its group, so
|
|
12
|
+
// a later repair can prove the group ended, before it stops the group itself.
|
|
13
|
+
const { spawn } = require('node:child_process')
|
|
14
|
+
const fs = require('node:fs')
|
|
15
|
+
const path = require('node:path')
|
|
16
|
+
const { replaceApprovedActiveMarker, syncDirectory } = require('./fence-marker.cjs')
|
|
17
|
+
|
|
18
|
+
const STOP_KILL_DELAY_MS = 50
|
|
19
|
+
|
|
20
|
+
const config = JSON.parse(Buffer.from(process.argv[2], 'base64url').toString('utf8'))
|
|
21
|
+
const { fence, approved } = config
|
|
22
|
+
|
|
23
|
+
// The group ends only through SIGKILL below, after the action had its SIGTERM; a stray terminal
|
|
24
|
+
// signal must not take the runner down and orphan the action outside its fence.
|
|
25
|
+
process.on('SIGTERM', () => {})
|
|
26
|
+
process.on('SIGINT', () => {})
|
|
27
|
+
|
|
28
|
+
const expired = config.spawnNotAfter !== undefined && Date.now() > Date.parse(config.spawnNotAfter)
|
|
29
|
+
const child = expired ? undefined : spawn(config.command, config.args, {
|
|
30
|
+
argv0: config.argv0, cwd: config.cwd, env: process.env, stdio: ['ignore', 'inherit', 'inherit'],
|
|
31
|
+
})
|
|
32
|
+
let reported = false
|
|
33
|
+
const report = code => {
|
|
34
|
+
if (reported) return
|
|
35
|
+
reported = true
|
|
36
|
+
fs.writeSync(3, `DONE ${Number.isInteger(code) ? code : 1}\n`)
|
|
37
|
+
}
|
|
38
|
+
if (expired) fs.writeSync(3, 'EXPIRED\n')
|
|
39
|
+
else {
|
|
40
|
+
child.once('error', () => report(1))
|
|
41
|
+
child.once('close', code => report(code))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function publishTerminating() {
|
|
45
|
+
if (!replaceApprovedActiveMarker(fence, approved, 'runner', { status: 'terminating', groupPid: process.pid })) return false
|
|
46
|
+
try {
|
|
47
|
+
try { fs.unlinkSync(fence.socketPath) } catch (error) { if (error.code !== 'ENOENT') return false }
|
|
48
|
+
syncDirectory(path.dirname(fence.socketPath))
|
|
49
|
+
return true
|
|
50
|
+
} catch {
|
|
51
|
+
// The 'terminating' marker is already durable; repair still waits for the socket to go.
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let stopping = false
|
|
57
|
+
let controlled = false
|
|
58
|
+
function stop(supervisorLost) {
|
|
59
|
+
if (stopping) return
|
|
60
|
+
if (supervisorLost) publishTerminating()
|
|
61
|
+
stopping = true
|
|
62
|
+
process.kill(-process.pid, 'SIGTERM')
|
|
63
|
+
setTimeout(() => process.kill(-process.pid, 'SIGKILL'), STOP_KILL_DELAY_MS)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let input = ''
|
|
67
|
+
process.stdin.setEncoding('utf8')
|
|
68
|
+
process.stdin.on('data', chunk => {
|
|
69
|
+
input += chunk
|
|
70
|
+
if (stopping || !input.includes('\n')) return
|
|
71
|
+
if (input !== 'STOP\n') return stop(true)
|
|
72
|
+
controlled = true
|
|
73
|
+
stop(false)
|
|
74
|
+
})
|
|
75
|
+
process.stdin.on('end', () => { if (!controlled) stop(true) })
|
|
76
|
+
// Keeps the runner alive after the action closes, until the supervisor sends STOP.
|
|
77
|
+
setInterval(() => {}, 1000)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Supervises one fenced action for the executor (packages/cockpit/src/environment-exec/execute.ts) so
|
|
4
|
+
// that an attempt whose executor died can still be ended and proven gone by `repairFencedExecution`.
|
|
5
|
+
//
|
|
6
|
+
// argv: <config>, base64url JSON { command, args, argv0, cwd, spawnNotAfter, fence }.
|
|
7
|
+
// fd 3 to the executor: 'READY\n' once the repair socket listens; 'EXPIRED\n' before exiting when the
|
|
8
|
+
// action was never released because its spawn deadline passed or the executor cancelled it.
|
|
9
|
+
// stdin from the executor: one release line, 'GO' or 'CANCEL', then the attempt id and the device and
|
|
10
|
+
// inode of the active marker and its parent directory that the executor published. Anything else exits.
|
|
11
|
+
// repair socket: a client that writes the attempt id is answered { attemptId, supervisorPid } and the
|
|
12
|
+
// action is stopped.
|
|
13
|
+
// exit code: the action's exit code, or 125 when the fence could not be kept (protocol violation, the
|
|
14
|
+
// runner lost, or no durable 'terminated' marker). The supervisor exits only after the action's process
|
|
15
|
+
// group is gone and the marker reads 'terminated', so that marker proves nothing of the attempt runs.
|
|
16
|
+
const { spawn } = require('node:child_process')
|
|
17
|
+
const { createServer } = require('node:net')
|
|
18
|
+
const fs = require('node:fs')
|
|
19
|
+
const path = require('node:path')
|
|
20
|
+
const { replaceApprovedActiveMarker } = require('./fence-marker.cjs')
|
|
21
|
+
|
|
22
|
+
const FENCE_LOST = 125
|
|
23
|
+
const GROUP_EXIT_WAIT_MS = 2000
|
|
24
|
+
const RUNNER = path.join(__dirname, 'fenced-group-runner.cjs')
|
|
25
|
+
|
|
26
|
+
const config = JSON.parse(Buffer.from(process.argv[2], 'base64url').toString('utf8'))
|
|
27
|
+
const { fence } = config
|
|
28
|
+
|
|
29
|
+
let action
|
|
30
|
+
let approved
|
|
31
|
+
let started = false
|
|
32
|
+
let finished = false
|
|
33
|
+
let terminating = false
|
|
34
|
+
let expired = false
|
|
35
|
+
let actionCode = 1
|
|
36
|
+
|
|
37
|
+
const groupGone = pid => {
|
|
38
|
+
try { process.kill(-pid, 0); return false } catch (error) { return error.code === 'ESRCH' }
|
|
39
|
+
}
|
|
40
|
+
const removeSocket = () => {
|
|
41
|
+
try { fs.unlinkSync(fence.socketPath) } catch { /* already absent; repair checks absence itself */ }
|
|
42
|
+
}
|
|
43
|
+
const abandon = () => {
|
|
44
|
+
removeSocket()
|
|
45
|
+
process.exit(FENCE_LOST)
|
|
46
|
+
}
|
|
47
|
+
const publishTerminated = () => approved !== undefined && replaceApprovedActiveMarker(
|
|
48
|
+
fence, { ...approved, supervisorPid: process.pid }, 'supervisor',
|
|
49
|
+
{ status: 'terminated', finishedAt: new Date().toISOString() },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const server = createServer(socket => {
|
|
53
|
+
let request = ''
|
|
54
|
+
socket.setEncoding('utf8')
|
|
55
|
+
socket.on('data', chunk => {
|
|
56
|
+
request += chunk
|
|
57
|
+
if (request.length > 256) return socket.destroy()
|
|
58
|
+
if (!request.includes('\n')) return
|
|
59
|
+
if (request.trim() !== fence.attemptId) return socket.destroy()
|
|
60
|
+
socket.end(`${JSON.stringify({ attemptId: fence.attemptId, supervisorPid: process.pid })}\n`)
|
|
61
|
+
terminate()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function finish(deadline) {
|
|
66
|
+
if (finished) return
|
|
67
|
+
if (action && !groupGone(action.pid)) {
|
|
68
|
+
if (Date.now() >= deadline) return abandon()
|
|
69
|
+
return setTimeout(() => finish(deadline), 10)
|
|
70
|
+
}
|
|
71
|
+
finished = true
|
|
72
|
+
server.close()
|
|
73
|
+
removeSocket()
|
|
74
|
+
if (!publishTerminated()) process.exit(FENCE_LOST)
|
|
75
|
+
if (expired) fs.writeSync(3, 'EXPIRED\n')
|
|
76
|
+
process.exit(actionCode)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function terminate(code) {
|
|
80
|
+
if (!action || terminating) return
|
|
81
|
+
terminating = true
|
|
82
|
+
actionCode = Number.isInteger(code) ? code : 1
|
|
83
|
+
// A stop line that cannot be written means the action is unreachable; abandon exits with the fence-lost status.
|
|
84
|
+
try { action.stdin.end('STOP\n') } catch { abandon() }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function startAction() {
|
|
88
|
+
const runnerConfig = Buffer.from(JSON.stringify({
|
|
89
|
+
command: config.command, args: config.args, argv0: config.argv0, cwd: config.cwd,
|
|
90
|
+
spawnNotAfter: config.spawnNotAfter, fence, approved: { ...approved, supervisorPid: process.pid },
|
|
91
|
+
})).toString('base64url')
|
|
92
|
+
action = spawn(process.execPath, ['--', RUNNER, runnerConfig], {
|
|
93
|
+
cwd: config.cwd, env: process.env, stdio: ['pipe', 'inherit', 'inherit', 'pipe'], detached: true,
|
|
94
|
+
})
|
|
95
|
+
let control = ''
|
|
96
|
+
action.stdio[3].setEncoding('utf8')
|
|
97
|
+
action.stdio[3].on('data', chunk => {
|
|
98
|
+
control += chunk
|
|
99
|
+
if (control.length > 64) return abandon()
|
|
100
|
+
if (!control.includes('\n')) return
|
|
101
|
+
if (control === 'EXPIRED\n') {
|
|
102
|
+
expired = true
|
|
103
|
+
return terminate(1)
|
|
104
|
+
}
|
|
105
|
+
const done = /^DONE (-?[0-9]+)\n$/.exec(control)
|
|
106
|
+
if (!done) return abandon()
|
|
107
|
+
terminate(Number(done[1]))
|
|
108
|
+
})
|
|
109
|
+
action.once('error', abandon)
|
|
110
|
+
action.once('close', () => {
|
|
111
|
+
if (!terminating) return abandon()
|
|
112
|
+
finish(Date.now() + GROUP_EXIT_WAIT_MS)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try { fs.unlinkSync(fence.socketPath) } catch (error) { if (error.code !== 'ENOENT') process.exit(FENCE_LOST) }
|
|
117
|
+
server.listen(fence.socketPath, () => fs.writeSync(3, 'READY\n'))
|
|
118
|
+
|
|
119
|
+
let release = ''
|
|
120
|
+
process.stdin.setEncoding('utf8')
|
|
121
|
+
process.stdin.on('data', chunk => {
|
|
122
|
+
release += chunk
|
|
123
|
+
if (started || !release.includes('\n')) return
|
|
124
|
+
const fields = release.trim().split(' ')
|
|
125
|
+
if (fields.length !== 6 || (fields[0] !== 'GO' && fields[0] !== 'CANCEL') || fields[1] !== fence.attemptId
|
|
126
|
+
|| fields.slice(2).some(value => !/^[0-9]+$/.test(value))) return abandon()
|
|
127
|
+
approved = { markerDev: fields[2], markerIno: fields[3], parentDev: fields[4], parentIno: fields[5] }
|
|
128
|
+
started = true
|
|
129
|
+
if (fields[0] === 'CANCEL' || config.spawnNotAfter !== undefined && Date.now() > Date.parse(config.spawnNotAfter)) {
|
|
130
|
+
expired = true
|
|
131
|
+
return finish(Date.now() + GROUP_EXIT_WAIT_MS)
|
|
132
|
+
}
|
|
133
|
+
startAction()
|
|
134
|
+
})
|
|
135
|
+
process.stdin.on('end', () => { if (!started) abandon() })
|
|
136
|
+
process.on('SIGTERM', () => terminate(1))
|
|
137
|
+
process.on('SIGINT', () => terminate(1))
|