Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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)
modifiedapps/cli/src/commands/runner.ts+106−12View file
@@ -1,10 +1,11 @@
11 import { spawn, type ChildProcess } from "node:child_process";
22 import { existsSync } from "node:fs";
3-import { mkdir } from "node:fs/promises";
3+import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
44 import { homedir, hostname } from "node:os";
55 import path from "node:path";
66 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";
89 import { list, parseRepoArg, repoPath, str, type Command, type Ctx, type RepoRef } from "../cli.js";
910 import { CliError, type Io } from "../io.js";
1011
@@ -31,13 +32,64 @@
3132 return abs;
3233 }
3334
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 });
3738 if (res.status === 204 || !res.data || typeof res.data !== "object") return null;
3839 return res.data;
3940 }
4041
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+
4193 /** Environment for the agent: the job's credentials, minus the CLI's own token. */
4294 export function agentEnv(base: NodeJS.ProcessEnv, job: ClaimedJob, workRoot: string, echo: boolean): NodeJS.ProcessEnv {
4395 const env: NodeJS.ProcessEnv = { ...base };
@@ -83,9 +135,22 @@
83135 name: "runner",
84136 summary: "Run self-hosted workflow jobs on this machine",
85137 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.
88140
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+
89154 ${AGENT_HELP}`,
90155 options: {
91156 repo: { type: "string", short: "R", multiple: true, desc: "Repository to take jobs from (default: the git origin)", arg: "OWNER/NAME" },
@@ -96,6 +161,8 @@
96161 "work-dir": { type: "string", short: "w", desc: "Working directory for jobs (default ~/.cache/diffinery/runner/<name>)", arg: "DIR" },
97162 interval: { type: "string", desc: "Seconds between polls when idle (default 5)", arg: "SECONDS" },
98163 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" },
99166 },
100167 maxArgs: 0,
101168 async run(ctx, o) {
@@ -107,8 +174,34 @@
107174 if (!Number.isFinite(intervalMs)) throw new CliError("--interval must be a number", 2);
108175 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");
109176 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();
111177 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+ }
112205
113206 let stopping = false;
114207 let child: ChildProcess | null = null;
@@ -134,14 +227,15 @@
134227 let lastError = "";
135228 while (!stopping) {
136229 let ran = false;
137- for (const repo of repos) {
230+ for (const { repo, api, body, file } of sources) {
138231 if (stopping) break;
139232 let job: ClaimedJob | null;
140233 try {
141- job = await claim(api, repo, name, labels);
234+ job = await claim(api, repo, body);
142235 lastError = "";
143236 } 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;
145239 const msg = (e as Error).message;
146240 if (msg !== lastError) ctx.err(`${c.yellow("warning:")} ${msg} (will retry)`);
147241 lastError = msg;
@@ -150,7 +244,7 @@
150244 if (!job) continue;
151245 ran = true;
152246 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;
154248 if (stopping) break;
155249 }
156250 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";
22 import path from "node:path";
33 import { afterEach, beforeEach, describe, expect, it } from "vitest";
44 import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
@@ -91,4 +91,69 @@
9191 expect(r.code).toBe(1);
9292 expect(r.stderr).toContain("admin access required");
9393 });
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+ });
94159 });
modifiedapps/diffinery/docs/actions/runners.md+42−17View file
@@ -176,31 +176,40 @@
176176 ```
177177
178178 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`).
180180 - **Bash** (the default shell for `run:` steps; `sh` is used when bash is missing), `git` and `tar`, which the built-in actions use.
181181 - **Docker**, only if jobs use `container:`, `services:` or Docker actions.
182182
183183 ### Start a runner
184184
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):
186186
187187 ```sh
188- diffinery login --token dfy_...
188+ diffinery api POST /repos/acme/website/actions/runners/registration-token
189189 ```
190190
191-2. Start the runner for one or more repositories:
191+ ```json
192+ {"token": "drg_…", "expires_at": "2026-10-13T19:45:00.000Z"}
193+ ```
192194
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+
193197 ```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
195199 ```
196200
197- It prints the labels it offers and waits:
201+ It registers, saves the runner's credentials, prints the labels it offers and waits:
198202
199203 ```text
204+ Registered runner my-mac with acme/website (saved to ~/.config/diffinery/runners/diffinery.com/acme/website/my-mac.json)
200205 Runner my-mac offering [self-hosted, macos, arm64] for acme/website
201206 Waiting for jobs. Press Ctrl-C to stop.
202207 ```
203208
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+
204213 3. Target it from a workflow:
205214
206215 ```yaml
@@ -226,6 +235,8 @@
226235 | `--work-dir DIR` | `-w` | `~/.cache/diffinery/runner/<name>` (or `$XDG_CACHE_HOME/diffinery/runner/<name>`) | Working directory for jobs. |
227236 | `--interval SECONDS` | | `5` | Seconds between polls when idle. |
228237 | `--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. |
229240
230241 The full generated reference is at [CLI reference](/docs/reference/cli).
231242
@@ -240,14 +251,14 @@
240251 ### How jobs are claimed
241252
242253 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:
244255
245256 ```http
246257 POST /api/v3/repos/acme/website/actions/runners/claim
247- Authorization: Bearer dfy_...
258+ Authorization: Bearer drn_...
248259 Content-Type: application/json
249260
250- {"name": "my-mac", "labels": ["macos", "arm64"]}
261+ {}
251262 ```
252263
253264 ```json
@@ -259,8 +270,21 @@
259270
260271 A job that no runner claims fails after 6 hours. While a job waits, the run page shows it as queued.
261272
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`.
263274
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+
264288 ### What a job sees on a self-hosted runner
265289
266290 - 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 @@
284308 sudo install -D -m 0644 containers/runner/dist/agent.mjs /opt/diffinery/agent.mjs
285309 ```
286310
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:
288312
289313 ```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
292316 ```
293317
318+ Its credentials are saved in that user's `~/.config/diffinery/runners`, so the service needs no token.
319+
294320 3. Create `/etc/systemd/system/diffinery-runner.service`:
295321
296322 ```text
@@ -302,7 +328,6 @@
302328 [Service]
303329 User=diffinery-runner
304330 WorkingDirectory=/home/diffinery-runner
305- EnvironmentFile=/etc/diffinery-runner.env
306331 Environment=DIFFINERY_URL=https://diffinery.com
307332 Environment=DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs
308333 ExecStart=/usr/bin/node /usr/local/bin/diffinery runner -R acme/website --labels linux,gpu --name gpu-box-1 --quiet
@@ -325,7 +350,7 @@
325350
326351 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.
327352
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.
329354
330355 ### Docker on self-hosted runners
331356
@@ -354,9 +379,9 @@
354379 A self-hosted runner runs code from your repository's workflows on your machine. Before you start one:
355380
356381 - **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.
358383 - **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.
360385 - **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.
361386
362387 ## Troubleshooting
modifiedapps/diffinery/docs/reference/limits.md+1−0View file
@@ -181,6 +181,7 @@
181181 | Hosted job containers at once | 100 | `max_instances` of the runner container class. |
182182 | 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. |
183183 | 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. |
184185 | 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. |
185186 | Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. |
186187 | 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 @@
642642 - `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.
643643 - `PUT`, `DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue` and `GET /repos/{owner}/{repo}/merge-queue/{branch}` drive the merge queue.
644644 - `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)).
646646 - `GET /repos/{owner}/{repo}/raw/{ref}/{path}` returns a file's raw bytes.
647647 - `GET /repos/{owner}/{repo}/packages` lists container images linked to a repository, and `PATCH` on a package changes its visibility or linked repository.
648648
modifiedapps/diffinery/docs/security/audit-log.md+3−0View file
@@ -119,6 +119,9 @@
119119 | `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 |
120120 | `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"` |
121121 | `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` |
122125
123126 ### Environments
124127
modifiedapps/diffinery/docs/self-hosting/architecture.md+1−1View file
@@ -167,7 +167,7 @@
167167 - **`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.
168168 - **`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.
169169
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).
171171
172172 ## Actions cache and artifact services
173173
modifiedapps/diffinery/src/auth/principal.ts+2−1View file
@@ -74,7 +74,8 @@
7474 }
7575 return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds };
7676 }
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_")) {
7879 const repoId = cred.split("_")[1];
7980 if (!repoId) throw new HttpError(401, "malformed token");
8081 return { type: "repo-token", repoId, token: cred };
modifiedapps/diffinery/src/http/api.ts+41−0View file
@@ -787,13 +787,54 @@
787787 return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201);
788788 });
789789 // 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.
790792 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+ }
791801 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
792802 const b = await body<{ name?: string; labels?: string[] }>(c);
793803 const job = await stub.claimJob(b.name ?? "runner", Array.isArray(b.labels) ? b.labels.map(String) : [], caller);
794804 if (!job) return c.body(null, 204);
795805 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 });
796806 });
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+});
797838 api.get("/repos/:owner/:repo/actions/runs", async (c) => {
798839 const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
799840 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 @@
874874 claimJob(...a: Parameters<Actions["claimJob"]>) {
875875 return this.actions.claimJob(...a);
876876 }
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+ }
877892 jobStatus(jobId: string): string {
878893 return this.actions.getJob(jobId).status;
879894 }
modifiedapps/diffinery/src/repo/actions.ts+93−1View file
@@ -48,6 +48,24 @@
4848 export const MAX_HOSTED_JOBS = 20;
4949 /** Runs one repository may have unfinished at once; past it, new ones aren't started. */
5050 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+}
5169 const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i;
5270
5371 export type RunRow = {
@@ -103,6 +121,7 @@
103121 concurrency_cancel: number;
104122 log_chars: number;
105123 fleet_held: number;
124+ runner_id: number | null;
106125 };
107126
108127 export type RunInfo = {
@@ -992,6 +1011,11 @@
9921011 */
9931012 async claimJob(runnerName: string, runnerLabels: string[], caller: Caller): Promise<{ jobId: string; token: string; runId: string; name: string } | null> {
9941013 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> {
9951019 const offered = new Set(["self-hosted", ...runnerLabels.map((l) => l.toLowerCase())]);
9961020 const waiting = this.sql
9971021 .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 @@
10011025 const m = this.repo.meta;
10021026 const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`;
10031027 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);
10051029 this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id });
10061030 return { jobId: job.id, token, runId: job.run_id, name: job.name };
10071031 }
10081032
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+
10091099 private async stopContainer(j: JobRow): Promise<void> {
10101100 if (!j.container || j.container.startsWith("self-hosted:")) return;
10111101 try {
@@ -1401,6 +1491,8 @@
14011491 const queued = siblings.find((s) => s.status === "queued");
14021492 if (queued) await this.launch(queued.id);
14031493 }
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);
14041496 // A hosted job hands its slot to the oldest job waiting for one.
14051497 if (!job.labels && job.container) {
14061498 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 @@
147147 CREATE TABLE IF NOT EXISTS import_refs (ref TEXT PRIMARY KEY, oid TEXT NOT NULL, import_id TEXT NOT NULL);
148148 -- A copy of the owning organization's Actions variables (D1 org_variables), kept in sync by the Directory.
149149 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);
150154 `;
151155
152156 /** Columns added after a table was first created; added with ALTER TABLE when missing. */
@@ -179,6 +183,8 @@
179183 ["jobs", "log_chars", "INTEGER NOT NULL DEFAULT 0"],
180184 // 1 while a queued job waits for one of the repository's hosted runner slots (repo/actions.ts MAX_HOSTED_JOBS).
181185 ["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"],
182188 // The GitHub import that wrote this row (null for everything else); a retry may only overwrite its own rows.
183189 ["issues", "imported", "TEXT"],
184190 ["pulls", "imported", "TEXT"],
modifiedapps/diffinery/test/e2e/security.test.mjs+46−0View file
@@ -1890,3 +1890,49 @@
18901890 await status(401, "GET", "/user", undefined, { cookie: S.oliveSpare });
18911891 assert.equal((await ok("GET", "/user", undefined, { cookie: S.oliveOld })).login, "olive");
18921892 });
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+});