Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The first job after a quiet spell waits about a minute for its pool to come up.

| Command | |
|---|---|
| `register <pool> --repo OWNER/REPO\|--org ORG [--count N]` | Create a pool and configure its runners |
| `register <pool> --repo OWNER/REPO\|--org ORG [--count N] [--allow-public]` | Create a pool and configure its runners |
| `set-count <pool> N` | Change a pool's runner count |
| `up` / `down <pool>` | Bring a pool online, or stand it down |
| `status [--json]` | Local state alongside what GitHub actually sees |
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion bin/runpool
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ _rp_help() {
cat <<'HELP'
runpool — on-demand self-hosted GitHub Actions runner pools for macOS

register <pool> --repo OWNER/REPO|--org ORG [--count N]
register <pool> --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 <pool> N change a pool's runner count after registration
up|down <pool> bring a pool online / stand it down
up-all|down-all every pool
Expand Down
3 changes: 3 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
53 changes: 47 additions & 6 deletions lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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}"
}
Expand Down
Loading