@b00dea/cockpit 0.0.0-bootstrap.0 → 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 +21 -1
- 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 +76 -1
- 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,1175 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { constants, lstatSync } from 'node:fs';
|
|
4
|
+
import { chmod, link, lstat, mkdir, mkdtemp, open, realpath, rm, unlink } from 'node:fs/promises';
|
|
5
|
+
import { createConnection } from 'node:net';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { readFenceMarkerFile } from '../../libexec/fence-marker.cjs';
|
|
10
|
+
import { hasErrorCode, ENVIRONMENT_CODES, EXECUTION_FAILURE_CODES, sameFilesystemObject, snapshotExecutionPlan, snapshotExecutionStep, snapshotJson, } from '../environment/contract.js';
|
|
11
|
+
import { redactSensitiveMessage } from '../environment/sensitive.js';
|
|
12
|
+
import { fileFingerprint, recordVerifiedDigest, verifiedDigest } from './verified-digest.js';
|
|
13
|
+
const FENCE_READY_MS = 2_000;
|
|
14
|
+
const FENCE_REPAIR_MS = 2_000;
|
|
15
|
+
const fencedTerminations = new WeakMap();
|
|
16
|
+
function validExecutionFence(fence) {
|
|
17
|
+
return /^[a-f0-9]{64}$/.test(fence.attemptId)
|
|
18
|
+
&& [fence.markerPath, fence.socketPath].every(path => isAbsolute(path) && normalize(path) === path && !path.includes('\0'))
|
|
19
|
+
&& fence.markerPath !== fence.socketPath && Buffer.byteLength(fence.socketPath) <= 103;
|
|
20
|
+
}
|
|
21
|
+
async function secureFenceSocketDirectory(fence) {
|
|
22
|
+
const directory = dirname(fence.socketPath);
|
|
23
|
+
try {
|
|
24
|
+
await mkdir(directory, { recursive: false, mode: 0o700 });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!hasErrorCode(error, 'EEXIST'))
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const identity = await lstat(directory);
|
|
32
|
+
return identity.isDirectory() && !identity.isSymbolicLink()
|
|
33
|
+
&& identity.uid === (process.getuid?.() ?? identity.uid) && (identity.mode & 0o077) === 0;
|
|
34
|
+
}
|
|
35
|
+
catch { /* Any lstat failure means the fence directory is not provably private; the caller refuses the fence. */
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function exactFenceMarker(value, fence, identity) {
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
41
|
+
return undefined;
|
|
42
|
+
const marker = value;
|
|
43
|
+
const markerKeys = marker.status === 'terminated'
|
|
44
|
+
? ['attemptId', 'finishedAt', 'format', 'markerDev', 'markerIno', 'parentDev', 'parentIno', 'status', 'supervisorPid']
|
|
45
|
+
: marker.status === 'terminating'
|
|
46
|
+
? ['attemptId', 'format', 'groupPid', 'markerDev', 'markerIno', 'parentDev', 'parentIno', 'status', 'supervisorPid']
|
|
47
|
+
: marker.status === 'active'
|
|
48
|
+
? ['attemptId', 'format', 'markerDev', 'markerIno', 'parentDev', 'parentIno', 'status', 'supervisorPid']
|
|
49
|
+
: ['attemptId', 'format', 'markerDev', 'markerIno', 'parentDev', 'parentIno', 'status'];
|
|
50
|
+
if (JSON.stringify(Object.keys(marker).sort()) !== JSON.stringify(markerKeys)
|
|
51
|
+
|| marker.format !== 'cockpit-executor/v1' || marker.attemptId !== fence.attemptId
|
|
52
|
+
|| marker.markerDev !== identity.markerDev || marker.markerIno !== identity.markerIno
|
|
53
|
+
|| marker.parentDev !== identity.parentDev || marker.parentIno !== identity.parentIno
|
|
54
|
+
|| marker.status !== 'cancelled' && (!Number.isSafeInteger(marker.supervisorPid) || marker.supervisorPid < 1)
|
|
55
|
+
|| marker.status === 'terminating' && (!Number.isSafeInteger(marker.groupPid) || marker.groupPid < 1)
|
|
56
|
+
|| marker.status !== 'active' && marker.status !== 'terminating' && marker.status !== 'terminated' && marker.status !== 'cancelled'
|
|
57
|
+
|| marker.status === 'terminated' && (typeof marker.finishedAt !== 'string' || !Number.isFinite(Date.parse(marker.finishedAt))))
|
|
58
|
+
return undefined;
|
|
59
|
+
return marker;
|
|
60
|
+
}
|
|
61
|
+
function readFenceMarker(fence) {
|
|
62
|
+
const read = readFenceMarkerFile(fence.markerPath);
|
|
63
|
+
return read && exactFenceMarker(read.value, fence, read.identity);
|
|
64
|
+
}
|
|
65
|
+
async function pathAbsent(path) {
|
|
66
|
+
try {
|
|
67
|
+
await lstat(path);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return hasErrorCode(error, 'ENOENT');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function issueFencedTermination(fence) {
|
|
75
|
+
const evidence = { kind: 'fenced-execution-termination' };
|
|
76
|
+
fencedTerminations.set(evidence, Object.freeze({ ...fence }));
|
|
77
|
+
return evidence;
|
|
78
|
+
}
|
|
79
|
+
async function publishFenceMarker(fence, status, supervisorPid) {
|
|
80
|
+
const prepared = `${fence.markerPath}.${status}-${process.pid}.prepared`;
|
|
81
|
+
let handle;
|
|
82
|
+
try {
|
|
83
|
+
const parent = await lstat(dirname(fence.markerPath), { bigint: true });
|
|
84
|
+
if (!parent.isDirectory() || parent.isSymbolicLink())
|
|
85
|
+
return undefined;
|
|
86
|
+
handle = await open(prepared, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
87
|
+
const markerIdentity = await handle.stat({ bigint: true });
|
|
88
|
+
const marker = { format: 'cockpit-executor/v1', attemptId: fence.attemptId,
|
|
89
|
+
...(status === 'active' ? { supervisorPid } : {}),
|
|
90
|
+
markerDev: String(markerIdentity.dev), markerIno: String(markerIdentity.ino),
|
|
91
|
+
parentDev: String(parent.dev), parentIno: String(parent.ino), status };
|
|
92
|
+
await handle.writeFile(`${JSON.stringify(marker)}\n`);
|
|
93
|
+
await handle.sync();
|
|
94
|
+
await handle.close();
|
|
95
|
+
handle = undefined;
|
|
96
|
+
await link(prepared, fence.markerPath);
|
|
97
|
+
await unlink(prepared);
|
|
98
|
+
const directory = await open(dirname(fence.markerPath), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
99
|
+
try {
|
|
100
|
+
await directory.sync();
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await directory.close();
|
|
104
|
+
}
|
|
105
|
+
const authenticated = readFenceMarker(fence);
|
|
106
|
+
return authenticated?.status === status && authenticated.supervisorPid === supervisorPid ? authenticated : undefined;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
try {
|
|
110
|
+
await handle?.close();
|
|
111
|
+
}
|
|
112
|
+
catch { /* incomplete prepared marker */ }
|
|
113
|
+
try {
|
|
114
|
+
await unlink(prepared);
|
|
115
|
+
}
|
|
116
|
+
catch { /* linked winner or absent preparation */ }
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const writeActiveFence = (fence, supervisorPid) => publishFenceMarker(fence, 'active', supervisorPid);
|
|
121
|
+
const writeCancelledFence = (fence) => publishFenceMarker(fence, 'cancelled');
|
|
122
|
+
// The supervisor and its group runner are separate programs so a fenced action outlives this
|
|
123
|
+
// process; see libexec/fenced-supervisor.cjs for their protocol.
|
|
124
|
+
const FENCED_SUPERVISOR = fileURLToPath(new URL('../../libexec/fenced-supervisor.cjs', import.meta.url));
|
|
125
|
+
async function waitForFenceReady(child) {
|
|
126
|
+
const control = child.stdio[3];
|
|
127
|
+
if (!control || !('on' in control))
|
|
128
|
+
return false;
|
|
129
|
+
return new Promise(resolve => {
|
|
130
|
+
let settled = false, input = '';
|
|
131
|
+
const settleFenceReady = (value) => { if (!settled) {
|
|
132
|
+
settled = true;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
resolve(value);
|
|
135
|
+
} };
|
|
136
|
+
const timer = setTimeout(() => settleFenceReady(false), FENCE_READY_MS);
|
|
137
|
+
const readable = control;
|
|
138
|
+
readable.setEncoding('utf8');
|
|
139
|
+
readable.on('data', (chunk) => { input += chunk; if (input === 'READY\n')
|
|
140
|
+
settleFenceReady(true);
|
|
141
|
+
else if (input.length > 6)
|
|
142
|
+
settleFenceReady(false); });
|
|
143
|
+
child.once('error', () => settleFenceReady(false));
|
|
144
|
+
child.once('close', () => settleFenceReady(false));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async function spawnFencedSupervisor(step, argv) {
|
|
148
|
+
const fence = step.executionFence;
|
|
149
|
+
if (!fence || !validExecutionFence(fence) || !await secureFenceSocketDirectory(fence))
|
|
150
|
+
return undefined;
|
|
151
|
+
const [command, ...args] = argv;
|
|
152
|
+
const encoded = Buffer.from(JSON.stringify({ command, args, argv0: step.argv0, cwd: step.cwd, spawnNotAfter: step.spawnNotAfter, fence })).toString('base64url');
|
|
153
|
+
const supervisor = spawn(process.execPath, ['--', FENCED_SUPERVISOR, encoded], {
|
|
154
|
+
cwd: step.cwd, env: step.env, detached: true, stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
|
|
155
|
+
});
|
|
156
|
+
const active = supervisor.pid === undefined || !await waitForFenceReady(supervisor)
|
|
157
|
+
? undefined : await writeActiveFence(fence, supervisor.pid);
|
|
158
|
+
if (supervisor.pid === undefined || !active) {
|
|
159
|
+
supervisor.stdin?.destroy();
|
|
160
|
+
try {
|
|
161
|
+
process.kill(-supervisor.pid, 'SIGKILL');
|
|
162
|
+
}
|
|
163
|
+
catch { /* no released action */ }
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
// Startup and durable acknowledgement can outlive the evidence that authorized the action.
|
|
167
|
+
let control = '';
|
|
168
|
+
const readable = supervisor.stdio[3];
|
|
169
|
+
readable.on('data', (chunk) => { control += chunk; });
|
|
170
|
+
const release = step.spawnNotAfter !== undefined && Date.now() > Date.parse(step.spawnNotAfter) ? 'CANCEL' : 'GO';
|
|
171
|
+
supervisor.stdin?.end(`${release} ${fence.attemptId} ${active.markerDev} ${active.markerIno} ${active.parentDev} ${active.parentIno}\n`);
|
|
172
|
+
return { child: supervisor, spawnExpired: () => control === 'EXPIRED\n' };
|
|
173
|
+
}
|
|
174
|
+
function fencedProcessGone(pid) {
|
|
175
|
+
try {
|
|
176
|
+
process.kill(pid, 0);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return hasErrorCode(error, 'ESRCH');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export async function repairFencedExecution(fence) {
|
|
184
|
+
if (!validExecutionFence(fence) || !await secureFenceSocketDirectory(fence))
|
|
185
|
+
return { kind: 'refusal', code: ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED };
|
|
186
|
+
let marker = readFenceMarker(fence);
|
|
187
|
+
if (!marker) {
|
|
188
|
+
if (!await pathAbsent(fence.markerPath))
|
|
189
|
+
return { kind: 'refusal', code: ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED };
|
|
190
|
+
marker = await writeCancelledFence(fence) ?? readFenceMarker(fence);
|
|
191
|
+
if (!marker)
|
|
192
|
+
return { kind: 'refusal', code: ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED };
|
|
193
|
+
}
|
|
194
|
+
if (marker.status === 'active') {
|
|
195
|
+
const expectedPid = marker.supervisorPid;
|
|
196
|
+
const acknowledged = await new Promise(resolve => {
|
|
197
|
+
const socket = createConnection(fence.socketPath);
|
|
198
|
+
let input = '';
|
|
199
|
+
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, FENCE_REPAIR_MS);
|
|
200
|
+
socket.setEncoding('utf8');
|
|
201
|
+
socket.once('connect', () => socket.write(`${fence.attemptId}\n`));
|
|
202
|
+
socket.on('data', chunk => { input += chunk; });
|
|
203
|
+
socket.once('error', () => { clearTimeout(timer); resolve(false); });
|
|
204
|
+
socket.once('close', () => { clearTimeout(timer); try {
|
|
205
|
+
const reply = JSON.parse(input);
|
|
206
|
+
resolve(reply.attemptId === fence.attemptId && reply.supervisorPid === expectedPid);
|
|
207
|
+
}
|
|
208
|
+
catch { /* An unparseable reply is not an acknowledgement from the expected supervisor; repair stays required. */
|
|
209
|
+
resolve(false);
|
|
210
|
+
} });
|
|
211
|
+
});
|
|
212
|
+
if (!acknowledged)
|
|
213
|
+
return { kind: 'refusal', code: ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED };
|
|
214
|
+
}
|
|
215
|
+
const deadline = Date.now() + FENCE_REPAIR_MS;
|
|
216
|
+
while (Date.now() <= deadline) {
|
|
217
|
+
marker = readFenceMarker(fence);
|
|
218
|
+
if (marker?.status === 'cancelled' && await pathAbsent(fence.socketPath))
|
|
219
|
+
return { kind: 'complete', evidence: issueFencedTermination(fence) };
|
|
220
|
+
if (marker?.status === 'terminated' && fencedProcessGone(marker.supervisorPid))
|
|
221
|
+
return { kind: 'complete', evidence: issueFencedTermination(fence) };
|
|
222
|
+
if (marker?.status === 'terminating' && fencedProcessGone(marker.supervisorPid)
|
|
223
|
+
&& fencedProcessGone(-marker.groupPid) && await pathAbsent(fence.socketPath)) {
|
|
224
|
+
return { kind: 'complete', evidence: issueFencedTermination(fence) };
|
|
225
|
+
}
|
|
226
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
227
|
+
}
|
|
228
|
+
return { kind: 'refusal', code: ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED };
|
|
229
|
+
}
|
|
230
|
+
export async function cleanupFencedExecution(fence) {
|
|
231
|
+
if (!validExecutionFence(fence) || !await secureFenceSocketDirectory(fence))
|
|
232
|
+
return false;
|
|
233
|
+
const marker = readFenceMarker(fence);
|
|
234
|
+
if (!marker)
|
|
235
|
+
return await pathAbsent(fence.markerPath) && await pathAbsent(fence.socketPath);
|
|
236
|
+
if (marker.status === 'active' || marker.status === 'terminated' && !fencedProcessGone(marker.supervisorPid)
|
|
237
|
+
|| marker.status === 'terminating' && (!fencedProcessGone(marker.supervisorPid) || !fencedProcessGone(-marker.groupPid))
|
|
238
|
+
|| !await pathAbsent(fence.socketPath))
|
|
239
|
+
return false;
|
|
240
|
+
try {
|
|
241
|
+
await unlink(fence.markerPath);
|
|
242
|
+
const directory = await open(dirname(fence.markerPath), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
243
|
+
try {
|
|
244
|
+
await directory.sync();
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await directory.close();
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
catch { /* A marker that cannot be removed durably leaves the fence unreleased, which the false result reports. */
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export function consumeFencedExecutionTermination(evidence, fence) {
|
|
256
|
+
if (!evidence || typeof evidence !== 'object')
|
|
257
|
+
return false;
|
|
258
|
+
const issued = fencedTerminations.get(evidence);
|
|
259
|
+
if (!issued || issued.attemptId !== fence.attemptId || issued.markerPath !== fence.markerPath || issued.socketPath !== fence.socketPath)
|
|
260
|
+
return false;
|
|
261
|
+
fencedTerminations.delete(evidence);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
const TERMINATION_GRACE_MS = 100;
|
|
265
|
+
const TERMINATION_KILL_MS = TERMINATION_GRACE_MS / 2;
|
|
266
|
+
// The step each outcome came from, so a caller can prove its result came from this executor for that step.
|
|
267
|
+
const issuedOutcomes = new WeakMap();
|
|
268
|
+
function failedStep(step, failure) {
|
|
269
|
+
return { id: step.id, exitCode: 1, signal: null, stdoutBytes: 0, stderrBytes: 0, failure };
|
|
270
|
+
}
|
|
271
|
+
function stepFingerprint(step) {
|
|
272
|
+
return createHash('sha256').update(JSON.stringify(step)).digest('hex');
|
|
273
|
+
}
|
|
274
|
+
function executionReceipt(stepId, fingerprint, startedAt, finishedAt) {
|
|
275
|
+
return {
|
|
276
|
+
stepId,
|
|
277
|
+
startedAt,
|
|
278
|
+
finishedAt,
|
|
279
|
+
receiptMatches: (candidate) => {
|
|
280
|
+
const owned = snapshotExecutionStep(candidate);
|
|
281
|
+
return owned !== undefined && stepFingerprint(owned) === fingerprint;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
async function checkFilesystem(check) {
|
|
286
|
+
let handle;
|
|
287
|
+
try {
|
|
288
|
+
const stat = await lstat(check.path);
|
|
289
|
+
if (stat.isSymbolicLink())
|
|
290
|
+
return false;
|
|
291
|
+
if (check.expected === 'absent')
|
|
292
|
+
return false;
|
|
293
|
+
if (check.expected === 'directory')
|
|
294
|
+
return stat.isDirectory();
|
|
295
|
+
if (!stat.isFile())
|
|
296
|
+
return false;
|
|
297
|
+
if (check.sha256 === undefined)
|
|
298
|
+
return true;
|
|
299
|
+
if (check.maxBytes === undefined || stat.size > check.maxBytes)
|
|
300
|
+
return false;
|
|
301
|
+
handle = await open(check.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
302
|
+
const opened = await handle.stat();
|
|
303
|
+
if (!opened.isFile() || opened.size > check.maxBytes || opened.dev !== stat.dev || opened.ino !== stat.ino)
|
|
304
|
+
return false;
|
|
305
|
+
const hash = createHash('sha256');
|
|
306
|
+
const chunk = Buffer.allocUnsafe(65_536);
|
|
307
|
+
let total = 0;
|
|
308
|
+
while (true) {
|
|
309
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.byteLength, check.maxBytes - total + 1), null);
|
|
310
|
+
if (bytesRead === 0)
|
|
311
|
+
break;
|
|
312
|
+
total += bytesRead;
|
|
313
|
+
if (total > check.maxBytes)
|
|
314
|
+
return false;
|
|
315
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
316
|
+
}
|
|
317
|
+
const after = await handle.stat();
|
|
318
|
+
return total === opened.size && after.size === opened.size && after.mtimeMs === opened.mtimeMs
|
|
319
|
+
&& hash.digest('hex') === check.sha256;
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
return check.expected === 'absent' && hasErrorCode(error, 'ENOENT');
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
try {
|
|
326
|
+
await handle?.close();
|
|
327
|
+
}
|
|
328
|
+
catch { /* A closed precondition descriptor carries no execution authority. */ }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function filesystemChecksPass(checks) {
|
|
332
|
+
for (const check of checks) {
|
|
333
|
+
if (!await checkFilesystem(check))
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
function filesystemIdentitiesPass(checks) {
|
|
339
|
+
try {
|
|
340
|
+
return checks.every((check) => {
|
|
341
|
+
if (check.identity === undefined)
|
|
342
|
+
return true;
|
|
343
|
+
for (const ancestor of check.identity.ancestors) {
|
|
344
|
+
const status = lstatSync(ancestor.path, { bigint: true });
|
|
345
|
+
if (!status.isDirectory() || status.isSymbolicLink() || !sameFilesystemObject(status, ancestor))
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
const status = lstatSync(check.path, { bigint: true });
|
|
349
|
+
const typeMatches = check.expected === 'directory' ? status.isDirectory() : status.isFile();
|
|
350
|
+
return typeMatches && !status.isSymbolicLink() && sameFilesystemObject(status, check.identity);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Any lstat failure means the checked path no longer matches its recorded identity; false reports the change.
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const STAGING_PREFIX = 'cockpit-execution-';
|
|
359
|
+
const ownedStagingDirectories = new Set();
|
|
360
|
+
// Data inputs are copied into a private snapshot so the process reads the bytes that were hashed.
|
|
361
|
+
// Executables run from their own path and are re-checked just before spawn instead.
|
|
362
|
+
async function stageVerifiedFile(file, snapshot) {
|
|
363
|
+
let source;
|
|
364
|
+
let staged;
|
|
365
|
+
try {
|
|
366
|
+
const before = await lstat(file.path);
|
|
367
|
+
if (before.isSymbolicLink() || !before.isFile() || before.size > file.maxBytes)
|
|
368
|
+
return undefined;
|
|
369
|
+
source = await open(file.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
370
|
+
const opened = await source.stat();
|
|
371
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > file.maxBytes)
|
|
372
|
+
return undefined;
|
|
373
|
+
staged = await open(snapshot, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
374
|
+
const hash = createHash('sha256');
|
|
375
|
+
const chunk = Buffer.allocUnsafe(65_536);
|
|
376
|
+
let total = 0;
|
|
377
|
+
while (true) {
|
|
378
|
+
const { bytesRead } = await source.read(chunk, 0, Math.min(chunk.byteLength, file.maxBytes - total + 1), null);
|
|
379
|
+
if (bytesRead === 0)
|
|
380
|
+
break;
|
|
381
|
+
total += bytesRead;
|
|
382
|
+
if (total > file.maxBytes)
|
|
383
|
+
return undefined;
|
|
384
|
+
const bytes = chunk.subarray(0, bytesRead);
|
|
385
|
+
hash.update(bytes);
|
|
386
|
+
await staged.writeFile(bytes);
|
|
387
|
+
}
|
|
388
|
+
const after = await source.stat();
|
|
389
|
+
if (total !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs)
|
|
390
|
+
return undefined;
|
|
391
|
+
const digest = hash.digest('hex');
|
|
392
|
+
if (digest !== file.sha256)
|
|
393
|
+
return undefined;
|
|
394
|
+
await staged.close();
|
|
395
|
+
staged = undefined;
|
|
396
|
+
await chmod(snapshot, 0o400);
|
|
397
|
+
return digest;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
// Staging failures surface as the staging-failed outcome; the outcome carries a code, not a cause.
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
try {
|
|
405
|
+
await staged?.close();
|
|
406
|
+
}
|
|
407
|
+
catch { /* The private incomplete file will be removed with its staging directory. */ }
|
|
408
|
+
try {
|
|
409
|
+
await source?.close();
|
|
410
|
+
}
|
|
411
|
+
catch { /* The descriptor is already outside the execution boundary. */ }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async function consumedFileMatches(file) {
|
|
415
|
+
let source;
|
|
416
|
+
try {
|
|
417
|
+
const before = await lstat(file.path);
|
|
418
|
+
if (before.isSymbolicLink() || !before.isFile() || before.size > file.maxBytes
|
|
419
|
+
|| (file.executable && (before.mode & 0o111) === 0))
|
|
420
|
+
return false;
|
|
421
|
+
source = await open(file.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
422
|
+
const opened = await source.stat();
|
|
423
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > file.maxBytes
|
|
424
|
+
|| (file.executable && (opened.mode & 0o111) === 0))
|
|
425
|
+
return false;
|
|
426
|
+
const fingerprint = fileFingerprint(file.path, opened);
|
|
427
|
+
if (verifiedDigest(fingerprint) === file.sha256) {
|
|
428
|
+
const linked = await lstat(file.path);
|
|
429
|
+
return fileFingerprint(file.path, linked) === fingerprint;
|
|
430
|
+
}
|
|
431
|
+
const hash = createHash('sha256');
|
|
432
|
+
const chunk = Buffer.allocUnsafe(Math.min(file.maxBytes + 1, 1_048_576));
|
|
433
|
+
let total = 0;
|
|
434
|
+
while (true) {
|
|
435
|
+
const { bytesRead } = await source.read(chunk, 0, Math.min(chunk.byteLength, file.maxBytes - total + 1), null);
|
|
436
|
+
if (bytesRead === 0)
|
|
437
|
+
break;
|
|
438
|
+
total += bytesRead;
|
|
439
|
+
if (total > file.maxBytes)
|
|
440
|
+
return false;
|
|
441
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
442
|
+
}
|
|
443
|
+
const after = await source.stat();
|
|
444
|
+
const linked = await lstat(file.path);
|
|
445
|
+
const matches = total === opened.size && after.size === opened.size && after.mtimeMs === opened.mtimeMs
|
|
446
|
+
&& linked.dev === opened.dev && linked.ino === opened.ino && linked.size === opened.size && linked.mtimeMs === opened.mtimeMs
|
|
447
|
+
&& (!file.executable || (linked.mode & 0o111) !== 0)
|
|
448
|
+
&& hash.digest('hex') === file.sha256;
|
|
449
|
+
if (matches && fileFingerprint(file.path, after) === fingerprint)
|
|
450
|
+
recordVerifiedDigest(fingerprint, file.sha256);
|
|
451
|
+
return matches;
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// Any read or stat failure means the file does not match its pinned digest; false reports the mismatch.
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
try {
|
|
459
|
+
await source?.close();
|
|
460
|
+
}
|
|
461
|
+
catch { /* Failure remains closed even when descriptor cleanup also fails. */ }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function removePreparedDirectory(directory) {
|
|
465
|
+
if (dirname(directory) !== tmpdir() || !basename(directory).startsWith(STAGING_PREFIX)
|
|
466
|
+
|| !ownedStagingDirectories.has(directory))
|
|
467
|
+
return false;
|
|
468
|
+
try {
|
|
469
|
+
await rm(directory, { recursive: true, force: true });
|
|
470
|
+
ownedStagingDirectories.delete(directory);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// A directory that cannot be removed is reported as the cleanup-failed outcome.
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function prepareExecutionPlan(plan) {
|
|
479
|
+
let directory;
|
|
480
|
+
const prepared = new Map();
|
|
481
|
+
let sequence = 0;
|
|
482
|
+
const prepare = async (step) => {
|
|
483
|
+
if (prepared.has(step))
|
|
484
|
+
return true;
|
|
485
|
+
const argv = [...step.argv];
|
|
486
|
+
const verifiedPathExecutables = [];
|
|
487
|
+
for (const file of step.consumedFiles) {
|
|
488
|
+
if (file.executable) {
|
|
489
|
+
verifiedPathExecutables.push(file);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (directory === undefined) {
|
|
493
|
+
directory = await mkdtemp(join(tmpdir(), STAGING_PREFIX));
|
|
494
|
+
ownedStagingDirectories.add(directory);
|
|
495
|
+
}
|
|
496
|
+
const snapshot = join(directory, `${String(sequence).padStart(4, '0')}-${file.sha256}`);
|
|
497
|
+
sequence += 1;
|
|
498
|
+
const digest = await stageVerifiedFile(file, snapshot);
|
|
499
|
+
if (digest === undefined)
|
|
500
|
+
return false;
|
|
501
|
+
for (const index of file.argvIndexes)
|
|
502
|
+
argv[index] = snapshot;
|
|
503
|
+
}
|
|
504
|
+
prepared.set(step, {
|
|
505
|
+
logical: step,
|
|
506
|
+
argv: argv,
|
|
507
|
+
verifiedPathExecutables,
|
|
508
|
+
});
|
|
509
|
+
for (const compensation of step.compensation)
|
|
510
|
+
if (!await prepare(compensation))
|
|
511
|
+
return false;
|
|
512
|
+
return true;
|
|
513
|
+
};
|
|
514
|
+
let reason = 'staging-failed';
|
|
515
|
+
try {
|
|
516
|
+
for (const step of plan.steps)
|
|
517
|
+
if (!await prepare(step)) {
|
|
518
|
+
reason = 'input-changed';
|
|
519
|
+
throw new TypeError('consumed execution file changed');
|
|
520
|
+
}
|
|
521
|
+
return { kind: 'prepared', plan: { directory, steps: prepared } };
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// Both the changed-input marker and fs failures surface through the typed failure reason chosen above.
|
|
525
|
+
if (directory !== undefined && !await removePreparedDirectory(directory))
|
|
526
|
+
return { kind: 'failure', reason: 'cleanup-failed' };
|
|
527
|
+
return { kind: 'failure', reason };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async function executeStep(prepared, signal) {
|
|
531
|
+
const step = prepared.logical;
|
|
532
|
+
const beforeSpawnFailure = (failure) => ({
|
|
533
|
+
...failedStep(step, failure), stdoutObservedBytes: 0, stderrObservedBytes: 0,
|
|
534
|
+
stdoutRetainedBytes: 0, stderrRetainedBytes: 0,
|
|
535
|
+
started: false, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), resolvedStep: step,
|
|
536
|
+
});
|
|
537
|
+
const fingerprint = stepFingerprint(step);
|
|
538
|
+
const stepId = step.id;
|
|
539
|
+
const spawnExpired = () => step.spawnNotAfter !== undefined && Date.now() > Date.parse(step.spawnNotAfter);
|
|
540
|
+
if (spawnExpired())
|
|
541
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED);
|
|
542
|
+
if (signal?.aborted)
|
|
543
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_ABORTED);
|
|
544
|
+
if (!await filesystemChecksPass(step.filesystemChecks)) {
|
|
545
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED);
|
|
546
|
+
}
|
|
547
|
+
for (const executable of prepared.verifiedPathExecutables) {
|
|
548
|
+
// Node cannot bind an opened descriptor to spawn. Same-UID replacement after this
|
|
549
|
+
// final check remains outside the cooperative filesystem authority promised here.
|
|
550
|
+
if (!await consumedFileMatches(executable))
|
|
551
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED);
|
|
552
|
+
}
|
|
553
|
+
if (signal?.aborted)
|
|
554
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_ABORTED);
|
|
555
|
+
if (spawnExpired())
|
|
556
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED);
|
|
557
|
+
// Node cannot bind an lstat result to a later removal syscall. This synchronous final
|
|
558
|
+
// check closes cooperative replacement, not an adversarial same-UID lstat-to-spawn race.
|
|
559
|
+
if (!filesystemIdentitiesPass(step.filesystemChecks)) {
|
|
560
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED);
|
|
561
|
+
}
|
|
562
|
+
const [command, ...args] = prepared.argv;
|
|
563
|
+
let runningProcess;
|
|
564
|
+
let fenced;
|
|
565
|
+
try {
|
|
566
|
+
fenced = step.executionFence ? await spawnFencedSupervisor(step, prepared.argv) : undefined;
|
|
567
|
+
if (step.executionFence && !fenced)
|
|
568
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED);
|
|
569
|
+
runningProcess = fenced?.child ?? spawn(command, args, {
|
|
570
|
+
argv0: step.argv0,
|
|
571
|
+
cwd: step.cwd,
|
|
572
|
+
env: step.env,
|
|
573
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
574
|
+
detached: true,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// The outcome carries the spawn-failed code; spawn errors echo argv and environment details.
|
|
579
|
+
return beforeSpawnFailure(ENVIRONMENT_CODES.EXECUTION_SPAWN_FAILED);
|
|
580
|
+
}
|
|
581
|
+
const startedAt = new Date().toISOString();
|
|
582
|
+
return await new Promise((resolveStep) => {
|
|
583
|
+
let stdoutBytes = 0;
|
|
584
|
+
let stderrBytes = 0;
|
|
585
|
+
let stdoutRetainedBytes = 0;
|
|
586
|
+
let stderrRetainedBytes = 0;
|
|
587
|
+
const stdout = [];
|
|
588
|
+
const stderr = [];
|
|
589
|
+
let failure;
|
|
590
|
+
let settled = false;
|
|
591
|
+
let terminationConfirmed = true;
|
|
592
|
+
let closed;
|
|
593
|
+
let killTimer;
|
|
594
|
+
let terminationPoll;
|
|
595
|
+
const result = () => {
|
|
596
|
+
const expired = fenced?.spawnExpired() === true;
|
|
597
|
+
const started = runningProcess.pid !== undefined && !expired;
|
|
598
|
+
const retainedStdout = retainedOutput(stdout, step.stdoutLimitBytes);
|
|
599
|
+
const retainedStderr = retainedOutput(stderr, step.stderrLimitBytes);
|
|
600
|
+
const processSignal = closed?.signal ?? null;
|
|
601
|
+
const processFailure = failure ?? (expired ? ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED : closed?.code === 0 && processSignal === null
|
|
602
|
+
? undefined : ENVIRONMENT_CODES.EXECUTION_STEP_FAILED);
|
|
603
|
+
return {
|
|
604
|
+
id: step.id,
|
|
605
|
+
exitCode: processFailure === undefined ? (closed?.code ?? 0) : closed?.code && closed.code > 0 ? closed.code : 1,
|
|
606
|
+
signal: processSignal,
|
|
607
|
+
stdoutBytes: Math.min(stdoutBytes, step.stdoutLimitBytes),
|
|
608
|
+
stderrBytes: Math.min(stderrBytes, step.stderrLimitBytes),
|
|
609
|
+
stdoutObservedBytes: stdoutBytes,
|
|
610
|
+
stderrObservedBytes: stderrBytes,
|
|
611
|
+
stdoutRetainedBytes: retainedStdout.byteLength,
|
|
612
|
+
stderrRetainedBytes: retainedStderr.byteLength,
|
|
613
|
+
failure: processFailure,
|
|
614
|
+
started,
|
|
615
|
+
stdout: retainedStdout,
|
|
616
|
+
stderr: retainedStderr,
|
|
617
|
+
receipt: started && terminationConfirmed
|
|
618
|
+
? executionReceipt(stepId, fingerprint, startedAt, new Date().toISOString())
|
|
619
|
+
: undefined,
|
|
620
|
+
resolvedStep: step,
|
|
621
|
+
};
|
|
622
|
+
};
|
|
623
|
+
const settle = () => {
|
|
624
|
+
if (settled)
|
|
625
|
+
return;
|
|
626
|
+
settled = true;
|
|
627
|
+
clearTimeout(timer);
|
|
628
|
+
if (killTimer !== undefined)
|
|
629
|
+
clearTimeout(killTimer);
|
|
630
|
+
if (terminationPoll !== undefined)
|
|
631
|
+
clearTimeout(terminationPoll);
|
|
632
|
+
signal?.removeEventListener('abort', abort);
|
|
633
|
+
resolveStep(result());
|
|
634
|
+
};
|
|
635
|
+
const terminate = (terminationSignal) => {
|
|
636
|
+
if (runningProcess.pid !== undefined) {
|
|
637
|
+
try {
|
|
638
|
+
process.kill(-runningProcess.pid, terminationSignal);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// The child may have exited between observation and termination.
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
runningProcess.kill(terminationSignal);
|
|
647
|
+
}
|
|
648
|
+
catch { /* The child may have exited before fallback termination. */ }
|
|
649
|
+
};
|
|
650
|
+
const processGroupGone = () => {
|
|
651
|
+
if (closed === undefined)
|
|
652
|
+
return false;
|
|
653
|
+
if (runningProcess.pid === undefined)
|
|
654
|
+
return true;
|
|
655
|
+
try {
|
|
656
|
+
process.kill(-runningProcess.pid, 0);
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
return hasErrorCode(error, 'ESRCH');
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
let terminationDeadline = 0;
|
|
664
|
+
const confirmTermination = () => {
|
|
665
|
+
if (settled || terminationConfirmed)
|
|
666
|
+
return;
|
|
667
|
+
if (processGroupGone()) {
|
|
668
|
+
terminationConfirmed = true;
|
|
669
|
+
settle();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (Date.now() >= terminationDeadline) {
|
|
673
|
+
failure = ENVIRONMENT_CODES.EXECUTION_TERMINATION_UNCONFIRMED;
|
|
674
|
+
settle();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
terminationPoll = setTimeout(confirmTermination, 5);
|
|
678
|
+
};
|
|
679
|
+
const beginTermination = () => {
|
|
680
|
+
if (!terminationConfirmed)
|
|
681
|
+
return;
|
|
682
|
+
terminationConfirmed = false;
|
|
683
|
+
terminationDeadline = Date.now() + (step.executionFence ? FENCE_REPAIR_MS : TERMINATION_GRACE_MS);
|
|
684
|
+
terminate('SIGTERM');
|
|
685
|
+
if (!step.executionFence) {
|
|
686
|
+
killTimer = setTimeout(() => {
|
|
687
|
+
terminate('SIGKILL');
|
|
688
|
+
}, TERMINATION_KILL_MS);
|
|
689
|
+
}
|
|
690
|
+
confirmTermination();
|
|
691
|
+
};
|
|
692
|
+
const stop = (code) => {
|
|
693
|
+
if (failure !== undefined)
|
|
694
|
+
return;
|
|
695
|
+
failure = code;
|
|
696
|
+
beginTermination();
|
|
697
|
+
};
|
|
698
|
+
const timer = setTimeout(() => stop(ENVIRONMENT_CODES.EXECUTION_TIMEOUT), step.timeoutMs);
|
|
699
|
+
const abort = () => stop(ENVIRONMENT_CODES.EXECUTION_ABORTED);
|
|
700
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
701
|
+
if (signal?.aborted)
|
|
702
|
+
abort();
|
|
703
|
+
// A tail-retaining step keeps running past its limit and keeps only the newest bytes, because a
|
|
704
|
+
// long tool's verdict is its exit status and its final lines name the failure; memory stays bounded.
|
|
705
|
+
// A long follow delivers many small chunks, so each one only appends and drops whole chunks that
|
|
706
|
+
// the rest already covers; the single copy and cut happen when the outcome is built.
|
|
707
|
+
const retainTail = (buffers, chunk, retained, limit) => {
|
|
708
|
+
buffers.push(chunk);
|
|
709
|
+
let total = retained + chunk.byteLength;
|
|
710
|
+
while (buffers.length > 1 && total - buffers[0].byteLength >= limit)
|
|
711
|
+
total -= buffers.shift().byteLength;
|
|
712
|
+
return total;
|
|
713
|
+
};
|
|
714
|
+
// The cut can land inside a UTF-8 character; its leftover continuation bytes decode to U+FFFD
|
|
715
|
+
// or fail a strict decoder, and they carry nothing, so the tail starts at the next character.
|
|
716
|
+
const retainedOutput = (buffers, limit) => {
|
|
717
|
+
const joined = Buffer.concat(buffers);
|
|
718
|
+
if (step.outputOverflow !== 'retain-tail')
|
|
719
|
+
return joined.subarray(0, limit);
|
|
720
|
+
let start = Math.max(0, joined.byteLength - limit);
|
|
721
|
+
for (let skipped = 0; start > 0 && skipped < 3 && start < joined.byteLength && (joined[start] & 0xc0) === 0x80; skipped += 1)
|
|
722
|
+
start += 1;
|
|
723
|
+
return joined.subarray(start);
|
|
724
|
+
};
|
|
725
|
+
runningProcess.stdout?.on('data', (chunk) => {
|
|
726
|
+
stdoutBytes += chunk.length;
|
|
727
|
+
if (step.outputOverflow === 'retain-tail') {
|
|
728
|
+
stdoutRetainedBytes = retainTail(stdout, chunk, stdoutRetainedBytes, step.stdoutLimitBytes);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (stdoutRetainedBytes < step.stdoutLimitBytes) {
|
|
732
|
+
const retained = chunk.subarray(0, step.stdoutLimitBytes - stdoutRetainedBytes);
|
|
733
|
+
stdout.push(retained);
|
|
734
|
+
stdoutRetainedBytes += retained.byteLength;
|
|
735
|
+
}
|
|
736
|
+
if (stdoutBytes > step.stdoutLimitBytes)
|
|
737
|
+
stop(ENVIRONMENT_CODES.EXECUTION_STDOUT_LIMIT);
|
|
738
|
+
});
|
|
739
|
+
runningProcess.stderr?.on('data', (chunk) => {
|
|
740
|
+
stderrBytes += chunk.length;
|
|
741
|
+
if (step.outputOverflow === 'retain-tail') {
|
|
742
|
+
stderrRetainedBytes = retainTail(stderr, chunk, stderrRetainedBytes, step.stderrLimitBytes);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (stderrRetainedBytes < step.stderrLimitBytes) {
|
|
746
|
+
const retained = chunk.subarray(0, step.stderrLimitBytes - stderrRetainedBytes);
|
|
747
|
+
stderr.push(retained);
|
|
748
|
+
stderrRetainedBytes += retained.byteLength;
|
|
749
|
+
}
|
|
750
|
+
if (stderrBytes > step.stderrLimitBytes)
|
|
751
|
+
stop(ENVIRONMENT_CODES.EXECUTION_STDERR_LIMIT);
|
|
752
|
+
});
|
|
753
|
+
runningProcess.on('error', () => stop(ENVIRONMENT_CODES.EXECUTION_SPAWN_FAILED));
|
|
754
|
+
runningProcess.on('close', (code, closeSignal) => {
|
|
755
|
+
closed = { code, signal: closeSignal };
|
|
756
|
+
clearTimeout(timer);
|
|
757
|
+
signal?.removeEventListener('abort', abort);
|
|
758
|
+
if (failure === undefined && processGroupGone())
|
|
759
|
+
settle();
|
|
760
|
+
else if (failure === undefined)
|
|
761
|
+
beginTermination();
|
|
762
|
+
else
|
|
763
|
+
confirmTermination();
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
function invalidStepExecution() {
|
|
768
|
+
return {
|
|
769
|
+
id: 'execution-input', exitCode: 1, signal: null, stdoutBytes: 0, stderrBytes: 0,
|
|
770
|
+
stdoutObservedBytes: 0, stderrObservedBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0,
|
|
771
|
+
failure: ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, started: false,
|
|
772
|
+
stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const FAILED_STEP_STDERR_CHARACTERS = 2_048;
|
|
776
|
+
// Only a step that declares `failureOutput` gets this: for a tool such as git clone, its last words
|
|
777
|
+
// are the only statement of the cause (an unreachable host, a missing repository).
|
|
778
|
+
function failedStepStderr(stderr) {
|
|
779
|
+
// Redact before cutting, so a cut inside a URL cannot leave its credentials behind the cut, and
|
|
780
|
+
// drop a partial first line.
|
|
781
|
+
const text = new TextDecoder('utf-8').decode(stderr)
|
|
782
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '').replace(/(\w+:\/\/)[^/@\s]+@/g, '$1[redacted]@');
|
|
783
|
+
const cut = text.slice(-FAILED_STEP_STDERR_CHARACTERS);
|
|
784
|
+
const tail = (cut.length < text.length ? cut.slice(cut.indexOf('\n') + 1) : cut).trim().replace(/\s*\n\s*/g, ' ');
|
|
785
|
+
return tail === '' ? undefined : redactSensitiveMessage(tail);
|
|
786
|
+
}
|
|
787
|
+
function immutablePlanOutcome(outcome) {
|
|
788
|
+
const diagnostics = outcome.diagnostics.map((item) => ({ ...item }));
|
|
789
|
+
if (outcome.kind === 'complete')
|
|
790
|
+
return {
|
|
791
|
+
...outcome, diagnostics,
|
|
792
|
+
result: { steps: outcome.result.steps.map((step) => ({ ...step })) },
|
|
793
|
+
};
|
|
794
|
+
return {
|
|
795
|
+
...outcome, diagnostics,
|
|
796
|
+
execution: {
|
|
797
|
+
completedStepIds: [...outcome.execution.completedStepIds],
|
|
798
|
+
failedStep: { ...outcome.execution.failedStep },
|
|
799
|
+
compensation: {
|
|
800
|
+
attemptedStepIds: [...outcome.execution.compensation.attemptedStepIds],
|
|
801
|
+
completedStepIds: [...outcome.execution.compensation.completedStepIds],
|
|
802
|
+
failed: outcome.execution.compensation.failed.map((failure) => ({ ...failure })),
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
function immutableProbeOutput(value, stdout, stderr) {
|
|
808
|
+
return Object.defineProperties(value, {
|
|
809
|
+
stdout: { enumerable: true, get: () => Buffer.from(stdout) },
|
|
810
|
+
stderr: { enumerable: true, get: () => Buffer.from(stderr) },
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function issueProbeOutcome(outcome, resolvedStep) {
|
|
814
|
+
if (resolvedStep !== undefined)
|
|
815
|
+
issuedOutcomes.set(outcome, stepFingerprint(resolvedStep));
|
|
816
|
+
return outcome;
|
|
817
|
+
}
|
|
818
|
+
/** The outcome when this executor produced it for exactly `step`; undefined for any other value. */
|
|
819
|
+
export function authenticatedExecutionOutcome(outcome, step) {
|
|
820
|
+
if (outcome === null || typeof outcome !== 'object')
|
|
821
|
+
return undefined;
|
|
822
|
+
const resolved = snapshotExecutionStep(step);
|
|
823
|
+
return resolved !== undefined && issuedOutcomes.get(outcome) === stepFingerprint(resolved) ? outcome : undefined;
|
|
824
|
+
}
|
|
825
|
+
function captureForegroundInput(input) {
|
|
826
|
+
try {
|
|
827
|
+
const executable = input.executable;
|
|
828
|
+
const argv = input.argv;
|
|
829
|
+
const cwd = input.cwd;
|
|
830
|
+
const env = input.env;
|
|
831
|
+
if (executable === null || typeof executable !== 'object'
|
|
832
|
+
|| typeof executable.canonicalPath !== 'string' || typeof executable.invocationPath !== 'string'
|
|
833
|
+
|| typeof executable.sha256 !== 'string'
|
|
834
|
+
|| executable.executionMode !== 'verified-path'
|
|
835
|
+
|| !Array.isArray(argv) || argv.length > 4_096
|
|
836
|
+
|| argv.some(value => typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > 65_536)
|
|
837
|
+
|| typeof cwd !== 'string' || !isAbsolute(cwd) || normalize(cwd) !== cwd || cwd.includes('\0')
|
|
838
|
+
|| env === null || typeof env !== 'object' || Array.isArray(env)
|
|
839
|
+
|| Object.getPrototypeOf(env) !== Object.prototype && Object.getPrototypeOf(env) !== null)
|
|
840
|
+
return undefined;
|
|
841
|
+
const environment = {};
|
|
842
|
+
let environmentBytes = 0;
|
|
843
|
+
const descriptors = Object.getOwnPropertyDescriptors(env);
|
|
844
|
+
const keys = Reflect.ownKeys(env);
|
|
845
|
+
if (keys.length > 4_096 || keys.some(key => typeof key !== 'string'))
|
|
846
|
+
return undefined;
|
|
847
|
+
for (const key of keys) {
|
|
848
|
+
const descriptor = descriptors[key];
|
|
849
|
+
if (!descriptor?.enumerable || !('value' in descriptor) || typeof descriptor.value !== 'string'
|
|
850
|
+
|| key.length === 0 || key.includes('\0') || descriptor.value.includes('\0'))
|
|
851
|
+
return undefined;
|
|
852
|
+
environmentBytes += Buffer.byteLength(key) + Buffer.byteLength(descriptor.value);
|
|
853
|
+
if (environmentBytes > 1_048_576)
|
|
854
|
+
return undefined;
|
|
855
|
+
Object.defineProperty(environment, key, { enumerable: true, value: descriptor.value });
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
executable: { ...executable },
|
|
859
|
+
argv: [...argv],
|
|
860
|
+
cwd,
|
|
861
|
+
env: environment,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// A throwing accessor or proxy trap means the step is malformed; the caller refuses it.
|
|
866
|
+
return undefined;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/** Runs one operator/agent session process while retaining ownership of its whole process group. */
|
|
870
|
+
export async function executeForegroundProcess(input, options = {}) {
|
|
871
|
+
const captured = captureForegroundInput(input);
|
|
872
|
+
let signal;
|
|
873
|
+
try {
|
|
874
|
+
signal = options.signal;
|
|
875
|
+
if (signal !== undefined && Object.getPrototypeOf(signal) !== AbortSignal.prototype)
|
|
876
|
+
throw new TypeError('invalid signal');
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
// A throwing accessor or proxy trap on the options means they are malformed, which is refused.
|
|
880
|
+
return { kind: 'refusal', code: ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED,
|
|
881
|
+
exitCode: 1, signal: null, started: false, terminationConfirmed: true };
|
|
882
|
+
}
|
|
883
|
+
if (!captured || signal?.aborted)
|
|
884
|
+
return {
|
|
885
|
+
kind: 'refusal',
|
|
886
|
+
code: signal?.aborted ? ENVIRONMENT_CODES.EXECUTION_ABORTED : ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED,
|
|
887
|
+
exitCode: 1, signal: null, started: false, terminationConfirmed: true,
|
|
888
|
+
};
|
|
889
|
+
const executable = captured.executable;
|
|
890
|
+
const consumed = {
|
|
891
|
+
path: executable.canonicalPath, sha256: executable.sha256, maxBytes: 536_870_912,
|
|
892
|
+
argvIndexes: [0], executable: true, executionMode: 'verified-path',
|
|
893
|
+
};
|
|
894
|
+
if (!await consumedFileMatches(consumed))
|
|
895
|
+
return {
|
|
896
|
+
kind: 'refusal', code: ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED,
|
|
897
|
+
exitCode: 1, signal: null, started: false, terminationConfirmed: true,
|
|
898
|
+
};
|
|
899
|
+
try {
|
|
900
|
+
if (await realpath(executable.invocationPath) !== executable.canonicalPath)
|
|
901
|
+
throw new Error('identity changed');
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// A changed or unresolvable executable path is reported by the precondition-changed refusal.
|
|
905
|
+
return { kind: 'refusal', code: ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED,
|
|
906
|
+
exitCode: 1, signal: null, started: false, terminationConfirmed: true };
|
|
907
|
+
}
|
|
908
|
+
let child;
|
|
909
|
+
try {
|
|
910
|
+
child = spawn(executable.canonicalPath, captured.argv, {
|
|
911
|
+
argv0: executable.invocationPath,
|
|
912
|
+
cwd: captured.cwd,
|
|
913
|
+
env: captured.env,
|
|
914
|
+
stdio: 'inherit',
|
|
915
|
+
detached: true,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
// The refusal carries the spawn-failed code; spawn errors echo argv and environment details.
|
|
920
|
+
return { kind: 'refusal', code: ENVIRONMENT_CODES.EXECUTION_SPAWN_FAILED,
|
|
921
|
+
exitCode: 1, signal: null, started: false, terminationConfirmed: true };
|
|
922
|
+
}
|
|
923
|
+
return await new Promise(resolveProcess => {
|
|
924
|
+
let closed;
|
|
925
|
+
let failure;
|
|
926
|
+
let terminationStarted = false;
|
|
927
|
+
let settled = false;
|
|
928
|
+
let killTimer;
|
|
929
|
+
let pollTimer;
|
|
930
|
+
let deadline = 0;
|
|
931
|
+
const groupGone = () => {
|
|
932
|
+
if (child.pid === undefined)
|
|
933
|
+
return closed !== undefined;
|
|
934
|
+
try {
|
|
935
|
+
process.kill(-child.pid, 0);
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
return hasErrorCode(error, 'ESRCH');
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
const terminate = (terminationSignal) => {
|
|
943
|
+
if (child.pid === undefined)
|
|
944
|
+
return;
|
|
945
|
+
try {
|
|
946
|
+
process.kill(-child.pid, terminationSignal);
|
|
947
|
+
}
|
|
948
|
+
catch { /* Exit races are authenticated by groupGone. */ }
|
|
949
|
+
};
|
|
950
|
+
const finish = (terminationConfirmed) => {
|
|
951
|
+
if (settled)
|
|
952
|
+
return;
|
|
953
|
+
settled = true;
|
|
954
|
+
if (killTimer !== undefined)
|
|
955
|
+
clearTimeout(killTimer);
|
|
956
|
+
if (pollTimer !== undefined)
|
|
957
|
+
clearTimeout(pollTimer);
|
|
958
|
+
signal?.removeEventListener('abort', abort);
|
|
959
|
+
const processSignal = closed?.signal ?? null;
|
|
960
|
+
const exitCode = closed?.code ?? (failure === undefined ? 0 : 1);
|
|
961
|
+
resolveProcess(failure === undefined
|
|
962
|
+
? { kind: 'complete', exitCode, signal: processSignal, terminationConfirmed: true }
|
|
963
|
+
: { kind: 'refusal', code: failure, exitCode: exitCode === 0 ? 1 : exitCode,
|
|
964
|
+
signal: processSignal, started: child.pid !== undefined, terminationConfirmed });
|
|
965
|
+
};
|
|
966
|
+
const confirmGone = () => {
|
|
967
|
+
if (settled)
|
|
968
|
+
return;
|
|
969
|
+
if (groupGone()) {
|
|
970
|
+
finish(true);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (Date.now() >= deadline) {
|
|
974
|
+
failure = ENVIRONMENT_CODES.EXECUTION_TERMINATION_UNCONFIRMED;
|
|
975
|
+
finish(false);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
pollTimer = setTimeout(confirmGone, 5);
|
|
979
|
+
};
|
|
980
|
+
const beginTermination = () => {
|
|
981
|
+
if (terminationStarted)
|
|
982
|
+
return;
|
|
983
|
+
terminationStarted = true;
|
|
984
|
+
deadline = Date.now() + TERMINATION_GRACE_MS;
|
|
985
|
+
terminate('SIGTERM');
|
|
986
|
+
killTimer = setTimeout(() => terminate('SIGKILL'), TERMINATION_KILL_MS);
|
|
987
|
+
confirmGone();
|
|
988
|
+
};
|
|
989
|
+
const abort = () => {
|
|
990
|
+
if (failure === undefined)
|
|
991
|
+
failure = ENVIRONMENT_CODES.EXECUTION_ABORTED;
|
|
992
|
+
beginTermination();
|
|
993
|
+
};
|
|
994
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
995
|
+
if (signal?.aborted)
|
|
996
|
+
abort();
|
|
997
|
+
child.on('error', () => {
|
|
998
|
+
failure = ENVIRONMENT_CODES.EXECUTION_SPAWN_FAILED;
|
|
999
|
+
beginTermination();
|
|
1000
|
+
});
|
|
1001
|
+
child.on('close', (code, closeSignal) => {
|
|
1002
|
+
closed = { code, signal: closeSignal };
|
|
1003
|
+
if (groupGone())
|
|
1004
|
+
finish(true);
|
|
1005
|
+
else
|
|
1006
|
+
beginTermination();
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
export async function executeProbe(step, options = {}) {
|
|
1011
|
+
let signal;
|
|
1012
|
+
let optionsReadable = true;
|
|
1013
|
+
try {
|
|
1014
|
+
signal = options.signal;
|
|
1015
|
+
if (signal !== undefined && Object.getPrototypeOf(signal) !== AbortSignal.prototype)
|
|
1016
|
+
optionsReadable = false;
|
|
1017
|
+
}
|
|
1018
|
+
catch { /* A throwing accessor or proxy trap on the options means they are malformed, which is refused below. */
|
|
1019
|
+
optionsReadable = false;
|
|
1020
|
+
}
|
|
1021
|
+
const resolved = optionsReadable ? snapshotExecutionStep(step) : undefined;
|
|
1022
|
+
const preparation = resolved === undefined ? undefined : await prepareExecutionPlan({ steps: [resolved] });
|
|
1023
|
+
let observation;
|
|
1024
|
+
if (resolved === undefined || preparation === undefined || preparation.kind === 'failure') {
|
|
1025
|
+
observation = invalidStepExecution();
|
|
1026
|
+
if (preparation?.kind === 'failure' && preparation.reason !== 'input-changed')
|
|
1027
|
+
observation.failure = preparation.reason === 'cleanup-failed'
|
|
1028
|
+
? ENVIRONMENT_CODES.EXECUTION_STAGING_CLEANUP_FAILED : ENVIRONMENT_CODES.EXECUTION_STAGING_FAILED;
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
const prepared = preparation.plan;
|
|
1032
|
+
const execution = prepared.steps.get(resolved);
|
|
1033
|
+
observation = execution === undefined ? invalidStepExecution() : await executeStep(execution, signal);
|
|
1034
|
+
if (observation.failure !== ENVIRONMENT_CODES.EXECUTION_TERMINATION_UNCONFIRMED
|
|
1035
|
+
&& prepared.directory !== undefined && !await removePreparedDirectory(prepared.directory)) {
|
|
1036
|
+
observation.failure = ENVIRONMENT_CODES.EXECUTION_STAGING_CLEANUP_FAILED;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (observation.failure !== undefined && observation.exitCode === 0)
|
|
1040
|
+
observation.exitCode = 1;
|
|
1041
|
+
const { failure, stdout, stderr, receipt, resolvedStep: _resolvedStep, ...executionValue } = observation;
|
|
1042
|
+
const execution = executionValue;
|
|
1043
|
+
if (failure)
|
|
1044
|
+
return issueProbeOutcome(receipt === undefined
|
|
1045
|
+
? immutableProbeOutput({ kind: 'refusal', code: failure, execution }, stdout, stderr)
|
|
1046
|
+
: immutableProbeOutput({ kind: 'refusal', code: failure, execution, receipt }, stdout, stderr), resolved);
|
|
1047
|
+
if (receipt === undefined)
|
|
1048
|
+
throw new TypeError('completed process is missing its execution receipt');
|
|
1049
|
+
return issueProbeOutcome(immutableProbeOutput({ kind: 'complete', execution, receipt }, stdout, stderr), resolved);
|
|
1050
|
+
}
|
|
1051
|
+
const PREPARATION_REFUSALS = {
|
|
1052
|
+
'input-changed': { code: ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, message: 'consumed execution input changed', target: 'execution-input' },
|
|
1053
|
+
'cleanup-failed': { code: ENVIRONMENT_CODES.EXECUTION_STAGING_CLEANUP_FAILED, message: 'execution staging cleanup failed', target: 'execution-staging' },
|
|
1054
|
+
'staging-failed': { code: ENVIRONMENT_CODES.EXECUTION_STAGING_FAILED, message: 'execution staging failed', target: 'execution-staging' },
|
|
1055
|
+
};
|
|
1056
|
+
export async function executePlan(plan, options = {}) {
|
|
1057
|
+
let signal;
|
|
1058
|
+
let optionsReadable = true;
|
|
1059
|
+
try {
|
|
1060
|
+
signal = options.signal;
|
|
1061
|
+
if (signal !== undefined && Object.getPrototypeOf(signal) !== AbortSignal.prototype)
|
|
1062
|
+
optionsReadable = false;
|
|
1063
|
+
}
|
|
1064
|
+
catch { /* A throwing accessor or proxy trap on the options means they are malformed, which is refused below. */
|
|
1065
|
+
optionsReadable = false;
|
|
1066
|
+
}
|
|
1067
|
+
const resolvedPlan = optionsReadable ? snapshotExecutionPlan(plan) : undefined;
|
|
1068
|
+
if (resolvedPlan === undefined)
|
|
1069
|
+
return immutablePlanOutcome({
|
|
1070
|
+
kind: 'refusal', status: 2, result: null,
|
|
1071
|
+
diagnostics: [{ severity: 'error', code: ENVIRONMENT_CODES.EXECUTION_PRECONDITION_CHANGED, message: 'execution plan is malformed', target: 'execution-input' }],
|
|
1072
|
+
execution: {
|
|
1073
|
+
completedStepIds: [], failedStep: { id: 'execution-input', started: false },
|
|
1074
|
+
compensation: { attemptedStepIds: [], completedStepIds: [], failed: [] },
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
1077
|
+
const preparation = await prepareExecutionPlan(resolvedPlan);
|
|
1078
|
+
if (preparation.kind === 'failure') {
|
|
1079
|
+
const { code, message, target } = PREPARATION_REFUSALS[preparation.reason];
|
|
1080
|
+
return immutablePlanOutcome({
|
|
1081
|
+
kind: 'refusal', status: 2, result: null,
|
|
1082
|
+
diagnostics: [{ severity: 'error', code, message, target }],
|
|
1083
|
+
execution: {
|
|
1084
|
+
completedStepIds: [], failedStep: { id: target, started: false },
|
|
1085
|
+
compensation: { attemptedStepIds: [], completedStepIds: [], failed: [] },
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
const preparedPlan = preparation.plan;
|
|
1090
|
+
let preserveStaging = false;
|
|
1091
|
+
const runPreparedPlan = async () => {
|
|
1092
|
+
const completed = [];
|
|
1093
|
+
for (const step of resolvedPlan.steps) {
|
|
1094
|
+
const preparedStep = preparedPlan.steps.get(step);
|
|
1095
|
+
const observation = preparedStep === undefined ? invalidStepExecution() : await executeStep(preparedStep, signal);
|
|
1096
|
+
if (!observation.failure) {
|
|
1097
|
+
const { failure: _failure, started: _started, stdout: _stdout, stderr: _stderr, receipt: _receipt, stdoutObservedBytes: _stdoutObservedBytes, stderrObservedBytes: _stderrObservedBytes, stdoutRetainedBytes: _stdoutRetainedBytes, stderrRetainedBytes: _stderrRetainedBytes, resolvedStep: _resolvedStep, ...execution } = observation;
|
|
1098
|
+
completed.push(execution);
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
const attempted = [];
|
|
1102
|
+
const compensated = [];
|
|
1103
|
+
const compensationFailures = [];
|
|
1104
|
+
const compensationDiagnostics = [];
|
|
1105
|
+
if (observation.failure === ENVIRONMENT_CODES.EXECUTION_TERMINATION_UNCONFIRMED)
|
|
1106
|
+
preserveStaging = true;
|
|
1107
|
+
const compensations = preserveStaging ? [] : observation.started ? observation.resolvedStep?.compensation ?? [] : [];
|
|
1108
|
+
for (const compensation of compensations) {
|
|
1109
|
+
attempted.push(compensation.id);
|
|
1110
|
+
const preparedCompensation = preparedPlan.steps.get(compensation);
|
|
1111
|
+
const compensationObservation = preparedCompensation === undefined
|
|
1112
|
+
? invalidStepExecution()
|
|
1113
|
+
: await executeStep(preparedCompensation);
|
|
1114
|
+
if (!compensationObservation.failure) {
|
|
1115
|
+
compensated.push(compensation.id);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
compensationFailures.push({ id: compensation.id, code: compensationObservation.failure });
|
|
1119
|
+
if (compensationObservation.failure === ENVIRONMENT_CODES.EXECUTION_TERMINATION_UNCONFIRMED)
|
|
1120
|
+
preserveStaging = true;
|
|
1121
|
+
compensationDiagnostics.push({
|
|
1122
|
+
severity: 'error',
|
|
1123
|
+
code: compensationObservation.failure === ENVIRONMENT_CODES.EXECUTION_TERMINATION_UNCONFIRMED
|
|
1124
|
+
? ENVIRONMENT_CODES.EXECUTION_TERMINATION_UNCONFIRMED
|
|
1125
|
+
: compensationObservation.failure === ENVIRONMENT_CODES.EXECUTION_TIMEOUT
|
|
1126
|
+
? ENVIRONMENT_CODES.EXECUTION_COMPENSATION_TIMEOUT
|
|
1127
|
+
: ENVIRONMENT_CODES.EXECUTION_COMPENSATION_STEP_FAILED,
|
|
1128
|
+
message: `compensation step ${compensation.id} failed (${compensationObservation.failure})`,
|
|
1129
|
+
target: compensation.id,
|
|
1130
|
+
});
|
|
1131
|
+
if (preserveStaging)
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
const stderrTail = observation.resolvedStep?.failureOutput === 'stderr-tail' ? failedStepStderr(observation.stderr) : undefined;
|
|
1135
|
+
return {
|
|
1136
|
+
kind: 'refusal', status: 2, result: null,
|
|
1137
|
+
diagnostics: [
|
|
1138
|
+
{ severity: 'error', code: observation.failure,
|
|
1139
|
+
message: `execution step ${observation.id} failed`,
|
|
1140
|
+
target: observation.id },
|
|
1141
|
+
...compensationDiagnostics,
|
|
1142
|
+
],
|
|
1143
|
+
execution: {
|
|
1144
|
+
completedStepIds: completed.map(({ id }) => id),
|
|
1145
|
+
failedStep: { id: observation.id, started: observation.started, ...(stderrTail === undefined ? {} : { stderr: stderrTail }) },
|
|
1146
|
+
compensation: {
|
|
1147
|
+
attemptedStepIds: attempted,
|
|
1148
|
+
completedStepIds: compensated,
|
|
1149
|
+
failed: compensationFailures,
|
|
1150
|
+
},
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
return { kind: 'complete', status: 0, diagnostics: [], result: { steps: completed } };
|
|
1155
|
+
};
|
|
1156
|
+
const outcome = await runPreparedPlan();
|
|
1157
|
+
if (preserveStaging)
|
|
1158
|
+
return immutablePlanOutcome(outcome);
|
|
1159
|
+
if (preparedPlan.directory === undefined || await removePreparedDirectory(preparedPlan.directory))
|
|
1160
|
+
return immutablePlanOutcome(outcome);
|
|
1161
|
+
if (outcome.kind === 'refusal')
|
|
1162
|
+
return immutablePlanOutcome({
|
|
1163
|
+
...outcome,
|
|
1164
|
+
diagnostics: [...outcome.diagnostics, { severity: 'error', code: ENVIRONMENT_CODES.EXECUTION_STAGING_CLEANUP_FAILED, message: 'execution staging cleanup failed', target: 'execution-staging' }],
|
|
1165
|
+
});
|
|
1166
|
+
return immutablePlanOutcome({
|
|
1167
|
+
kind: 'refusal', status: 2, result: null,
|
|
1168
|
+
diagnostics: [{ severity: 'error', code: ENVIRONMENT_CODES.EXECUTION_STAGING_CLEANUP_FAILED, message: 'execution staging cleanup failed', target: 'execution-staging' }],
|
|
1169
|
+
execution: {
|
|
1170
|
+
completedStepIds: outcome.result.steps.map(({ id }) => id),
|
|
1171
|
+
failedStep: { id: 'execution-staging', started: true },
|
|
1172
|
+
compensation: { attemptedStepIds: [], completedStepIds: [], failed: [] },
|
|
1173
|
+
},
|
|
1174
|
+
});
|
|
1175
|
+
}
|