Self-hosted runners register with a one-hour token and claim jobs with their own (#88)
A repository admin creates a registration token (GitHub's endpoint); a machine trades it for a runner token that can claim that repository's jobs, with the labels it registered, and nothing else. --ephemeral runners' tokens end with their one job. Runners can be listed and removed. The CLI registers with --registration-token (or with the signed-in admin's token) and saves the runner's credentials, so a runner machine needs no personal token that its jobs could read. Personal-token claims still work for older runners. Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 06c3bc3commit 8a8ac92d371b160c2b339ef6ac62fd0eb87fb2dbBrowse files
13 files changed+423−34
Files (13)
- apps/cli/src/commands/runner.ts+106−12
- apps/cli/test/runner.test.ts+66−1
- apps/diffinery/docs/actions/runners.md+42−17
- apps/diffinery/docs/reference/limits.md+1−0
- apps/diffinery/docs/reference/rest-api.md+1−1
- apps/diffinery/docs/security/audit-log.md+3−0
- apps/diffinery/docs/self-hosting/architecture.md+1−1
- apps/diffinery/src/auth/principal.ts+2−1
- apps/diffinery/src/http/api.ts+41−0
- apps/diffinery/src/repo/RepoDO.ts+15−0
- apps/diffinery/src/repo/actions.ts+93−1
- apps/diffinery/src/repo/schema.ts+6−0
- apps/diffinery/test/e2e/security.test.mjs+46−0
modifiedapps/cli/src/commands/runner.ts+106−12View file
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { spawn, type ChildProcess } from "node:child_process"; |
| 2 | 2 | import { existsSync } from "node:fs"; |
| 3 | -import { mkdir } from "node:fs/promises"; | |
| 3 | +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 4 | 4 | import { homedir, hostname } from "node:os"; |
| 5 | 5 | import path from "node:path"; |
| 6 | 6 | import { createInterface } from "node:readline"; |
| 7 | -import { ApiError, type Api } from "../api.js"; | |
| 7 | +import { Api, ApiError } from "../api.js"; | |
| 8 | +import { configDir } from "../config.js"; | |
| 8 | 9 | import { list, parseRepoArg, repoPath, str, type Command, type Ctx, type RepoRef } from "../cli.js"; |
| 9 | 10 | import { CliError, type Io } from "../io.js"; |
| 10 | 11 | |
| @@ -31,13 +32,64 @@ | ||
| 31 | 32 | return abs; |
| 32 | 33 | } |
| 33 | 34 | |
| 34 | -/** POST .../actions/runners/claim: a job, or null when nothing is waiting (204). */ | |
| 35 | -export async function claim(api: Api, repo: RepoRef, name: string, labels: string[]): Promise<ClaimedJob | null> { | |
| 36 | - const res = await api.raw<ClaimedJob | null>("POST", `${repoPath(repo)}/actions/runners/claim`, { body: { name, labels } }); | |
| 35 | +/** POST .../actions/runners/claim: a job, or null when nothing is waiting (204). A registered runner sends an empty body. */ | |
| 36 | +export async function claim(api: Api, repo: RepoRef, body: { name?: string; labels?: string[] }): Promise<ClaimedJob | null> { | |
| 37 | + const res = await api.raw<ClaimedJob | null>("POST", `${repoPath(repo)}/actions/runners/claim`, { body }); | |
| 37 | 38 | if (res.status === 204 || !res.data || typeof res.data !== "object") return null; |
| 38 | 39 | return res.data; |
| 39 | 40 | } |
| 40 | 41 | |
| 42 | +/** A registered runner's own credentials: the token claims this repository's jobs and nothing else. */ | |
| 43 | +export interface RunnerCredentials { | |
| 44 | + url: string; | |
| 45 | + owner: string; | |
| 46 | + repo: string; | |
| 47 | + id: number; | |
| 48 | + name: string; | |
| 49 | + labels: string[]; | |
| 50 | + token: string; | |
| 51 | +} | |
| 52 | + | |
| 53 | +const safe = (s: string) => s.replace(/[^A-Za-z0-9._-]/g, "_"); | |
| 54 | + | |
| 55 | +/** Where a registered runner's credentials are kept (mode 0600). */ | |
| 56 | +export function runnerFile(env: NodeJS.ProcessEnv, url: string, repo: RepoRef, name: string): string { | |
| 57 | + return path.join(configDir(env), "runners", safe(new URL(url).host), safe(repo.owner), safe(repo.name), `${safe(name)}.json`); | |
| 58 | +} | |
| 59 | + | |
| 60 | +async function readRunner(file: string): Promise<RunnerCredentials | null> { | |
| 61 | + try { | |
| 62 | + const v = JSON.parse(await readFile(file, "utf8")) as RunnerCredentials; | |
| 63 | + return typeof v.token === "string" && v.token.startsWith("drn_") ? v : null; | |
| 64 | + } catch (e) { | |
| 65 | + if ((e as NodeJS.ErrnoException).code === "ENOENT") return null; | |
| 66 | + throw new CliError(`${file} is not a valid runner registration; delete it and register again`); | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +async function saveRunner(file: string, creds: RunnerCredentials): Promise<void> { | |
| 71 | + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); | |
| 72 | + await writeFile(file, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 }); | |
| 73 | + await chmod(file, 0o600); | |
| 74 | +} | |
| 75 | + | |
| 76 | +/** POST .../actions/runners/register: trade a registration token for the runner's own token. */ | |
| 77 | +export async function register(url: string, repo: RepoRef, registrationToken: string, name: string, labels: string[], ephemeral: boolean): Promise<RunnerCredentials> { | |
| 78 | + const res = await new Api(url, null).raw<{ id: number; name: string; labels: string[]; token: string }>("POST", `${repoPath(repo)}/actions/runners/register`, { | |
| 79 | + body: { registration_token: registrationToken, name, labels, ephemeral }, | |
| 80 | + }); | |
| 81 | + return { url, owner: repo.owner, repo: repo.name, id: res.data.id, name: res.data.name, labels: res.data.labels, token: res.data.token }; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** How the runner claims from one repository: with its own registered token, or (older servers) the signed-in person's. */ | |
| 85 | +interface Source { | |
| 86 | + repo: RepoRef; | |
| 87 | + api: Api; | |
| 88 | + body: { name?: string; labels?: string[] }; | |
| 89 | + /** The saved registration, when the token came from one. */ | |
| 90 | + file?: string; | |
| 91 | +} | |
| 92 | + | |
| 41 | 93 | /** Environment for the agent: the job's credentials, minus the CLI's own token. */ |
| 42 | 94 | export function agentEnv(base: NodeJS.ProcessEnv, job: ClaimedJob, workRoot: string, echo: boolean): NodeJS.ProcessEnv { |
| 43 | 95 | const env: NodeJS.ProcessEnv = { ...base }; |
| @@ -83,9 +135,22 @@ | ||
| 83 | 135 | name: "runner", |
| 84 | 136 | summary: "Run self-hosted workflow jobs on this machine", |
| 85 | 137 | description: `Polls the given repositories for jobs with 'runs-on: [self-hosted, ...]' whose labels this |
| 86 | -runner offers, and runs each with the Diffinery runner agent. Requires admin access to the | |
| 87 | -repositories. Jobs run directly on this machine with your user's permissions. | |
| 138 | +runner offers, and runs each with the Diffinery runner agent. Jobs run directly on this machine | |
| 139 | +with your user's permissions. | |
| 88 | 140 | |
| 141 | +The runner registers with each repository and claims jobs with its own token, which can't do | |
| 142 | +anything else. Register it with a registration token from a repository admin | |
| 143 | +(POST /repos/OWNER/NAME/actions/runners/registration-token, valid for an hour): | |
| 144 | + | |
| 145 | + diffinery runner -R OWNER/NAME --registration-token drg_... --labels linux | |
| 146 | + | |
| 147 | +so the machine never holds a personal token, which the jobs it runs could read. The runner's | |
| 148 | +credentials are saved under ~/.config/diffinery/runners and used on the next start. Signed in | |
| 149 | +as a repository admin instead, the runner registers itself with your token. | |
| 150 | + | |
| 151 | +--ephemeral registers a runner for one job: its token stops working once it takes the job, and | |
| 152 | +the runner exits when the job finishes. | |
| 153 | + | |
| 89 | 154 | ${AGENT_HELP}`, |
| 90 | 155 | options: { |
| 91 | 156 | repo: { type: "string", short: "R", multiple: true, desc: "Repository to take jobs from (default: the git origin)", arg: "OWNER/NAME" }, |
| @@ -96,6 +161,8 @@ | ||
| 96 | 161 | "work-dir": { type: "string", short: "w", desc: "Working directory for jobs (default ~/.cache/diffinery/runner/<name>)", arg: "DIR" }, |
| 97 | 162 | interval: { type: "string", desc: "Seconds between polls when idle (default 5)", arg: "SECONDS" }, |
| 98 | 163 | quiet: { type: "boolean", short: "q", desc: "Do not echo step output locally (logs still go to the server)" }, |
| 164 | + "registration-token": { type: "string", desc: "Register this runner with a repository admin's registration token (one repository)", arg: "TOKEN" }, | |
| 165 | + ephemeral: { type: "boolean", desc: "Register for a single job, then exit when it finishes" }, | |
| 99 | 166 | }, |
| 100 | 167 | maxArgs: 0, |
| 101 | 168 | async run(ctx, o) { |
| @@ -107,8 +174,34 @@ | ||
| 107 | 174 | if (!Number.isFinite(intervalMs)) throw new CliError("--interval must be a number", 2); |
| 108 | 175 | const cacheHome = ctx.io.env.XDG_CACHE_HOME && path.isAbsolute(ctx.io.env.XDG_CACHE_HOME) ? ctx.io.env.XDG_CACHE_HOME : path.join(ctx.io.env.HOME || homedir(), ".cache"); |
| 109 | 176 | const workRoot = path.resolve(ctx.io.cwd, str(o, "work-dir") ?? path.join(cacheHome, "diffinery", "runner", name.replace(/[^A-Za-z0-9._-]/g, "_"))); |
| 110 | - const api = await ctx.authedApi(); | |
| 111 | 177 | const c = ctx.c; |
| 178 | + const ephemeral = o.ephemeral === true; | |
| 179 | + const registration = str(o, "registration-token") ?? ctx.io.env.DIFFINERY_RUNNER_REGISTRATION_TOKEN; | |
| 180 | + if (registration && repos.length > 1) throw new CliError("a registration token is for one repository: pass a single -R OWNER/NAME", 2); | |
| 181 | + const url = (await ctx.config()).url; | |
| 182 | + const sources: Source[] = []; | |
| 183 | + for (const repo of repos) { | |
| 184 | + const file = runnerFile(ctx.io.env, url, repo, name); | |
| 185 | + const saved = ephemeral ? null : await readRunner(file); | |
| 186 | + let creds: RunnerCredentials | null = null; | |
| 187 | + if (registration) creds = await register(url, repo, registration, name, labels, ephemeral); | |
| 188 | + else if (saved) { | |
| 189 | + creds = saved; | |
| 190 | + if (labels.length && labels.join(",") !== saved.labels.join(",")) ctx.err(`${c.yellow("warning:")} ${name} was registered with ${repo.owner}/${repo.name} offering [${saved.labels.join(", ")}]; to change its labels, delete ${file} and register it again`); | |
| 191 | + } else { | |
| 192 | + // Signed in: register with the person's token, so jobs are claimed with the runner's own. | |
| 193 | + const user = await ctx.authedApi(); | |
| 194 | + const t = await user.raw<{ token: string }>("POST", `${repoPath(repo)}/actions/runners/registration-token`, { allow: [404, 405] }); | |
| 195 | + if (t.status === 201 && t.data?.token) creds = await register(url, repo, t.data.token, name, labels, ephemeral); | |
| 196 | + else sources.push({ repo, api: user, body: { name, labels } }); // an older server without registration | |
| 197 | + } | |
| 198 | + if (!creds) continue; | |
| 199 | + if (!saved && !ephemeral) { | |
| 200 | + await saveRunner(file, creds); | |
| 201 | + ctx.out(`Registered runner ${c.bold(creds.name)} with ${repo.owner}/${repo.name} ${c.dim(`(saved to ${file})`)}`); | |
| 202 | + } | |
| 203 | + sources.push({ repo, api: new Api(url, creds.token), body: {}, file: ephemeral ? undefined : file }); | |
| 204 | + } | |
| 112 | 205 | |
| 113 | 206 | let stopping = false; |
| 114 | 207 | let child: ChildProcess | null = null; |
| @@ -134,14 +227,15 @@ | ||
| 134 | 227 | let lastError = ""; |
| 135 | 228 | while (!stopping) { |
| 136 | 229 | let ran = false; |
| 137 | - for (const repo of repos) { | |
| 230 | + for (const { repo, api, body, file } of sources) { | |
| 138 | 231 | if (stopping) break; |
| 139 | 232 | let job: ClaimedJob | null; |
| 140 | 233 | try { |
| 141 | - job = await claim(api, repo, name, labels); | |
| 234 | + job = await claim(api, repo, body); | |
| 142 | 235 | lastError = ""; |
| 143 | 236 | } catch (e) { |
| 144 | - if (o.once || (e instanceof ApiError && [401, 403, 404].includes(e.status))) throw e; | |
| 237 | + if (file && e instanceof ApiError && e.status === 401) throw new CliError(`runner ${name} was removed from ${repo.owner}/${repo.name}; delete ${file} and register it again`); | |
| 238 | + if (o.once || ephemeral || (e instanceof ApiError && [401, 403, 404].includes(e.status))) throw e; | |
| 145 | 239 | const msg = (e as Error).message; |
| 146 | 240 | if (msg !== lastError) ctx.err(`${c.yellow("warning:")} ${msg} (will retry)`); |
| 147 | 241 | lastError = msg; |
| @@ -150,7 +244,7 @@ | ||
| 150 | 244 | if (!job) continue; |
| 151 | 245 | ran = true; |
| 152 | 246 | const code = await runJob(ctx, agent, job, repo, workRoot, !o.quiet, (ch) => (child = ch)); |
| 153 | - if (o.once) return code; | |
| 247 | + if (o.once || ephemeral) return code; | |
| 154 | 248 | if (stopping) break; |
| 155 | 249 | } |
| 156 | 250 | if (o.once) { |
modifiedapps/cli/test/runner.test.ts+66−1View file
| @@ -1,4 +1,4 @@ | ||
| 1 | -import { existsSync, readFileSync, writeFileSync } from "node:fs"; | |
| 1 | +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; | |
| 2 | 2 | import path from "node:path"; |
| 3 | 3 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 4 | 4 | import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; |
| @@ -91,4 +91,69 @@ | ||
| 91 | 91 | expect(r.code).toBe(1); |
| 92 | 92 | expect(r.stderr).toContain("admin access required"); |
| 93 | 93 | }); |
| 94 | + | |
| 95 | + describe("registered runners", () => { | |
| 96 | + const job = { body: { job_id: "j9", run_id: "r9", name: "build", token: "djt_repo_j9_secret", api_url: "" } }; | |
| 97 | + const registered = { status: 201, body: { id: 7, name: "box", labels: ["linux"], token: "drn_repo_7_runnersecret" } }; | |
| 98 | + | |
| 99 | + it("registers with a registration token, keeps no personal token, and claims with its own", async () => { | |
| 100 | + const bare = isolatedEnv({ DIFFINERY_URL: srv.url }); | |
| 101 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered); | |
| 102 | + let claims = 0; | |
| 103 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () => (++claims === 1 ? job : { status: 204 })); | |
| 104 | + const { agent, record } = fakeAgent(0); | |
| 105 | + const r = await run(["runner", "-R", "acme/app", "--registration-token", "drg_abc", "--labels", "linux", "--name", "box", "--once", "--agent", agent, "--work-dir", tempDir()], { env: bare }); | |
| 106 | + expect(r.code).toBe(0); | |
| 107 | + const reg = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/register"); | |
| 108 | + expect(reg[0]!.body).toEqual({ registration_token: "drg_abc", name: "box", labels: ["linux"], ephemeral: false }); | |
| 109 | + expect(reg[0]!.headers.authorization).toBeUndefined(); | |
| 110 | + const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim"); | |
| 111 | + expect(claimReq[0]!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret"); | |
| 112 | + expect(claimReq[0]!.body).toEqual({}); | |
| 113 | + // The job never sees the runner's token. | |
| 114 | + expect(JSON.stringify(JSON.parse(readFileSync(record, "utf8")).env)).not.toContain("drn_"); | |
| 115 | + expect(r.stdout).toContain("Registered runner"); | |
| 116 | + expect(r.stdout).not.toContain("drn_"); | |
| 117 | + const file = path.join(bare.XDG_CONFIG_HOME ?? path.join(bare.HOME!, ".config"), "diffinery", "runners", srv.host.replace(":", "_"), "acme", "app", "box.json"); | |
| 118 | + expect(JSON.parse(readFileSync(file, "utf8")).token).toBe("drn_repo_7_runnersecret"); | |
| 119 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 120 | + | |
| 121 | + // The next start uses the saved registration. | |
| 122 | + const again = await run(["runner", "-R", "acme/app", "--name", "box", "--once", "--agent", agent], { env: bare }); | |
| 123 | + expect(again.code).toBe(0); | |
| 124 | + expect(srv.find("POST", "/api/v3/repos/acme/app/actions/runners/register")).toHaveLength(1); | |
| 125 | + expect(srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim").at(-1)!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret"); | |
| 126 | + | |
| 127 | + // A removed runner says so. | |
| 128 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 401, body: { message: "this runner was removed" } }); | |
| 129 | + const gone = await run(["runner", "-R", "acme/app", "--name", "box", "--agent", agent, "--interval", "0"], { env: bare }); | |
| 130 | + expect(gone.code).toBe(1); | |
| 131 | + expect(gone.stderr).toContain("was removed from acme/app"); | |
| 132 | + expect(gone.stderr).toContain(file); | |
| 133 | + }); | |
| 134 | + | |
| 135 | + it("signed in as an admin, registers itself and claims with the runner's token", async () => { | |
| 136 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/registration-token", { status: 201, body: { token: "drg_auto", expires_at: "2099-01-01T00:00:00Z" } }); | |
| 137 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered); | |
| 138 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 }); | |
| 139 | + const { agent } = fakeAgent(0); | |
| 140 | + const r = await run(["runner", "-R", "acme/app", "--name", "box", "--once", "--agent", agent], { env }); | |
| 141 | + expect(r.code).toBe(0); | |
| 142 | + expect(srv.find("POST", /registration-token$/)[0]!.headers.authorization).toBe("Bearer dfy_user_token"); | |
| 143 | + expect(srv.find("POST", /register$/)[0]!.body).toMatchObject({ registration_token: "drg_auto" }); | |
| 144 | + expect(srv.find("POST", /claim$/)[0]!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret"); | |
| 145 | + }); | |
| 146 | + | |
| 147 | + it("--ephemeral takes one job, saves nothing and exits with its code", async () => { | |
| 148 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered); | |
| 149 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", job); | |
| 150 | + const { agent } = fakeAgent(4); | |
| 151 | + const bare = isolatedEnv({ DIFFINERY_URL: srv.url }); | |
| 152 | + const r = await run(["runner", "-R", "acme/app", "--registration-token", "drg_abc", "--ephemeral", "--name", "box", "--agent", agent, "--work-dir", tempDir()], { env: bare }); | |
| 153 | + expect(r.code).toBe(4); | |
| 154 | + expect(srv.find("POST", /register$/)[0]!.body).toMatchObject({ ephemeral: true }); | |
| 155 | + expect(srv.find("POST", /claim$/)).toHaveLength(1); | |
| 156 | + expect(r.stdout).not.toContain("Registered runner"); | |
| 157 | + }); | |
| 158 | + }); | |
| 94 | 159 | }); |
modifiedapps/diffinery/docs/actions/runners.md+42−17View file
| @@ -176,31 +176,40 @@ | ||
| 176 | 176 | ``` |
| 177 | 177 | |
| 178 | 178 | Copy `containers/runner/dist/agent.mjs` to the runner machine (for example to `/opt/diffinery/agent.mjs`) and pass it with `--agent` or the `DIFFINERY_RUNNER_AGENT` environment variable. |
| 179 | -- **A token with the `admin` scope**, belonging to a person who has the **admin** role on each repository the runner serves. Claiming jobs requires admin access; a weaker role or token scope gets `403 admin access required`. Agents can't run self-hosted runners, because an agent's role is capped at write. Create the token under **Settings → Access tokens**, and restrict it to the repositories the runner serves (see [Personal access tokens](/docs/accounts/tokens)). | |
| 179 | +- **A registration token** for each repository the runner serves, from a person with the **admin** role on it. A registration token works for an hour, and the runner trades it for its own **runner token**, which can claim that repository's jobs and do nothing else. Agents, job tokens and people without admin can't create one (`403 only repository admins can add self-hosted runners`). | |
| 180 | 180 | - **Bash** (the default shell for `run:` steps; `sh` is used when bash is missing), `git` and `tar`, which the built-in actions use. |
| 181 | 181 | - **Docker**, only if jobs use `container:`, `services:` or Docker actions. |
| 182 | 182 | |
| 183 | 183 | ### Start a runner |
| 184 | 184 | |
| 185 | -1. Log in once, or set `DIFFINERY_TOKEN` (and `DIFFINERY_URL` if you don't use `https://diffinery.com`): | |
| 185 | +1. On your own computer, as a repository admin, create a registration token (it works for an hour): | |
| 186 | 186 | |
| 187 | 187 | ```sh |
| 188 | - diffinery login --token dfy_... | |
| 188 | + diffinery api POST /repos/acme/website/actions/runners/registration-token | |
| 189 | 189 | ``` |
| 190 | 190 | |
| 191 | -2. Start the runner for one or more repositories: | |
| 191 | + ```json | |
| 192 | + {"token": "drg_…", "expires_at": "2026-10-13T19:45:00.000Z"} | |
| 193 | + ``` | |
| 192 | 194 | |
| 195 | +2. On the runner machine, register and start the runner. Set `DIFFINERY_URL` if you don't use `https://diffinery.com`. No login is needed: | |
| 196 | + | |
| 193 | 197 | ```sh |
| 194 | - diffinery runner -R acme/website --labels macos,arm64 --agent /opt/diffinery/agent.mjs | |
| 198 | + diffinery runner -R acme/website --registration-token drg_… --labels macos,arm64 --agent /opt/diffinery/agent.mjs | |
| 195 | 199 | ``` |
| 196 | 200 | |
| 197 | - It prints the labels it offers and waits: | |
| 201 | + It registers, saves the runner's credentials, prints the labels it offers and waits: | |
| 198 | 202 | |
| 199 | 203 | ```text |
| 204 | + Registered runner my-mac with acme/website (saved to ~/.config/diffinery/runners/diffinery.com/acme/website/my-mac.json) | |
| 200 | 205 | Runner my-mac offering [self-hosted, macos, arm64] for acme/website |
| 201 | 206 | Waiting for jobs. Press Ctrl-C to stop. |
| 202 | 207 | ``` |
| 203 | 208 | |
| 209 | + Later starts use the saved registration: `diffinery runner -R acme/website --agent /opt/diffinery/agent.mjs`. Its labels are the ones it registered with. To change them, delete the saved file and register again. | |
| 210 | + | |
| 211 | + On a machine where you're signed in as a repository admin (`diffinery login`), `diffinery runner` registers itself with your token and claims jobs with the runner's own token. Prefer a registration token on shared or dedicated runner machines, so no personal token is kept where jobs could read it. | |
| 212 | + | |
| 204 | 213 | 3. Target it from a workflow: |
| 205 | 214 | |
| 206 | 215 | ```yaml |
| @@ -226,6 +235,8 @@ | ||
| 226 | 235 | | `--work-dir DIR` | `-w` | `~/.cache/diffinery/runner/<name>` (or `$XDG_CACHE_HOME/diffinery/runner/<name>`) | Working directory for jobs. | |
| 227 | 236 | | `--interval SECONDS` | | `5` | Seconds between polls when idle. | |
| 228 | 237 | | `--quiet` | `-q` | off | Don't echo step output locally. Logs still go to Diffinery. | |
| 238 | +| `--registration-token TOKEN` | | `DIFFINERY_RUNNER_REGISTRATION_TOKEN` | Register this runner with one repository, using a registration token from a repository admin. | | |
| 239 | +| `--ephemeral` | | off | Register for a single job: the runner's token stops working once it takes a job, the runner exits with the job's exit code, and nothing is saved. Use it in throwaway VMs or containers. | | |
| 229 | 240 | |
| 230 | 241 | The full generated reference is at [CLI reference](/docs/reference/cli). |
| 231 | 242 | |
| @@ -240,14 +251,14 @@ | ||
| 240 | 251 | ### How jobs are claimed |
| 241 | 252 | |
| 242 | 253 | 1. When a self-hosted job is ready to start, Diffinery marks it **waiting** and creates a queued check on the commit. |
| 243 | -2. Each runner polls `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` with its name and labels, every `--interval` seconds per repository while idle. The response is `204 No Content` when nothing matches, or the job with a fresh job token: | |
| 254 | +2. Each runner polls `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` with its runner token, every `--interval` seconds per repository while idle. It offers the labels it registered with. The response is `204 No Content` when nothing matches, or the job with a fresh job token: | |
| 244 | 255 | |
| 245 | 256 | ```http |
| 246 | 257 | POST /api/v3/repos/acme/website/actions/runners/claim |
| 247 | - Authorization: Bearer dfy_... | |
| 258 | + Authorization: Bearer drn_... | |
| 248 | 259 | Content-Type: application/json |
| 249 | 260 | |
| 250 | - {"name": "my-mac", "labels": ["macos", "arm64"]} | |
| 261 | + {} | |
| 251 | 262 | ``` |
| 252 | 263 | |
| 253 | 264 | ```json |
| @@ -259,8 +270,21 @@ | ||
| 259 | 270 | |
| 260 | 271 | A job that no runner claims fails after 6 hours. While a job waits, the run page shows it as queued. |
| 261 | 272 | |
| 262 | -If the runner can't reach Diffinery it prints `warning: ... (will retry)` and keeps polling. A `401`, `403` or `404` from the claim request stops the runner, because retrying won't help (wrong token, missing admin access, or a mistyped repository). | |
| 273 | +If the runner can't reach Diffinery it prints `warning: ... (will retry)` and keeps polling. A `401`, `403` or `404` from the claim request stops the runner, because retrying won't help. For a removed runner it says `runner <name> was removed from <owner>/<repo>; delete <file> and register it again`. | |
| 263 | 274 | |
| 275 | +A repository admin's personal token with the `admin` scope can also claim jobs, sending the runner's name and labels in the body (`{"name": "my-mac", "labels": ["macos", "arm64"]}`). This is how runners worked before registration, and the CLI still falls back to it on a Diffinery server without registration. | |
| 276 | + | |
| 277 | +### Manage runners | |
| 278 | + | |
| 279 | +| Endpoint | Does | Who | | |
| 280 | +|---|---|---| | |
| 281 | +| `POST /repos/{owner}/{repo}/actions/runners/registration-token` | A registration token, valid for an hour and for any number of runners: `{"token": "drg_…", "expires_at": "…"}` | Repository admins (people) | | |
| 282 | +| `POST /repos/{owner}/{repo}/actions/runners/register` | Register a runner: send `{"registration_token": "drg_…", "name": "my-mac", "labels": ["macos"], "ephemeral": false}` and get `{"id", "name", "labels", "ephemeral", "token": "drn_…"}`. The runner token is shown once. A wrong or expired registration token, or a repository that doesn't exist, gets `401 invalid or expired registration token`. | The registration token is the credential | | |
| 283 | +| `GET /repos/{owner}/{repo}/actions/runners` | GitHub's runner list: `id`, `name`, `os`, `status` (`online` when it polled in the last 90 seconds), `busy`, `ephemeral` and `labels` | Repository admins | | |
| 284 | +| `DELETE /repos/{owner}/{repo}/actions/runners/{id}` | Remove a runner. Its token stops working at once; a job it's running carries on with its own job token. | Repository admins (people) | | |
| 285 | + | |
| 286 | +A repository can have at most 100 registered runners, each with at most 20 labels. An ephemeral runner is removed when its job finishes. Registering a runner, creating a registration token and removing a runner are recorded in the audit log. | |
| 287 | + | |
| 264 | 288 | ### What a job sees on a self-hosted runner |
| 265 | 289 | |
| 266 | 290 | - The job runs **directly on the machine**, as the user that started `diffinery runner`, with that user's files, network and permissions. |
| @@ -284,13 +308,15 @@ | ||
| 284 | 308 | sudo install -D -m 0644 containers/runner/dist/agent.mjs /opt/diffinery/agent.mjs |
| 285 | 309 | ``` |
| 286 | 310 | |
| 287 | -2. Store the token in a file only root can read: | |
| 311 | +2. Register the runner once as that user, with a registration token from a repository admin. Stop it with Ctrl-C once it says it's waiting: | |
| 288 | 312 | |
| 289 | 313 | ```sh |
| 290 | - echo 'DIFFINERY_TOKEN=dfy_...' | sudo tee /etc/diffinery-runner.env >/dev/null | |
| 291 | - sudo chmod 600 /etc/diffinery-runner.env | |
| 314 | + sudo -u diffinery-runner -H env DIFFINERY_URL=https://diffinery.com DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs \ | |
| 315 | + diffinery runner -R acme/website --registration-token drg_… --labels linux,gpu --name gpu-box-1 | |
| 292 | 316 | ``` |
| 293 | 317 | |
| 318 | + Its credentials are saved in that user's `~/.config/diffinery/runners`, so the service needs no token. | |
| 319 | + | |
| 294 | 320 | 3. Create `/etc/systemd/system/diffinery-runner.service`: |
| 295 | 321 | |
| 296 | 322 | ```text |
| @@ -302,7 +328,6 @@ | ||
| 302 | 328 | [Service] |
| 303 | 329 | User=diffinery-runner |
| 304 | 330 | WorkingDirectory=/home/diffinery-runner |
| 305 | - EnvironmentFile=/etc/diffinery-runner.env | |
| 306 | 331 | Environment=DIFFINERY_URL=https://diffinery.com |
| 307 | 332 | Environment=DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs |
| 308 | 333 | ExecStart=/usr/bin/node /usr/local/bin/diffinery runner -R acme/website --labels linux,gpu --name gpu-box-1 --quiet |
| @@ -325,7 +350,7 @@ | ||
| 325 | 350 | |
| 326 | 351 | Stopping the service sends the stop signal to the runner and the agent, which cancels the running job and reports it as cancelled. On macOS, use a `launchd` agent with the same command line and environment variables. |
| 327 | 352 | |
| 328 | -For a clean machine per job, run `diffinery runner --once` inside a fresh virtual machine or container and throw it away afterwards; `--once` exits with the job's exit code. | |
| 353 | +For a clean machine per job, run `diffinery runner --ephemeral --registration-token drg_…` inside a fresh virtual machine or container and throw it away afterwards. It exits with the job's exit code, and its token can't be used again. | |
| 329 | 354 | |
| 330 | 355 | ### Docker on self-hosted runners |
| 331 | 356 | |
| @@ -354,9 +379,9 @@ | ||
| 354 | 379 | A self-hosted runner runs code from your repository's workflows on your machine. Before you start one: |
| 355 | 380 | |
| 356 | 381 | - **Anyone who can push can run code on the machine.** People and agents with write access can push a branch or open a pull request whose workflow targets `self-hosted`, and `push` and `pull_request` runs use the workflow file from that commit. Only serve repositories whose writers you trust with the machine. |
| 357 | -- **Jobs run as the runner's user.** A job can read anything that user can, including the runner's own token (from the CLI's config file `~/.config/diffinery/config.json` or the service's environment) and other repositories' checkouts in the work directory. Use a dedicated OS user with no other access, and a token restricted to the repositories the runner serves. | |
| 382 | +- **Jobs run as the runner's user.** A job can read anything that user can, including the runner's saved registration and other repositories' checkouts in the work directory. A runner registered with a registration token keeps only its runner token, which can claim that repository's jobs and nothing else: it can't read the repository, push, or call the API. Someone who steals it can still take that runner's future jobs, with their secrets, until you remove the runner. So use a dedicated OS user with no other access, don't sign in with a personal token on the runner machine, and prefer `--ephemeral` in a throwaway VM for anything sensitive. | |
| 358 | 383 | - **Jobs get the repository's secrets**, just like on hosted runners, and can reach whatever the machine can reach on your network. A job with `environment:` also gets that environment's secrets when its branch policy and the rules for environment secrets allow it, so a runner that serves deploy jobs handles production credentials. See [Deployments and environments](/docs/actions/environments). |
| 359 | -- **State persists.** The work directory, tool cache and anything a job writes elsewhere stay on the machine. A malicious job can leave something behind for the next job. For untrusted workloads, use `--once` in a throwaway VM. | |
| 384 | +- **State persists.** The work directory, tool cache and anything a job writes elsewhere stay on the machine. A malicious job can leave something behind for the next job. For untrusted workloads, use `--ephemeral` in a throwaway VM: its token ends when it takes its one job. | |
| 360 | 385 | - **Cancelling isn't instant on your machine.** When a run is cancelled, Diffinery marks the job cancelled and revokes its job token right away, but the agent finishes executing the current steps locally (their logs and results are discarded). Stop the runner (Ctrl-C) to stop the work immediately; a second Ctrl-C kills the agent. |
| 361 | 386 | |
| 362 | 387 | ## Troubleshooting |
modifiedapps/diffinery/docs/reference/limits.md+1−0View file
| @@ -181,6 +181,7 @@ | ||
| 181 | 181 | | Hosted job containers at once | 100 | `max_instances` of the runner container class. | |
| 182 | 182 | | Hosted jobs at once per repository | 20 | Further jobs stay queued and start, oldest first, as the repository's own jobs finish, so one repository can't take every container. Self-hosted jobs don't count. | |
| 183 | 183 | | Unfinished runs per repository | 500 | Past it, a `workflow_dispatch` or `repository_dispatch` is refused with `429 this repository has 500 unfinished workflow runs, the most allowed; wait for some to finish`, and other events start no run. | |
| 184 | +| Self-hosted runners per repository | 100, each with at most 20 labels | Registering one more gets `429 this repository has 100 self-hosted runners, the most allowed; remove one first`. A registration token works for an hour. | | |
| 184 | 185 | | Jobs during a deploy | Not interrupted | A deploy doesn't replace the container of a running hosted job: it finishes on the version it started with. If the platform replaces a job's container anyway, the job keeps going for up to 13 minutes, then is cancelled. | |
| 185 | 186 | | Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. | |
| 186 | 187 | | Log line length | 16,384 characters | Longer lines are cut and end with `…`. | |
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | - `PUT /api/v3/user/avatar` sets your profile picture from the raw image in the request body (JPEG, PNG or WebP, up to 5 MB and 3072×3072 pixels), and `DELETE` goes back to generated initials. `PUT` and `DELETE /api/v3/orgs/{org}/avatar` do the same for an organization (owners only). Both return `{"avatar_url": ...}`. `GET /api/avatars/{login}?s=64|128|256` serves the picture to anyone, as WebP, or initials as SVG; it is the `avatar_url` of every user object. |
| 643 | 643 | - `PUT`, `DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue` and `GET /repos/{owner}/{repo}/merge-queue/{branch}` drive the merge queue. |
| 644 | 644 | - `PUT /repos/{owner}/{repo}/pulls/{n}/auto-merge` turns on auto-merge (GitHub's `enablePullRequestAutoMerge`, which it offers through GraphQL only) with `merge_method`, `commit_title`, `commit_message`, `delete_branch` and `sha` (`409` if the head moved); `DELETE` turns it off (`diffinery pr merge --auto` and `--disable-auto`). It returns `{"state": "enabled" | "queued" | "merged", "auto_merge": ..., "merge_commit_sha": ...}`. |
| 645 | -- `POST /repos/{owner}/{repo}/actions/runners/claim` is how self-hosted runners pick up jobs (see [Runners](/docs/actions/runners)). | |
| 645 | +- `POST /repos/{owner}/{repo}/actions/runners/claim` is how self-hosted runners pick up jobs, with the runner token they get from `POST .../actions/runners/register` and a registration token (`POST .../actions/runners/registration-token`). `GET .../actions/runners` and `DELETE .../actions/runners/{id}` list and remove them (see [Runners](/docs/actions/runners#manage-runners)). | |
| 646 | 646 | - `GET /repos/{owner}/{repo}/raw/{ref}/{path}` returns a file's raw bytes. |
| 647 | 647 | - `GET /repos/{owner}/{repo}/packages` lists container images linked to a repository, and `PATCH` on a package changes its visibility or linked repository. |
| 648 | 648 |
modifiedapps/diffinery/docs/security/audit-log.md+3−0View file
| @@ -119,6 +119,9 @@ | ||
| 119 | 119 | | `repo.actions_workflow_settings` | An admin changes the repository's workflow permissions on the **Actions** settings tab or with `PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow` (see [Workflow permissions](/docs/actions/workflow-permissions)) | the new settings: `defaultPermissions` (`read` or `write`), `canApprovePullRequests` and `agentRunSecrets`, plus `via: "api"` when the change came through the API | |
| 120 | 120 | | `actions.cache.delete` | Actions caches are deleted with `DELETE /api/v3/repos/{owner}/{repo}/actions/caches?key=...` or `DELETE /api/v3/repos/{owner}/{repo}/actions/caches/{id}` | `key`, `ids`, `via: "api"` | |
| 121 | 121 | | `actions.artifact.delete` | A workflow artifact is deleted with `DELETE /api/v3/repos/{owner}/{repo}/actions/artifacts/{id}` | `id`, `name`, `via: "api"` | |
| 122 | +| `runner.registration_token` | A repository admin creates a self-hosted runner registration token with `POST /api/v3/repos/{owner}/{repo}/actions/runners/registration-token` | none | | |
| 123 | +| `runner.register` | A self-hosted runner registers with a registration token; the actor is the admin who created that token | `id`, `name`, `labels`, `ephemeral` | | |
| 124 | +| `runner.remove` | A self-hosted runner is removed with `DELETE /api/v3/repos/{owner}/{repo}/actions/runners/{id}` | `id` | | |
| 122 | 125 | |
| 123 | 126 | ### Environments |
| 124 | 127 |
modifiedapps/diffinery/docs/self-hosting/architecture.md+1−1View file
| @@ -167,7 +167,7 @@ | ||
| 167 | 167 | - **`SandboxContainer`** runs one agent sandbox: the repository checked out at a ref, with an HTTP API on port 8080 for `exec`, file reads and file writes. The Worker forwards REST and MCP sandbox calls to it with a per-sandbox secret. Instance type `standard-1`, at most 50 at once, stopped after 30 minutes without requests. |
| 168 | 168 | - **`ImportContainer`** runs the Git transfer of one GitHub import: the runner agent in import mode, started with `DIFFINERY_API_URL`, `DIFFINERY_IMPORT_ID` and `DIFFINERY_IMPORT_TOKEN`. One container per try, named `<import id>-<attempt>-<try>`. If it stops before the agent reports a result (a crash, running out of memory, a platform restart), the transfer counts as failed and the import's Workflow can try it again. Instance type `standard-4` (a mirror clone of a large repository needs disk and memory), at most 20 at once, internet access on, stopped after 7 hours without activity. |
| 169 | 169 | |
| 170 | -Self-hosted runners (`runs-on: [self-hosted, ...]`) run the same agent outside Cloudflare. They poll `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` and then talk to the same runner API. See [Runners](/docs/actions/runners). | |
| 170 | +Self-hosted runners (`runs-on: [self-hosted, ...]`) run the same agent outside Cloudflare. They register with a repository admin's one-hour registration token, poll `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` with their own runner token (kept in the repository's Durable Object, hashed), and then talk to the same runner API. See [Runners](/docs/actions/runners). | |
| 171 | 171 | |
| 172 | 172 | ## Actions cache and artifact services |
| 173 | 173 |
modifiedapps/diffinery/src/auth/principal.ts+2−1View file
| @@ -74,7 +74,8 @@ | ||
| 74 | 74 | } |
| 75 | 75 | return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds }; |
| 76 | 76 | } |
| 77 | - if (cred.startsWith("djt_") || cred.startsWith("dst_")) { | |
| 77 | + // Job and sandbox tokens, and self-hosted runners' own tokens (which only claim jobs: runnerAuth refuses them). | |
| 78 | + if (cred.startsWith("djt_") || cred.startsWith("dst_") || cred.startsWith("drn_")) { | |
| 78 | 79 | const repoId = cred.split("_")[1]; |
| 79 | 80 | if (!repoId) throw new HttpError(401, "malformed token"); |
| 80 | 81 | return { type: "repo-token", repoId, token: cred }; |
modifiedapps/diffinery/src/http/api.ts+41−0View file
| @@ -787,13 +787,54 @@ | ||
| 787 | 787 | return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201); |
| 788 | 788 | }); |
| 789 | 789 | // Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`). |
| 790 | +// A registered runner claims with its own token and the labels it registered with; a repository | |
| 791 | +// admin's personal token still works too, offering the labels in the body. | |
| 790 | 792 | api.post("/repos/:owner/:repo/actions/runners/claim", async (c) => { |
| 793 | + const p = c.get("principal"); | |
| 794 | + if (p.type === "repo-token" && p.token.startsWith("drn_")) { | |
| 795 | + const repo = await c.get("dir").repo(P(c).owner, P(c).repo); | |
| 796 | + if (!repo || repo.id !== p.repoId) throw new HttpError(401, "this runner token belongs to another repository"); | |
| 797 | + const job = await repoStub(c.env, repo.id).claimJobAsRunner(p.token); | |
| 798 | + if (!job) return c.body(null, 204); | |
| 799 | + return c.json({ job_id: job.jobId, run_id: job.runId, name: job.name, token: job.token, api_url: new URL(c.req.url).origin }); | |
| 800 | + } | |
| 791 | 801 | const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 792 | 802 | const b = await body<{ name?: string; labels?: string[] }>(c); |
| 793 | 803 | const job = await stub.claimJob(b.name ?? "runner", Array.isArray(b.labels) ? b.labels.map(String) : [], caller); |
| 794 | 804 | if (!job) return c.body(null, 204); |
| 795 | 805 | return c.json({ job_id: job.jobId, run_id: job.runId, name: job.name, token: job.token, api_url: new URL(c.req.url).origin }); |
| 796 | 806 | }); |
| 807 | +// GitHub's runner registration token: an hour to register runners (`diffinery runner --registration-token`). | |
| 808 | +api.post("/repos/:owner/:repo/actions/runners/registration-token", async (c) => { | |
| 809 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 810 | + if (c.get("principal").type !== "account") throw new HttpError(403, "only repository admins can add self-hosted runners"); | |
| 811 | + const t = await stub.runnerRegistrationToken(caller); | |
| 812 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, action: "runner.registration_token", target: `${P(c).owner}/${P(c).repo}` })); | |
| 813 | + return c.json(t, 201); | |
| 814 | +}); | |
| 815 | +// A machine trades a registration token for its own runner token. The registration token is the only credential. | |
| 816 | +api.post("/repos/:owner/:repo/actions/runners/register", async (c) => { | |
| 817 | + const b = await body<{ registration_token?: unknown; name?: unknown; labels?: unknown; ephemeral?: unknown }>(c); | |
| 818 | + if (typeof b.registration_token !== "string" || !b.registration_token.startsWith("drg_")) throw new HttpError(401, "invalid or expired registration token"); | |
| 819 | + // A repository that doesn't exist answers like a wrong token, so the endpoint can't probe for private names. | |
| 820 | + const repo = await c.get("dir").repo(P(c).owner, P(c).repo); | |
| 821 | + if (!repo) throw new HttpError(401, "invalid or expired registration token"); | |
| 822 | + const labels = Array.isArray(b.labels) ? b.labels.map(String) : []; | |
| 823 | + const { registeredBy, ...runner } = await repoStub(c.env, repo.id).registerRunner(b.registration_token, typeof b.name === "string" ? b.name : "runner", labels, b.ephemeral === true); | |
| 824 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorLogin: registeredBy, action: "runner.register", target: `${repo.owner_login}/${repo.name}`, detail: { id: runner.id, name: runner.name, labels: runner.labels, ephemeral: runner.ephemeral } })); | |
| 825 | + return c.json(runner, 201); | |
| 826 | +}); | |
| 827 | +api.get("/repos/:owner/:repo/actions/runners", async (c) => { | |
| 828 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 829 | + const runners = await stub.runners(caller); | |
| 830 | + return c.json({ total_count: runners.length, runners }); | |
| 831 | +}); | |
| 832 | +api.delete("/repos/:owner/:repo/actions/runners/:id", async (c) => { | |
| 833 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 834 | + await stub.deleteRunner(Number(c.req.param("id")), caller); | |
| 835 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, action: "runner.remove", target: `${P(c).owner}/${P(c).repo}`, detail: { id: Number(c.req.param("id")) } })); | |
| 836 | + return c.body(null, 204); | |
| 837 | +}); | |
| 797 | 838 | api.get("/repos/:owner/:repo/actions/runs", async (c) => { |
| 798 | 839 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo); |
| 799 | 840 | const perPage = Math.min(Number(c.req.query("per_page") ?? 25), 100); |
modifiedapps/diffinery/src/repo/RepoDO.ts+15−0View file
| @@ -874,6 +874,21 @@ | ||
| 874 | 874 | claimJob(...a: Parameters<Actions["claimJob"]>) { |
| 875 | 875 | return this.actions.claimJob(...a); |
| 876 | 876 | } |
| 877 | + claimJobAsRunner(...a: Parameters<Actions["claimJobAsRunner"]>) { | |
| 878 | + return this.actions.claimJobAsRunner(...a); | |
| 879 | + } | |
| 880 | + runnerRegistrationToken(...a: Parameters<Actions["runnerRegistrationToken"]>) { | |
| 881 | + return this.actions.runnerRegistrationToken(...a); | |
| 882 | + } | |
| 883 | + registerRunner(...a: Parameters<Actions["registerRunner"]>) { | |
| 884 | + return this.actions.registerRunner(...a); | |
| 885 | + } | |
| 886 | + runners(...a: Parameters<Actions["runners"]>) { | |
| 887 | + return this.actions.runners(...a); | |
| 888 | + } | |
| 889 | + deleteRunner(...a: Parameters<Actions["deleteRunner"]>) { | |
| 890 | + return this.actions.deleteRunner(...a); | |
| 891 | + } | |
| 877 | 892 | jobStatus(jobId: string): string { |
| 878 | 893 | return this.actions.getJob(jobId).status; |
| 879 | 894 | } |
modifiedapps/diffinery/src/repo/actions.ts+93−1View file
| @@ -48,6 +48,24 @@ | ||
| 48 | 48 | export const MAX_HOSTED_JOBS = 20; |
| 49 | 49 | /** Runs one repository may have unfinished at once; past it, new ones aren't started. */ |
| 50 | 50 | export const MAX_ACTIVE_RUNS = 500; |
| 51 | +/** Self-hosted runners one repository may have registered. */ | |
| 52 | +export const MAX_RUNNERS = 100; | |
| 53 | +/** How long a runner registration token works, as on GitHub. */ | |
| 54 | +const REGISTRATION_TTL_MS = 3600_000; | |
| 55 | +/** A runner that polled this recently shows as online. */ | |
| 56 | +const RUNNER_ONLINE_MS = 90_000; | |
| 57 | + | |
| 58 | +type RunnerRow = { id: number; name: string; labels: string; token_hash: string | null; ephemeral: number; created_by: string; created_at: string; last_seen_at: string | null }; | |
| 59 | + | |
| 60 | +export interface RunnerInfo { | |
| 61 | + id: number; | |
| 62 | + name: string; | |
| 63 | + os: string; | |
| 64 | + status: "online" | "offline"; | |
| 65 | + busy: boolean; | |
| 66 | + ephemeral: boolean; | |
| 67 | + labels: Array<{ id: number; name: string; type: "read-only" | "custom" }>; | |
| 68 | +} | |
| 51 | 69 | const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i; |
| 52 | 70 | |
| 53 | 71 | export type RunRow = { |
| @@ -103,6 +121,7 @@ | ||
| 103 | 121 | concurrency_cancel: number; |
| 104 | 122 | log_chars: number; |
| 105 | 123 | fleet_held: number; |
| 124 | + runner_id: number | null; | |
| 106 | 125 | }; |
| 107 | 126 | |
| 108 | 127 | export type RunInfo = { |
| @@ -992,6 +1011,11 @@ | ||
| 992 | 1011 | */ |
| 993 | 1012 | async claimJob(runnerName: string, runnerLabels: string[], caller: Caller): Promise<{ jobId: string; token: string; runId: string; name: string } | null> { |
| 994 | 1013 | if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to run self-hosted jobs"); |
| 1014 | + return this.assignJob(runnerName, runnerLabels, null); | |
| 1015 | + } | |
| 1016 | + | |
| 1017 | + /** The oldest waiting job whose runs-on labels the runner all offers, handed to it with a fresh job token. */ | |
| 1018 | + private async assignJob(runnerName: string, runnerLabels: string[], runnerId: number | null): Promise<{ jobId: string; token: string; runId: string; name: string } | null> { | |
| 995 | 1019 | const offered = new Set(["self-hosted", ...runnerLabels.map((l) => l.toLowerCase())]); |
| 996 | 1020 | const waiting = this.sql |
| 997 | 1021 | .exec<JobRow>("SELECT j.* FROM jobs j JOIN runs r ON r.id = j.run_id WHERE j.status = 'waiting' AND j.labels IS NOT NULL AND j.container IS NULL AND r.status != 'completed' ORDER BY r.created_at, j.rowid") |
| @@ -1001,11 +1025,77 @@ | ||
| 1001 | 1025 | const m = this.repo.meta; |
| 1002 | 1026 | const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`; |
| 1003 | 1027 | const container = `self-hosted:${runnerName.slice(0, 64)}`; |
| 1004 | - this.sql.exec("UPDATE jobs SET token_hash = ?, container = ? WHERE id = ? AND container IS NULL", await sha256(token), container, job.id); | |
| 1028 | + this.sql.exec("UPDATE jobs SET token_hash = ?, container = ?, runner_id = ? WHERE id = ? AND container IS NULL", await sha256(token), container, runnerId, job.id); | |
| 1005 | 1029 | this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id }); |
| 1006 | 1030 | return { jobId: job.id, token, runId: job.run_id, name: job.name }; |
| 1007 | 1031 | } |
| 1008 | 1032 | |
| 1033 | + // ------------------------------------------------------------ registered self-hosted runners | |
| 1034 | + | |
| 1035 | + /** | |
| 1036 | + * GitHub's runner registration token: a repository admin's credential, good for an hour, that a | |
| 1037 | + * machine trades for its own runner token. So the machine never holds a person's token, and the | |
| 1038 | + * jobs it runs (as the same OS user) can't read one. | |
| 1039 | + */ | |
| 1040 | + async runnerRegistrationToken(caller: Caller): Promise<{ token: string; expires_at: string }> { | |
| 1041 | + if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can add self-hosted runners"); | |
| 1042 | + const token = `drg_${randomToken(32)}`; | |
| 1043 | + const expires = new Date(Date.now() + REGISTRATION_TTL_MS).toISOString(); | |
| 1044 | + const hash = await sha256(token); | |
| 1045 | + this.sql.exec("DELETE FROM runner_registrations WHERE expires_at < ?", now()); | |
| 1046 | + this.sql.exec("INSERT INTO runner_registrations (token_hash, created_by, expires_at) VALUES (?, ?, ?)", hash, caller.login, expires); | |
| 1047 | + return { token, expires_at: expires }; | |
| 1048 | + } | |
| 1049 | + | |
| 1050 | + /** Register a runner with a registration token; returns its own token, shown once. */ | |
| 1051 | + async registerRunner(registration: string, name: string, labels: string[], ephemeral: boolean): Promise<{ id: number; name: string; labels: string[]; ephemeral: boolean; token: string; registeredBy: string }> { | |
| 1052 | + const hash = await sha256(registration); | |
| 1053 | + const reg = this.sql.exec<{ created_by: string }>("SELECT created_by FROM runner_registrations WHERE token_hash = ? AND expires_at > ?", hash, now()).toArray()[0]; | |
| 1054 | + if (!reg) throw new HttpError(401, "invalid or expired registration token"); | |
| 1055 | + const clean = [...new Set(labels.map((l) => String(l).trim().toLowerCase()).filter((l) => l && l !== "self-hosted"))]; | |
| 1056 | + if (clean.length > 20) throw new HttpError(422, "a runner can offer at most 20 labels"); | |
| 1057 | + const bad = clean.find((l) => !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(l)); | |
| 1058 | + if (bad) throw new HttpError(422, `invalid label ${JSON.stringify(bad.slice(0, 70))}: use letters, digits, '.', '_' and '-' (up to 64)`); | |
| 1059 | + const runnerName = name.trim().slice(0, 64) || "runner"; | |
| 1060 | + const count = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runners").one().n; | |
| 1061 | + if (count >= MAX_RUNNERS) throw new HttpError(429, `this repository has ${count} self-hosted runners, the most allowed; remove one first`); | |
| 1062 | + const id = this.sql.exec<{ id: number }>("INSERT INTO runners (name, labels, ephemeral, created_by, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id", runnerName, JSON.stringify(clean), ephemeral ? 1 : 0, reg.created_by, now()).one().id; | |
| 1063 | + const token = `drn_${this.repo.meta.repoId}_${id}_${randomToken(32)}`; | |
| 1064 | + this.sql.exec("UPDATE runners SET token_hash = ? WHERE id = ?", await sha256(token), id); | |
| 1065 | + return { id, name: runnerName, labels: clean, ephemeral, token, registeredBy: reg.created_by }; | |
| 1066 | + } | |
| 1067 | + | |
| 1068 | + /** A registered runner asks for work with its own token. An ephemeral runner's token ends with the job it takes. */ | |
| 1069 | + async claimJobAsRunner(token: string): Promise<{ jobId: string; token: string; runId: string; name: string } | null> { | |
| 1070 | + const id = Number(token.split("_")[2]); | |
| 1071 | + const r = Number.isSafeInteger(id) ? this.sql.exec<RunnerRow>("SELECT * FROM runners WHERE id = ?", id).toArray()[0] : undefined; | |
| 1072 | + if (!r || !r.token_hash || r.token_hash !== (await sha256(token))) throw new HttpError(401, "this runner was removed, or its token is invalid; register it again"); | |
| 1073 | + this.sql.exec("UPDATE runners SET last_seen_at = ? WHERE id = ?", now(), r.id); | |
| 1074 | + const job = await this.assignJob(r.name, JSON.parse(r.labels) as string[], r.id); | |
| 1075 | + if (job && r.ephemeral) this.sql.exec("UPDATE runners SET token_hash = NULL WHERE id = ?", r.id); | |
| 1076 | + return job; | |
| 1077 | + } | |
| 1078 | + | |
| 1079 | + runners(caller: Caller): RunnerInfo[] { | |
| 1080 | + if (!atLeast(caller.role, "admin") || caller.permissions) throw new HttpError(403, "only repository admins can list self-hosted runners"); | |
| 1081 | + const busy = new Set(this.sql.exec<{ runner_id: number }>("SELECT DISTINCT runner_id FROM jobs WHERE runner_id IS NOT NULL AND status != 'completed'").toArray().map((x) => x.runner_id)); | |
| 1082 | + return this.sql.exec<RunnerRow>("SELECT * FROM runners ORDER BY id").toArray().map((r) => ({ | |
| 1083 | + id: r.id, | |
| 1084 | + name: r.name, | |
| 1085 | + os: "unknown", | |
| 1086 | + status: r.last_seen_at && Date.now() - Date.parse(r.last_seen_at) < RUNNER_ONLINE_MS ? "online" : "offline", | |
| 1087 | + busy: busy.has(r.id), | |
| 1088 | + ephemeral: !!r.ephemeral, | |
| 1089 | + labels: ["self-hosted", ...(JSON.parse(r.labels) as string[])].map((name, i) => ({ id: i + 1, name, type: i === 0 ? "read-only" : "custom" })), | |
| 1090 | + })); | |
| 1091 | + } | |
| 1092 | + | |
| 1093 | + /** Remove a runner: its token stops working at once. A job it is running carries on with its own job token. */ | |
| 1094 | + deleteRunner(id: number, caller: Caller): void { | |
| 1095 | + if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can remove self-hosted runners"); | |
| 1096 | + if (!this.sql.exec("DELETE FROM runners WHERE id = ? RETURNING id", id).toArray().length) throw new HttpError(404, "runner not found"); | |
| 1097 | + } | |
| 1098 | + | |
| 1009 | 1099 | private async stopContainer(j: JobRow): Promise<void> { |
| 1010 | 1100 | if (!j.container || j.container.startsWith("self-hosted:")) return; |
| 1011 | 1101 | try { |
| @@ -1401,6 +1491,8 @@ | ||
| 1401 | 1491 | const queued = siblings.find((s) => s.status === "queued"); |
| 1402 | 1492 | if (queued) await this.launch(queued.id); |
| 1403 | 1493 | } |
| 1494 | + // An ephemeral runner is done once its one job is. | |
| 1495 | + if (job.runner_id) this.sql.exec("DELETE FROM runners WHERE id = ? AND ephemeral = 1 AND token_hash IS NULL", job.runner_id); | |
| 1404 | 1496 | // A hosted job hands its slot to the oldest job waiting for one. |
| 1405 | 1497 | if (!job.labels && job.container) { |
| 1406 | 1498 | const held = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held = 1 AND status = 'queued' ORDER BY rowid LIMIT 1").toArray()[0]; |
modifiedapps/diffinery/src/repo/schema.ts+6−0View file
| @@ -147,6 +147,10 @@ | ||
| 147 | 147 | CREATE TABLE IF NOT EXISTS import_refs (ref TEXT PRIMARY KEY, oid TEXT NOT NULL, import_id TEXT NOT NULL); |
| 148 | 148 | -- A copy of the owning organization's Actions variables (D1 org_variables), kept in sync by the Directory. |
| 149 | 149 | CREATE TABLE IF NOT EXISTS org_variables (name TEXT PRIMARY KEY, value TEXT NOT NULL); |
| 150 | +-- Self-hosted runners registered with a registration token (repo/actions.ts registerRunner). A runner | |
| 151 | +-- claims jobs with its own token, never a person's; an ephemeral one's token ends with its first job. | |
| 152 | +CREATE TABLE IF NOT EXISTS runners (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, labels TEXT NOT NULL, token_hash TEXT, ephemeral INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT); | |
| 153 | +CREATE TABLE IF NOT EXISTS runner_registrations (token_hash TEXT PRIMARY KEY, created_by TEXT NOT NULL, expires_at TEXT NOT NULL); | |
| 150 | 154 | `; |
| 151 | 155 | |
| 152 | 156 | /** Columns added after a table was first created; added with ALTER TABLE when missing. */ |
| @@ -179,6 +183,8 @@ | ||
| 179 | 183 | ["jobs", "log_chars", "INTEGER NOT NULL DEFAULT 0"], |
| 180 | 184 | // 1 while a queued job waits for one of the repository's hosted runner slots (repo/actions.ts MAX_HOSTED_JOBS). |
| 181 | 185 | ["jobs", "fleet_held", "INTEGER NOT NULL DEFAULT 0"], |
| 186 | + // The registered self-hosted runner that claimed the job (null for hosted jobs and personal-token claims). | |
| 187 | + ["jobs", "runner_id", "INTEGER"], | |
| 182 | 188 | // The GitHub import that wrote this row (null for everything else); a retry may only overwrite its own rows. |
| 183 | 189 | ["issues", "imported", "TEXT"], |
| 184 | 190 | ["pulls", "imported", "TEXT"], |
modifiedapps/diffinery/test/e2e/security.test.mjs+46−0View file
| @@ -1890,3 +1890,49 @@ | ||
| 1890 | 1890 | await status(401, "GET", "/user", undefined, { cookie: S.oliveSpare }); |
| 1891 | 1891 | assert.equal((await ok("GET", "/user", undefined, { cookie: S.oliveOld })).login, "olive"); |
| 1892 | 1892 | }); |
| 1893 | + | |
| 1894 | +test("security: self-hosted runners register with a one-hour token and claim jobs with their own", async () => { | |
| 1895 | + const R = `/repos/${OWNER}/runner-home`; | |
| 1896 | + await repoWithReadme("runner-home", { private: true }); | |
| 1897 | + await repoWithReadme("runner-other", { private: true }); | |
| 1898 | + await status(204, "PUT", `${R}/collaborators/rita`, { permission: "write" }); | |
| 1899 | + // Only the repository's admins get a registration token. | |
| 1900 | + await status(403, "POST", `${R}/actions/runners/registration-token`, undefined, T.rita); | |
| 1901 | + const reg = await status(201, "POST", `${R}/actions/runners/registration-token`); | |
| 1902 | + assert.match(reg.token, /^drg_/); | |
| 1903 | + assert.ok(Date.parse(reg.expires_at) - Date.now() <= 3600_000); | |
| 1904 | + // Registering takes only that token; a wrong one, another repository or a missing one all get the same answer. | |
| 1905 | + for (const repo of ["runner-other", "no-such-repo"]) await status(401, "POST", `/repos/${OWNER}/${repo}/actions/runners/register`, { registration_token: reg.token, name: "m1" }, null); | |
| 1906 | + await status(401, "POST", `${R}/actions/runners/register`, { registration_token: "drg_wrong", name: "m1" }, null); | |
| 1907 | + const m1 = await status(201, "POST", `${R}/actions/runners/register`, { registration_token: reg.token, name: "m1", labels: ["linux"] }, null); | |
| 1908 | + const m2 = await status(201, "POST", `${R}/actions/runners/register`, { registration_token: reg.token, name: "m2", labels: ["linux"], ephemeral: true }, null); | |
| 1909 | + assert.match(m1.token, /^drn_/); | |
| 1910 | + assert.deepEqual(m1.labels, ["linux"]); | |
| 1911 | + | |
| 1912 | + // A runner's token claims this repository's jobs and nothing else, with the labels it registered. | |
| 1913 | + await status(404, "GET", R, undefined, m1.token); | |
| 1914 | + await status(401, "POST", `/repos/${OWNER}/runner-other/actions/runners/claim`, {}, m1.token); | |
| 1915 | + const wf = "name: ci\non: push\njobs:\n gpu:\n runs-on: [self-hosted, gpu]\n steps:\n - run: echo gpu\n a:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo a\n b:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo b\n"; | |
| 1916 | + await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) }); | |
| 1917 | + let first; | |
| 1918 | + await until("m1 claims a job", async () => { | |
| 1919 | + const r = await api("POST", `${R}/actions/runners/claim`, { labels: ["gpu"] }, m1.token); | |
| 1920 | + if (r.status === 200) first = r.json; | |
| 1921 | + return !!first; | |
| 1922 | + }); | |
| 1923 | + assert.notEqual(first.name, "gpu"); | |
| 1924 | + // The ephemeral runner takes one job, then its token stops working. | |
| 1925 | + const second = await status(200, "POST", `${R}/actions/runners/claim`, {}, m2.token); | |
| 1926 | + assert.notEqual(second.name, first.name); | |
| 1927 | + await status(401, "POST", `${R}/actions/runners/claim`, {}, m2.token); | |
| 1928 | + const listed = await ok("GET", `${R}/actions/runners`); | |
| 1929 | + assert.deepEqual(listed.runners.map((r) => [r.name, r.busy, r.ephemeral]), [["m1", true, false], ["m2", true, true]]); | |
| 1930 | + assert.deepEqual(listed.runners[0].labels.map((l) => l.name), ["self-hosted", "linux"]); | |
| 1931 | + await status(403, "GET", `${R}/actions/runners`, undefined, T.rita); | |
| 1932 | + // Once its job finishes, the ephemeral runner is gone. | |
| 1933 | + await fetch(`${base}/runner/jobs/${second.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${second.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) }); | |
| 1934 | + assert.deepEqual((await ok("GET", `${R}/actions/runners`)).runners.map((r) => r.name), ["m1"]); | |
| 1935 | + // Removing a runner ends its token at once. | |
| 1936 | + await status(204, "DELETE", `${R}/actions/runners/${m1.id}`); | |
| 1937 | + await status(401, "POST", `${R}/actions/runners/claim`, {}, m1.token); | |
| 1938 | +}); |