From cef38774d28f6f7dc29f637f349bed76ac98387c Mon Sep 17 00:00:00 2001 From: Nick Foster Date: Sat, 17 Oct 2026 11:16:16 -0500 Subject: [PATCH 1/5] refactor(ci): use the merged core systemd executor --- .github/workflows/validate.yml | 19 +- AGENTS.md | 23 +- README.md | 17 +- docs/LOCAL-VALIDATION.md | 118 ++++---- docs/RELEASE.md | 13 + docs/UPDATING.md | 26 +- scripts/local_validate.py | 63 ++-- scripts/local_validation_supervisor.py | 291 ------------------- scripts/run_candidate_tests.py | 3 +- scripts/test_candidate_runner.py | 5 +- scripts/test_core_compatibility.py | 3 +- scripts/test_executor_binding.py | 56 ++++ scripts/test_local_validation.py | 74 ++--- scripts/test_local_validation_supervisor.py | 305 -------------------- scripts/test_workflow_events.py | 6 +- 15 files changed, 271 insertions(+), 751 deletions(-) delete mode 100644 scripts/local_validation_supervisor.py create mode 100644 scripts/test_executor_binding.py delete mode 100644 scripts/test_local_validation_supervisor.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 47f7fae..cd5b641 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -55,8 +55,19 @@ jobs: persist-credentials: false fetch-depth: 0 + - name: Check out trusted executor + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: RandomDevelopment/ci-fleet + ref: 689c7c00deb0eebcb25f2284334a9f7168a25575 + path: .ci-fleet-executor + persist-credentials: false + fetch-depth: 0 + - name: Install validation tools - run: sudo apt-get update && sudo apt-get install -y shellcheck + run: | + sudo apt-get update && sudo apt-get install -y shellcheck + sudo python3 -I "$GITHUB_WORKSPACE/.ci-fleet-executor/scripts/local_validation_executor.py" --prepare --disposable-host - name: Run complete local validation env: @@ -74,7 +85,7 @@ jobs: main="$BASE" title_args+=(--pr-title "$PR_TITLE") fi - python3 -I "$GITHUB_WORKSPACE/.ci-fleet-suite/scripts/local_validate.py" \ + sudo python3 -I "$GITHUB_WORKSPACE/.ci-fleet-suite/scripts/local_validate.py" \ --repository "$GITHUB_WORKSPACE/candidate" --repository-name "$REPOSITORY" \ --source "$SOURCE" --base "$BASE" --main "$main" \ --policy-repository "$GITHUB_WORKSPACE/.ci-fleet-validator" --policy "$VALIDATOR" \ @@ -83,6 +94,6 @@ jobs: - name: Show validation report if: always() run: | - if [[ -f "$RUNNER_TEMP/local-validation/result.json" ]]; then - cat "$RUNNER_TEMP/local-validation/result.json" + if sudo test -f "$RUNNER_TEMP/local-validation/result.json"; then + sudo cat "$RUNNER_TEMP/local-validation/result.json" fi diff --git a/AGENTS.md b/AGENTS.md index 0c0449c..6deb9f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ complete exact-source suite from a clean, independently reviewed template checkout, with an independently pinned engine validator: ```bash -python3 -I /trusted/template/scripts/local_validate.py \ +sudo python3 -I /trusted/template/scripts/local_validate.py \ --repository /work/config --repository-name OWNER/REPOSITORY \ --source SOURCE_SHA --base BASE_SHA --main MAIN_SHA \ --policy-repository /trusted/ci-fleet --policy REVIEWED_ENGINE_VALIDATOR_SHA \ @@ -56,9 +56,9 @@ Candidate-added IDs run only after the required corpus. The mapped runner requires unique planned IDs and matching execution counts. No actual skips are accepted. Keep the trusted public fixtures and derived upstream-probe applicability intact. -Run the pinned caller, supervisor and candidate-runner definitions against the +Run the pinned caller, executor-binding and candidate-runner definitions against the candidate harness programs once in both upstream and adopter modes. Keep the -outer driver/supervisor pinned and runner self-test workflow inputs trusted; +outer driver and core executor pinned and runner self-test workflow inputs trusted; those fixture inputs must not restore an absent candidate workflow. In adopter policy, updater and release-update stages, run the complete public and candidate-mapped corpora concurrently through the pinned runner in separate @@ -79,13 +79,16 @@ grafts. Preserve trusted fixture overrides such as the updater's private index. These checks are not a sandbox against hostile root or same-stage transient tampering. Independently review candidate programs and the harness, and supervise execution on a disposable Linux machine -with mounted `/proc`, child subreaper and pidfd support, and Python 3.11 or newer. -The command supervisor never enables or changes the caller's existing -subreaper flag. It preserves either initial value, 0 or 1. It must confirm -complete descendant cleanup; missing results or forced cleanup after the -60-second grace fail validation. An external hard kill of the harness or -supervisor, or lost supervision, requires discarding the disposable execution -machine before another run. +with systemd 255 or newer, cgroup v2 and Python 3.11 or newer. +Prepare the shared core executor from the independently reviewed revision named +by `EXECUTOR_REVISION`. Require its protected installed revision/hash manifest; +never load an executor supplied by the candidate or add a fallback supervisor. +The root launcher gives ordinary stages a locked unprivileged account, read-only +trusted inputs and separate writable source/home/scratch paths. Harness fixtures +that exercise the root launcher remain disposable-machine-only. The system +manager must confirm cgroup cleanup; missing receipts and forced termination +fail. Lost-worker runs are incomplete. Export one complete verified result +bundle outside the worker before disposal. Signing authority remains separate. Preflight rejects standard installed fleet and signing-role markers, including broken symlinks. Platform and host admission happen before Git inspection, output-directory creation, tool probes or candidate execution. Early admission diff --git a/README.md b/README.md index 9461ce2..03a08df 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,14 @@ Optional `--default-bridge-cidr 192.0.2.1/24` also prints a review-only Docker n ## Complete local validation -Run this command on a supervised disposable Linux machine with `/proc` mounted, -kernel support for child subreapers and pidfds, Bash, Git, Python 3.11 or newer -and the documented validation tools. Replace every SHA placeholder with a full +Run this command as a trusted root launcher on a prepared disposable Linux +worker with systemd 255 or newer, cgroup v2, Bash, Git, Python 3.11 or newer +and the documented validation tools. The shared core executor runs ordinary +configuration stages unprivileged and keeps trusted inputs read-only. Replace every SHA placeholder with a full reviewed commit identity: ```bash -python3 -I /trusted/template/scripts/local_validate.py \ +sudo python3 -I /trusted/template/scripts/local_validate.py \ --repository /work/config --repository-name OWNER/REPOSITORY \ --source SOURCE_SHA --base BASE_SHA --main MAIN_SHA \ --policy-repository /trusted/ci-fleet --policy REVIEWED_ENGINE_VALIDATOR_SHA \ @@ -82,8 +83,8 @@ their ancestry, without unrelated branches or tags. Each stage restores an independent copy of the verified frozen seeds, including Git metadata, modes and symlinks. Files are not hardlinked back to a seed. Git identity checks use stored objects with replacement refs and grafts disabled; orchestration and stage -environments filter caller-supplied `GIT_*` settings. A separate command -supervisor owns descendants through cleanup. It preserves the standalone policy, updater, +environments filter caller-supplied `GIT_*` settings. The installed, revision-pinned core executor delegates process ownership and +cleanup to the system manager. It preserves the standalone policy, updater, exact-core, release-update and initializer checks. It writes per-check logs, tool versions, identities and digests to the new output directory. Public fictional fixtures remain distinct from strict initialized-adopter validation. @@ -99,9 +100,9 @@ workflow inputs in trusted fictional fixtures. Upstream checks also use the candidate's release/setup documents. Candidate-added test IDs run afterward as supplemental coverage; they cannot replace the required definitions. The mapped runner requires unique planned IDs and matching execution counts. -Actual skips fail validation. The pinned caller, supervisor and runner tests +Actual skips fail validation. The pinned caller, executor-binding and runner tests exercise the candidate harness programs once in both upstream and adopter runs; -the outer execution driver and supervisor remain independently pinned. Runner +the outer execution driver and installed core executor remain independently pinned. Runner self-tests use pinned workflow fixtures without restoring an absent candidate workflow. Initialized adopters retain the complete trusted public policy, updater, release and workflow corpus as well. diff --git a/docs/LOCAL-VALIDATION.md b/docs/LOCAL-VALIDATION.md index 2df690c..55f5520 100644 --- a/docs/LOCAL-VALIDATION.md +++ b/docs/LOCAL-VALIDATION.md @@ -7,15 +7,31 @@ adopter, but runs only the focused policy check. ## Prerequisites and trusted inputs -Use a separate disposable Linux execution machine under operator supervision, -with `/proc` mounted and kernel support for child subreapers and pidfds. -Use Bash 4 or newer, Git 2.40 or newer, Python 3.11 or newer, -OpenSSL, ShellCheck and GNU `stat` available on the standard system path. -The harness rejects other operating systems or an unavailable `/proc`, and the -supervisor checks subreaper and pidfd support before launching a command. -The updater fixtures need Git's -`merge-tree --merge-base` support. No Docker daemon, root account, runner -registration or fleet credential is needed. +Use a separate disposable Linux execution worker with systemd 255 or newer as +PID 1 and cgroup v2. Use Bash 4+, Git 2.40+, Python 3.11+, OpenSSL, ShellCheck +and GNU stat. Updater fixtures need Git's `merge-tree --merge-base` support. +The trusted launcher runs as root; ordinary configuration stages use a locked +unprivileged account and read-only trusted inputs. Writable paths are limited to +that stage's source, home and scratch. Root-launcher regression fixtures run as +root only on this disposable worker. No Docker daemon, runner registration or +fleet credential is needed. There is no custom process-tracking fallback. + +From a clean core checkout at the exact revision in the trusted suite's +`EXECUTOR_REVISION`, prepare each fresh worker once: + +```bash +sudo python3 -I /trusted/executor/scripts/local_validation_executor.py \ + --prepare --disposable-host +``` + +Preparation installs the shared executor and a revision/hash manifest under +`/usr/local/lib/ci-fleet-validation` and creates a locked execution account and +protected workspace parent. It adds no daemon, timer or unique fleet state. +Use a Linux VM or remote prepared worker from macOS or Windows. Shared executor support merged in core [PR #144](https://github.com/RandomDevelopment/ci-fleet/pull/144) +at `689c7c00deb0eebcb25f2284334a9f7168a25575`. Its pin is separate from the retained +aa9bbb configuration-policy baseline. Changing +execution tooling does not adopt a newer schema or controller runtime. + Never execute candidate scripts on an active fleet host or a machine holding the evidence-signing private key. Source programs are proposed code and need independent review before execution. @@ -37,13 +53,12 @@ Review the complete template harness as well as the engine validator. The harness records its actual `suite_revision` and `suite_tree`; a caller cannot supply a suite revision to relabel another program. Engine policy is selected independently through `--policy`, never from candidate configuration. -The harness binds both `scripts/local_validate.py` and its -`scripts/local_validation_supervisor.py` helper to that same committed suite. -Identity and committed-byte checks read stored Git objects with replacement -refs and grafts disabled. -The helper captures its source once at import, before candidate execution. -Every child supervisor runs that frozen code through Python `-I -c`, so replacing -the original helper path cannot change the supervisor used by later commands. +The harness binds its caller and candidate-test runner to the committed suite. +It loads only the protected installed core executor, requires its exact revision +and records `executor_revision`. Identity and committed-byte checks disable Git +replacement refs and grafts. Candidate-local modules cannot select the executor. +An independent signing authority must check that recorded executor revision +against the reviewed suite pin before accepting the bundle. Fetch the required commits and fresh engine history before running. Source, main, reviewed head, engine policy and any nonzero base must be full lowercase @@ -65,7 +80,7 @@ configuration Git. Replace all SHA placeholders and use a new external output directory: ```bash -python3 -I /trusted/template/scripts/local_validate.py \ +sudo python3 -I /trusted/template/scripts/local_validate.py \ --repository /work/config --repository-name OWNER/REPOSITORY \ --source SOURCE_SHA --base BASE_SHA --main MAIN_SHA \ --policy-repository /trusted/ci-fleet --policy REVIEWED_ENGINE_VALIDATOR_SHA \ @@ -160,10 +175,10 @@ absent, a pinned availability test checks the local entry point and the report records `hosted-workflow-present: false`. This applicability does not use a runtime skip. Adopter runs still execute the full trusted public workflow corpus. -The pinned local-validation, supervisor and candidate-runner test inventories +The pinned local-validation, executor-binding and candidate-runner test inventories run once against candidate harness programs in both upstream and adopter modes. -The outer runner and command supervisor still execute from the selected trusted -suite. Candidate harness files are programs under test, never the outer driver. +The outer runner comes from the selected trusted suite; the executor is the +independently selected installed core revision. Candidate harness files are programs under test, never the outer driver. Runner self-tests read their workflow content and definitions from the trusted fixture root. Those fixture resources do not restore a missing candidate workflow or change its availability result. @@ -172,16 +187,16 @@ Adopter policy, updater and release-update stages each run two checks concurrent in independent fixture copies. One uses the trusted public suite as its input; the other uses candidate programs in derived mode. Both use the pinned runner's required test plans and execution counts. The other stages remain sequential, -and the existing command supervisor still owns both checks and their descendants. +and the system manager owns both checks and their descendants. Either failure fails the stage. The driver waits for both checks, including after a child launch failure, and success requires both named child logs to exist. -The trusted parent freezes command vectors and private external child-log paths -before candidate execution. After the supervisor finishes cleanup, the parent +The trusted parent freezes command vectors and invocation-private scratch child-log paths +before candidate execution. After the system manager finishes cleanup, the parent assembles command headers, public output, candidate output and driver diagnostics in that order, including after failure, cancellation or timeout. Late descendant output therefore precedes the final stage-log digest. The combined stage log -listed in `result.json` is authoritative; named child logs are additional diagnostics. +listed in `result.json` is authoritative; child logs are temporary inputs discarded with the worker scratch space. Both checks share the existing command timeout and five-minute hosted job limit. Each candidate-mapped required corpus records its planned test IDs. IDs must @@ -273,37 +288,27 @@ exclude inherited credentials and Python configuration. Tests use invocation-specific temporary resources. Each command has a default 180-second timeout, adjustable with `--timeout`. -A dedicated supervisor becomes a Linux child subreaper for that command. -It never enables or changes the caller's existing subreaper flag. It preserves -either initial value, 0 or 1. The supervisor adopts orphaned descendants, -including programs that use `setsid` or double-fork to leave the command's -process group. It tracks descendants through `/proc` and signals them through -pidfds to avoid signalling a different process after PID reuse. - -After command completion, failure, timeout or handled cancellation, the -supervisor sends one initial `SIGTERM` sweep to remaining descendants and allows -60 seconds for cleanup before sending `SIGKILL`. During that grace period it -sends further `SIGTERM` only to newly adopted direct orphans. Cleanup helpers -under a still-living, already signalled parent can run until the same grace -deadline. The supervisor reaps exited children and returns only after `waitpid` -reports `ECHILD`, confirming no children remain. The orchestrator -requires a valid structured supervisor result with complete cleanup; a missing -or malformed result fails validation. A successful command leader still fails -its stage if a descendant requires `SIGKILL`. Graceful descendant cleanup does -not turn success into failure. Failed, timed-out and cancelled commands remain -failed or cancelled after cleanup. Temporary checkouts are removed; logs remain -in the output directory. - -A task stuck in an uninterruptible kernel wait can delay return beyond the -command timeout and cleanup grace. The supervisor keeps waiting for the kernel -to confirm that every descendant has exited and been reaped. A hard kill or -machine loss can prevent final reporting and temporary-resource cleanup. -Normal `SIGKILL` cleanup of owned children leaves the supervisor responsible for -reaping them and retains the failure result described above. If an external -hard kill interrupts the harness or supervisor, or supervision is lost, discard -that disposable execution machine and use a fresh one before another run. -Preserve available logs before discarding it. This suite creates no Docker -resources and does not require global pruning. +The shared executor starts a unique system-manager transient service per command. +PID 1 tracks the entire cgroup, including detached and double-fork children, and +enforces the deadline. When the command's recorded main process exits, the +trusted client asks the manager to stop its cgroup. Cgroup lifetime keeps +cleanup helpers alive through the 60-second stop grace, even if a shell exits +before its child. Forced termination fails the check. + +Success requires a protected manager receipt, a successful command result and +an empty cgroup before another stage begins. Missing receipts, cancellation, +timeout and incomplete cleanup fail validation. There are no Python process-tree +walks, pidfds or child subreapers in this repository. Manager failure or uncertain +cleanup requires discarding the worker. Killing the launcher leaves PID 1's +deadline in place, but cannot produce accepted completion evidence. + +Export the one complete result directory once to durable storage outside the +worker, verify its report and every recorded log hash there, and use that bundle +for review and signing. Interrupted execution or export cannot authorize host +application. GitHub artifact uploads are optional. Rebuild a lost worker from +its reviewed prerequisites and rerun the exact pinned inputs. No machine backup +or persistent validator service is required. This suite creates no Docker +resources and does not use global pruning. Fresh stage checkouts prevent a stage from persistently substituting source or pinned scripts for later stages. These checks are not a sandbox against a @@ -380,8 +385,7 @@ passed all eight stages outside Actions on reviewed source and policy `a8552da5c902c54c0d78b19e6f093e2e96482f62`, with `expected_skips: []`. That engine result does not replace this complete standalone suite. The standalone ordinary-CI compatibility baseline remains -`aa9bbb27a4746fc80ce27d8932de68450b28364b`. The shared supervisor and Git/output -helpers retain their reviewed implementation provenance at +`aa9bbb27a4746fc80ce27d8932de68450b28364b`. The historical Git/output helpers retain their reviewed implementation provenance at `96f277b3a75f3d2bef3c04ceb7b3c8778b8998c2`. Core availability and functional suite results establish no template release, private engine promotion or signed operational acceptance. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index ad25392..7413b7c 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,5 +1,18 @@ # Template releases +## Unreleased validation execution change + +The current development suite uses `ci-fleet-config-standalone-v2` and the +separately pinned shared core systemd executor. Prepare a disposable Linux +worker before running it. The repository no longer carries a process supervisor. +See [local validation](LOCAL-VALIDATION.md) for the root launcher, unprivileged +stages, prerequisites, cleanup and result export. + +This tooling change preserves the aa9bbb configuration-policy baseline and all +published v1.0.0/v1.1.0 identities and records below. It publishes no release and +changes no controller runtime or host acceptance policy. Existing adopters can +retain the released v1.1.0 public fixtures independently of execution tooling. + ## Published v1.1.0 release The [`v1.1.0` release](https://github.com/RandomDevelopment/ci-fleet-config-template/releases/tag/v1.1.0) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 167ee54..cf7d53e 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -263,17 +263,20 @@ fail validation. An absent optional workflow receives a pinned local-availability check; initialized adopters also retain the complete trusted public workflow corpus. Review candidate-added tests with the other candidate programs before execution. -Pinned caller, supervisor and candidate-runner definitions exercise the candidate -harness programs once in both modes. The outer driver/supervisor and runner -self-test workflow resources remain trusted; removed candidate workflows stay +Pinned caller, executor-binding and candidate-runner definitions exercise the candidate +harness programs once in both modes. The outer driver, installed core executor +and runner self-test workflow resources remain trusted; removed candidate workflows stay absent. Adopter policy, updater and release-update stages run their complete public and candidate checks concurrently in separate fixtures through the pinned runner. Stages remain sequential, and either failure fails the stage. Ordered combined logs include output through supervised cleanup, even after cancellation or timeout; -the named child logs are diagnostic copies. +child logs are temporary scratch inputs to the authoritative combined log. Run under operator supervision on a disposable Linux execution machine with -mounted `/proc`, child subreaper and pidfd support, and Python 3.11 or newer. +systemd 255 or newer, cgroup v2, Python 3.11 or newer and the exact shared +executor installed through the preparation command in the local-validation guide. +Run the trusted launcher as root; ordinary stages run unprivileged with +read-only trusted inputs. The suite verifies its seeds and fresh source, engine-policy and trusted-suite checkouts for each stage. Initial SHA-only fetches include the selected ancestry without unrelated branches or tags. Effective origin/main metadata is fixed @@ -283,12 +286,13 @@ complete cleanup remain in force. Git identity checks read stored objects with replacement refs and grafts disabled. Orchestration and stage environments filter all inherited or supplied `GIT_*` settings, then apply fixed trusted controls. Nested updater fixtures retain their deliberate private-index override. -Each command's separate subreaper owns descendants through cleanup, including -`setsid` and double-fork children. -Missing supervisor results and forced cleanup after the 60-second grace fail -validation. See [cleanup limits](LOCAL-VALIDATION.md#results-failures-and-cleanup) -for kernel waits and hard kills. This prevents persistent substitution between -stages; it does not contain hostile root or transient tampering within a stage. +Each command uses the shared systemd executor. PID 1 owns its cgroup and +cleanup deadline, including `setsid`, double-fork and cleanup-helper children. +A missing manager receipt, forced termination or incomplete cgroup cleanup +fails validation. See [cleanup limits](LOCAL-VALIDATION.md#results-failures-and-cleanup) +for manager failures and lost workers. Export one verified result bundle to +durable storage before discarding the worker. Keep signing separate and do not +claim a sandbox against hostile root fixtures or same-stage transient tampering. The ordinary-CI compatibility pin remains `aa9bbb27a4746fc80ce27d8932de68450b28364b`; fictional example engine pins and diff --git a/scripts/local_validate.py b/scripts/local_validate.py index 8ff3a57..6cd08e1 100644 --- a/scripts/local_validate.py +++ b/scripts/local_validate.py @@ -18,13 +18,12 @@ import time ROOT = Path(__file__).resolve().parents[1] -MODULE_PATH = Path(__file__).resolve().with_name("local_validation_supervisor.py") -SPEC = importlib.util.spec_from_file_location("local_validation_supervisor", MODULE_PATH) -supervisor = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(supervisor) +EXECUTOR_PATH = Path("/usr/local/lib/ci-fleet-validation/executor.py") +# Shared execution tooling is independent of the configuration-policy baseline. +EXECUTOR_REVISION = "689c7c00deb0eebcb25f2284334a9f7168a25575" SHA = re.compile(r"[0-9a-f]{40}") NO_PRIOR_STATE = "0" * 40 -SUITE = "ci-fleet-config-standalone-v1" +SUITE = "ci-fleet-config-standalone-v2" CHECKS = ("commits", "history-secrets", "syntax", "engine-policy", "configuration", "core-compatibility", "initializer", "policy", "updater", "release-update", "harness") UPSTREAM = "https://github.com/RandomDevelopment/ci-fleet-config-template" @@ -119,9 +118,23 @@ def verify_checkout(root: Path, expected: dict, *, allow_generated: bool = False raise RuntimeError("checkout changed: unexpected " + str(root / path)) -def require_proc() -> None: - if sys.platform != "linux" or not Path("/proc/self/stat").is_file(): - raise RuntimeError("validation requires Linux with mounted /proc") +def require_worker() -> None: + if sys.platform != "linux" or os.geteuid() != 0: + raise RuntimeError("the trusted launcher requires root on a disposable Linux worker") + if not Path("/run/systemd/system").is_dir() or not Path("/sys/fs/cgroup/cgroup.controllers").is_file(): + raise RuntimeError("validation requires the system systemd manager and cgroup v2") + + +def load_executor(): + for path in (EXECUTOR_PATH, *EXECUTOR_PATH.parents): + mode = path.lstat() + if path.is_symlink() or mode.st_uid != 0 or mode.st_mode & 0o022: + raise RuntimeError("unsafe installed executor; prepare the disposable worker") + specification = importlib.util.spec_from_file_location("ci_fleet_executor", EXECUTOR_PATH) + module = importlib.util.module_from_spec(specification) + exec(compile(EXECUTOR_PATH.read_bytes(), str(EXECUTOR_PATH), "exec"), module.__dict__) + module.require_revision(EXECUTOR_REVISION) + return module def require_execution_host() -> None: @@ -170,8 +183,9 @@ def output_directory(path: Path, *repositories: Path) -> Path: def run(command: list[str], cwd: Path, env: dict, log: Path, timeout: int) -> None: - require_proc() - supervisor.run(command, cwd, env, log, timeout, grace=CLEANUP_GRACE_SECONDS) + require_worker() + load_executor().run(command, cwd, env, log, timeout, grace=CLEANUP_GRACE_SECONDS, + user=env.get("CI_FLEET_STAGE_USER", "root")) def check_log(raw: bytes) -> None: @@ -180,14 +194,16 @@ def check_log(raw: bytes) -> None: def execute(args: argparse.Namespace) -> int: - require_proc() + require_worker() require_execution_host() output = output_directory(args.output, args.repository, args.policy_repository) + executor = load_executor() + executor.require_worker() output.mkdir(mode=0o700, parents=True, exist_ok=False) report = {"schema_version": 1, "suite": SUITE, "profile": "full", "status": "incomplete", "source": args.source, "reviewed_head": args.reviewed_head or args.source, "base": args.base, "prior_state_absent": args.base == NO_PRIOR_STATE, - "main": args.main, "policy": args.policy, + "main": args.main, "policy": args.policy, "executor_revision": EXECUTOR_REVISION, "repository": args.repository_name, "pr_title": args.pr_title, "expected_skips": [], "tools": {}, "results": [{"name": name, "status": "incomplete", "log": name + ".log"} for name in CHECKS], "started_at": int(time.time())} @@ -197,8 +213,8 @@ def execute(args: argparse.Namespace) -> int: raise ValueError("repository-name must be owner/repository") suite_revision = git(ROOT, "rev-parse", "HEAD") require_commit(ROOT, suite_revision) - # Bind the caller and its process supervisor to the same committed suite. - for path in (Path(__file__), MODULE_PATH): + # Bind the caller and mapped test runner to the same committed suite. + for path in (Path(__file__), ROOT / "scripts/run_candidate_tests.py"): committed = subprocess.check_output(["git", "-C", str(ROOT), "show", suite_revision + ":scripts/" + path.name], env=git_environment()) if committed != path.read_bytes(): @@ -224,9 +240,9 @@ def execute(args: argparse.Namespace) -> int: "hosted-workflow-present": bool(git(repository, "ls-tree", args.source, "--", ".github/workflows/validate.yml")), "candidate-regressions": True, "upstream-probes-covered-by-trusted-fixtures": not upstream} - with tempfile.TemporaryDirectory(prefix="ci-fleet-config-validation-") as temporary: + with tempfile.TemporaryDirectory(prefix="ci-fleet-config-validation-", dir=executor.WORK) as temporary: root = Path(temporary) - root.chmod(0o700) + root.chmod(0o711) source, policy, suite = (root / name for name in ("source", "policy", "suite")) clone(repository, args.source, source, source_identities) clone(policy_repository, args.policy, policy, [args.policy]) @@ -271,10 +287,10 @@ def execute(args: argparse.Namespace) -> int: "configuration": [["bash", str(suite / "scripts/validate-local-configuration.sh")]], "core-compatibility": [[*py, str(suite / "scripts/test_core_compatibility.py"), "--standalone-root", str(source), *([] if upstream else ["--derived"])]], "initializer": [["bash", str(suite / "scripts/validate-local-configuration.sh")]], - "harness": [[*py, "-I", *(["-W", "error::ResourceWarning"] if module == "test_local_validation_supervisor" else []), + "harness": [[*py, "-I", *(["-W", "error::ResourceWarning"] if module == "test_executor_binding" else []), str(suite / "scripts/run_candidate_tests.py"), "--repository", str(source), "--test", module, *([] if upstream else ["--derived"])] - for module in ("test_local_validation", "test_local_validation_supervisor", "test_candidate_runner")], + for module in ("test_local_validation", "test_executor_binding", "test_candidate_runner")], } paired_logs = {} for name, module in (("policy", "test_policy"), ("updater", "test_template_updater"), @@ -286,8 +302,8 @@ def execute(args: argparse.Namespace) -> int: if not upstream and name != "harness": public = [*py, "-I", str(suite / "scripts/run_candidate_tests.py"), "--repository", str(suite), "--test", module] - paired_logs[name] = [(public, output / (name + "-public.log")), - (candidate, output / (name + "-candidate.log"))] + paired_logs[name] = [(public, fixtures / (name + "-public.log")), + (candidate, fixtures / (name + "-candidate.log"))] plans[name].append([*py, "-I", "-c", PAIRED_CHECKS_PROGRAM, json.dumps([(command, str(path)) for command, path in paired_logs[name]])]) elif not upstream: @@ -314,12 +330,15 @@ def execute(args: argparse.Namespace) -> int: stage_env["CI_FLEET_STATIC_ENGINE_POLICY"] = str(name == "engine-policy").lower() stage_env["CI_FLEET_CONFIG_ONLY"] = str(name in {"engine-policy", "configuration"}).lower() stage_env["CI_FLEET_INITIALIZER_ONLY"] = str(name == "initializer").lower() + stage_env["CI_FLEET_STAGE_USER"] = "root" if name == "harness" else "ci-fleet-validation" try: for seed, expected, target in seeds: verify_checkout(seed, expected) shutil.copytree(seed, target, symlinks=True) verify_checkout(target, expected) - stage_trees = [(target, expected) for _, expected, target in seeds] + if name != "harness": + executor.prepare_stage([source, home, fixtures], [policy, suite]) + stage_trees = [(target, checkout_manifest(target)) for _, _, target in seeds] with (output / entry["log"]).open("wb") as log: for command in plans[name]: label = "Paired driver command: " if name in paired_logs else "$ " @@ -331,7 +350,7 @@ def execute(args: argparse.Namespace) -> int: if any(not path.is_file() for _, path in paired_logs.get(name, [])): raise RuntimeError("paired check did not produce both child logs") finally: - # The supervisor finishes descendant cleanup before log assembly. + # The system manager finishes cgroup cleanup before log assembly. for child_command, child_log in paired_logs.get(name, []): log.write(("$ " + json.dumps(child_command) + "\n").encode()) if child_log.exists(): diff --git a/scripts/local_validation_supervisor.py b/scripts/local_validation_supervisor.py deleted file mode 100644 index 9fcb895..0000000 --- a/scripts/local_validation_supervisor.py +++ /dev/null @@ -1,291 +0,0 @@ -#!/usr/bin/env python3 -"""Own and reap one validation stage, including descendants that call setsid.""" -from __future__ import annotations - -import ctypes -import json -import os -from pathlib import Path -import signal -import subprocess -import sys -import threading -import time - -if __name__ != "__main__": - # Later candidates can write files owned by this UID, so never reload this path. - _SUPERVISOR_PROGRAM = Path(__file__).read_text() - - -def _identity(pid: int) -> tuple[int, int, str]: - fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split() - return int(fields[1]), int(fields[19]), fields[0] - - -def _enable_subreaper() -> None: - if not Path("/proc/self/stat").is_file(): - raise RuntimeError("Linux /proc is required for process cleanup") - # This process owns only one stage. Never make the caller a subreaper. - libc = ctypes.CDLL(None, use_errno=True) - if libc.prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER - raise OSError(ctypes.get_errno(), "cannot enable child subreaper") - descriptor = os.pidfd_open(os.getpid()) - try: - signal.pidfd_send_signal(descriptor, 0) - finally: - os.close(descriptor) - - -def _descendants(owner: int, handles: dict) -> None: - snapshot = {} - for entry in Path("/proc").glob("[0-9]*/stat"): - pid = int(entry.parent.name) - try: - snapshot[pid] = _identity(pid) - except (FileNotFoundError, ProcessLookupError, PermissionError): - continue - descendants = {owner} - while True: - found = {pid for pid, (parent, _, _) in snapshot.items() if parent in descendants} - if found <= descendants: - break - descendants.update(found) - for pid in descendants - {owner}: - identity = snapshot[pid] - if pid in handles and handles[pid][0] == identity[1]: - continue - try: - descriptor = os.pidfd_open(pid) - except ProcessLookupError: - continue - try: - # A pidfd cannot signal a replacement process after PID reuse. - current = _identity(pid) - parent = current[0] - if (current[:2] != identity[:2] - or parent not in descendants - or (parent != owner and _identity(parent)[1] != snapshot[parent][1])): - os.close(descriptor) - continue - except (FileNotFoundError, ProcessLookupError): - os.close(descriptor) - continue - previous = handles.pop(pid, None) - if previous: - os.close(previous[1]) - handles[pid] = (identity[1], descriptor) - - -def _signal_descendants(handles: dict, sent: set, signum: int, adopted_only: bool = False) -> bool: - signalled = False - for pid, (start, descriptor) in list(handles.items()): - try: - parent, actual_start, state = _identity(pid) - except (FileNotFoundError, ProcessLookupError): - actual_start, state = None, "Z" - if actual_start != start: - os.close(descriptor) - del handles[pid] - continue - identity = (pid, start) - if state == "Z" or identity in sent or (adopted_only and parent != os.getpid()): - continue - try: - signal.pidfd_send_signal(descriptor, signum) - signalled = True - sent.add(identity) - except ProcessLookupError: - pass - return signalled - - -def _supervise(request: dict, output) -> dict: - result = {"version": 1, "launched": False, "leader": None, "timed_out": False, - "cancelled": None, "forced": False, "cleanup_complete": False, "error": None} - cancelled = None - - def cancel(signum, _frame): - nonlocal cancelled - if cancelled is None: - cancelled = signum - - handles = {} - process = None - previous = {} - try: - _enable_subreaper() - for signum in (signal.SIGINT, signal.SIGTERM): - previous[signum] = signal.signal(signum, cancel) - process = subprocess.Popen(request["command"], cwd=request["cwd"], env=request["env"], - stdout=output, stderr=subprocess.STDOUT, start_new_session=True) - result["launched"] = True - deadline = time.monotonic() + request["timeout"] - cleanup_deadline = None - initial_cleanup = True - terminated, killed = set(), set() - while True: - empty = False - while True: - try: - pid, status = os.waitpid(-1, os.WNOHANG) - except ChildProcessError: - empty = True - break - if pid == 0: - break - if pid == process.pid: - result["leader"] = process.returncode = os.waitstatus_to_exitcode(status) - if empty: - result["cleanup_complete"] = True - break - now = time.monotonic() - if cleanup_deadline is None: - if cancelled is not None: - result["cancelled"] = cancelled - elif result["leader"] is None and now >= deadline: - result["timed_out"] = True - if result["leader"] is not None or result["timed_out"] or cancelled is not None: - cleanup_deadline = now + request["grace"] - if cleanup_deadline is not None: - _descendants(os.getpid(), handles) - if now >= cleanup_deadline: - result["forced"] |= _signal_descendants(handles, killed, signal.SIGKILL) - else: - # Let signalled parents run cleanup helpers during their grace. - _signal_descendants(handles, terminated, signal.SIGTERM, adopted_only=not initial_cleanup) - initial_cleanup = False - time.sleep(0.01) - except Exception as error: - result["error"] = f"{type(error).__name__}: {error}"[:2000] - if process is None: - result["cleanup_complete"] = True - else: - # Keep ownership until the kernel confirms every child was reaped. - while True: - _descendants(os.getpid(), handles) - result["forced"] |= _signal_descendants(handles, set(), signal.SIGKILL) - try: - pid, status = os.waitpid(-1, os.WNOHANG) - if pid == process.pid: - result["leader"] = process.returncode = os.waitstatus_to_exitcode(status) - except ChildProcessError: - result["cleanup_complete"] = True - break - time.sleep(0.01) - finally: - for _, descriptor in handles.values(): - os.close(descriptor) - for signum, handler in previous.items(): - signal.signal(signum, handler) - return result - - -def run(command: list[str], cwd: Path, env: dict, log: Path, timeout: int, - grace: float = 60) -> None: - """Run one stage and wait for all its descendants before returning or raising.""" - if timeout <= 0 or grace < 0: - raise ValueError("timeout must be positive and cleanup grace nonnegative") - request = {"command": command, "cwd": str(cwd), "env": env, "timeout": timeout, "grace": grace} - previous = {} - pending = None - interruption = None - descriptor = None - process = None - - def cancel(signum, frame): - nonlocal pending - if pending is None: - pending = (signum, frame) - if descriptor is not None: - try: - signal.pidfd_send_signal(descriptor, signum) - except ProcessLookupError: - pass - elif process is not None: - # An unreaped direct child cannot have its PID reused. - process.send_signal(signum) - - try: - if threading.current_thread() is threading.main_thread(): - for signum in (signal.SIGINT, signal.SIGTERM): - handler = signal.getsignal(signum) - if handler != signal.SIG_IGN: - previous[signum] = signal.signal(signum, cancel) - with log.open("wb") as output: - process = subprocess.Popen([sys.executable, "-I", "-c", _SUPERVISOR_PROGRAM, "--supervise"], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=output, - cwd="/", env={"PATH": os.defpath, "LANG": "C.UTF-8"}, - start_new_session=True) - try: - descriptor = os.pidfd_open(process.pid) - process.stdin.write(json.dumps(request).encode()) - process.stdin.close() - if pending is not None: - cancel(*pending) - process.wait() - except BaseException as error: - interruption = error - cancel(signal.SIGTERM, None) - # Repeated cancellation cannot cut short descendant cleanup. - while process.poll() is None: - try: - process.wait() - except KeyboardInterrupt: - cancel(signal.SIGTERM, None) - finally: - try: - process.stdin.close() - except BrokenPipeError: - pass - response = process.stdout.read() - process.stdout.close() - finally: - for signum, handler in previous.items(): - signal.signal(signum, handler) - if descriptor is not None: - os.close(descriptor) - if interruption is not None: - raise interruption - if pending is not None: - signum, frame = pending - handler = previous.get(signum, signal.SIG_DFL) - if handler == signal.SIG_DFL: - os.kill(os.getpid(), signum) - elif callable(handler): - handler(signum, frame) - if handler != signal.SIG_IGN: - raise RuntimeError(f"check cancelled by signal {signum}; see {log}") - try: - result = json.loads(response) - if (process.returncode != 0 or result.get("version") != 1 - or result.get("cleanup_complete") is not True - or type(result.get("forced")) is not bool - or type(result.get("timed_out")) is not bool): - raise ValueError("incomplete supervisor result") - except (ValueError, TypeError, AttributeError) as error: - raise RuntimeError(f"invalid stage supervisor result; see {log}") from error - if result.get("error"): - raise RuntimeError(f"stage supervisor failed: {result['error']}; see {log}") - if result["timed_out"]: - raise subprocess.TimeoutExpired(command, timeout) - if result.get("cancelled") is not None: - raise RuntimeError(f"check cancelled; see {log}") - if result.get("launched") is not True or type(result.get("leader")) is not int: - raise RuntimeError(f"incomplete stage supervisor result; see {log}") - if result["leader"]: - raise RuntimeError(f"check exited {result['leader']}; see {log}") - if result["forced"]: - raise RuntimeError("check required forced termination; cleanup is incomplete") - - -if __name__ == "__main__": - if sys.argv[1:] != ["--supervise"]: - raise SystemExit("use the trusted validation orchestrator") - try: - configuration = json.loads(sys.stdin.buffer.read()) - # stdout is the private result pipe. Candidate output goes only to stderr. - outcome = _supervise(configuration, sys.stderr) - print(json.dumps(outcome), flush=True) - except Exception as failure: - print(f"stage supervisor failed: {failure}", file=sys.stderr) - raise SystemExit(1) diff --git a/scripts/run_candidate_tests.py b/scripts/run_candidate_tests.py index 6985d74..5117a47 100644 --- a/scripts/run_candidate_tests.py +++ b/scripts/run_candidate_tests.py @@ -18,7 +18,6 @@ IMPLEMENTATIONS = ( "scripts/validate.py", "scripts/validate.sh", "scripts/init.py", "scripts/init.sh", "scripts/scan_committed_secrets.py", "scripts/update_template.py", "scripts/local_validate.py", - "scripts/local_validation_supervisor.py", "scripts/run_candidate_tests.py", "scripts/validate-local-configuration.sh", "fleet.schema.json", "template-compatibility.json", ) @@ -90,7 +89,7 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repository", type=Path, required=True) parser.add_argument("--test", choices=("test_policy", "test_template_updater", "test_release_update", "test_workflow_events", - "test_local_validation", "test_local_validation_supervisor", "test_candidate_runner"), required=True) + "test_local_validation", "test_executor_binding", "test_candidate_runner"), required=True) parser.add_argument("--derived", action="store_true") args = parser.parse_args() try: diff --git a/scripts/test_candidate_runner.py b/scripts/test_candidate_runner.py index 009231f..c0d54a4 100644 --- a/scripts/test_candidate_runner.py +++ b/scripts/test_candidate_runner.py @@ -19,7 +19,7 @@ "test_exact_core_drift_check_rejects_corrupt_allowlisted_core_bytes", ) MODULES = ("test_policy", "test_template_updater", "test_release_update", "test_workflow_events", - "test_local_validation", "test_local_validation_supervisor", "test_candidate_runner") + "test_local_validation", "test_executor_binding", "test_candidate_runner") UPSTREAM_INPUTS = ( "README.md", "AGENTS.md", "docs/LOCAL-VALIDATION.md", "docs/UPDATING.md", "docs/RELEASE.md", "docs/DEPLOYMENT-POLICY.md", @@ -27,7 +27,6 @@ IMPLEMENTATIONS = ( "scripts/validate.py", "scripts/validate.sh", "scripts/init.py", "scripts/init.sh", "scripts/scan_committed_secrets.py", "scripts/update_template.py", "scripts/local_validate.py", - "scripts/local_validation_supervisor.py", "scripts/run_candidate_tests.py", "scripts/validate-local-configuration.sh", "fleet.schema.json", "template-compatibility.json", ) @@ -88,7 +87,7 @@ def test_candidate_import(self): def test_harness_corpora_exercise_candidate_programs_with_pinned_definitions(self) -> None: for name, program in (("test_local_validation", "local_validate"), - ("test_local_validation_supervisor", "local_validation_supervisor"), + ("test_executor_binding", "local_validate"), ("test_candidate_runner", "run_candidate_tests")): corpus = f'''import unittest from {program} import candidate_value diff --git a/scripts/test_core_compatibility.py b/scripts/test_core_compatibility.py index 30dfe2c..b82d83b 100644 --- a/scripts/test_core_compatibility.py +++ b/scripts/test_core_compatibility.py @@ -44,11 +44,10 @@ "fleet.json", "fleet.schema.json", "scripts/local_validate.py", - "scripts/local_validation_supervisor.py", "scripts/run_candidate_tests.py", "scripts/test_candidate_runner.py", "scripts/test_local_validation.py", - "scripts/test_local_validation_supervisor.py", + "scripts/test_executor_binding.py", "scripts/test_workflow_events.py", "scripts/validate-local-configuration.sh", "scripts/init.py", diff --git a/scripts/test_executor_binding.py b/scripts/test_executor_binding.py new file mode 100644 index 0000000..78bf6b5 --- /dev/null +++ b/scripts/test_executor_binding.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Verify the standalone driver uses only its reviewed installed core executor.""" +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + +import local_validate as local + + +class ExecutorBindingTests(unittest.TestCase): + def test_selected_executor_has_the_fixed_installed_location_and_revision(self): + self.assertEqual(local.EXECUTOR_PATH, Path("/usr/local/lib/ci-fleet-validation/executor.py")) + self.assertRegex(local.EXECUTOR_REVISION, r"^[0-9a-f]{40}$") + module = local.load_executor() + self.assertEqual(Path(module.__file__), local.EXECUTOR_PATH) + module.require_revision(local.EXECUTOR_REVISION) + + def test_driver_forwards_the_command_user_deadline_and_cleanup_grace(self): + executor = Mock() + root = Path("/unused") + env = {"PATH": "/usr/bin:/bin", "CI_FLEET_STAGE_USER": "ci-fleet-validation"} + with patch.object(local, "require_worker"), patch.object(local, "load_executor", return_value=executor): + local.run(["true"], root, env, root / "log", 11) + executor.run.assert_called_once_with(["true"], root, env, root / "log", 11, + grace=local.CLEANUP_GRACE_SECONDS, user="ci-fleet-validation") + + def test_executor_failure_propagates_without_a_fallback(self): + executor = Mock() + executor.run.side_effect = RuntimeError("incomplete cgroup cleanup") + with patch.object(local, "require_worker"), patch.object(local, "load_executor", return_value=executor), \ + patch.object(local.subprocess, "Popen") as launch: + with self.assertRaisesRegex(RuntimeError, "incomplete cgroup cleanup"): + local.run(["true"], Path("/"), {}, Path("/unused"), 1) + launch.assert_not_called() + + def test_wrong_revision_refuses_before_starting_the_command(self): + with patch.object(local, "EXECUTOR_REVISION", "0" * 40), patch.object(local.subprocess, "Popen") as launch: + with self.assertRaisesRegex(RuntimeError, "exact reviewed"): + local.run(["true"], Path("/"), {}, Path("/unused"), 1) + launch.assert_not_called() + + def test_writable_or_missing_executor_is_never_imported(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "executor.py" + path.write_text("raise AssertionError('untrusted executor imported')\n") + path.chmod(0o666) + with patch.object(local, "EXECUTOR_PATH", path), self.assertRaisesRegex(RuntimeError, "unsafe installed"): + local.load_executor() + path.unlink() + with patch.object(local, "EXECUTOR_PATH", path), self.assertRaises(FileNotFoundError): + local.load_executor() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_local_validation.py b/scripts/test_local_validation.py index 0737744..9a38106 100644 --- a/scripts/test_local_validation.py +++ b/scripts/test_local_validation.py @@ -54,13 +54,13 @@ def setUp(self) -> None: self.git("init", "-q") self.git("config", "user.name", "Fixture") self.git("config", "user.email", "fixture@example.invalid") - for relative in ("scripts/local_validate.py", "scripts/local_validation_supervisor.py", + for relative in ("scripts/local_validate.py", "scripts/run_candidate_tests.py", "scripts/validate_commits.py", "scripts/scan_committed_secrets.py", "templates/config-repository/scripts/validate.py", "templates/config-repository/fleet.schema.json"): path = self.repository / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(Path(local.__file__).with_name(path.name).read_bytes() - if path.name in {"local_validate.py", "local_validation_supervisor.py"} else b"fixture\n") + if path.name in {"local_validate.py", "run_candidate_tests.py"} else b"fixture\n") self.git("add", ".") self.git("commit", "-qm", "test: initial fixture") self.base = self.git("rev-parse", "HEAD") @@ -88,7 +88,9 @@ def run(command, cwd, env, log, timeout): child_log = Path(path) if not child_log.exists(): child_log.write_text(f"fixture child executed {len(commands)}-{index}\n") - with patch.object(local, "ROOT", self.repository), patch.object(local, "run", run), \ + executor = local.load_executor() + with patch.object(local, "load_executor", return_value=executor), patch.object(executor, "prepare_stage"), \ + patch.object(local, "ROOT", self.repository), patch.object(local, "run", run), \ contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): code = local.execute(self.args) return code, json.loads((self.args.output / "result.json").read_text()), commands @@ -139,8 +141,8 @@ def test_fixed_suite_owns_upstream_and_derived_regression_plans(self) -> None: for child, path in children: self.assertEqual(child[:2], ["python3", "-I"]) self.assertTrue(child[2].endswith("/suite/scripts/run_candidate_tests.py")) - self.assertEqual(Path(path).parent, self.args.output) - self.assertTrue(Path(path).is_file()) + self.assertEqual(Path(path).parent.name, "tmp") + self.assertFalse(Path(path).exists()) nested.append(children) mapped.append(candidate) elif "--test" in command: @@ -148,14 +150,14 @@ def test_fixed_suite_owns_upstream_and_derived_regression_plans(self) -> None: self.assertEqual(len(nested), 0 if upstream else 3) self.assertEqual([command[command.index("--test") + 1] for command in mapped], ["test_policy", "test_template_updater", "test_release_update", - "test_local_validation", "test_local_validation_supervisor", + "test_local_validation", "test_executor_binding", "test_candidate_runner", "test_workflow_events"]) for command in mapped: self.assertTrue(any(part.endswith("/suite/scripts/run_candidate_tests.py") for part in command), command) self.assertTrue(command[command.index("--repository") + 1].endswith("/source"), command) self.assertEqual("--derived" in command, not upstream) - if command[command.index("--test") + 1] == "test_local_validation_supervisor": + if command[command.index("--test") + 1] == "test_executor_binding": self.assertEqual(command[command.index("-W") + 1], "error::ResourceWarning") corpora = [command for command, _, _ in commands if any( command[-1].endswith("/scripts/" + module + ".py") @@ -238,7 +240,9 @@ def cleanup(*_): time.sleep(.01) print("public leader", flush=True) ''' - real_run = local.run + original_run = local.run + real_run = lambda command, cwd, env, log, timeout: original_run( + command, cwd, {**env, "CI_FLEET_STAGE_USER": "root"}, log, timeout) def action(command, env, log): if local.PAIRED_CHECKS_PROGRAM in command and "policy-public.log" in command[-1]: @@ -258,13 +262,16 @@ def action(command, env, log): self.assertEqual(policy["sha256"], hashlib.sha256(raw).hexdigest()) def test_paired_failure_timeout_and_cancellation_preserve_child_logs(self) -> None: - real_run = local.run + original_run = local.run + real_run = lambda command, cwd, env, log, timeout: original_run( + command, cwd, {**env, "CI_FLEET_STAGE_USER": "root"}, log, timeout) for failure in ("launch", "timeout", "cancel"): self.args.output = self.root / ("paired-failure-" + failure) with self.subTest(failure=failure): pids = [] ready = [self.root / (failure + "-ready-" + str(index)) for index in range(2)] errors = [] + error_messages = [] cancelled = threading.Event() def action(command, env, log): @@ -298,6 +305,7 @@ def cancel_ready(): 1 if failure == "timeout" else 5) except BaseException as error: errors.append(type(error)) + error_messages.append(str(error)) raise finally: if worker: @@ -308,7 +316,9 @@ def cancel_ready(): code, report, _ = self.execute(action) self.assertEqual(code, 1, report) self.assertEqual(errors, [{"launch": RuntimeError, "timeout": subprocess.TimeoutExpired, - "cancel": KeyboardInterrupt}[failure]]) + "cancel": RuntimeError}[failure]]) + if failure == "cancel": + self.assertIn("cancelled", error_messages[0]) self.assertEqual(cancelled.is_set(), failure == "cancel") self.assertEqual([entry["status"] for entry in report["results"]], ["passed"] * 7 + ["failed"] + ["incomplete"] * 3) raw = (self.args.output / "policy.log").read_bytes() @@ -563,7 +573,7 @@ def test_output_in_source_policy_or_executing_suite_is_rejected_before_writes(se self.args.output = protected / "uncreated/nested/results" with self.subTest(protected=protected), \ patch.object(local, "__file__", str(suite / "scripts/local_validate.py")), \ - patch.object(local, "require_proc"), patch.object(local, "require_execution_host"), \ + patch.object(local, "require_worker"), patch.object(local, "require_execution_host"), \ patch.object(local, "git") as git, \ patch.object(local.subprocess, "check_output") as tools, patch.object(local, "run") as run: with self.assertRaisesRegex(ValueError, "--output must be outside Git worktrees and repositories"): @@ -809,18 +819,18 @@ def test_clone_rejects_zero_identity_before_fetch(self) -> None: run.assert_not_called() self.assertFalse(target.exists()) - def test_committed_supervisor_drift_fails_before_commands(self) -> None: - helper = self.repository / "scripts/local_validation_supervisor.py" - helper.write_bytes(helper.read_bytes() + b"\n# unreviewed supervisor drift\n") + def test_committed_caller_drift_fails_before_commands(self) -> None: + helper = self.repository / "scripts/local_validate.py" + helper.write_bytes(helper.read_bytes() + b"\n# unreviewed caller drift\n") self.git("add", ".") - self.git("commit", "-qm", "test: changed supervisor fixture") + self.git("commit", "-qm", "test: changed caller fixture") code, result, commands = self.execute() self.assertEqual(code, 1, result) self.assertEqual(commands, []) self.assertTrue(all(check["status"] == "incomplete" for check in result["results"]), result) def test_isolated_entrypoint_loads_its_absolute_trusted_helper(self) -> None: - for name in ("subprocess.py", "local_validation_supervisor.py"): + for name in ("subprocess.py", "local_validation_executor.py"): (self.repository / name).write_text("raise RuntimeError('candidate import executed')\n") result = subprocess.run([sys.executable, "-I", str(Path(local.__file__).resolve()), "--help"], cwd=self.repository, env={"PATH": "/usr/bin:/bin", "PYTHONPATH": str(self.repository)}, @@ -829,21 +839,13 @@ def test_isolated_entrypoint_loads_its_absolute_trusted_helper(self) -> None: self.assertIn("--repository", result.stdout) self.assertNotIn("candidate import executed", result.stderr) - def test_loaded_supervisor_does_not_reload_mutated_helper_path(self) -> None: - trusted = self.root / "trusted-caller" - trusted.mkdir() - caller = trusted / "local_validate.py" - caller.write_bytes(Path(local.__file__).read_bytes()) - helper = trusted / "local_validation_supervisor.py" - helper.write_bytes(local.MODULE_PATH.read_bytes()) - spec = importlib.util.spec_from_file_location("trusted_local_fixture", caller) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - helper.write_text("raise RuntimeError('replacement helper executed')\n") - log = self.root / "frozen-helper.log" - module.run([sys.executable, "-I", "-c", "print('original supervisor executed')"], - self.root, {"PATH": "/usr/bin:/bin"}, log, 5) - self.assertEqual(log.read_text(), "original supervisor executed\n") + def test_candidate_helper_cannot_replace_installed_executor(self) -> None: + for name in ("executor.py", "local_validation_executor.py"): + (self.repository / name).write_text("raise RuntimeError('candidate helper executed')\n") + log = self.root / "shared-executor.log" + local.run([sys.executable, "-I", "-c", "print('shared executor used')"], + self.root, {"PATH": "/usr/bin:/bin"}, log, 5) + self.assertEqual(log.read_text(), "shared executor used\n") def test_skip_word_in_valid_title_does_not_invalidate_log(self) -> None: local.check_log(b'$ ["--pr-title", "ci: skip optional hosted execution"]\nOK\n') @@ -903,7 +905,9 @@ def test_zero_base_scans_removed_secret_and_all_commit_policy(self) -> None: self.args.main = self.args.source self.args.base = local.NO_PRIOR_STATE expected = self.git("rev-list", "--reverse", self.args.source).splitlines() - real_run = local.run + original_run = local.run + real_run = lambda command, cwd, env, log, timeout: original_run( + command, cwd, {**env, "CI_FLEET_STAGE_USER": "root"}, log, timeout) def scan(command, env, log): if "scan_committed_secrets.py" in " ".join(command): @@ -991,7 +995,7 @@ def cleanup(): def assert_descendant_stopped(self, ready): for pid in map(int, ready.read_text().split()): - self.assertFalse(Path(f"/proc/{pid}").exists(), "a child was not reaped before the supervisor returned") + self.assertFalse(Path(f"/proc/{pid}").exists(), "a child was not reaped before the system manager returned") def test_missing_proc_fails_before_starting_a_command(self) -> None: with patch.object(local.Path, "is_file", return_value=False), \ @@ -1005,7 +1009,7 @@ def test_missing_proc_fails_before_starting_a_command(self) -> None: patch.object(local, "git") as git, \ patch.object(local.subprocess, "check_output") as tools, \ patch.object(local, "run") as run: - with self.assertRaisesRegex(RuntimeError, "Linux with mounted /proc"): + with self.assertRaisesRegex(RuntimeError, "system systemd manager"): local.execute(self.args) host.assert_not_called() resolve.assert_not_called() @@ -1026,7 +1030,7 @@ def test_main_reports_preflight_errors_without_allocating_results(self) -> None: for error in (OSError("preflight I/O denied"), ValueError(HOST_REFUSAL), RuntimeError("preflight denied")): with self.subTest(error=type(error).__name__), \ patch.object(sys, "argv", arguments), patch.object(local.signal, "signal"), \ - patch.object(local, "require_proc", side_effect=error), \ + patch.object(local, "require_worker", side_effect=error), \ patch.object(local, "require_execution_host") as host, \ contextlib.redirect_stderr(io.StringIO()) as stderr: self.assertEqual(local.main(), 2) diff --git a/scripts/test_local_validation_supervisor.py b/scripts/test_local_validation_supervisor.py deleted file mode 100644 index 963fd8e..0000000 --- a/scripts/test_local_validation_supervisor.py +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env python3 -"""Process-ownership regressions for the trusted local stage supervisor.""" -from __future__ import annotations - -import importlib.util -import ctypes -import json -import os -from pathlib import Path -import signal -import subprocess -import sys -import tempfile -import time -import unittest -from unittest import mock - -MODULE_PATH = Path(__file__).with_name("local_validation_supervisor.py") -SPEC = importlib.util.spec_from_file_location("local_validation_supervisor", MODULE_PATH) -supervisor = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(supervisor) - -PRELUDE = """ -import os, signal, sys, time -from pathlib import Path -root = Path(sys.argv[1]) -def record(): - start = Path('/proc/self/stat').read_text().rsplit(')', 1)[1].split()[19] - with (root / 'pids').open('ab', buffering=0) as handle: - handle.write((str(os.getpid()) + ' ' + start + '\\n').encode()) -def ready(): - (root / 'ready').touch() -def wait_ready(): - deadline = time.monotonic() + 5 - while not (root / 'ready').exists(): - if time.monotonic() >= deadline: - raise RuntimeError('child did not become ready') - time.sleep(.005) -record() -""" - - -class SupervisorTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory(prefix="local-supervisor-test-") - self.root = Path(self.temporary.name) - self.log = self.root / "stage.log" - self.env = {"PATH": os.defpath, "LANG": "C.UTF-8"} - - def tearDown(self): - # A broken regression must not leave its owned live sleepers running. - for pid, start in self.recorded(): - try: - if supervisor._identity(pid)[1] == start: - descriptor = os.pidfd_open(pid) - try: - if supervisor._identity(pid)[1] == start: - signal.pidfd_send_signal(descriptor, signal.SIGKILL) - finally: - os.close(descriptor) - except (FileNotFoundError, ProcessLookupError): - pass - self.temporary.cleanup() - - def recorded(self): - path = self.root / "pids" - return [tuple(map(int, line.split())) for line in path.read_text().splitlines()] if path.exists() else [] - - def assert_reaped(self, minimum=1): - recorded = self.recorded() - self.assertGreaterEqual(len(recorded), minimum) - for pid, start in recorded: - try: - self.assertNotEqual(supervisor._identity(pid)[1], start, f"owned process {pid} remains") - except (FileNotFoundError, ProcessLookupError): - pass - - def execute(self, source, timeout=5, grace=.2): - command = [sys.executable, "-I", "-c", PRELUDE + source, str(self.root)] - return supervisor.run(command, self.root, self.env, self.log, timeout, grace) - - def test_success_reaps_session_escaped_child_gracefully(self): - self.execute(""" -child = os.fork() -if child == 0: - os.setsid() - record() - def finish(*_): - (root / 'cleaned').touch() - os._exit(0) - signal.signal(signal.SIGTERM, finish) - ready() - while True: signal.pause() -wait_ready() -""") - self.assertTrue((self.root / "cleaned").exists()) - self.assert_reaped(2) - - def test_double_fork_is_adopted_and_forced_cleanup_fails(self): - with self.assertRaisesRegex(RuntimeError, "forced termination"): - self.execute(""" -child = os.fork() -if child == 0: - os.setsid() - record() - if os.fork(): os._exit(0) - record() - signal.signal(signal.SIGTERM, signal.SIG_IGN) - ready() - while True: signal.pause() -os.waitpid(child, 0) -wait_ready() -""") - self.assert_reaped(3) - - def test_term_handler_can_wait_for_delayed_cleanup_helper(self): - helper = PRELUDE + "time.sleep(.15); (root / 'cleaned').touch()" - self.execute(f""" -import subprocess -if os.fork() == 0: - os.setsid() - record() - def finish(*_): - subprocess.run([sys.executable, '-I', '-c', {helper!r}, str(root)], check=True) - os._exit(0) - signal.signal(signal.SIGTERM, finish) - ready() - while True: signal.pause() -wait_ready() -""", grace=1) - self.assertTrue((self.root / "cleaned").exists()) - self.assert_reaped(3) - - def test_rediscover_children_forked_during_cleanup(self): - self.execute(""" -if os.fork() == 0: - os.setsid() - generation = 0 - record() - def finish(*_): - global generation - previous = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGTERM}) - if generation == 3: os._exit(0) - child = os.fork() - if child: os._exit(0) - generation += 1 - os.setsid() - # Allow rediscovery before recording without reentering this handler. - time.sleep(.02) - record() - signal.pthread_sigmask(signal.SIG_SETMASK, previous) - signal.signal(signal.SIGTERM, finish) - ready() - while True: signal.pause() -wait_ready() -""", grace=3) - self.assert_reaped(5) - - def test_term_ignored_by_leader_times_out_after_cleanup(self): - started = time.monotonic() - with self.assertRaises(subprocess.TimeoutExpired): - self.execute(""" -signal.signal(signal.SIGTERM, signal.SIG_IGN) -ready() -while True: signal.pause() -""", timeout=1, grace=.15) - self.assertLess(time.monotonic() - started, 3) - self.assertTrue((self.root / "ready").exists()) - self.assert_reaped() - - def test_leader_failure_is_preserved_after_child_cleanup(self): - with self.assertRaisesRegex(RuntimeError, "check exited 7"): - self.execute(""" -if os.fork() == 0: - os.setsid() - record() - ready() - while True: signal.pause() -wait_ready() -sys.exit(7) -""") - self.assert_reaped(2) - - def test_no_other_stage_or_unrelated_process_is_signalled(self): - unrelated = subprocess.Popen([sys.executable, "-I", "-c", "import time; time.sleep(30)"], - start_new_session=True) - try: - self.test_double_fork_is_adopted_and_forced_cleanup_fails() - self.assertIsNone(unrelated.poll()) - finally: - unrelated.terminate() - unrelated.wait(timeout=5) - - def test_setup_failure_launches_no_command(self): - with mock.patch.object(supervisor, "_enable_subreaper", side_effect=OSError("setup denied")), \ - mock.patch.object(supervisor.subprocess, "Popen") as launch: - result = supervisor._supervise({}, None) - launch.assert_not_called() - self.assertFalse(result["launched"]) - self.assertTrue(result["cleanup_complete"]) - self.assertIn("setup denied", result["error"]) - - def test_parent_does_not_become_a_subreaper(self): - value = ctypes.c_int() - libc = ctypes.CDLL(None, use_errno=True) - self.assertEqual(libc.prctl(37, ctypes.byref(value), 0, 0, 0), 0) - before = value.value - self.execute("pass") - self.assertEqual(libc.prctl(37, ctypes.byref(value), 0, 0, 0), 0) - self.assertEqual(value.value, before) - - def test_launch_failure_reports_error_and_restores_handlers(self): - before = {number: signal.getsignal(number) for number in (signal.SIGINT, signal.SIGTERM)} - with self.assertRaisesRegex(RuntimeError, "supervisor failed: FileNotFoundError"): - supervisor.run([str(self.root / "missing-command")], self.root, self.env, self.log, 5, .1) - self.assertEqual(before, {number: signal.getsignal(number) for number in before}) - - def test_supervisor_imports_do_not_use_candidate_directory(self): - (self.root / "subprocess.py").write_text("raise RuntimeError('candidate import executed')") - self.execute("pass") - self.assert_reaped() - - def test_missing_or_incomplete_supervisor_result_fails_closed(self): - for response in ("", "{}", '{"version":1,"cleanup_complete":false}', - '{"version":1,"cleanup_complete":true,"forced":false,"timed_out":false}'): - with self.subTest(response=response): - program = "import sys; sys.stdin.buffer.read(); print(" + repr(response) + ")" - with mock.patch.object(supervisor, "_SUPERVISOR_PROGRAM", program): - with self.assertRaisesRegex(RuntimeError, "supervisor result"): - self.execute("raise RuntimeError('candidate must not run')") - - def test_candidate_cannot_replace_supervisor_for_later_stage(self): - copied = self.root / "trusted-supervisor.py" - copied.write_text(MODULE_PATH.read_text()) - spec = importlib.util.spec_from_file_location("copied_supervisor", copied) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - forged = {"version": 1, "launched": True, "leader": 0, "timed_out": False, - "cancelled": None, "forced": False, "cleanup_complete": True, "error": None} - replacement = "import sys; sys.stdin.buffer.read(); print(" + repr(json.dumps(forged)) + ")" - command = [sys.executable, "-I", "-c", - "from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])", - str(copied), replacement] - module.run(command, self.root, self.env, self.log, 5, .2) - self.assertEqual(copied.read_text(), replacement) - with self.assertRaisesRegex(RuntimeError, "check exited 23"): - module.run([sys.executable, "-I", "-c", "raise SystemExit(23)"], - self.root, self.env, self.log, 5, .2) - - def cancellation(self, signum, expected): - source = """ -if os.fork() == 0: - os.setsid() - record() - signal.signal(signal.SIGTERM, signal.SIG_IGN) - ready() - while True: signal.pause() -wait_ready() -while True: signal.pause() -""" - command = [sys.executable, "-I", "-c", PRELUDE + source, str(self.root)] - worker_code = """ -import importlib.util, json, pathlib, sys -spec = importlib.util.spec_from_file_location('supervisor', sys.argv[1]) -module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(module) -root = pathlib.Path(sys.argv[2]) -module.run(json.loads(sys.argv[3]), root, {'PATH': '/usr/bin:/bin'}, root / 'stage.log', 10, .15) -""" - worker = subprocess.Popen([sys.executable, "-I", "-c", worker_code, str(MODULE_PATH), - str(self.root), json.dumps(command)], stdout=subprocess.PIPE, - stderr=subprocess.PIPE, start_new_session=True) - try: - deadline = time.monotonic() + 5 - while not (self.root / "ready").exists(): - if worker.poll() is not None or time.monotonic() >= deadline: - self.fail("cancellation fixture did not start") - time.sleep(.01) - worker.send_signal(signum) - _, stderr = worker.communicate(timeout=5) - self.assertEqual(worker.returncode, expected, stderr.decode()) - self.assert_reaped(2) - finally: - if worker.poll() is None: - worker.send_signal(signal.SIGTERM) - worker.wait(timeout=5) - - def test_sigint_waits_for_escaped_descendant_cleanup(self): - self.cancellation(signal.SIGINT, -signal.SIGINT) - - def test_sigterm_waits_for_escaped_descendant_cleanup(self): - self.cancellation(signal.SIGTERM, -signal.SIGTERM) - - def test_signal_handlers_restored_after_success_and_failure(self): - before = {number: signal.getsignal(number) for number in (signal.SIGINT, signal.SIGTERM)} - self.execute("print('stage output')") - self.assertIn("stage output", self.log.read_text()) - self.assertEqual(before, {number: signal.getsignal(number) for number in before}) - with self.assertRaisesRegex(RuntimeError, "check exited 2"): - self.execute("sys.exit(2)") - self.assertEqual(before, {number: signal.getsignal(number) for number in before}) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_workflow_events.py b/scripts/test_workflow_events.py index e8d71d2..018f79d 100644 --- a/scripts/test_workflow_events.py +++ b/scripts/test_workflow_events.py @@ -36,6 +36,9 @@ def setUp(self) -> None: "Path(os.environ['CAPTURE']).write_text(json.dumps(sys.argv[1:]))\n" "raise SystemExit(int(os.environ.get('VALIDATOR_EXIT', '0')))\n") python.chmod(0o755) + sudo = bin_path / "sudo" + sudo.write_text('#!/bin/sh\nexec "$@"\n') + sudo.chmod(0o755) git = bin_path / "git" git.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$GIT_CALLED"\nexit 97\n') git.chmod(0o755) @@ -127,7 +130,8 @@ def test_candidate_entry_point_cannot_replace_the_trusted_driver(self) -> None: f"Path({str(trusted_marker)!r}).touch()\n" + "Path(os.environ['CAPTURE']).write_text(json.dumps(sys.argv[1:]))\n" + "raise SystemExit(29)\n") - env = {**self.env, "PATH": "/usr/bin:/bin"} + env = {**self.env, "PATH": str(self.root / "bin") + ":/usr/bin:/bin"} + (self.root / "bin/python3").unlink() process = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", self.shell], cwd=self.source, env=env, capture_output=True, text=True) self.assertFalse(candidate_marker.exists(), "candidate replaced the trusted validation driver") From 085fa7cf330b6c617b6ba8cb5f36a17209069b9c Mon Sep 17 00:00:00 2001 From: Nick Foster Date: Sat, 17 Oct 2026 11:27:03 -0500 Subject: [PATCH 2/5] docs: match the shared executor suite identifier --- docs/LOCAL-VALIDATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/LOCAL-VALIDATION.md b/docs/LOCAL-VALIDATION.md index 55f5520..8e52cee 100644 --- a/docs/LOCAL-VALIDATION.md +++ b/docs/LOCAL-VALIDATION.md @@ -231,7 +231,7 @@ The report records source, original reviewed head, tree, base, whether prior integrated state is absent, main, engine-policy revision, suite revision/tree, tool versions, timestamps, results, expected skips and log SHA-256 digests. The suite ID is -`ci-fleet-config-standalone-v1`; the profile is `full`. +`ci-fleet-config-standalone-v2`; the profile is `full`. Only a completed report with `status: passed` and a successful process exit is a completed developer result. Nonzero exits, missing or incomplete reports, From c7d409a104685ca4fe83bad4c65464f0d1347b8a Mon Sep 17 00:00:00 2001 From: Nick Foster Date: Sat, 17 Oct 2026 11:37:19 -0500 Subject: [PATCH 3/5] fix: require completed candidate tests and budget full validation --- .github/workflows/validate.yml | 2 +- AGENTS.md | 12 ++++++++---- README.md | 2 ++ docs/LOCAL-VALIDATION.md | 16 +++++++++++++++- scripts/run_candidate_tests.py | 9 ++++++++- scripts/test_candidate_runner.py | 20 +++++++++++++++++++- scripts/test_workflow_events.py | 2 +- 7 files changed, 54 insertions(+), 9 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index cd5b641..b28f28e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,7 +19,7 @@ jobs: # Convenience only. Required-check and host policy need separate migration. if: ${{ vars.CI_FLEET_HOSTED_VALIDATION == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.state == 'open') }} runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 20 steps: - name: Require independently pinned validator env: diff --git a/AGENTS.md b/AGENTS.md index 6deb9f1..517af6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,10 @@ requires independently reviewed full engine and standalone-suite SHAs in `CI_FLEET_VALIDATOR_REVISION` and `CI_FLEET_SUITE_REVISION`. The workflow runs the pinned public standalone suite with the separate candidate checkout only as its source input. Neither enabling it nor a local developer result changes -required checks or trusted host policy. +required checks or trusted host policy. The optional full-suite job has a +documented 20-minute developer-acceptance budget because complete validation +exceeds five minutes. Restore five minutes only after setup and complete +coverage reliably fit a four-minute payload. Ordinary task jobs keep five minutes. Preserve existing required checks until an authorized policy migration and tested recovery are complete. Core local-evidence support merged at `67beabbddaad73c19f15bc3baa227779bc88dc96`; template release, compatible-manager @@ -53,9 +56,10 @@ Required regression inventories come from the pinned suite for both upstream and adopter validation. Candidate programs, optional workflows and upstream release/setup documents supply inputs, never replacement required tests. Candidate-added IDs run only after the required corpus. The mapped runner -requires unique planned IDs and matching execution counts. No actual skips are -accepted. Keep the trusted public -fixtures and derived upstream-probe applicability intact. +requires unique planned IDs and matching execution counts. Its standard-library +process worker must return a normal integer result; zero-status process exit +cannot substitute for test completion. Systemd owns deadlines and cleanup. +No actual skips are accepted. Keep the trusted public fixtures and derived upstream-probe applicability intact. Run the pinned caller, executor-binding and candidate-runner definitions against the candidate harness programs once in both upstream and adopter modes. Keep the outer driver and core executor pinned and runner self-test workflow inputs trusted; diff --git a/README.md b/README.md index 03a08df..d59b552 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ to the literal `true`. Independently pin `CI_FLEET_VALIDATOR_REVISION` to a reviewed full engine commit SHA and `CI_FLEET_SUITE_REVISION` to a compatible reviewed full commit in `RandomDevelopment/ci-fleet-config-template`. The workflow runs that trusted suite's entry point with read-only permissions. +Its complete developer-acceptance suite has a 20-minute job limit; ordinary task +jobs retain five minutes. See the [budget and setup guidance](docs/LOCAL-VALIDATION.md#optional-hosted-convenience). The candidate, trusted suite and engine use separate sibling checkouts with no persisted checkout credentials. Missing or malformed pins fail validation. Set the flag to `false`, delete it, or remove that workflow to stop hosted validation. Local validation still works. The flag diff --git a/docs/LOCAL-VALIDATION.md b/docs/LOCAL-VALIDATION.md index 8e52cee..ebf726f 100644 --- a/docs/LOCAL-VALIDATION.md +++ b/docs/LOCAL-VALIDATION.md @@ -197,7 +197,13 @@ assembles command headers, public output, candidate output and driver diagnostic in that order, including after failure, cancellation or timeout. Late descendant output therefore precedes the final stage-log digest. The combined stage log listed in `result.json` is authoritative; child logs are temporary inputs discarded with the worker scratch space. -Both checks share the existing command timeout and five-minute hosted job limit. +Both checks share the existing command timeout. The optional hosted full-suite +job has the separate budget documented below. + +The trusted parent runs candidate-mapped tests in a standard-library process +worker and requires its normal integer result. An abrupt zero-status exit, +including `os._exit(0)`, or `SystemExit(0)` fails validation. Systemd continues +to own command deadlines and descendant cleanup. Each candidate-mapped required corpus records its planned test IDs. IDs must be nonempty and unique, the executed count must match the plan, and actual @@ -354,6 +360,14 @@ target-branch changes refresh the recorded title, base and main context. Closed PRs cannot run the job. PR and main-push concurrency identities remain separate, so edits to a closed PR cannot cancel main validation. +The optional complete developer-acceptance job has a 20-minute limit. This is +a slower class than ordinary task-matrix CI. Complete source validation measured +314 seconds on the disposable worker before hosted setup, and root-launcher +fixtures in the private adopter alone exceeded 300 seconds. Enabling the job +can consume up to 20 hosted minutes per run. Return it to five minutes only +after setup and complete coverage reliably fit the four-minute payload budget. +Ordinary task jobs retain their five-minute ceiling. + Use your available hosted allowance or paid capacity if desired. Hosted availability and billing never determine whether the local command can run. Set the flag to `false`, remove it, or delete the optional validation workflow diff --git a/scripts/run_candidate_tests.py b/scripts/run_candidate_tests.py index 5117a47..72e1735 100644 --- a/scripts/run_candidate_tests.py +++ b/scripts/run_candidate_tests.py @@ -3,8 +3,10 @@ from __future__ import annotations import argparse +from concurrent.futures import ProcessPoolExecutor import importlib.util import json +from multiprocessing import get_context import os from pathlib import Path import shutil @@ -114,7 +116,12 @@ def main() -> int: ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "test: candidate programs"], ): subprocess.run(["git", "-C", str(fixture), *command], check=True, stdout=subprocess.DEVNULL) - return run_tests(fixture, args.test, args.derived, candidate_tests) + # Require a normal test result even if candidate code exits zero. + with ProcessPoolExecutor(max_workers=1, mp_context=get_context("fork")) as workers: + result = workers.submit(run_tests, fixture, args.test, args.derived, candidate_tests).result() + if type(result) is not int: + raise RuntimeError("invalid regression worker result") + return result except (Exception, SystemExit) as error: print("candidate test execution failed: " + str(error), file=sys.stderr) return 2 diff --git a/scripts/test_candidate_runner.py b/scripts/test_candidate_runner.py index c0d54a4..7970db9 100644 --- a/scripts/test_candidate_runner.py +++ b/scripts/test_candidate_runner.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +import os import shutil import subprocess import sys @@ -64,7 +65,8 @@ def run_module(self, name: str, body: str, *, derived: bool = False): "--repository", str(self.repository), "--test", name] if derived: command.append("--derived") - return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=10) + return subprocess.run(command, env={**os.environ, "TMPDIR": str(self.root)}, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=10) def policy_fixture(self, *, probes=PROBES, extra: str = "") -> str: methods = "".join(f" def {name}(self):\n self.fail('upstream probe executed')\n" for name in probes) @@ -85,6 +87,22 @@ def test_candidate_import(self): self.assertEqual(result.returncode, 0, result.stdout) self.assertIn("Ran 1 test", result.stdout) + def test_exit_zero_during_import_test_or_addition_cannot_complete_the_worker(self) -> None: + for location in ("import", "test", "addition"): + with self.subTest(location=location): + (self.repository / "scripts/update_template.py").write_text( + "import os; os._exit(0)\n" if location == "import" else "# fixture\n") + addition = self.repository / "scripts/test_policy.py" + if location == "addition": + addition.write_text("import os; os._exit(0)\n") + else: + addition.unlink(missing_ok=True) + action = "os._exit(0)" if location == "test" else "pass" + result = self.run_module("test_policy", "import unittest, os, update_template\n" + "class ExitTests(unittest.TestCase):\n" + f" def test_candidate(self): {action}\n") + self.assertNotEqual(result.returncode, 0, result.stdout) + def test_harness_corpora_exercise_candidate_programs_with_pinned_definitions(self) -> None: for name, program in (("test_local_validation", "local_validate"), ("test_executor_binding", "local_validate"), diff --git a/scripts/test_workflow_events.py b/scripts/test_workflow_events.py index 018f79d..40b12b5 100644 --- a/scripts/test_workflow_events.py +++ b/scripts/test_workflow_events.py @@ -62,7 +62,7 @@ def test_optional_job_has_a_distinct_context_and_read_only_permissions(self) -> self.assertEqual(re.findall(r"^ ([A-Za-z0-9_-]+):", jobs, re.M), ["hosted-validation"]) self.assertIn(" name: hosted-validation\n", jobs) self.assertIn("vars.CI_FLEET_HOSTED_VALIDATION == 'true'", jobs) - self.assertIn(" timeout-minutes: 5\n", jobs) + self.assertIn(" timeout-minutes: 20\n", jobs) self.assertIn("permissions:\n contents: read\n", self.workflow) self.assertNotIn("contents: write", self.workflow) From 96aa17fa271af7906b37c757d4ded7ffd76a1385 Mon Sep 17 00:00:00 2001 From: Nick Foster Date: Sat, 17 Oct 2026 11:50:02 -0500 Subject: [PATCH 4/5] fix: complete admission before Git and confine checkout links --- AGENTS.md | 7 +++++-- docs/LOCAL-VALIDATION.md | 6 ++++++ scripts/local_validate.py | 5 ++++- scripts/test_executor_binding.py | 26 ++++++++++++++++++++++++++ scripts/test_local_validation.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 517af6a..44c1cfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,9 @@ Fresh verified source, engine-policy and trusted-suite checkouts for each stage prevent persistent cross-stage substitution. Initial narrow Git fetches build the seeds. Freeze their effective origin/main metadata before manifests, then restore independent stage copies with symlinks and modes preserved, without -hardlinks or repeated fetches. Check seeds before restoration, require each +hardlinks or repeated fetches. Symlinks must resolve inside their own worktree +and outside `.git`; reject trusted-sibling and external targets, including +dangling ones, before candidate stages. Check seeds before restoration, require each copied target to match that frozen seed manifest before candidate execution, and check both seeds and stage trees afterward. All orchestrator Git calls and stage environments must filter inherited and supplied `GIT_*` variables and @@ -95,7 +97,8 @@ fail. Lost-worker runs are incomplete. Export one complete verified result bundle outside the worker before disposal. Signing authority remains separate. Preflight rejects standard installed fleet and signing-role markers, including broken symlinks. Platform and host admission happen before Git inspection, -output-directory creation, tool probes or candidate execution. Early admission +output-directory creation, tool probes or candidate execution. Installed-executor +revision, permission and host admission also precedes Git output discovery. Early admission failures or invalid output locations return nonzero with a diagnostic on stderr and create no output directory or report. Verify custom installations and the execution machine's role separately, and keep the signing authority elsewhere. diff --git a/docs/LOCAL-VALIDATION.md b/docs/LOCAL-VALIDATION.md index ebf726f..055c591 100644 --- a/docs/LOCAL-VALIDATION.md +++ b/docs/LOCAL-VALIDATION.md @@ -197,6 +197,12 @@ assembles command headers, public output, candidate output and driver diagnostic in that order, including after failure, cancellation or timeout. Late descendant output therefore precedes the final stage-log digest. The combined stage log listed in `result.json` is authoritative; child logs are temporary inputs discarded with the worker scratch space. +Each checkout symlink must resolve within its own worktree and outside `.git`. +Links into trusted sibling checkouts, external paths or Git metadata fail before +candidate stages run, even when dangling. Internal worktree links remain valid. +Complete installed-executor revision, permission and host admission precedes +Git output discovery and creation. Refusal creates no result directory or report. + Both checks share the existing command timeout. The optional hosted full-suite job has the separate budget documented below. diff --git a/scripts/local_validate.py b/scripts/local_validate.py index 6cd08e1..3828c74 100644 --- a/scripts/local_validate.py +++ b/scripts/local_validate.py @@ -88,6 +88,9 @@ def checkout_manifest(root: Path) -> dict: with path.open("rb") as handle: value = hashlib.file_digest(handle, "sha256").hexdigest() elif stat.S_ISLNK(mode): + resolved = path.resolve() + if not resolved.is_relative_to(root.resolve()) or resolved.is_relative_to((root / ".git").resolve()): + raise ValueError("checkout symlink leaves committed worktree: " + str(path)) value = os.readlink(path) elif stat.S_ISDIR(mode): value = None @@ -196,9 +199,9 @@ def check_log(raw: bytes) -> None: def execute(args: argparse.Namespace) -> int: require_worker() require_execution_host() - output = output_directory(args.output, args.repository, args.policy_repository) executor = load_executor() executor.require_worker() + output = output_directory(args.output, args.repository, args.policy_repository) output.mkdir(mode=0o700, parents=True, exist_ok=False) report = {"schema_version": 1, "suite": SUITE, "profile": "full", "status": "incomplete", "source": args.source, "reviewed_head": args.reviewed_head or args.source, diff --git a/scripts/test_executor_binding.py b/scripts/test_executor_binding.py index 78bf6b5..428ce41 100644 --- a/scripts/test_executor_binding.py +++ b/scripts/test_executor_binding.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Verify the standalone driver uses only its reviewed installed core executor.""" +import argparse from pathlib import Path import tempfile import unittest @@ -9,6 +10,31 @@ class ExecutorBindingTests(unittest.TestCase): + def test_complete_executor_admission_precedes_git_and_output_creation(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for failure in ("missing", "unsafe", "wrong-revision", "host-refusal"): + with self.subTest(failure=failure): + args = argparse.Namespace(repository=root, policy_repository=root, output=root / failure, + source="1" * 40, base="2" * 40, reviewed_head=None, + engine_policy="3" * 40, policy="3" * 40, main="2" * 40, + repository_name="fictional/config", pr_title=None, timeout=1) + executor = Mock() + error = RuntimeError(failure) + if failure == "host-refusal": + executor.require_worker.side_effect = error + with patch.object(local, "require_worker"), \ + patch.object(local, "require_execution_host"), \ + patch.object(local, "load_executor", return_value=executor, + side_effect=error if failure != "host-refusal" else None), \ + patch.object(local, "output_directory", return_value=args.output) as discovery, \ + patch.object(local, "git") as git: + with self.assertRaisesRegex(RuntimeError, failure): + local.execute(args) + discovery.assert_not_called() + git.assert_not_called() + self.assertFalse(args.output.exists()) + def test_selected_executor_has_the_fixed_installed_location_and_revision(self): self.assertEqual(local.EXECUTOR_PATH, Path("/usr/local/lib/ci-fleet-validation/executor.py")) self.assertRegex(local.EXECUTOR_REVISION, r"^[0-9a-f]{40}$") diff --git a/scripts/test_local_validation.py b/scripts/test_local_validation.py index 9a38106..98c9737 100644 --- a/scripts/test_local_validation.py +++ b/scripts/test_local_validation.py @@ -45,6 +45,34 @@ def unprotected_fixture_environment(): class LocalValidationTests(unittest.TestCase): + def test_checkout_symlinks_cannot_substitute_trusted_sibling_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source" + (source / "scripts").mkdir(parents=True) + (root / "suite/scripts").mkdir(parents=True) + (root / "suite/scripts/local_validate.py").write_text("# trusted implementation\n") + (root / "adopter-policy").mkdir() + (root / "adopter-policy/delivery-policy.json").write_text("{}\n") + (source / ".git").mkdir() + (source / ".git/config").write_text("# generated Git metadata\n") + for relative, target in (("scripts/local_validate.py", "../../suite/scripts/local_validate.py"), + ("delivery-policy.json", "../adopter-policy/delivery-policy.json"), + ("metadata-link", ".git/config"), + ("directory-link", "../suite/scripts"), + ("dangling-link", "../missing")): + with self.subTest(relative=relative): + link = source / relative + link.symlink_to(target) + try: + with self.assertRaisesRegex(ValueError, "checkout symlink leaves committed worktree"): + local.checkout_manifest(source) + finally: + link.unlink() + (source / "candidate.py").write_text("# committed candidate implementation\n") + (source / "scripts/local_validate.py").symlink_to("../candidate.py") + local.checkout_manifest(source) + def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) From 3e27ddbf4bb760e7b77ea93f407d8be72d0b02f7 Mon Sep 17 00:00:00 2001 From: Nick Foster Date: Sat, 17 Oct 2026 11:56:34 -0500 Subject: [PATCH 5/5] test: isolate output discovery from installed worker admission --- scripts/test_local_validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test_local_validation.py b/scripts/test_local_validation.py index 98c9737..b5bcbab 100644 --- a/scripts/test_local_validation.py +++ b/scripts/test_local_validation.py @@ -602,6 +602,7 @@ def test_output_in_source_policy_or_executing_suite_is_rejected_before_writes(se with self.subTest(protected=protected), \ patch.object(local, "__file__", str(suite / "scripts/local_validate.py")), \ patch.object(local, "require_worker"), patch.object(local, "require_execution_host"), \ + patch.object(local, "load_executor"), \ patch.object(local, "git") as git, \ patch.object(local.subprocess, "check_output") as tools, patch.object(local, "run") as run: with self.assertRaisesRegex(ValueError, "--output must be outside Git worktrees and repositories"):