diff --git a/scripts/ci/runners/README.md b/scripts/ci/runners/README.md index 20e7203550..7d840024eb 100644 --- a/scripts/ci/runners/README.md +++ b/scripts/ci/runners/README.md @@ -1,40 +1,146 @@ -# scripts/ci/runners — isolated self-hosted runner tooling (CI-04 `#2328`) +# Isolated self-hosted runner tooling -Last Updated: 2026-08-30 · Threat model: `docs/ci/RUNNER_TOPOLOGY_AND_THREAT_MODEL.md` · Decision: ADR-0066 §7–9 +Last Updated: 2026-09-10 · Issue: CI-04 `#2328` · Decision: ADR-0066 sections 7–9 · Threat model: [RUNNER_TOPOLOGY_AND_THREAT_MODEL.md](../../../docs/ci/RUNNER_TOPOLOGY_AND_THREAT_MODEL.md) -**Nothing here registers a runner.** Registration tokens, `config.sh`/`config.cmd` runs and the -GitHub UI association are maintainer actions performed **only after the repository is private** -(`OUTSTANDING_TASKS.md` §J SC-7). While the repository is public, no self-hosted runner exists — -any fork PR could target it. +Nothing in this directory associates a runner with GitHub. The scripts contain no association +credential, package downloader, repository-setting operation, or GitHub API call. They prepare and +verify only the fixed guest-local account, filesystem, policy, cleanup-hook, architecture, and +preinstalled-toolchain contract. -| File | Purpose | +## Shipped preparation contract + +| File | Contract | | --- | --- | -| `Invoke-TaskdeckCiRunnerVm.ps1` | Hyper-V broker: `-Action Start` before qualifying a PR, `-Action Stop` after, `-Action Status` any time. No GitHub calls, no tokens, `-WhatIf` supported. | -| (CI-04) `bootstrap-linux.sh` | Versioned golden-image bootstrap for `td-ci-linux` — arrives with CI-04, not here. | -| (CI-04) `bootstrap-windows.ps1` | Versioned bootstrap for `td-ci-windows` — arrives with CI-04. | - -## Bootstrap outline (what CI-04 must produce) - -```text -td-ci-linux (desktop, Hyper-V) - Ubuntu LTS · unprivileged `runner` account · no host mounts / clipboard / SSH agent / browser - .NET 8 SDK · Node 24 · Docker + BuildKit (rootless where practical) · Playwright Chromium deps - labels: self-hosted, taskdeck, trusted, linux, x64, heavy (ci/policy.v1.json runnerClasses) - one job at a time · NAT outbound only · caches bounded: NuGet, npm, Playwright browsers, BuildKit - pre-job: clean workspace, remove untracked worktrees, clear temp post-job: same + prune - monthly: rebuild from the golden image; after any suspicious job: destroy the disk, rebuild - -td-ci-windows (laptop or second VM) - isolated Windows VM preferred; dedicated low-privilege local account as fallback - labels: self-hosted, taskdeck, trusted, windows, x64, compatibility - runs only the Windows compatibility contract (CI-07) on ordinary PRs; full sweep weekly/release +| `bootstrap-linux.sh` | Defaults to non-mutating `Check`; explicit root-only `Apply` creates or validates the locked `taskdeck-runner` account, fixed directories, immutable policy and hook. Checks x64, Node 24.13.1, .NET SDK 8.0.415, Git, rootless Docker, and an available BuildKit builder. | +| `bootstrap-windows.ps1` | Defaults to non-mutating `Check`; explicit Administrator-only `Apply` validates an existing enabled local `taskdeck-runner` account has only the Builtin Users membership and the fixed account profile, then creates fixed cleanup directories and protected ACLs. Checks x64, Node 24.13.1, .NET SDK 8.0.415, and x64 Git. It never creates the account or accepts an account credential. | +| `cleanup-linux.sh` | Installed as an immutable hook. Clears only children of validated service-home, work, temp, npm, NuGet, Playwright, and Docker-client cache roots, removes rootless job containers/volumes/networks, and applies both age and size limits to BuildKit. | +| `cleanup-windows.ps1` | Installed as an immutable hook. Clears only children of the validated fixed tool profile-state, work, temp, npm, NuGet, and Playwright cache roots. It does not clear the active Windows account profile or HKCU. | +| `Invoke-TaskdeckCiRunnerVm.ps1` | Starts, stops, or inspects already-created Hyper-V guests. It does not create or associate a VM. | +| `runner-bootstrap-contract.test.mjs` | Pins the verify-first, containment, output, account, and no-association contract. The Smart CI test directory imports it so the existing planner self-test discovers it. | + +Both cleanup hooks: + +- load only the immutable, fixed policy file installed outside the runner application and writable + work/cache trees; +- reject filesystem roots, conventional host user-profile paths, UNC/network paths, shared/mounted host + filesystems, and any symlink, junction, or reparse point in a governed root or ancestor; +- ignore job-controlled workspace, temp, home, and cache variables; +- run as the dedicated unprivileged service account and fail for any other identity; +- emit stable action codes and counts, never paths, user names, environment values, or child names; +- return nonzero on validation, cleanup, or prune failure; and +- impose a 120-second internal limit because the runner does not supply a hook timeout. + +### Canonical guest roots + +| Purpose | Linux | Windows | +| --- | --- | --- | +| Immutable policy and hooks | `/etc/taskdeck-runner/` | `C:\ProgramData\TaskdeckRunner\Policy\` | +| Account home/profile | `/var/lib/taskdeck-runner/home` (cleared) | `C:\TaskdeckRunner\Profile` (validated, not cleared) | +| Cleared tool profile-state | Same as account home | `C:\TaskdeckRunner\ProfileState` | +| Work | `/var/lib/taskdeck-runner/work` | `C:\TaskdeckRunner\Work` | +| Temp | `/var/lib/taskdeck-runner/temp` | `C:\TaskdeckRunner\Temp` | +| Bounded caches | `/var/cache/taskdeck-runner/` | `C:\TaskdeckRunner\Cache\` | +| Linux rootless container state | `/var/cache/taskdeck-runner/buildkit` | Not used by the Windows compatibility runner | + +The policy/hook and runner-state parent roots are owned by root or the local Administrators group. +Within the roots managed by these scripts, the service account has read-and-execute access to those +parents and write access only to the fixed Linux home or Windows profile-state, work, temp, and cache +child roots. On Windows, writable roots use inherit-only child permissions and do not grant the +account permission to delete or replace the roots themselves. The active Windows account profile is +validated but is not ACL-managed or cleared by these scripts. The bootstraps reject a different root +instead of accepting a path parameter. + +## Image verification + +Run these only inside the isolated guest whose host-sharing controls have already been inspected. +`Apply` does not install the toolchain. It is idempotent preparation for a human-built, checksum-pinned +image and fails closed when that image does not match the contract. + +Linux: + +```bash +sudo scripts/ci/runners/bootstrap-linux.sh +sudo scripts/ci/runners/bootstrap-linux.sh --action Apply --dry-run +sudo scripts/ci/runners/bootstrap-linux.sh --action Apply +``` + +Windows, from an elevated PowerShell session for `Apply` or `-WhatIf`: + +```powershell +& scripts/ci/runners/bootstrap-windows.ps1 +& scripts/ci/runners/bootstrap-windows.ps1 -Action Apply -WhatIf +& scripts/ci/runners/bootstrap-windows.ps1 -Action Apply ``` -Rules the runners must satisfy (proven by CI-04's acceptance list, never assumed): +The existing Windows account must have exactly one direct local-group membership, Builtin Users, and +its `ProfileImagePath` must already be `C:\TaskdeckRunner\Profile`. Linux permits only the account's +dedicated primary group and no supplementary group. These allowlists fail closed instead of trying to +enumerate every root-equivalent group name. + +During the later Windows service setup, configure the runner work directory as +`C:\TaskdeckRunner\Work`; set `HOME` and `DOTNET_CLI_HOME` to +`C:\TaskdeckRunner\ProfileState`; set `TEMP` and `TMP` to `C:\TaskdeckRunner\Temp`; and set +`npm_config_cache`, `NUGET_PACKAGES`, and `PLAYWRIGHT_BROWSERS_PATH` to their fixed cache roots in +the table above. Keep the OS-managed `USERPROFILE` at `C:\TaskdeckRunner\Profile`. These mappings +bound cooperative tool state only. The hook clears only the enumerated fixed roots, so arbitrary code +can still persist elsewhere in the active account profile or HKCU. A persistent Windows VM is not a +security reset; unexpected state makes the guest suspect and requires discard and rebuild. + +Linux `Check` must run as root or as the service account so it can prove both the locked-account and +rootless-daemon boundary. A first Linux `Apply` may create the account and fixed roots, then stop at +the rootless-Docker check; configure the pinned rootless daemon as that account and rerun `Apply`. +That bounded partial state contains no GitHub association and is safe to reconcile idempotently. + +There is deliberately no global Playwright-package check. Playwright is a repository dependency, +so the honest runtime proof is a repository job that installs the lockfile-pinned dependencies and +launches the bundled browser through the repository's Playwright test command. Golden-image OS and +browser dependencies are installed separately from checksummed inputs; the first real browser launch +remains part of the post-cutover workload rehearsal. + +## Hook pinning after the private cutover + +After the later maintainer-only association step, configure the runner service with these exact +absolute hook paths: + +| Guest | `ACTIONS_RUNNER_HOOK_JOB_STARTED` | `ACTIONS_RUNNER_HOOK_JOB_COMPLETED` | +| --- | --- | --- | +| Linux | `/etc/taskdeck-runner/hooks/cleanup-linux.sh` | `/etc/taskdeck-runner/hooks/cleanup-linux.sh` | +| Windows | `C:\ProgramData\TaskdeckRunner\Policy\Hooks\cleanup-windows.ps1` | `C:\ProgramData\TaskdeckRunner\Policy\Hooks\cleanup-windows.ps1` | + +The runner invokes each hook synchronously as its service account. A pre-job nonzero exit prevents the +job from running and marks it failed. Both post-job hooks also return nonzero on failure, but whether +that changes an already-completed job conclusion is **not yet proven** for this runner version. Until +the real-run rehearsal establishes that behavior, any post-job failure makes the guest suspect: stop +it, remove its association, and rebuild rather than treating the completed result as clean evidence. + +The hook variables are pinned only after association. This repository does not write the runner +service environment or application directory. + +## Remaining human and runtime gates + +The static/check contract above does not prove or perform any of these steps. VM creation and all +host-level changes remain human operations: + +1. Create the Linux and Windows VMs; disable host drives, shares, clipboard, agent forwarding, browser + profiles, inbound ports, and other host integration; allocate bounded guest resources. +2. Install the exact toolchain and guest dependencies from checksum-verified inputs. Configure Linux + rootless Docker to keep its state at the canonical root and make one BuildKit builder available. + Cleanup removes job containers, volumes, and custom networks, prunes images and BuildKit entries + older than seven days, caps retained BuildKit data at 20 GB, and rejects total container state above + 30 GiB rather than silently accepting a full disk. +3. Complete the private-repository cutover. No self-hosted runner may exist while the repository is + public. +4. Perform runner registration and service setup as the maintainer, outside the repository; pin the + absolute hooks above and verify exact labels: + - Linux: `self-hosted, taskdeck, trusted, linux, x64, heavy` + - Windows: `self-hosted, taskdeck, trusted, windows, x64, compatibility` +5. Run the no-credential real workload and Playwright browser-launch rehearsal. Prove normal, failed, + cancelled, disk-exhausted, pre-hook-failed, post-hook-failed, fixed profile-state cleanup, inspection + for residue outside the bounded roots (including the active Windows account profile and HKCU), and + host-containment cases. Do not treat bounded cleanup as proof of a globally clean machine. +6. Prove an offline runner leaves the required gate queued or failed, never green. Only then may the + maintainer consider moving `CI_EXECUTION_MODE` away from `hosted`. -- ordinary self-hosted jobs receive **no** repository/environment/release secrets and a read-only - token (`permissions: contents: read`, `persist-credentials: false`); -- T2/T3/R4 plans never resolve to a self-hosted class (`scripts/ci/smart-ci/plan.test.mjs`); -- `CI_EXECUTION_MODE=hosted` (repository variable) or the `ci:hosted` label forces every lane hosted; -- an offline runner leaves its job queued and the gate pending — never a false green; -- the incident path is: stop VM → remove runner → rotate anything exposed → discard disk → rebuild. +The human gates remain in `OUTSTANDING_TASKS.md` as SC-6 and SC-7. An anomaly follows the incident +path in the threat model: stop, disassociate, rotate any potentially exposed external material, discard +the guest disk, and rebuild from the reviewed image inputs. diff --git a/scripts/ci/runners/bootstrap-linux.sh b/scripts/ci/runners/bootstrap-linux.sh new file mode 100755 index 0000000000..b5d66a8024 --- /dev/null +++ b/scripts/ci/runners/bootstrap-linux.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' +export PATH +umask 077 + +ACTION='Check' +DRY_RUN=0 + +RUNNER_ACCOUNT='taskdeck-runner' +POLICY_ROOT='/etc/taskdeck-runner' +HOOK_ROOT='/etc/taskdeck-runner/hooks' +CONFIG_PATH='/etc/taskdeck-runner/policy.conf' +HOOK_PATH='/etc/taskdeck-runner/hooks/cleanup-linux.sh' +STATE_ROOT='/var/lib/taskdeck-runner' +HOME_ROOT='/var/lib/taskdeck-runner/home' +WORK_ROOT='/var/lib/taskdeck-runner/work' +TEMP_ROOT='/var/lib/taskdeck-runner/temp' +CACHE_ROOT='/var/cache/taskdeck-runner' +NPM_CACHE_ROOT='/var/cache/taskdeck-runner/npm' +NUGET_CACHE_ROOT='/var/cache/taskdeck-runner/nuget' +PLAYWRIGHT_CACHE_ROOT='/var/cache/taskdeck-runner/playwright' +DOCKER_CONFIG_ROOT='/var/cache/taskdeck-runner/docker-config' +BUILDKIT_STATE_ROOT='/var/cache/taskdeck-runner/buildkit' + +emit() { + printf 'RUNNER_BOOTSTRAP %s\n' "$1" +} + +fail() { + printf 'RUNNER_BOOTSTRAP ERROR code=%s\n' "$1" >&2 + exit 1 +} + +usage() { + printf '%s\n' 'usage: bootstrap-linux.sh [--action Check|Apply] [--dry-run]' +} + +while (($# > 0)); do + case "$1" in + --action) + (($# >= 2)) || fail 'argument_missing' + ACTION="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + fail 'argument_invalid' + ;; + esac +done + +case "$ACTION" in + Check|Apply) ;; + *) fail 'action_invalid' ;; +esac + +if [[ "$ACTION" == 'Apply' && "$EUID" -ne 0 ]]; then + fail 'apply_requires_root' +fi + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "tool_missing_$2" +} + +assert_guest_local_path() { + local candidate="$1" + local resolved existing filesystem + + case "$candidate" in + /|/root|/home|/home/*|/mnt|/mnt/*|/media|/media/*|/run/media|/run/media/*) + fail 'path_scope' + ;; + esac + + resolved="$(realpath -m -- "$candidate" 2>/dev/null)" || fail 'path_resolve' + [[ "$resolved" == "$candidate" ]] || fail 'path_reparse' + + existing="$candidate" + while [[ ! -e "$existing" ]]; do + existing="$(dirname -- "$existing")" + [[ "$existing" != '/' ]] || break + done + while [[ "$existing" != '/' ]]; do + [[ ! -L "$existing" ]] || fail 'path_reparse' + existing="$(dirname -- "$existing")" + done + + existing="$candidate" + while [[ ! -e "$existing" ]]; do + existing="$(dirname -- "$existing")" + done + filesystem="$(findmnt -T "$existing" -n -o FSTYPE 2>/dev/null)" || fail 'filesystem_unknown' + case "$filesystem" in + 9p|virtiofs|cifs|nfs|nfs4|fuse.sshfs|drvfs|vboxsf|hgfs) + fail 'filesystem_shared' + ;; + esac +} + +verify_paths() { + local candidate + for candidate in \ + "$POLICY_ROOT" "$HOOK_ROOT" "$CONFIG_PATH" "$HOOK_PATH" \ + "$STATE_ROOT" "$HOME_ROOT" "$WORK_ROOT" "$TEMP_ROOT" \ + "$CACHE_ROOT" "$NPM_CACHE_ROOT" "$NUGET_CACHE_ROOT" \ + "$PLAYWRIGHT_CACHE_ROOT" "$DOCKER_CONFIG_ROOT" "$BUILDKIT_STATE_ROOT"; do + assert_guest_local_path "$candidate" + done + + case "$POLICY_ROOT/" in + "$STATE_ROOT/"*|"$CACHE_ROOT/"*) fail 'policy_under_runner_root' ;; + esac +} + +verify_toolchain() { + local node_version node_arch dotnet_version dotnet_info git_version machine + + require_command uname 'uname' + require_command realpath 'realpath' + require_command findmnt 'findmnt' + require_command getent 'getent' + require_command node 'node' + require_command dotnet 'dotnet' + require_command git 'git' + require_command runuser 'runuser' + require_command passwd 'passwd' + + machine="$(uname -m 2>/dev/null)" || fail 'architecture_probe' + [[ "$machine" == 'x86_64' ]] || fail 'architecture_not_x64' + + node_version="$(node --version 2>/dev/null)" || fail 'node_probe' + [[ "$node_version" == 'v24.13.1' ]] || fail 'node_version' + node_arch="$(node -p 'process.arch' 2>/dev/null)" || fail 'node_arch_probe' + [[ "$node_arch" == 'x64' ]] || fail 'node_architecture' + + dotnet_version="$(dotnet --version 2>/dev/null)" || fail 'dotnet_probe' + [[ "$dotnet_version" == '8.0.415' ]] || fail 'dotnet_version' + dotnet_info="$(dotnet --info 2>/dev/null)" || fail 'dotnet_info_probe' + grep -Eq '^ RID:[[:space:]]+linux-x64$' <<<"$dotnet_info" || fail 'dotnet_architecture' + + git_version="$(git --version 2>/dev/null)" || fail 'git_probe' + [[ "$git_version" =~ ^git\ version\ [0-9]+\.[0-9]+ ]] || fail 'git_version' +} + +account_exists() { + getent passwd "$RUNNER_ACCOUNT" >/dev/null 2>&1 +} + +verify_account() { + local passwd_entry uid gid home shell status groups primary_gid + local -a group_ids + + account_exists || fail 'account_missing' + passwd_entry="$(getent passwd "$RUNNER_ACCOUNT")" || fail 'account_lookup' + IFS=':' read -r _ _ uid gid _ home shell <<<"$passwd_entry" + + [[ "$uid" =~ ^[0-9]+$ && "$uid" -ne 0 ]] || fail 'account_uid' + [[ "$gid" =~ ^[0-9]+$ && "$gid" -ne 0 ]] || fail 'account_gid' + [[ "$home" == "$HOME_ROOT" ]] || fail 'account_home' + [[ "$shell" == '/usr/sbin/nologin' || "$shell" == '/sbin/nologin' ]] || fail 'account_shell' + + if [[ "$EUID" -eq 0 ]]; then + status="$(passwd --status "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_lock_probe' + elif [[ "$(id -u)" == "$uid" ]]; then + status="$(passwd --status 2>/dev/null)" || fail 'account_lock_probe' + else + fail 'account_lock_unverifiable' + fi + [[ "$(awk '{ print $2 }' <<<"$status")" =~ ^L ]] || fail 'account_not_locked' + + groups="$(id -nG "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_group_probe' + grep -Eq '(^|[[:space:]])(root|sudo|wheel|admin|docker)([[:space:]]|$)' <<<"$groups" \ + && fail 'account_privileged_group' + primary_gid="$(id -g "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_group_probe' + read -r -a group_ids <<<"$(id -G "$RUNNER_ACCOUNT" 2>/dev/null)" + [[ "${#group_ids[@]}" -eq 1 && "${group_ids[0]}" == "$primary_gid" ]] \ + || fail 'account_supplementary_group' + + if command -v sudo >/dev/null 2>&1 && sudo -n -l -U "$RUNNER_ACCOUNT" >/dev/null 2>&1; then + fail 'account_sudo_rule' + fi +} + +run_as_runner() { + local uid + uid="$(id -u "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_uid_probe' + + if [[ "$(id -u)" == "$uid" ]]; then + env -i \ + PATH='/usr/local/bin:/usr/bin:/bin' \ + HOME="$HOME_ROOT" \ + XDG_RUNTIME_DIR="/run/user/$uid" \ + DOCKER_HOST="unix:///run/user/$uid/docker.sock" \ + DOCKER_CONFIG="$DOCKER_CONFIG_ROOT" \ + "$@" + elif [[ "$EUID" -eq 0 ]]; then + runuser -u "$RUNNER_ACCOUNT" -- env -i \ + PATH='/usr/local/bin:/usr/bin:/bin' \ + HOME="$HOME_ROOT" \ + XDG_RUNTIME_DIR="/run/user/$uid" \ + DOCKER_HOST="unix:///run/user/$uid/docker.sock" \ + DOCKER_CONFIG="$DOCKER_CONFIG_ROOT" \ + "$@" + else + fail 'runner_identity_required' + fi +} + +verify_rootless_buildkit() { + local security_options docker_root buildx_prune_help + + security_options="$(run_as_runner docker info --format '{{json .SecurityOptions}}' 2>/dev/null)" \ + || fail 'docker_rootless_probe' + grep -q 'name=rootless' <<<"$security_options" || fail 'docker_not_rootless' + + docker_root="$(run_as_runner docker info --format '{{.DockerRootDir}}' 2>/dev/null)" \ + || fail 'docker_state_probe' + [[ "$docker_root" == "$BUILDKIT_STATE_ROOT" ]] || fail 'docker_state_root' + + # docker buildx version proves the plugin is available without creating a builder. + run_as_runner docker buildx version >/dev/null 2>&1 || fail 'buildkit_plugin' + buildx_prune_help="$(run_as_runner docker buildx prune --help 2>/dev/null)" \ + || fail 'buildkit_prune_probe' + grep -q -- '--max-used-space' <<<"$buildx_prune_help" || fail 'buildkit_size_limit_unsupported' + run_as_runner docker buildx inspect >/dev/null 2>&1 || fail 'buildkit_builder' +} + +verify_root_owned_file() { + local candidate="$1" expected_mode="$2" metadata + [[ -f "$candidate" && ! -L "$candidate" ]] || fail 'policy_file_missing' + metadata="$(stat -c '%u:%g:%a' -- "$candidate" 2>/dev/null)" || fail 'policy_file_stat' + [[ "$metadata" == "0:0:$expected_mode" ]] || fail 'policy_file_permissions' +} + +verify_directory() { + local candidate="$1" expected_uid="$2" expected_gid="$3" expected_mode="$4" metadata + [[ -d "$candidate" && ! -L "$candidate" ]] || fail 'directory_missing' + metadata="$(stat -c '%u:%g:%a' -- "$candidate" 2>/dev/null)" || fail 'directory_stat' + [[ "$metadata" == "$expected_uid:$expected_gid:$expected_mode" ]] || fail 'directory_permissions' +} + +verify_layout() { + local uid gid candidate + uid="$(id -u "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_uid_probe' + gid="$(id -g "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_gid_probe' + + verify_directory "$POLICY_ROOT" 0 0 755 + verify_directory "$HOOK_ROOT" 0 0 755 + verify_directory "$STATE_ROOT" 0 0 755 + verify_directory "$CACHE_ROOT" 0 0 755 + verify_root_owned_file "$CONFIG_PATH" 444 + verify_root_owned_file "$HOOK_PATH" 555 + + for candidate in \ + "$HOME_ROOT" "$WORK_ROOT" "$TEMP_ROOT" "$NPM_CACHE_ROOT" "$NUGET_CACHE_ROOT" \ + "$PLAYWRIGHT_CACHE_ROOT" "$DOCKER_CONFIG_ROOT" "$BUILDKIT_STATE_ROOT"; do + verify_directory "$candidate" "$uid" "$gid" 700 + done +} + +apply_layout() { + local script_dir cleanup_source runner_group config_stage + script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P 2>/dev/null)" \ + || fail 'script_root' + cleanup_source="$script_dir/cleanup-linux.sh" + [[ -f "$cleanup_source" && ! -L "$cleanup_source" ]] || fail 'cleanup_source' + + if ! account_exists; then + if [[ "$DRY_RUN" -eq 1 ]]; then + emit 'ACTION code=account_create' + emit 'ACTION code=layout_create' + emit 'OK code=dry_run_account_dependent_checks_deferred' + return + fi + + useradd --system --user-group --create-home --home-dir "$HOME_ROOT" \ + --shell /usr/sbin/nologin "$RUNNER_ACCOUNT" >/dev/null 2>&1 \ + || fail 'account_create' + passwd --lock "$RUNNER_ACCOUNT" >/dev/null 2>&1 || fail 'account_lock' + fi + + if [[ "$DRY_RUN" -eq 1 ]]; then + emit 'ACTION code=layout_reconcile' + return + fi + + runner_group="$(id -gn "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_group_probe' + install -d -o root -g root -m 0755 "$POLICY_ROOT" "$HOOK_ROOT" "$STATE_ROOT" "$CACHE_ROOT" \ + >/dev/null 2>&1 \ + || fail 'layout_policy_create' + install -d -o "$RUNNER_ACCOUNT" -g "$runner_group" -m 0700 \ + "$HOME_ROOT" "$WORK_ROOT" "$TEMP_ROOT" "$NPM_CACHE_ROOT" "$NUGET_CACHE_ROOT" \ + "$PLAYWRIGHT_CACHE_ROOT" "$DOCKER_CONFIG_ROOT" "$BUILDKIT_STATE_ROOT" \ + >/dev/null 2>&1 \ + || fail 'layout_runner_create' + + install -o root -g root -m 0555 "$cleanup_source" "$HOOK_PATH" \ + >/dev/null 2>&1 \ + || fail 'hook_install' + + config_stage="$POLICY_ROOT/.policy.conf.stage" + trap 'rm -f -- "$config_stage" >/dev/null 2>&1' RETURN + { + printf '%s\n' \ + 'POLICY_VERSION=1' \ + "HOME_ROOT=$HOME_ROOT" \ + "WORK_ROOT=$WORK_ROOT" \ + "TEMP_ROOT=$TEMP_ROOT" \ + "NPM_CACHE_ROOT=$NPM_CACHE_ROOT" \ + "NUGET_CACHE_ROOT=$NUGET_CACHE_ROOT" \ + "PLAYWRIGHT_CACHE_ROOT=$PLAYWRIGHT_CACHE_ROOT" \ + "DOCKER_CONFIG_ROOT=$DOCKER_CONFIG_ROOT" \ + "BUILDKIT_STATE_ROOT=$BUILDKIT_STATE_ROOT" \ + 'BUILDKIT_MAX_USED_SPACE=20gb' \ + 'CONTAINER_STATE_MAX_BYTES=32212254720' \ + >"$config_stage" + } 2>/dev/null || fail 'policy_write' + chown root:root "$config_stage" >/dev/null 2>&1 || fail 'policy_owner' + chmod 0444 "$config_stage" >/dev/null 2>&1 || fail 'policy_mode' + mv -f -- "$config_stage" "$CONFIG_PATH" >/dev/null 2>&1 || fail 'policy_publish' + trap - RETURN +} + +verify_paths +verify_toolchain + +if [[ "$ACTION" == 'Apply' ]]; then + apply_layout + if [[ "$DRY_RUN" -eq 1 ]]; then + emit 'OK code=dry_run_complete' + exit 0 + fi +fi + +verify_account +verify_layout +verify_rootless_buildkit +emit 'OK code=verified' +emit 'ACTION code=playwright_repository_job_proof_required' diff --git a/scripts/ci/runners/bootstrap-windows.ps1 b/scripts/ci/runners/bootstrap-windows.ps1 new file mode 100644 index 0000000000..e99df24c14 --- /dev/null +++ b/scripts/ci/runners/bootstrap-windows.ps1 @@ -0,0 +1,443 @@ +<# +.SYNOPSIS +Checks or applies the fixed Taskdeck Windows runner image contract. + +.DESCRIPTION +Check is the non-mutating default. Apply only creates the fixed guest-local directory, policy and +hook layout. Toolchain installation and runner association remain separate maintainer operations. +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [ValidateSet('Check', 'Apply')] + [string]$Action = 'Check' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$RunnerAccount = 'taskdeck-runner' +$PolicyContainer = 'C:\ProgramData\TaskdeckRunner' +$PolicyRoot = 'C:\ProgramData\TaskdeckRunner\Policy' +$HookRoot = 'C:\ProgramData\TaskdeckRunner\Policy\Hooks' +$ConfigPath = 'C:\ProgramData\TaskdeckRunner\Policy\RunnerPolicy.psd1' +$HookPath = 'C:\ProgramData\TaskdeckRunner\Policy\Hooks\cleanup-windows.ps1' +$StateRoot = 'C:\TaskdeckRunner' +$AccountProfileRoot = 'C:\TaskdeckRunner\Profile' +$ProfileStateRoot = 'C:\TaskdeckRunner\ProfileState' +$WorkRoot = 'C:\TaskdeckRunner\Work' +$TempRoot = 'C:\TaskdeckRunner\Temp' +$CacheRoot = 'C:\TaskdeckRunner\Cache' +$NpmCacheRoot = 'C:\TaskdeckRunner\Cache\npm' +$NugetCacheRoot = 'C:\TaskdeckRunner\Cache\nuget' +$PlaywrightCacheRoot = 'C:\TaskdeckRunner\Cache\playwright' + +$SystemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$AdministratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$UsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + +function Write-ContractEvent { + param([Parameter(Mandatory = $true)][string]$Text) + Write-Output "RUNNER_BOOTSTRAP $Text" +} + +function Stop-Contract { + param([Parameter(Mandatory = $true)][string]$Code) + throw [InvalidOperationException]::new("contract:$Code") +} + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Assert-GuestLocalPath { + param( + [Parameter(Mandatory = $true)][string]$Path, + [switch]$MayNotExist, + [switch]$AllowUserProfile + ) + + if ($Path -notmatch '^[A-Za-z]:\\' -or $Path.StartsWith('\\')) { + Stop-Contract 'path_not_local_absolute' + } + + $fullPath = [IO.Path]::GetFullPath($Path) + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ($fullPath.TrimEnd('\') -eq $pathRoot.TrimEnd('\')) { + Stop-Contract 'path_is_filesystem_root' + } + if (-not $AllowUserProfile -and + ($fullPath.StartsWith('C:\Users\', [StringComparison]::OrdinalIgnoreCase) -or + $fullPath.StartsWith('C:\Documents and Settings\', [StringComparison]::OrdinalIgnoreCase))) { + Stop-Contract 'path_is_user_profile' + } + + $drive = [IO.DriveInfo]::new($pathRoot) + if ($drive.DriveType -ne [IO.DriveType]::Fixed) { + Stop-Contract 'path_drive_not_fixed' + } + + $current = $pathRoot + $relative = $fullPath.Substring($pathRoot.Length) + foreach ($segment in $relative.Split([char]'\', [StringSplitOptions]::RemoveEmptyEntries)) { + $current = Join-Path -Path $current -ChildPath $segment + if (Test-Path -LiteralPath $current) { + $item = Get-Item -LiteralPath $current -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Stop-Contract 'path_reparse' + } + } + elseif (-not $MayNotExist) { + Stop-Contract 'path_missing' + } + } +} + +function Get-RunnerIdentity { + try { + $account = Get-LocalUser -Name $RunnerAccount -ErrorAction Stop + } + catch { + Stop-Contract 'account_missing' + } + if (-not $account.Enabled -or $null -eq $account.SID -or $account.SID.Value.EndsWith('-500')) { + Stop-Contract 'account_invalid' + } + + try { + $memberships = @( + foreach ($group in @(Get-LocalGroup -ErrorAction Stop)) { + $members = @(Get-LocalGroupMember -Group $group -ErrorAction Stop) + foreach ($member in $members) { + if ($null -eq $member.SID -or $null -eq $group.SID) { + Stop-Contract 'account_group_unverifiable' + } + if ($member.SID.Value -eq $account.SID.Value) { + $group.SID.Value + break + } + } + } + ) + } + catch { + Stop-Contract 'account_group_unverifiable' + } + if ($memberships.Count -ne 1 -or $memberships[0] -ne $UsersSid.Value) { + Stop-Contract 'account_group_not_permitted' + } + + $profileKey = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$($account.SID.Value)" + try { + $profilePath = [string](Get-ItemPropertyValue -LiteralPath $profileKey -Name 'ProfileImagePath' -ErrorAction Stop) + } + catch { + Stop-Contract 'account_profile_unverifiable' + } + if (-not $profilePath.Equals($AccountProfileRoot, [StringComparison]::OrdinalIgnoreCase)) { + Stop-Contract 'account_profile_root' + } + return $account.SID +} + +function Assert-Toolchain { + $nodePath = 'C:\Program Files\nodejs\node.exe' + $dotnetPath = 'C:\Program Files\dotnet\dotnet.exe' + $gitPath = 'C:\Program Files\Git\cmd\git.exe' + + foreach ($toolPath in @($nodePath, $dotnetPath, $gitPath)) { + Assert-GuestLocalPath -Path $toolPath + } + + $nodeVersion = (& $nodePath --version 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $nodeVersion -ne 'v24.13.1') { + Stop-Contract 'node_version' + } + $nodeArchitecture = (& $nodePath -p 'process.arch' 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $nodeArchitecture -ne 'x64') { + Stop-Contract 'node_architecture' + } + + $dotnetVersion = (& $dotnetPath --version 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $dotnetVersion -ne '8.0.415') { + Stop-Contract 'dotnet_version' + } + $dotnetInfo = (& $dotnetPath --info 2>$null | Out-String) + if ($LASTEXITCODE -ne 0 -or $dotnetInfo -notmatch '(?m)^ RID:\s+win-x64\s*$') { + Stop-Contract 'dotnet_architecture' + } + + $gitVersion = (& $gitPath --version 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $gitVersion -notmatch '^git version \d+\.\d+') { + Stop-Contract 'git_version' + } + $gitBuild = (& $gitPath version --build-options 2>$null | Out-String) + if ($LASTEXITCODE -ne 0 -or $gitBuild -notmatch '(?m)^cpu:\s+x86_64\s*$') { + Stop-Contract 'git_architecture' + } + + try { + $nativeArchitecture = [string](Get-ItemPropertyValue -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name 'PROCESSOR_ARCHITECTURE' -ErrorAction Stop) + } + catch { + Stop-Contract 'operating_system_probe' + } + if ($nativeArchitecture -ne 'AMD64' -or -not [Environment]::Is64BitProcess) { + Stop-Contract 'operating_system_not_x64' + } +} + +function New-PolicyDirectorySecurity { + param( + [Parameter(Mandatory = $true)][Security.Principal.SecurityIdentifier]$RunnerSid, + [Parameter(Mandatory = $true)][bool]$RunnerWritable + ) + + $security = [Security.AccessControl.DirectorySecurity]::new() + $security.SetOwner($AdministratorsSid) + $security.SetAccessRuleProtection($true, $false) + $inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $propagation = [Security.AccessControl.PropagationFlags]::None + $allow = [Security.AccessControl.AccessControlType]::Allow + + foreach ($sid in @($SystemSid, $AdministratorsSid)) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + $propagation, + $allow) + [void]$security.AddAccessRule($rule) + } + + if ($RunnerWritable) { + $rootRights = [Security.AccessControl.FileSystemRights]::ReadAndExecute -bor + [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles + $rootRule = [Security.AccessControl.FileSystemAccessRule]::new( + $RunnerSid, + $rootRights, + [Security.AccessControl.InheritanceFlags]::None, + [Security.AccessControl.PropagationFlags]::None, + $allow) + [void]$security.AddAccessRule($rootRule) + + $childRule = [Security.AccessControl.FileSystemAccessRule]::new( + $RunnerSid, + [Security.AccessControl.FileSystemRights]::Modify, + $inheritance, + [Security.AccessControl.PropagationFlags]::InheritOnly, + $allow) + [void]$security.AddAccessRule($childRule) + } + else { + $runnerRule = [Security.AccessControl.FileSystemAccessRule]::new( + $RunnerSid, + [Security.AccessControl.FileSystemRights]::ReadAndExecute, + $inheritance, + $propagation, + $allow) + [void]$security.AddAccessRule($runnerRule) + } + return $security +} + +function New-PolicyFileSecurity { + param([Parameter(Mandatory = $true)][Security.Principal.SecurityIdentifier]$RunnerSid) + + $security = [Security.AccessControl.FileSecurity]::new() + $security.SetOwner($AdministratorsSid) + $security.SetAccessRuleProtection($true, $false) + $allow = [Security.AccessControl.AccessControlType]::Allow + foreach ($sid in @($SystemSid, $AdministratorsSid)) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [Security.AccessControl.FileSystemRights]::FullControl, + $allow) + [void]$security.AddAccessRule($rule) + } + $runnerRule = [Security.AccessControl.FileSystemAccessRule]::new( + $RunnerSid, + [Security.AccessControl.FileSystemRights]::ReadAndExecute, + $allow) + [void]$security.AddAccessRule($runnerRule) + return $security +} + +function Assert-ProtectedAcl { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][Security.Principal.SecurityIdentifier]$RunnerSid, + [Parameter(Mandatory = $true)][bool]$RunnerWritable + ) + + $acl = Get-Acl -LiteralPath $Path + if (-not $acl.AreAccessRulesProtected) { + Stop-Contract 'acl_inheritance' + } + $owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]) + if ($owner.Value -notin @($SystemSid.Value, $AdministratorsSid.Value)) { + Stop-Contract 'acl_owner' + } + + $allowedSids = @($SystemSid.Value, $AdministratorsSid.Value, $RunnerSid.Value) + $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + if ($rules.Count -lt 3 -or $rules.Where({ $_.IsInherited }).Count -ne 0) { + Stop-Contract 'acl_rules' + } + $runnerRuleSeen = $false + $runnerCanWrite = $false + foreach ($rule in $rules) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + $rule.IdentityReference.Value -notin $allowedSids) { + Stop-Contract 'acl_unexpected_principal' + } + if ($rule.IdentityReference.Value -eq $RunnerSid.Value) { + $runnerRuleSeen = $true + $runnerControlMask = [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + if (($rule.FileSystemRights -band $runnerControlMask) -ne 0) { + Stop-Contract 'acl_runner_control' + } + $writeMask = [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + if (($rule.FileSystemRights -band $writeMask) -ne 0) { + $runnerCanWrite = $true + } + if ($RunnerWritable -and + $rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::InheritOnly -and + ($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::Delete) -ne 0) { + Stop-Contract 'acl_runner_can_delete_root' + } + } + } + if (-not $runnerRuleSeen -or $runnerCanWrite -ne $RunnerWritable) { + Stop-Contract 'acl_runner_rights' + } +} + +function Assert-Layout { + param([Parameter(Mandatory = $true)][Security.Principal.SecurityIdentifier]$RunnerSid) + + foreach ($path in @($PolicyContainer, $PolicyRoot, $HookRoot, $ConfigPath, $HookPath, $StateRoot, $AccountProfileRoot, $ProfileStateRoot, $WorkRoot, $TempRoot, + $CacheRoot, $NpmCacheRoot, $NugetCacheRoot, $PlaywrightCacheRoot)) { + Assert-GuestLocalPath -Path $path + } + + foreach ($path in @($PolicyContainer, $PolicyRoot, $HookRoot, $ConfigPath, $HookPath, $StateRoot, $CacheRoot)) { + Assert-ProtectedAcl -Path $path -RunnerSid $RunnerSid -RunnerWritable $false + } + foreach ($path in @($ProfileStateRoot, $WorkRoot, $TempRoot, $NpmCacheRoot, $NugetCacheRoot, $PlaywrightCacheRoot)) { + Assert-ProtectedAcl -Path $path -RunnerSid $RunnerSid -RunnerWritable $true + } + + $policy = Import-PowerShellDataFile -LiteralPath $ConfigPath + $expected = [ordered]@{ + PolicyVersion = 1 + AccountProfileRoot = $AccountProfileRoot + ProfileStateRoot = $ProfileStateRoot + WorkRoot = $WorkRoot + TempRoot = $TempRoot + NpmCacheRoot = $NpmCacheRoot + NugetCacheRoot = $NugetCacheRoot + PlaywrightCacheRoot = $PlaywrightCacheRoot + } + if ($policy.Count -ne $expected.Count) { + Stop-Contract 'policy_incomplete' + } + foreach ($key in $expected.Keys) { + if (-not $policy.ContainsKey($key) -or $policy[$key] -ne $expected[$key]) { + Stop-Contract 'policy_value' + } + } +} + +function Apply-Layout { + param([Parameter(Mandatory = $true)][Security.Principal.SecurityIdentifier]$RunnerSid) + + $cleanupSource = Join-Path -Path $PSScriptRoot -ChildPath 'cleanup-windows.ps1' + Assert-GuestLocalPath -Path $cleanupSource -AllowUserProfile + if (-not (Test-Path -LiteralPath $cleanupSource -PathType Leaf)) { + Stop-Contract 'cleanup_source' + } + + foreach ($path in @($PolicyContainer, $PolicyRoot, $HookRoot, $StateRoot, $ProfileStateRoot, $WorkRoot, $TempRoot, $CacheRoot, + $NpmCacheRoot, $NugetCacheRoot, $PlaywrightCacheRoot)) { + [void](New-Item -ItemType Directory -Path $path -Force -ErrorAction Stop) + } + + foreach ($path in @($PolicyContainer, $PolicyRoot, $HookRoot, $StateRoot, $CacheRoot)) { + Set-Acl -LiteralPath $path -AclObject (New-PolicyDirectorySecurity -RunnerSid $RunnerSid -RunnerWritable $false) + } + foreach ($path in @($ProfileStateRoot, $WorkRoot, $TempRoot, $NpmCacheRoot, $NugetCacheRoot, $PlaywrightCacheRoot)) { + Set-Acl -LiteralPath $path -AclObject (New-PolicyDirectorySecurity -RunnerSid $RunnerSid -RunnerWritable $true) + } + + Copy-Item -LiteralPath $cleanupSource -Destination $HookPath -Force -ErrorAction Stop + $configStage = Join-Path -Path $PolicyRoot -ChildPath 'RunnerPolicy.stage' + try { + [IO.File]::WriteAllLines($configStage, @( + '@{', + ' PolicyVersion = 1', + " AccountProfileRoot = '$AccountProfileRoot'", + " ProfileStateRoot = '$ProfileStateRoot'", + " WorkRoot = '$WorkRoot'", + " TempRoot = '$TempRoot'", + " NpmCacheRoot = '$NpmCacheRoot'", + " NugetCacheRoot = '$NugetCacheRoot'", + " PlaywrightCacheRoot = '$PlaywrightCacheRoot'", + '}' + ), [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $configStage -Destination $ConfigPath -Force -ErrorAction Stop + } + finally { + if (Test-Path -LiteralPath $configStage) { + Remove-Item -LiteralPath $configStage -Force -ErrorAction Stop + } + } + + foreach ($path in @($ConfigPath, $HookPath)) { + Set-Acl -LiteralPath $path -AclObject (New-PolicyFileSecurity -RunnerSid $RunnerSid) + } +} + +function Invoke-Main { + foreach ($path in @($PolicyContainer, $PolicyRoot, $HookRoot, $ConfigPath, $HookPath, $StateRoot, $AccountProfileRoot, $ProfileStateRoot, $WorkRoot, $TempRoot, + $CacheRoot, $NpmCacheRoot, $NugetCacheRoot, $PlaywrightCacheRoot)) { + Assert-GuestLocalPath -Path $path -MayNotExist + } + Assert-Toolchain + $runnerSid = Get-RunnerIdentity + Assert-GuestLocalPath -Path $AccountProfileRoot + + if ($Action -eq 'Apply') { + if (-not (Test-IsAdministrator)) { + Stop-Contract 'apply_requires_administrator' + } + if (-not $PSCmdlet.ShouldProcess('runner-image-policy', 'Apply fixed runner image contract')) { + Write-ContractEvent 'OK code=whatif_complete' + return + } + Apply-Layout -RunnerSid $runnerSid + } + + Assert-Layout -RunnerSid $runnerSid + Write-ContractEvent 'OK code=verified' + Write-ContractEvent 'ACTION code=playwright_repository_job_proof_required' +} + +try { + Invoke-Main +} +catch { + $code = 'unexpected' + if ($_.Exception.Message -match '^contract:([a-z0-9_]+)$') { + $code = $Matches[1] + } + Write-ContractEvent "ERROR code=$code" + exit 1 +} diff --git a/scripts/ci/runners/cleanup-linux.sh b/scripts/ci/runners/cleanup-linux.sh new file mode 100755 index 0000000000..262c6414ab --- /dev/null +++ b/scripts/ci/runners/cleanup-linux.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +PATH='/usr/bin:/bin' +export PATH +umask 077 + +CONFIG_PATH='/etc/taskdeck-runner/policy.conf' +HOOK_PATH='/etc/taskdeck-runner/hooks/cleanup-linux.sh' +RUNNER_ACCOUNT='taskdeck-runner' +HOME_ROOT='/var/lib/taskdeck-runner/home' +WORK_ROOT='/var/lib/taskdeck-runner/work' +TEMP_ROOT='/var/lib/taskdeck-runner/temp' +NPM_CACHE_ROOT='/var/cache/taskdeck-runner/npm' +NUGET_CACHE_ROOT='/var/cache/taskdeck-runner/nuget' +PLAYWRIGHT_CACHE_ROOT='/var/cache/taskdeck-runner/playwright' +DOCKER_CONFIG_ROOT='/var/cache/taskdeck-runner/docker-config' +BUILDKIT_STATE_ROOT='/var/cache/taskdeck-runner/buildkit' +BUILDKIT_MAX_USED_SPACE='20gb' +CONTAINER_STATE_MAX_BYTES=32212254720 + +DRY_RUN=0 +INTERNAL_RUN=0 + +emit() { + printf 'RUNNER_CLEANUP %s\n' "$1" +} + +fail() { + printf 'RUNNER_CLEANUP ERROR code=%s\n' "$1" >&2 + exit 1 +} + +unexpected_error() { + local status=$? + trap - ERR + emit 'ERROR code=unexpected' + exit "$status" +} +trap unexpected_error ERR + +usage() { + printf '%s\n' 'usage: cleanup-linux.sh [--dry-run]' +} + +while (($# > 0)); do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --internal-run) + INTERNAL_RUN=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + fail 'argument_invalid' + ;; + esac +done + +if [[ "$INTERNAL_RUN" -eq 0 ]]; then + [[ -x /usr/bin/timeout ]] || fail 'timeout_unavailable' + [[ -x "$HOOK_PATH" && ! -L "$HOOK_PATH" ]] || fail 'hook_not_immutable_path' + [[ "$(realpath -e -- "$HOOK_PATH" 2>/dev/null)" == "$HOOK_PATH" ]] || fail 'hook_not_immutable_path' + + bounded_args=('--internal-run') + if [[ "$DRY_RUN" -eq 1 ]]; then + bounded_args+=('--dry-run') + fi + + if /usr/bin/timeout --signal=TERM --kill-after=10s 120s "$HOOK_PATH" "${bounded_args[@]}"; then + exit 0 + else + bounded_status=$? + case "$bounded_status" in + 124|137) fail 'timeout' ;; + *) fail 'bounded_worker' ;; + esac + fi +fi + +assert_guest_local_path() { + local candidate="$1" + local resolved existing filesystem + + case "$candidate" in + /|/root|/home|/home/*|/mnt|/mnt/*|/media|/media/*|/run/media|/run/media/*) + fail 'path_scope' + ;; + esac + + resolved="$(realpath -e -- "$candidate" 2>/dev/null)" || fail 'path_missing' + [[ "$resolved" == "$candidate" ]] || fail 'path_reparse' + [[ ! -L "$candidate" ]] || fail 'path_reparse' + + existing="$candidate" + while [[ "$existing" != '/' ]]; do + [[ ! -L "$existing" ]] || fail 'path_reparse' + existing="$(dirname -- "$existing")" + done + + filesystem="$(findmnt -T "$candidate" -n -o FSTYPE 2>/dev/null)" || fail 'filesystem_unknown' + case "$filesystem" in + 9p|virtiofs|cifs|nfs|nfs4|fuse.sshfs|drvfs|vboxsf|hgfs) + fail 'filesystem_shared' + ;; + esac +} + +verify_immutable_file() { + local candidate="$1" expected_mode="$2" metadata + assert_guest_local_path "$candidate" + [[ -f "$candidate" ]] || fail 'policy_file_missing' + metadata="$(stat -c '%u:%g:%a' -- "$candidate" 2>/dev/null)" || fail 'policy_file_stat' + [[ "$metadata" == "0:0:$expected_mode" ]] || fail 'policy_file_permissions' +} + +verify_runner_directory() { + local candidate="$1" expected_uid="$2" expected_gid="$3" metadata + assert_guest_local_path "$candidate" + [[ -d "$candidate" ]] || fail 'runner_directory_missing' + metadata="$(stat -c '%u:%g:%a' -- "$candidate" 2>/dev/null)" || fail 'runner_directory_stat' + [[ "$metadata" == "$expected_uid:$expected_gid:700" ]] || fail 'runner_directory_permissions' +} + +verify_policy() { + local metadata line key value + local expected_uid expected_gid + local -a group_ids + declare -A settings=() + + verify_immutable_file "$CONFIG_PATH" 444 + verify_immutable_file "$HOOK_PATH" 555 + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[A-Z_]+=/?[A-Za-z0-9._-]+$ ]] \ + || fail 'policy_format' + key="${line%%=*}" + value="${line#*=}" + [[ -z "${settings[$key]+present}" ]] || fail 'policy_duplicate' + case "$key" in + POLICY_VERSION|HOME_ROOT|WORK_ROOT|TEMP_ROOT|NPM_CACHE_ROOT|NUGET_CACHE_ROOT|PLAYWRIGHT_CACHE_ROOT|DOCKER_CONFIG_ROOT|BUILDKIT_STATE_ROOT|BUILDKIT_MAX_USED_SPACE|CONTAINER_STATE_MAX_BYTES) ;; + *) fail 'policy_key' ;; + esac + settings[$key]="$value" + done <"$CONFIG_PATH" + + [[ "${#settings[@]}" -eq 11 ]] || fail 'policy_incomplete' + [[ "${settings[POLICY_VERSION]}" == '1' ]] || fail 'policy_version' + [[ "${settings[HOME_ROOT]}" == "$HOME_ROOT" ]] || fail 'policy_home_root' + [[ "${settings[WORK_ROOT]}" == "$WORK_ROOT" ]] || fail 'policy_work_root' + [[ "${settings[TEMP_ROOT]}" == "$TEMP_ROOT" ]] || fail 'policy_temp_root' + [[ "${settings[NPM_CACHE_ROOT]}" == "$NPM_CACHE_ROOT" ]] || fail 'policy_npm_root' + [[ "${settings[NUGET_CACHE_ROOT]}" == "$NUGET_CACHE_ROOT" ]] || fail 'policy_nuget_root' + [[ "${settings[PLAYWRIGHT_CACHE_ROOT]}" == "$PLAYWRIGHT_CACHE_ROOT" ]] || fail 'policy_playwright_root' + [[ "${settings[DOCKER_CONFIG_ROOT]}" == "$DOCKER_CONFIG_ROOT" ]] || fail 'policy_docker_root' + [[ "${settings[BUILDKIT_STATE_ROOT]}" == "$BUILDKIT_STATE_ROOT" ]] || fail 'policy_buildkit_root' + [[ "${settings[BUILDKIT_MAX_USED_SPACE]}" == "$BUILDKIT_MAX_USED_SPACE" ]] || fail 'policy_buildkit_limit' + [[ "${settings[CONTAINER_STATE_MAX_BYTES]}" == "$CONTAINER_STATE_MAX_BYTES" ]] || fail 'policy_state_limit' + + expected_uid="$(id -u "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_lookup' + expected_gid="$(id -g "$RUNNER_ACCOUNT" 2>/dev/null)" || fail 'account_lookup' + [[ "$(id -u)" == "$expected_uid" ]] || fail 'runner_identity' + read -r -a group_ids <<<"$(id -G "$RUNNER_ACCOUNT" 2>/dev/null)" + [[ "${#group_ids[@]}" -eq 1 && "${group_ids[0]}" == "$expected_gid" ]] \ + || fail 'account_supplementary_group' + + for candidate in \ + "$HOME_ROOT" "$WORK_ROOT" "$TEMP_ROOT" "$NPM_CACHE_ROOT" "$NUGET_CACHE_ROOT" \ + "$PLAYWRIGHT_CACHE_ROOT" "$DOCKER_CONFIG_ROOT" "$BUILDKIT_STATE_ROOT"; do + verify_runner_directory "$candidate" "$expected_uid" "$expected_gid" + done +} + +clear_children() { + local candidate="$1" action_code="$2" count remaining + count="$(find "$candidate" -mindepth 1 -maxdepth 1 -printf '.' 2>/dev/null | wc -c | tr -d '[:space:]')" \ + || fail 'count_failed' + [[ "$count" =~ ^[0-9]+$ ]] || fail 'count_invalid' + + if [[ "$DRY_RUN" -eq 0 && "$count" -gt 0 ]]; then + find "$candidate" -mindepth 1 -maxdepth 1 -exec rm --one-file-system -rf -- {} + \ + >/dev/null 2>&1 || fail 'clear_failed' + remaining="$(find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" \ + || fail 'clear_verify_failed' + [[ -z "$remaining" ]] || fail 'clear_incomplete' + fi + emit "ACTION code=$action_code count=$count" +} + +docker_runner() { + local uid + uid="$(id -u)" || fail 'identity_probe' + env -i \ + PATH='/usr/bin:/bin' \ + HOME='/var/lib/taskdeck-runner/home' \ + XDG_RUNTIME_DIR="/run/user/$uid" \ + DOCKER_HOST="unix:///run/user/$uid/docker.sock" \ + DOCKER_CONFIG='/var/cache/taskdeck-runner/docker-config' \ + /usr/bin/docker "$@" +} + +clean_container_state() { + local raw state_bytes + local -a container_ids=() volume_ids=() network_ids=() + + [[ -x /usr/bin/docker ]] || fail 'docker_missing' + if [[ "$DRY_RUN" -eq 1 ]]; then + emit 'ACTION code=container_cleanup_planned' + return + fi + + raw="$(docker_runner container ls --all --quiet 2>/dev/null)" || fail 'container_list_failed' + if [[ -n "$raw" ]]; then mapfile -t container_ids <<<"$raw"; fi + if ((${#container_ids[@]} > 0)); then + docker_runner container rm --force "${container_ids[@]}" >/dev/null 2>&1 \ + || fail 'container_remove_failed' + fi + emit "ACTION code=container_clear count=${#container_ids[@]}" + + raw="$(docker_runner volume ls --quiet 2>/dev/null)" || fail 'volume_list_failed' + if [[ -n "$raw" ]]; then mapfile -t volume_ids <<<"$raw"; fi + if ((${#volume_ids[@]} > 0)); then + docker_runner volume rm --force "${volume_ids[@]}" >/dev/null 2>&1 \ + || fail 'volume_remove_failed' + fi + emit "ACTION code=volume_clear count=${#volume_ids[@]}" + + raw="$(docker_runner network ls --filter 'type=custom' --quiet 2>/dev/null)" || fail 'network_list_failed' + if [[ -n "$raw" ]]; then mapfile -t network_ids <<<"$raw"; fi + if ((${#network_ids[@]} > 0)); then + docker_runner network rm "${network_ids[@]}" >/dev/null 2>&1 \ + || fail 'network_remove_failed' + fi + emit "ACTION code=network_clear count=${#network_ids[@]}" + + docker_runner image prune --all --force --filter 'until=168h' >/dev/null 2>&1 \ + || fail 'image_prune_failed' + docker_runner buildx prune --force --filter 'until=168h' >/dev/null 2>&1 \ + || fail 'buildkit_age_prune_failed' + docker_runner buildx prune --force --max-used-space "$BUILDKIT_MAX_USED_SPACE" >/dev/null 2>&1 \ + || fail 'buildkit_size_prune_failed' + + state_bytes="$(du -sb -- "$BUILDKIT_STATE_ROOT" 2>/dev/null | awk '{ print $1 }')" \ + || fail 'container_state_measure_failed' + [[ "$state_bytes" =~ ^[0-9]+$ ]] || fail 'container_state_measure_invalid' + ((state_bytes <= CONTAINER_STATE_MAX_BYTES)) || fail 'container_state_limit_exceeded' + emit "ACTION code=container_state_bounded count=$state_bytes" +} + +verify_policy +cd / + +clear_children "$HOME_ROOT" 'home_clear' +clear_children "$WORK_ROOT" 'work_clear' +clear_children "$TEMP_ROOT" 'temp_clear' +clear_children "$NPM_CACHE_ROOT" 'npm_clear' +clear_children "$NUGET_CACHE_ROOT" 'nuget_clear' +clear_children "$PLAYWRIGHT_CACHE_ROOT" 'playwright_clear' +clear_children "$DOCKER_CONFIG_ROOT" 'docker_config_clear' +clean_container_state + +emit 'OK code=complete' diff --git a/scripts/ci/runners/cleanup-windows.ps1 b/scripts/ci/runners/cleanup-windows.ps1 new file mode 100644 index 0000000000..ee1557d09c --- /dev/null +++ b/scripts/ci/runners/cleanup-windows.ps1 @@ -0,0 +1,349 @@ +<# +.SYNOPSIS +Clears bounded Taskdeck Windows runner state from immutable guest policy. + +.DESCRIPTION +The fixed hook starts one background worker and gives it 120 seconds. It never accepts deletion +roots from arguments or process state, and reports only stable action codes and counts. +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ConfigPath = 'C:\ProgramData\TaskdeckRunner\Policy\RunnerPolicy.psd1' +$HookPath = 'C:\ProgramData\TaskdeckRunner\Policy\Hooks\cleanup-windows.ps1' + +function Write-CleanupEvent { + param([Parameter(Mandatory = $true)][string]$Text) + Write-Output "RUNNER_CLEANUP $Text" +} + +function Stop-Cleanup { + param([Parameter(Mandatory = $true)][string]$Code) + throw [InvalidOperationException]::new("cleanup:$Code") +} + +function Assert-InstalledHookPath { + $invokedPath = [IO.Path]::GetFullPath($PSCommandPath) + if (-not $invokedPath.Equals($HookPath, [StringComparison]::OrdinalIgnoreCase)) { + Stop-Cleanup 'hook_path' + } + $hookItem = Get-Item -LiteralPath $HookPath -Force -ErrorAction Stop + if (($hookItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Stop-Cleanup 'hook_reparse' + } +} + +$worker = { + param([Parameter(Mandatory = $true)][bool]$DryRun) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $runnerAccount = 'taskdeck-runner' + $policyContainer = 'C:\ProgramData\TaskdeckRunner' + $policyRoot = 'C:\ProgramData\TaskdeckRunner\Policy' + $hookRoot = 'C:\ProgramData\TaskdeckRunner\Policy\Hooks' + $configPath = 'C:\ProgramData\TaskdeckRunner\Policy\RunnerPolicy.psd1' + $hookPath = 'C:\ProgramData\TaskdeckRunner\Policy\Hooks\cleanup-windows.ps1' + $stateRoot = 'C:\TaskdeckRunner' + $accountProfileRoot = 'C:\TaskdeckRunner\Profile' + $profileStateRoot = 'C:\TaskdeckRunner\ProfileState' + $workRoot = 'C:\TaskdeckRunner\Work' + $tempRoot = 'C:\TaskdeckRunner\Temp' + $cacheRoot = 'C:\TaskdeckRunner\Cache' + $npmCacheRoot = 'C:\TaskdeckRunner\Cache\npm' + $nugetCacheRoot = 'C:\TaskdeckRunner\Cache\nuget' + $playwrightCacheRoot = 'C:\TaskdeckRunner\Cache\playwright' + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + + function Stop-Worker { + param([Parameter(Mandatory = $true)][string]$Code) + throw [InvalidOperationException]::new("worker:$Code") + } + + function Assert-GuestLocalPath { + param([Parameter(Mandatory = $true)][string]$Path) + + if ($Path -notmatch '^[A-Za-z]:\\' -or $Path.StartsWith('\\')) { + Stop-Worker 'path_not_local_absolute' + } + $fullPath = [IO.Path]::GetFullPath($Path) + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ($fullPath.TrimEnd('\') -eq $pathRoot.TrimEnd('\')) { + Stop-Worker 'path_is_filesystem_root' + } + if ($fullPath.StartsWith('C:\Users\', [StringComparison]::OrdinalIgnoreCase) -or + $fullPath.StartsWith('C:\Documents and Settings\', [StringComparison]::OrdinalIgnoreCase)) { + Stop-Worker 'path_is_user_profile' + } + $drive = [IO.DriveInfo]::new($pathRoot) + if ($drive.DriveType -ne [IO.DriveType]::Fixed) { + Stop-Worker 'path_drive_not_fixed' + } + + $current = $pathRoot + $relative = $fullPath.Substring($pathRoot.Length) + foreach ($segment in $relative.Split([char]'\', [StringSplitOptions]::RemoveEmptyEntries)) { + $current = Join-Path -Path $current -ChildPath $segment + if (-not (Test-Path -LiteralPath $current)) { + Stop-Worker 'path_missing' + } + $item = Get-Item -LiteralPath $current -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Stop-Worker 'path_reparse' + } + } + } + + function Assert-ProtectedAcl { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][Security.Principal.SecurityIdentifier]$RunnerSid, + [Parameter(Mandatory = $true)][bool]$RunnerWritable + ) + + $acl = Get-Acl -LiteralPath $Path + if (-not $acl.AreAccessRulesProtected) { + Stop-Worker 'acl_inheritance' + } + $owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]) + if ($owner.Value -notin @($systemSid.Value, $administratorsSid.Value)) { + Stop-Worker 'acl_owner' + } + + $allowedSids = @($systemSid.Value, $administratorsSid.Value, $RunnerSid.Value) + $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + if ($rules.Count -lt 3 -or $rules.Where({ $_.IsInherited }).Count -ne 0) { + Stop-Worker 'acl_rules' + } + $runnerRuleSeen = $false + $runnerCanWrite = $false + foreach ($rule in $rules) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + $rule.IdentityReference.Value -notin $allowedSids) { + Stop-Worker 'acl_unexpected_principal' + } + if ($rule.IdentityReference.Value -eq $RunnerSid.Value) { + $runnerRuleSeen = $true + $runnerControlMask = [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + if (($rule.FileSystemRights -band $runnerControlMask) -ne 0) { + Stop-Worker 'acl_runner_control' + } + $writeMask = [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + if (($rule.FileSystemRights -band $writeMask) -ne 0) { + $runnerCanWrite = $true + } + if ($RunnerWritable -and + $rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::InheritOnly -and + ($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::Delete) -ne 0) { + Stop-Worker 'acl_runner_can_delete_root' + } + } + } + if (-not $runnerRuleSeen -or $runnerCanWrite -ne $RunnerWritable) { + Stop-Worker 'acl_runner_rights' + } + } + + function Assert-NoReparseDescendants { + param([Parameter(Mandatory = $true)][string]$Root) + + # Enumerate one directory at a time so a reparse point is rejected before it can be + # traversed. Get-ChildItem -Recurse is intentionally avoided here because containment + # depends on inspecting each child before adding its path to the walk. + $rootItem = Get-Item -LiteralPath $Root -Force -ErrorAction Stop + if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Stop-Worker 'path_reparse' + } + $pending = [Collections.Generic.Stack[string]]::new() + $pending.Push($Root) + while ($pending.Count -gt 0) { + $current = $pending.Pop() + foreach ($item in @(Get-ChildItem -LiteralPath $current -Force -ErrorAction Stop)) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Stop-Worker 'child_reparse' + } + if ($item.PSIsContainer) { + $pending.Push($item.FullName) + } + } + } + } + + function Clear-Children { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$ActionCode + ) + + Assert-NoReparseDescendants -Root $Root + $children = @(Get-ChildItem -LiteralPath $Root -Force -ErrorAction Stop) + if (-not $DryRun) { + foreach ($item in $children) { + Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction Stop + } + if (@(Get-ChildItem -LiteralPath $Root -Force -ErrorAction Stop).Count -ne 0) { + Stop-Worker 'clear_incomplete' + } + } + return "ACTION code=$ActionCode count=$($children.Count)" + } + + try { + foreach ($path in @($policyContainer, $policyRoot, $hookRoot, $configPath, $hookPath, + $stateRoot, $accountProfileRoot, $profileStateRoot, $workRoot, $tempRoot, $cacheRoot, $npmCacheRoot, + $nugetCacheRoot, $playwrightCacheRoot)) { + Assert-GuestLocalPath -Path $path + } + + try { + $account = Get-LocalUser -Name $runnerAccount -ErrorAction Stop + } + catch { + Stop-Worker 'account_missing' + } + if ($null -eq $account.SID) { + Stop-Worker 'account_missing' + } + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + if ($currentSid.Value -ne $account.SID.Value) { + Stop-Worker 'runner_identity' + } + + try { + $memberships = @( + foreach ($group in @(Get-LocalGroup -ErrorAction Stop)) { + $members = @(Get-LocalGroupMember -Group $group -ErrorAction Stop) + foreach ($member in $members) { + if ($null -eq $member.SID -or $null -eq $group.SID) { + Stop-Worker 'account_group_unverifiable' + } + if ($member.SID.Value -eq $account.SID.Value) { + $group.SID.Value + break + } + } + } + ) + } + catch { + Stop-Worker 'account_group_unverifiable' + } + if ($memberships.Count -ne 1 -or $memberships[0] -ne $usersSid.Value) { + Stop-Worker 'account_group_not_permitted' + } + + $profileKey = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$($account.SID.Value)" + try { + $profilePath = [string](Get-ItemPropertyValue -LiteralPath $profileKey -Name 'ProfileImagePath' -ErrorAction Stop) + } + catch { + Stop-Worker 'account_profile_unverifiable' + } + if (-not $profilePath.Equals($accountProfileRoot, [StringComparison]::OrdinalIgnoreCase)) { + Stop-Worker 'account_profile_root' + } + + foreach ($path in @($policyContainer, $policyRoot, $hookRoot, $configPath, $hookPath, $stateRoot, $cacheRoot)) { + Assert-ProtectedAcl -Path $path -RunnerSid $account.SID -RunnerWritable $false + } + foreach ($path in @($profileStateRoot, $workRoot, $tempRoot, $npmCacheRoot, $nugetCacheRoot, $playwrightCacheRoot)) { + Assert-ProtectedAcl -Path $path -RunnerSid $account.SID -RunnerWritable $true + } + + $policy = Import-PowerShellDataFile -LiteralPath $configPath + $expected = [ordered]@{ + PolicyVersion = 1 + AccountProfileRoot = $accountProfileRoot + ProfileStateRoot = $profileStateRoot + WorkRoot = $workRoot + TempRoot = $tempRoot + NpmCacheRoot = $npmCacheRoot + NugetCacheRoot = $nugetCacheRoot + PlaywrightCacheRoot = $playwrightCacheRoot + } + if ($policy.Count -ne $expected.Count) { + Stop-Worker 'policy_incomplete' + } + foreach ($key in $expected.Keys) { + if (-not $policy.ContainsKey($key) -or $policy[$key] -ne $expected[$key]) { + Stop-Worker 'policy_value' + } + } + + $events = @( + Clear-Children -Root $profileStateRoot -ActionCode 'profile_state_clear' + Clear-Children -Root $workRoot -ActionCode 'work_clear' + Clear-Children -Root $tempRoot -ActionCode 'temp_clear' + Clear-Children -Root $npmCacheRoot -ActionCode 'npm_clear' + Clear-Children -Root $nugetCacheRoot -ActionCode 'nuget_clear' + Clear-Children -Root $playwrightCacheRoot -ActionCode 'playwright_clear' + ) + [pscustomobject]@{ Success = $true; Code = 'complete'; Events = $events } + } + catch { + $code = 'unexpected' + if ($_.Exception.Message -match '^worker:([a-z0-9_]+)$') { + $code = $Matches[1] + } + [pscustomobject]@{ Success = $false; Code = $code; Events = @() } + } +} + +$job = $null +try { + Assert-InstalledHookPath + $job = Start-Job -ScriptBlock $worker -ArgumentList @([bool]$WhatIfPreference) -ErrorAction Stop + $completed = Wait-Job -Job $job -Timeout 120 -ErrorAction Stop + if ($null -eq $completed) { + Stop-Job -Job $job -ErrorAction Stop + Stop-Cleanup 'timeout' + } + if ($job.State -ne [Management.Automation.JobState]::Completed) { + Stop-Cleanup 'worker_state' + } + + $result = @(Receive-Job -Job $job -ErrorAction Stop) + if ($result.Count -ne 1 -or $null -eq $result[0].Success) { + Stop-Cleanup 'worker_result' + } + if (-not $result[0].Success) { + Stop-Cleanup ([string]$result[0].Code) + } + foreach ($event in @($result[0].Events)) { + Write-CleanupEvent ([string]$event) + } + Write-CleanupEvent 'OK code=complete' +} +catch { + $code = 'unexpected' + if ($_.Exception.Message -match '^cleanup:([a-z0-9_]+)$') { + $code = $Matches[1] + } + Write-CleanupEvent "ERROR code=$code" + exit 1 +} +finally { + if ($null -ne $job) { + try { + if ($job.State -eq [Management.Automation.JobState]::Running) { + Stop-Job -Job $job -ErrorAction Stop + } + Remove-Job -Job $job -Force -ErrorAction Stop + } + catch { + Write-CleanupEvent 'ERROR code=job_cleanup' + exit 1 + } + } +} diff --git a/scripts/ci/runners/runner-bootstrap-contract.test.mjs b/scripts/ci/runners/runner-bootstrap-contract.test.mjs new file mode 100644 index 0000000000..3ab3875ccd --- /dev/null +++ b/scripts/ci/runners/runner-bootstrap-contract.test.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const sourceUrl = (name) => new URL(name, import.meta.url) +const readSource = (name) => readFileSync(sourceUrl(name), 'utf8').replace(/\r\n/g, '\n') + +const linuxBootstrap = readSource('bootstrap-linux.sh') +const windowsBootstrap = readSource('bootstrap-windows.ps1') +const linuxCleanup = readSource('cleanup-linux.sh') +const windowsCleanup = readSource('cleanup-windows.ps1') +const readme = readSource('README.md') + +const executableSources = [linuxBootstrap, windowsBootstrap, linuxCleanup, windowsCleanup] + +test('runner scripts expose Check by default and privileged Apply explicitly', () => { + assert.match(linuxBootstrap, /ACTION='Check'/) + assert.match(linuxBootstrap, /Check\|Apply/) + assert.match(linuxBootstrap, /--dry-run/) + assert.match(linuxBootstrap, /EUID[^\n]+-ne 0/) + + assert.match(windowsBootstrap, /\[ValidateSet\('Check', 'Apply'\)\]/) + assert.match(windowsBootstrap, /\[string\]\$Action = 'Check'/) + assert.match(windowsBootstrap, /SupportsShouldProcess = \$true/) + assert.match(windowsBootstrap, /WindowsPrincipal/) + assert.match(windowsBootstrap, /Administrator/) +}) + +test('bootstraps pin the supported architecture and toolchain without installing packages', () => { + for (const source of [linuxBootstrap, windowsBootstrap]) { + assert.match(source, /24\.13\.1/) + assert.match(source, /8\.0\.415/) + assert.match(source, /x64/i) + assert.match(source, /git/i) + assert.doesNotMatch(source, /\b(?:apt(?:-get)?|dnf|yum|zypper|winget|choco)\b/i) + assert.doesNotMatch(source, /\b(?:curl|wget|Invoke-WebRequest|Start-BitsTransfer)\b/i) + assert.doesNotMatch(source, /(?:@|:|\binstall\s+)latest\b/i) + } + + assert.match(linuxBootstrap, /SecurityOptions/) + assert.match(linuxBootstrap, /rootless/) + assert.match(linuxBootstrap, /docker buildx version/) + assert.match(linuxBootstrap, /docker buildx prune --help/) + assert.match(linuxBootstrap, /--max-used-space/) + assert.match(readme, /Playwright[\s\S]{0,200}repository dependency[\s\S]{0,160}repository job/i) + assert.match(readme, /browser launch/i) + assert.doesNotMatch(readme, /global(?:ly)? installed Playwright/i) +}) + +test('Linux creates only the fixed locked, unprivileged runner account', () => { + assert.match(linuxBootstrap, /RUNNER_ACCOUNT='taskdeck-runner'/) + assert.match(linuxBootstrap, /useradd/) + assert.match(linuxBootstrap, /passwd[^\n]+--lock/) + assert.match(linuxBootstrap, /sudo|wheel/) + assert.match(linuxBootstrap, /docker/) + assert.match(linuxBootstrap, /id -u/) + assert.match(linuxBootstrap, /id -G/) + assert.match(linuxBootstrap, /group_ids\[@\][^\n]+-eq 1/) + assert.doesNotMatch(linuxBootstrap, /usermod[^\n]+(?:sudo|wheel|docker)/) +}) + +test('Windows accepts only an existing local non-admin runner account', () => { + assert.match(windowsBootstrap, /Get-LocalUser/) + assert.match(windowsBootstrap, /Get-LocalGroupMember/) + assert.match(windowsBootstrap, /S-1-5-32-544/) + assert.match(windowsBootstrap, /S-1-5-32-545/) + assert.match(windowsBootstrap, /memberships\.Count -ne 1/) + assert.match(windowsBootstrap, /foreach \(\$member in \$members\)/) + assert.doesNotMatch(windowsBootstrap, /\$members\.SID/) + assert.doesNotMatch(windowsBootstrap, /New-LocalUser|Set-LocalUser|ConvertTo-SecureString/) + assert.doesNotMatch(windowsBootstrap, /\bPassword\b/) +}) + +test('policy, hooks, work, temp and caches use fixed contained guest-local roots', () => { + assert.match(linuxBootstrap, /\/etc\/taskdeck-runner/) + assert.match(linuxBootstrap, /\/var\/lib\/taskdeck-runner\/work/) + assert.match(linuxBootstrap, /\/var\/lib\/taskdeck-runner\/home/) + assert.match(linuxBootstrap, /\/var\/lib\/taskdeck-runner\/temp/) + assert.match(linuxBootstrap, /\/var\/cache\/taskdeck-runner/) + assert.match(linuxBootstrap, /realpath -m/) + assert.match(linuxBootstrap, /findmnt/) + assert.match(linuxBootstrap, /9p\|virtiofs\|cifs\|nfs/) + + assert.match(windowsBootstrap, /C:\\ProgramData\\TaskdeckRunner\\Policy/) + assert.match(windowsBootstrap, /C:\\TaskdeckRunner\\Work/) + assert.match(windowsBootstrap, /C:\\TaskdeckRunner\\Profile/) + assert.match(windowsBootstrap, /C:\\TaskdeckRunner\\ProfileState/) + assert.match(windowsBootstrap, /C:\\TaskdeckRunner\\Temp/) + assert.match(windowsBootstrap, /C:\\TaskdeckRunner\\Cache/) + assert.match(windowsBootstrap, /GetPathRoot/) + assert.match(windowsBootstrap, /ReparsePoint/) + assert.match(windowsBootstrap, /DriveType[^\n]+Fixed/) + + for (const source of [windowsBootstrap, windowsCleanup]) { + assert.match(source, /StartsWith\('\\\\'/) + assert.match(source, /\^\[A-Za-z\]:\\\\/) + assert.doesNotMatch(source, /IsPathFullyQualified/) + } +}) + +test('cleanup trusts only immutable fixed policy and removes children, never roots', () => { + assert.match(linuxCleanup, /CONFIG_PATH='\/etc\/taskdeck-runner\/policy\.conf'/) + assert.match(linuxCleanup, /stat -c/) + assert.match(linuxCleanup, /-mindepth 1/) + assert.match(linuxCleanup, /-maxdepth 1/) + assert.match(linuxCleanup, /--one-file-system/) + assert.doesNotMatch(linuxCleanup, /rm\s+-rf\s+--?\s+"?\$(?:WORK|TEMP|CACHE|ROOT)/) + assert.match(linuxCleanup, /clear_children "\$HOME_ROOT" 'home_clear'/) + + assert.match(windowsCleanup, /C:\\ProgramData\\TaskdeckRunner\\Policy\\RunnerPolicy\.psd1/) + assert.match(windowsCleanup, /GetAccessRules/) + assert.match(windowsCleanup, /Get-ChildItem[^\n]+-LiteralPath/) + assert.match(windowsCleanup, /Remove-Item[^\n]+-LiteralPath/) + assert.match(windowsCleanup, /Clear-Children -Root \$profileStateRoot/) + assert.match(windowsCleanup, /Assert-NoReparseDescendants -Root \$Root/) + assert.match(windowsCleanup, /Stack\[string\]/) + assert.match(windowsCleanup, /child_reparse/) + assert.match(windowsCleanup, /acl_runner_control/) + assert.match(windowsBootstrap, /acl_runner_control/) + assert.doesNotMatch(windowsCleanup, /Clear-Children -Root \$accountProfileRoot/) + assert.doesNotMatch(windowsCleanup, /\$members\.SID/) + assert.match(windowsCleanup, /\$PSCommandPath/) + assert.doesNotMatch(windowsCleanup, /Remove-Item[^\n]+(?:WorkRoot|TempRoot|CacheRoot)/) + + for (const source of [linuxCleanup, windowsCleanup]) { + assert.doesNotMatch(source, /RUNNER_WORKSPACE|GITHUB_WORKSPACE|RUNNER_TEMP/) + } + assert.doesNotMatch(windowsCleanup, /\$env:(?:TEMP|TMP|HOME|USERPROFILE)/i) + assert.doesNotMatch(linuxCleanup, /\$(?:TMPDIR|HOME)\b/) +}) + +test('cleanup is bounded, supports a dry run and treats every failure as nonzero', () => { + assert.match(linuxCleanup, /\/usr\/bin\/timeout/) + assert.match(linuxCleanup, /--dry-run/) + assert.match(linuxCleanup, /exit 1/) + assert.doesNotMatch(linuxCleanup, /\|\|\s*true|set \+e/) + assert.doesNotMatch(linuxCleanup, /docker system prune|--volumes/) + assert.match(linuxCleanup, /docker_runner buildx prune/) + assert.match(linuxCleanup, /until=168h/) + assert.match(linuxCleanup, /--max-used-space/) + assert.match(linuxCleanup, /CONTAINER_STATE_MAX_BYTES/) + assert.match(linuxCleanup, /container rm --force/) + assert.match(linuxCleanup, /volume rm --force/) + + assert.match(windowsCleanup, /SupportsShouldProcess = \$true/) + assert.match(windowsCleanup, /Start-Job/) + assert.match(windowsCleanup, /Wait-Job[^\n]+-Timeout 120/) + assert.match(windowsCleanup, /exit 1/) + assert.doesNotMatch(windowsCleanup, /SilentlyContinue|catch\s*\{\s*\}/) +}) + +test('scripts exclude runner registration, credentials and download shortcuts', () => { + const dangerous = [ + /config\.(?:sh|cmd)/i, + /--token\b/i, + /RUNNER_TOKEN/i, + /\bgh\s+(?:api|auth)\b/i, + /Authorization:/i, + /persist-credentials/i, + /\b(?:PAT|password|secret)\b/i, + /curl[^\n|]*\|/i, + ] + + for (const source of executableSources) { + for (const pattern of dangerous) assert.doesNotMatch(source, pattern) + } +}) + +test('cleanup output is limited to stable codes and counts', () => { + assert.match(linuxCleanup, /RUNNER_CLEANUP (?:OK|ERROR|ACTION)/) + assert.doesNotMatch(linuxCleanup, /(?:printf|echo)[^\n]+\$(?:WORK|TEMP|CACHE|CONFIG|RUNNER_ACCOUNT)/) + assert.match(linuxCleanup, /-printf '\.' 2>\/dev\/null/) + assert.match(linuxCleanup, /-print -quit 2>\/dev\/null/) + assert.match(linuxBootstrap, /install -d -o root[\s\S]{0,180}>\/dev\/null 2>&1/) + assert.match(linuxBootstrap, /install -o root -g root -m 0555[\s\S]{0,160}>\/dev\/null 2>&1/) + assert.match(linuxBootstrap, /chown root:root[^\n]+>\/dev\/null 2>&1/) + assert.match(linuxBootstrap, /chmod 0444[^\n]+>\/dev\/null 2>&1/) + assert.match(linuxBootstrap, /mv -f --[^\n]+>\/dev\/null 2>&1/) + assert.match(windowsCleanup, /RUNNER_CLEANUP \$Text/) + assert.doesNotMatch(windowsCleanup, /Write-(?:Output|Host)[^\n]+\$(?:.*Path|.*Root|.*Account|env:)/i) +}) + +test('README separates the shipped contract from later human and runtime gates', () => { + for (const phrase of [ + 'VM creation', + 'checksum', + 'private-repository cutover', + 'runner registration', + 'real workload', + 'offline', + 'ACTIONS_RUNNER_HOOK_JOB_STARTED', + 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED', + ]) { + assert.match(readme, new RegExp(phrase, 'i')) + } + assert.match(readme, /pre-job[\s\S]{0,120}prevents the\s+job/i) + assert.match(readme, /post-job[\s\S]{0,160}not (?:yet )?proven/i) + assert.match(readme, /does not clear the active Windows account profile or HKCU/i) + assert.match(readme, /persistent Windows VM is not a\s+security reset/i) + assert.match(readme, /hook clears only the enumerated fixed roots/i) +}) + +test('Smart CI self-test bridge imports the runner contract', () => { + const bridge = readFileSync(new URL('../smart-ci/runner-bootstrap-contract.test.mjs', import.meta.url), 'utf8') + .replace(/\r\n/g, '\n') + assert.equal(bridge.trim(), "import '../runners/runner-bootstrap-contract.test.mjs'") +}) + +test('Linux runner entrypoints are tracked as executable', () => { + const scriptDirectory = fileURLToPath(new URL('.', import.meta.url)) + const result = spawnSync( + 'git', + ['ls-files', '--stage', '--full-name', '--', 'bootstrap-linux.sh', 'cleanup-linux.sh'], + { cwd: scriptDirectory, encoding: 'utf8' }, + ) + assert.equal(result.status, 0, result.stderr) + for (const path of [ + 'scripts/ci/runners/bootstrap-linux.sh', + 'scripts/ci/runners/cleanup-linux.sh', + ]) { + assert.ok( + result.stdout.split(/\r?\n/).some((line) => line.startsWith('100755 ') && line.endsWith(`\t${path}`)), + `${path} must be tracked as mode 100755`, + ) + } +}) + +const bashProbe = spawnSync('bash', ['--version'], { encoding: 'utf8' }) +for (const script of ['bootstrap-linux.sh', 'cleanup-linux.sh']) { + test(`${script} parses as Bash`, { skip: bashProbe.status !== 0 }, () => { + const scriptDirectory = fileURLToPath(new URL('.', import.meta.url)) + const result = spawnSync('bash', ['-n', script], { cwd: scriptDirectory, encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + }) +} diff --git a/scripts/ci/smart-ci/runner-bootstrap-contract.test.mjs b/scripts/ci/smart-ci/runner-bootstrap-contract.test.mjs new file mode 100644 index 0000000000..81d46c8feb --- /dev/null +++ b/scripts/ci/smart-ci/runner-bootstrap-contract.test.mjs @@ -0,0 +1 @@ +import '../runners/runner-bootstrap-contract.test.mjs'