diff --git a/AGENTS.md b/AGENTS.md index ca10f35..58b7c0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ Break any of these and the tool stops being what it is. - **No workflow-result watching.** Reporting on failed CI runs is observability and belongs to whatever receives the notifications, which can poll GitHub without depending on a laptop being awake. This tool reports only on the health of the pool itself. - **Nothing personal in the repository.** No real organisation names, repository names, hostnames, addresses or paths, in code, comments, docs or examples. Use `acme` / `acme-inc` / `me/side-project`. Installation specifics belong in the user's config file, never here. - **No secrets, ever.** Registration credentials live in the runtime directory, which is outside this repo by design. Nothing in a checkout should reveal anything about the machine it came from. +- **The public-repository check sits at whichever layer owns it, and the asymmetry is deliberate.** At *repository* scope it is RunPool's, because GitHub has no per-repository equivalent: `register` refuses by default, refuses again when visibility cannot be resolved, and takes `--allow-public` as an explicit, warned override. At *organisation* scope it is GitHub's: a runner group carries `allows_public_repositories`, it defaults to `false`, and runners land in the default group because `config.sh` is never passed `--runnergroup`. So RunPool reads and reports that setting and nothing more. **Do not enumerate an organisation's public repositories to re-derive the answer**, and do not "even up" the two branches. They differ because the controls differ. See `SECURITY.md`, which states this for users. ## Layout @@ -103,4 +104,4 @@ The pattern is already established: configuration precedence was found and fixed ## This repository's own CI -**Pinned to GitHub-hosted runners, permanently.** This repo is public, so a pull request from an untrusted fork runs its own workflow file. RunPool refuses to register a public repository for exactly that reason, and it would be absurd for the tool to break its own rule. +**Pinned to GitHub-hosted runners, permanently.** This repo is public, so a pull request from an untrusted fork runs its own workflow file. RunPool refuses to register a public repository for exactly that reason, and it would be absurd for the tool to break its own rule. `--allow-public` exists for users who have weighed the risk on their own machine; it is not licence to point this repository at a pool. diff --git a/README.md b/README.md index a5cac63..3af2a2c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ The first job after a quiet spell waits about a minute for its pool to come up. | Command | | |---|---| -| `register --repo OWNER/REPO\|--org ORG [--count N]` | Create a pool and configure its runners | +| `register --repo OWNER/REPO\|--org ORG [--count N] [--allow-public]` | Create a pool and configure its runners | | `set-count N` | Change a pool's runner count | | `up` / `down ` | Bring a pool online, or stand it down | | `status [--json]` | Local state alongside what GitHub actually sees | @@ -91,7 +91,8 @@ Unset, it reports nothing and works as well. `contrib/notify-webhook.sh` is a re ## Things worth knowing -- **Public repositories are refused at registration.** A pull request from an untrusted fork runs its own workflow file, so wiring one to a self-hosted runner hands any stranger a shell on your machine. A refusal, not a warning. +- **A public repository is refused at registration**, because a pull request from an untrusted fork runs its own workflow file, which would hand any stranger a shell on your machine. `--allow-public` overrides it with a warning, so the decision is explicit rather than pushed into a forked copy of the tool. Registration also refuses when visibility cannot be determined, rather than assuming private. +- **For an organisation, that control is GitHub's, not RunPool's.** A runner group carries `allows_public_repositories`, it is `false` by default, and runners land in the default group, so public repos in the org do not get them. RunPool reads that setting when you register and warns only if it has been turned on. [SECURITY.md](SECURITY.md) covers the whole picture, including what RunPool deliberately does not do. - **A runner can look healthy while GitHub has dropped it.** GitHub prunes registrations that have not connected for a long time. The local install still starts and connects and then picks up nothing, so jobs queue forever against a pool reporting as running. That is what the `github` column in `status` is for, and `reregister` fixes it. - **`services:` and `container:` do not force a hosted runner.** Those two workflow keys are Linux-only, but an ordinary `docker run` inside a step works anywhere Docker does, including here. - **More runners is not obviously more throughput**, and the contention warning scales with pool size: it defaults to six times core count, while a busy pool of N runners reaches roughly N times core count on its own. `runpool stats` and `contrib/telemetry-join.sh` settle both questions on your machine, using queue time rather than argument. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bfc36ec --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security + +RunPool runs GitHub Actions jobs on a Mac you also use, as your own user. This file says plainly what that means, where the boundaries are, and the two places RunPool deliberately differs from GitHub's published hardening guidance. + +## What a job on a RunPool runner can do + +There is no VM and no container. A job runs as **your macOS user**, which means it can reach: + +- **Your home directory**, including SSH keys, cloud credentials, and anything else readable without a prompt. +- **Your network**, including hosts reachable only from this machine. +- **The next job**, because runners are persistent and the working directory, caches and environment survive between jobs. + +The whole arrangement therefore rests on one thing: **only code you trust ever runs on the pool.** Everything below is in service of that. + +## Public repositories + +A pull request from a fork runs its own copy of the workflow file. On a public repository that means any stranger can propose a workflow and have it execute here. GitHub says the same in stronger terms: self-hosted runners "should almost never be used for public repositories". + +The control differs by scope, because GitHub's own controls do. + +- **Repository scope: RunPool refuses, by default.** `runpool register --repo` checks visibility and stops on a public repository. GitHub has no per-repository equivalent of the setting described below, so this one is RunPool's to make. If visibility cannot be determined, registration is refused rather than assumed safe. +- **The refusal is a default, not a wall.** `--allow-public` proceeds anyway, with a warning that states the risk. A refusal with no way past it just moves the problem somewhere less visible, like a forked copy of the tool or a hand-registered runner. If you use the flag, pair it with fork pull request approval below, and understand you are accepting the risk described at the top of this file. +- **Organisation scope: GitHub's control, and it already defaults safely.** Each runner group carries `allows_public_repositories`, which is `false` by default, and runners RunPool registers land in the default group. A public repository in the organisation does not get those runners. RunPool reads that setting at register time and warns only if it has been turned on. It does not reimplement the check, because duplicating a control that already exists and already defaults correctly only creates a second thing to get wrong. + +## Two deliberate differences from GitHub's guidance + +### Persistent runners + +GitHub recommends ephemeral runners and states that "autoscaling with persistent self-hosted runners is not recommended", so that each job starts from a clean environment. + +**RunPool runners are persistent on purpose.** Warm caches, warm toolchains and an unchanged working directory are most of the reason a local runner beats a hosted one, and discarding them each time would remove the point of the tool. + +**What that costs:** state leaks between jobs, and a job that compromises the runner stays compromised until you rebuild it. On a machine where every job comes from repositories you control, that is a reasonable trade. On a machine running code from people you do not know, it is not, and no setting in RunPool changes that. + +### Fork pull requests on private repositories + +GitHub warns that anyone able to fork a private repository and open a pull request, which generally means anyone with read access, can compromise a self-hosted runner. Keeping to private repositories reduces who that is; it does not reduce it to nobody. + +**RunPool cannot enforce this.** The control is GitHub's, per repository, under **Settings → Actions → General → Fork pull request workflows**. Set it to require approval for outside or first-time contributors. If a repository has collaborators you would not hand a shell to, set it before pointing that repository at a pool. + +## Why not just-in-time tokens + +JIT configuration (`generate-jitconfig`) exists for ephemeral runners: one job, then automatic deregistration. RunPool registers persistently, so adopting JIT would mean re-registering every runner on every job, which is a different tool with a different lifecycle. + +The exposure JIT would reduce here is a registration token that is valid for one hour and is never written to disk by RunPool. Without also going ephemeral, swapping it for JIT buys very little. This is a considered decision rather than an oversight; if RunPool ever grows an ephemeral mode, JIT is the right way to build it. + +## What RunPool does with credentials + +- **Registration tokens are never persisted.** One is requested per runner at register time, passed once to GitHub's `config.sh`, and exchanged by the runner for its own credentials. No RunPool file ever contains it. +- **A known, bounded exposure:** `config.sh` accepts the token only as a command-line argument, so it is briefly visible in `ps` output to other local users. RunPool cannot avoid this without upstream support from the runner. +- **The runner's own credentials** (`.credentials`, `.credentials_rsaparams`) are written and owned by GitHub's runner, in the runtime directory. RunPool neither reads nor relaxes them. +- **Keep the config file at mode 0600.** `~/.config/runpool/config` can hold `RUNPOOL_WEBHOOK_TOKEN`. The installer sets this; if you created the file by hand, set it yourself. +- **Nothing sensitive is logged.** Logs and the optional telemetry record timings, counts and machine state. Telemetry never leaves the machine. + +## Reporting a vulnerability + +Open a [security advisory](https://github.com/aicayzer/runpool/security/advisories/new) rather than a public issue. For anything low risk, a normal issue is fine. diff --git a/bin/runpool b/bin/runpool index 18be3f3..fc46425 100755 --- a/bin/runpool +++ b/bin/runpool @@ -46,8 +46,9 @@ _rp_help() { cat <<'HELP' runpool — on-demand self-hosted GitHub Actions runner pools for macOS - register --repo OWNER/REPO|--org ORG [--count N] + register --repo OWNER/REPO|--org ORG [--count N] [--allow-public] create a pool and configure its runners (left stopped) + a public repo is refused; --allow-public overrides it set-count N change a pool's runner count after registration up|down bring a pool online / stand it down up-all|down-all every pool diff --git a/install.sh b/install.sh index 09e8568..3b23f74 100755 --- a/install.sh +++ b/install.sh @@ -34,6 +34,9 @@ echo "linked ${PREFIX}/runpool -> ${ROOT}/bin/runpool" mkdir -p "${CONFIG_DIR}" if [ ! -f "${CONFIG_DIR}/config" ]; then cp "${ROOT}/runpool.conf.example" "${CONFIG_DIR}/config" + # The config is where a webhook token goes, and the default umask of 022 + # would leave it readable by every other user on the machine. + chmod 600 "${CONFIG_DIR}/config" echo "wrote ${CONFIG_DIR}/config (all defaults, edit as needed)" else echo "kept existing ${CONFIG_DIR}/config" diff --git a/lib/common.sh b/lib/common.sh index 70ce691..6e555d8 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -122,6 +122,23 @@ _rp_self_path() { echo "${RUNPOOL_SELF:-$0}"; } # --------------------------------------------------------------------------- _rp_pool_conf() { echo "${RUNPOOL_POOL_DIR}/$1.conf"; } +# A pool name becomes four different things: a config file path, a runner +# directory, a launchd label, and a bare string in the status JSON. It is +# constrained here to what is safe in all four, which is also what lets +# _rp_status_json assemble JSON without escaping anything. +# +# A 'case' glob rather than a bash regex, because stock bash 3.2 treats a +# quoted and an unquoted right-hand side of =~ differently and the difference +# is easy to get wrong. '.' and '..' pass a character-class test and are still +# path hazards, so they are rejected by name. +_rp_valid_pool_name() { + case "$1" in + ''|.|..) return 1 ;; + *[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + # Load POOL_* for pool $1 into the caller's scope. POOL_WATCH is optional and # only set on org pools, so every reader must use "${POOL_WATCH:-}". _rp_load_pool() { @@ -171,23 +188,47 @@ _rp_scope_path() { # --------------------------------------------------------------------------- # Fetch the latest osx-arm64 runner tarball once and echo its local path. _rp_fetch_runner_tarball() { - local url path jqf attempt + local out url digest path tmp jqf attempt sum # '[.]' matches a literal dot without a backslash, which keeps this filter - # safe to carry through shells that mangle escapes. - jqf='[.assets[] | select(.name | test("osx-arm64.*[.]tar[.]gz$")) | .browser_download_url][0]' + # safe to carry through shells that mangle escapes. The digest comes back as + # "sha256:..." and is empty on a release that does not publish one. + jqf='[.assets[] | select(.name | test("osx-arm64.*[.]tar[.]gz$")) + | "\(.browser_download_url) \(.digest // "")"][0] // ""' # releases/latest intermittently returns empty under secondary rate limiting, # so retry with backoff. Once cached this is skipped entirely. for attempt in 1 2 3 4 5; do - url=$(gh api repos/actions/runner/releases/latest --jq "${jqf}" 2>/dev/null) - [ -n "${url}" ] && break + out=$(gh api repos/actions/runner/releases/latest --jq "${jqf}" 2>/dev/null) + [ -n "${out}" ] && break sleep $(( attempt * 2 )) done + url="${out%% *}"; digest="${out#* }" [ -n "${url}" ] || { _rp_err "could not resolve the osx-arm64 runner tarball after retries"; return 1; } path="${RUNPOOL_BASE}/.cache/${url##*/}" mkdir -p "${RUNPOOL_BASE}/.cache" 2>/dev/null if [ ! -f "${path}" ]; then _rp_log "downloading runner: ${url##*/}" - curl -sSL "${url}" -o "${path}" || return 1 + # '-f' so an HTTP error is a failure. Without it curl writes the error body + # to the output path and exits 0, and the "already cached" test above then + # trusts that file forever: every later tar fails and nothing says why. + # + # Downloaded under a temporary name in the same directory and moved into + # place only once it is complete and verified, so an interrupted fetch + # cannot leave a partial file behind either. + tmp="${path}.part.$$" + curl -fsSL "${url}" -o "${tmp}" || { + rm -f "${tmp}"; _rp_err "download failed: ${url}"; return 1; } + # The release publishes a sha256 and shasum is stock on macOS, so verifying + # costs one field in the filter above and no new dependency. A release + # without a digest is skipped rather than refused. + if [ -n "${digest}" ]; then + sum="$(shasum -a 256 "${tmp}" 2>/dev/null | awk '{print $1}')" + if [ "${sum}" != "${digest#sha256:}" ]; then + rm -f "${tmp}" + _rp_err "checksum mismatch on ${url##*/}: expected ${digest#sha256:}, got ${sum:-none}" + return 1 + fi + fi + mv -f "${tmp}" "${path}" || { rm -f "${tmp}"; return 1; } fi echo "${path}" } diff --git a/lib/lifecycle.sh b/lib/lifecycle.sh index a9b21b7..6a447eb 100644 --- a/lib/lifecycle.sh +++ b/lib/lifecycle.sh @@ -16,13 +16,19 @@ _rp_register() { local name="$1"; shift [ -n "${name}" ] || { _rp_err "usage: runpool register --repo OWNER/REPO|--org ORG [--count N]"; return 1; } + _rp_valid_pool_name "${name}" || { + _rp_err "invalid pool name: '${name}'" + _rp_err "Letters, digits, dot, underscore and hyphen only: the name becomes a directory, a launch-agent label and a JSON field." + return 1 + } - local scope="" target="" count="2" + local scope="" target="" count="2" allow_public=0 vis="" pub="" while [ $# -gt 0 ]; do case "$1" in - --repo) scope="repo"; target="$2"; shift 2 ;; - --org) scope="org"; target="$2"; shift 2 ;; - --count) count="$2"; shift 2 ;; + --repo) scope="repo"; target="$2"; shift 2 ;; + --org) scope="org"; target="$2"; shift 2 ;; + --count) count="$2"; shift 2 ;; + --allow-public) allow_public=1; shift ;; *) _rp_err "unknown flag: $1"; return 1 ;; esac done @@ -30,15 +36,60 @@ _rp_register() { case "${count}" in ''|*[!0-9]*) _rp_err "--count must be a positive integer"; return 1 ;; esac [ "${count}" -ge 1 ] || { _rp_err "--count must be at least 1"; return 1; } - # A public repository is refused outright rather than warned about. A pull - # request from an untrusted fork runs its own workflow file, so wiring one to - # a self-hosted runner hands any stranger a shell on this machine. + # Whose job the public-repository check is depends on the scope, and the two + # cases are genuinely different. + # + # At REPOSITORY scope it is RunPool's, because GitHub has no per-repository + # equivalent of the runner group's allows_public_repositories. A pull request + # from an untrusted fork runs its own workflow file, so wiring a public repo + # to a self-hosted runner hands any stranger a shell on this machine. + # + # Refused by default rather than absolutely. A refusal with no way past it + # invites a forked copy of the tool or a hand-registered runner, and neither + # is visible here afterwards; an explicit flag keeps the decision in the open. if [ "${scope}" = "repo" ]; then - local vis; vis=$(gh repo view "${target}" --json visibility --jq '.visibility' 2>/dev/null) - if [ "${vis}" = "PUBLIC" ]; then - _rp_err "${target} is PUBLIC — refusing to register self-hosted runners on a public repo." - return 1 - fi + vis=$(gh repo view "${target}" --json visibility --jq '.visibility' 2>/dev/null) + case "${vis}" in + PRIVATE|INTERNAL) ;; + PUBLIC) + if [ "${allow_public}" = "1" ]; then + _rp_log "WARNING: ${target} is PUBLIC and --allow-public was given. Any fork's pull request can run its own workflow file here, as your user. Require approval for fork pull requests on that repository." + else + _rp_err "${target} is PUBLIC — refusing to register self-hosted runners on a public repo." + _rp_err "A pull request from any fork would run its own workflow file here, as your user." + _rp_err "If that is genuinely what you want: runpool register ${name} --repo ${target} --allow-public" + return 1 + fi + ;; + *) + # Fails closed. An empty answer means the API call failed, not that the + # repository is private, and treating those the same skipped the check + # exactly when GitHub was being unreliable. + _rp_err "could not determine the visibility of ${target} — refusing." + _rp_err "Check 'gh auth status' and that the repository exists, then retry." + return 1 + ;; + esac + else + # At ORGANISATION scope it is GitHub's, and GitHub already defaults it + # safely. A runner group carries allows_public_repositories, it is false by + # default, and runners registered here land in the default group because + # config.sh is never passed --runnergroup. So report GitHub's setting; + # do not enumerate the org's public repositories and re-derive the answer. + # + # A warning rather than a refusal, and no failing closed, precisely because + # this is not RunPool's control to enforce. + pub=$(gh api "/orgs/${target}/actions/runner-groups" \ + --jq '[.runner_groups[] | select(.default == true) | .allows_public_repositories][0]' 2>/dev/null) + case "${pub}" in + true) + _rp_log "WARNING: the default runner group on ${target} has allows_public_repositories=true, so public repositories in that organisation can use these runners. Turn it off in the organisation's Actions runner-group settings unless that is deliberate." + ;; + false) ;; + *) + _rp_log "note: could not read the runner groups for ${target} (needs admin:org). Whether public repositories there can use these runners is GitHub's allows_public_repositories setting, in the organisation's Actions settings." + ;; + esac fi local dir_base labels tarball i runner_dir runner_name token diff --git a/lib/scheduler.sh b/lib/scheduler.sh index 10250e1..b56f088 100644 --- a/lib/scheduler.sh +++ b/lib/scheduler.sh @@ -76,9 +76,11 @@ _rp_status() { # Machine-readable status, so anything wrapping this tool reads structured data # rather than scraping prose. Hand-assembled rather than shelled out to jq, -# because none of these values can contain a character needing escaping: pool -# names, targets and watched repos are all GitHub identifiers, and the rest are -# integers or fixed strings. +# because none of these values can contain a character needing escaping: +# targets and watched repos are GitHub identifiers, pool names are constrained +# by _rp_valid_pool_name at register, and the rest are integers or fixed +# strings. That validation is what makes this safe; before it existed a pool +# name containing a double quote produced malformed JSON here. # # $1 local_only: skip the GitHub query and report its two fields as null. # A caller refreshing on a timer must use it. One API call per pool per minute @@ -303,7 +305,34 @@ _rp_clean_if_overdue() { fi } -_rp_tick() { _rp_autoscale; _rp_sweep; _rp_load_check; _rp_health_check; _rp_clean_if_overdue; } +# A tick polls every watched repository of every pool that is down, one API +# call each, so a pool watching a dozen repos against a slow GitHub can still +# be working when launchd starts the next tick on its 60-second interval. Two +# ticks then interleave autoscale and sweep over the same activity timestamp. +# +# mkdir is the lock: it is atomic on every POSIX filesystem, and macOS has no +# flock. The stale break exists so a killed tick cannot wedge the scheduler +# permanently; 900s is far longer than any real tick and far shorter than the +# time anyone would take to notice. +RUNPOOL_TICK_LOCK="${RUNPOOL_STATE_DIR}/tick.lock" +RUNPOOL_TICK_STALE=900 + +_rp_tick() { + local age + if ! mkdir "${RUNPOOL_TICK_LOCK}" 2>/dev/null; then + age=$(( $(_rp_now) - $(stat -f %m "${RUNPOOL_TICK_LOCK}" 2>/dev/null || echo 0) )) + if [ "${age}" -lt "${RUNPOOL_TICK_STALE}" ]; then + return 0 # a tick is already running; skipping is the whole point + fi + _rp_log "tick: breaking a stale lock (${age}s old)" + rm -rf "${RUNPOOL_TICK_LOCK}" + mkdir "${RUNPOOL_TICK_LOCK}" 2>/dev/null || return 0 + fi + trap 'rm -rf "${RUNPOOL_TICK_LOCK}"' EXIT INT TERM + _rp_autoscale; _rp_sweep; _rp_load_check; _rp_health_check; _rp_clean_if_overdue + rm -rf "${RUNPOOL_TICK_LOCK}" + trap - EXIT INT TERM +} # --------------------------------------------------------------------------- # pause / resume — global kill switch, default resumed diff --git a/runpool.conf.example b/runpool.conf.example index 268fce9..5d140e1 100644 --- a/runpool.conf.example +++ b/runpool.conf.example @@ -3,6 +3,10 @@ # Everything here has a working default, so an empty file is a valid config. # This lives outside the repository because it is the only place installation # specifics belong; a checkout should carry none of them. +# +# Keep it mode 0600. RUNPOOL_WEBHOOK_TOKEN below is a secret, and the default +# umask would otherwise leave this readable by every user on the machine: +# chmod 600 ~/.config/runpool/config # Where runners, pool definitions, launch agents and state live. # Defaults to ~/.local/share/runpool. Registration credentials live here, so diff --git a/skills/runpool/SKILL.md b/skills/runpool/SKILL.md index ea1a4f9..f3d0657 100644 --- a/skills/runpool/SKILL.md +++ b/skills/runpool/SKILL.md @@ -63,10 +63,18 @@ Then set the repository variable `CI_RUNNER` to `self-hosted`. The fallback keep ## Never wire a public repository -`runpool register` refuses one outright. A pull request from an untrusted fork runs its own workflow file, so a public repo on a self-hosted runner hands any stranger a shell on the machine. Do not look for a way around this. +A pull request from an untrusted fork runs its own workflow file, so a public repo on a self-hosted runner hands any stranger a shell on the machine, as the user who owns it. + +**`runpool register --repo` refuses a public repository**, and refuses again if it cannot determine visibility rather than assuming private. There is an `--allow-public` override that warns and proceeds. **Do not reach for it on the user's behalf.** It exists so the decision is explicit rather than made in a forked copy of the tool; suggest it only if the user has said they understand the exposure, and pair it with fork pull request approval below. + +**For an organisation, this is GitHub's control, not RunPool's.** The runner group setting `allows_public_repositories` defaults to `false`, and runners register into the default group, so public repos in the org do not get them. `register --org` reads that setting and warns only when it has been switched on. If it warns, the fix is in the organisation's Actions runner-group settings, not in runpool. + +**Private is not the same as safe.** Anyone who can fork a private repo and open a pull request, which usually means anyone with read access, can run code on the pool. Set **Settings → Actions → General → Fork pull request workflows** to require approval for outside contributors on any repository pointed at a pool. RunPool cannot enforce this and will not know whether it is set. Keep publish, deploy and OIDC jobs on hosted runners too: npm provenance requires it. +`SECURITY.md` in the repo is the fuller statement, including why persistent runners are a deliberate choice and why JIT tokens are not used. + ## Diagnosing "the job is queued and nothing happens" Work down this list. @@ -139,6 +147,7 @@ RunPool ships with **no notifier** and works fully without one. Set `RUNPOOL_NOT ## Things that will bite - **Registration credentials live in the runtime directory**, not the repo. Never commit one, never copy one between machines. +- **Pool names are validated at `register`**: letters, digits, dot, underscore and hyphen. The name becomes a directory, a launch-agent label and a JSON field, so anything else is refused rather than sanitised. - **All runners share one HOME**, so each needs its own package store and cache. runpool sets this in the launch agent; if you hand-edit an agent, preserve it or concurrent installs collide. - **Ephemeral macOS VMs are capped at two per machine** by Apple's licence. If someone suggests Tart, Tartelet or Cilicon for more than two parallel macOS jobs, that ceiling is why it will not work. - **Nothing watches RunPool itself.** This is an accepted gap, not an oversight. Do not build a heartbeat for it.