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.

Only registered runners claim jobs; personal tokens can't (#118)

* Only registered runners claim jobs: personal tokens can't

A personal token kept on a runner machine can be read by the jobs it runs. Runners register with a
one-hour registration token (or the signed-in CLI does it for them) and claim with their own token,
so the personal-token path is closed. e2e tests claim as registered runners.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* Email e2e: the ignored-repository runs claim as a registered runner

#114 added a personal-token claim that this PR turns away.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* Environment reviewers e2e: the deploy jobs claim as a registered runner

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent ebca377commit d22843396f713412f94ff4d3a6095ee33408978bBrowse files
8 files changed+106−35
Files (8)
modifiedapps/diffinery/docs/actions/runners.md+1−1View file
@@ -272,7 +272,7 @@
272272
273273 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`.
274274
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.
275+Only registered runners can claim jobs. A personal token, even a repository admin's, gets `403` with `personal tokens can't claim jobs; register this runner: ...`. Runners used to claim that way before registration. If an older runner stops with that message, upgrade the CLI and start it again while signed in, which registers it, or register it with `--registration-token`.
276276
277277 ### Manage runners
278278
modifiedapps/diffinery/src/http/api.ts+4−7View file
@@ -883,8 +883,8 @@
883883 return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201);
884884 });
885885 // Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`).
886-// A registered runner claims with its own token and the labels it registered with; a repository
887-// admin's personal token still works too, offering the labels in the body.
886+// Only a registered runner claims, with its own token and the labels it registered with. A person's
887+// token can't: it would sit on the runner machine where the jobs it runs could read it.
888888 api.post("/repos/:owner/:repo/actions/runners/claim", async (c) => {
889889 const p = c.get("principal");
890890 if (p.type === "repo-token" && p.token.startsWith("drn_")) {
@@ -894,11 +894,8 @@
894894 if (!job) return c.body(null, 204);
895895 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 });
896896 }
897- const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
898- const b = await body<{ name?: string; labels?: string[] }>(c);
899- const job = await stub.claimJob(b.name ?? "runner", Array.isArray(b.labels) ? b.labels.map(String) : [], caller);
900- if (!job) return c.body(null, 204);
901- 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 });
897+ await loadRepo(c, P(c).owner, P(c).repo, "admin");
898+ throw new HttpError(403, `personal tokens can't claim jobs; register this runner: run \`diffinery runner -R ${P(c).owner}/${P(c).repo}\` signed in with an up-to-date CLI, or pass --registration-token (see /docs/actions/runners)`);
902899 });
903900 // GitHub's runner registration token: an hour to register runners (`diffinery runner --registration-token`).
904901 api.post("/repos/:owner/:repo/actions/runners/registration-token", async (c) => {
modifiedapps/diffinery/src/repo/RepoDO.ts+0−3View file
@@ -942,9 +942,6 @@
942942 runJobs(...a: Parameters<Actions["jobs"]>) {
943943 return this.actions.jobs(...a);
944944 }
945- claimJob(...a: Parameters<Actions["claimJob"]>) {
946- return this.actions.claimJob(...a);
947- }
948945 claimJobAsRunner(...a: Parameters<Actions["claimJobAsRunner"]>) {
949946 return this.actions.claimJobAsRunner(...a);
950947 }
modifiedapps/diffinery/src/repo/actions.ts+0−9View file
@@ -1276,15 +1276,6 @@
12761276 await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: exitCode ? `The runner stopped unexpectedly (exit code ${exitCode}).` : "The runner's container stopped before the job finished, without an exit code (the platform restarted or replaced it)." } });
12771277 }
12781278
1279- /**
1280- * A self-hosted runner asks for work. Returns the oldest waiting job whose
1281- * runs-on labels are all offered by the runner, with a fresh job token.
1282- */
1283- async claimJob(runnerName: string, runnerLabels: string[], caller: Caller): Promise<{ jobId: string; token: string; runId: string; name: string } | null> {
1284- if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to run self-hosted jobs");
1285- return this.assignJob(runnerName, runnerLabels, null);
1286- }
1287-
12881279 /** The oldest waiting job whose runs-on labels the runner all offers, handed to it with a fresh job token. */
12891280 private async assignJob(runnerName: string, runnerLabels: string[], runnerId: number | null): Promise<{ jobId: string; token: string; runId: string; name: string } | null> {
12901281 const offered = new Set(["self-hosted", ...runnerLabels.map((l) => l.toLowerCase())]);
modifiedapps/diffinery/test/e2e/e2e.test.mjs+26−5View file
@@ -53,6 +53,27 @@
5353 }
5454 return { status: res.status, json };
5555 }
56+
57+/**
58+ * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner`
59+ * does (personal tokens can't claim jobs): one runner per repository and set of labels.
60+ */
61+const e2eRunners = new Map();
62+async function runnerClaim(repoPath, labels = ["linux"]) {
63+ const key = `${repoPath} ${labels.join(",")}`;
64+ if (!e2eRunners.has(key)) {
65+ const reg = await api("POST", `${repoPath}/actions/runners/registration-token`);
66+ assert.equal(reg.status, 201, JSON.stringify(reg.json));
67+ const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels });
68+ assert.equal(runner.status, 201, JSON.stringify(runner.json));
69+ e2eRunners.set(key, runner.json.token);
70+ }
71+ const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key));
72+ // A repository deleted and created again under the same name needs a new runner.
73+ if (r.status !== 401) return r;
74+ e2eRunners.delete(key);
75+ return runnerClaim(repoPath, labels);
76+}
5677 async function ok(method, path, body, tok) {
5778 const r = await api(method, path, body, tok);
5879 assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
@@ -111,7 +132,7 @@
111132 */
112133 async function runOneJob(owner, repo, { allowFailure = false, envFor = () => ({}), timeoutMs = 180_000, labels = ["linux"] } = {}) {
113134 const claim = await until("a job to claim", async () => {
114- const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels });
135+ const r = await runnerClaim(`/repos/${owner}/${repo}`, labels);
115136 return r.status === 200 ? r.json : null;
116137 });
117138 const jobEnv = { ...envFor(claim), DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token };
@@ -1235,7 +1256,7 @@
12351256 /** Claims the job a push started and starts it, as an older runner would, without running steps. */
12361257 const startJob = async () => {
12371258 const claim = await until("a job to claim", async () => {
1238- const r = await api("POST", `/repos/${owner}/legacy-cache/actions/runners/claim`, { name: "old-runner", labels: ["linux"] });
1259+ const r = await runnerClaim(`/repos/${owner}/legacy-cache`, ["linux"]);
12391260 return r.status === 200 ? r.json : null;
12401261 });
12411262 const h = { authorization: `Bearer ${claim.token}` };
@@ -1297,7 +1318,7 @@
12971318 git(src, ["remote", "add", "origin", `${base}/${owner}/legacy-artifacts.git`]);
12981319 git(src, ["push", "-q", "origin", "main"]);
12991320 const claim = await until("a job to claim", async () => {
1300- const r = await api("POST", `/repos/${owner}/legacy-artifacts/actions/runners/claim`, { name: "old-runner", labels: ["linux"] });
1321+ const r = await runnerClaim(`/repos/${owner}/legacy-artifacts`, ["linux"]);
13011322 return r.status === 200 ? r.json : null;
13021323 });
13031324 const at = `${base}/runner/jobs/${claim.job_id}/artifacts/out`;
@@ -1333,7 +1354,7 @@
13331354 git(src, ["remote", "add", "origin", `${base}/${owner}/legacy-rest.git`]);
13341355 git(src, ["push", "-q", "origin", "main"]);
13351356 const claim = await until("a job to claim", async () => {
1336- const r = await api("POST", `/repos/${owner}/legacy-rest/actions/runners/claim`, { name: "old-runner", labels: ["linux"] });
1357+ const r = await runnerClaim(`/repos/${owner}/legacy-rest`, ["linux"]);
13371358 return r.status === 200 ? r.json : null;
13381359 });
13391360 const h = { authorization: `Bearer ${claim.token}` };
@@ -2505,7 +2526,7 @@
25052526 assert.equal((await api("PUT", `/repos/${owner}/imported-app/actions/secrets/DEPLOY_KEY`, { value: "never-for-forks" })).status, 204);
25062527 await ok("PATCH", `/repos/${owner}/imported-app/pulls/6`, { state: "open" });
25072528 const forkJob = await until("the fork pull request's job", async () => {
2508- const r = await api("POST", `/repos/${owner}/imported-app/actions/runners/claim`, { name: "fork-runner", labels: ["linux"] });
2529+ const r = await runnerClaim(`/repos/${owner}/imported-app`, ["linux"]);
25092530 return r.status === 200 ? r.json : null;
25102531 });
25112532 const forkSpec = await (await fetch(`${base}/runner/jobs/${forkJob.job_id}`, { headers: { authorization: `Bearer ${forkJob.token}` } })).json();
modifiedapps/diffinery/test/e2e/email.test.mjs+23−2View file
@@ -51,6 +51,27 @@
5151 }
5252 return { status: res.status, json };
5353 }
54+
55+/**
56+ * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner`
57+ * does (personal tokens can't claim jobs): one runner per repository and set of labels.
58+ */
59+const e2eRunners = new Map();
60+async function runnerClaim(repoPath, labels = ["linux"]) {
61+ const key = `${repoPath} ${labels.join(",")}`;
62+ if (!e2eRunners.has(key)) {
63+ const reg = await api("POST", `${repoPath}/actions/runners/registration-token`);
64+ assert.equal(reg.status, 201, JSON.stringify(reg.json));
65+ const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels });
66+ assert.equal(runner.status, 201, JSON.stringify(runner.json));
67+ e2eRunners.set(key, runner.json.token);
68+ }
69+ const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key));
70+ // A repository deleted and created again under the same name needs a new runner.
71+ if (r.status !== 401) return r;
72+ e2eRunners.delete(key);
73+ return runnerClaim(repoPath, labels);
74+}
5475 async function ok(method, path, body, tok) {
5576 const r = await api(method, path, body, tok);
5677 assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
@@ -245,7 +266,7 @@
245266 git(src, ["remote", "add", "origin", `${base}/${owner}/mailbox.git`]);
246267 git(src, ["push", "-q", "origin", "main"]);
247268 const claim = await until("a job to claim", async () => {
248- const r = await api("POST", `/repos/${owner}/mailbox/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
269+ const r = await runnerClaim(`/repos/${owner}/mailbox`, ["linux"]);
249270 return r.status === 200 ? r.json : null;
250271 });
251272 await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, 180_000);
@@ -262,7 +283,7 @@
262283 git(src, ["commit", "-q", "-m", `Add ${file}`]);
263284 git(src, ["push", "-q", "origin", "main"]);
264285 const c = await until("a job to claim", async () => {
265- const r = await api("POST", `/repos/${owner}/mailbox/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
286+ const r = await runnerClaim(`/repos/${owner}/mailbox`, ["linux"]);
266287 return r.status === 200 ? r.json : null;
267288 });
268289 await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: c.api_url, DIFFINERY_JOB_ID: c.job_id, DIFFINERY_JOB_TOKEN: c.token, DIFFINERY_WORK_ROOT: join(work, `job-${c.job_id}`) }, 180_000);
modifiedapps/diffinery/test/e2e/registry.test.mjs+22−1View file
@@ -75,6 +75,27 @@
7575 }
7676 return { status: res.status, json };
7777 }
78+
79+/**
80+ * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner`
81+ * does (personal tokens can't claim jobs): one runner per repository and set of labels.
82+ */
83+const e2eRunners = new Map();
84+async function runnerClaim(repoPath, labels = ["linux"]) {
85+ const key = `${repoPath} ${labels.join(",")}`;
86+ if (!e2eRunners.has(key)) {
87+ const reg = await api("POST", `${repoPath}/actions/runners/registration-token`);
88+ assert.equal(reg.status, 201, JSON.stringify(reg.json));
89+ const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels });
90+ assert.equal(runner.status, 201, JSON.stringify(runner.json));
91+ e2eRunners.set(key, runner.json.token);
92+ }
93+ const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key));
94+ // A repository deleted and created again under the same name needs a new runner.
95+ if (r.status !== 401) return r;
96+ e2eRunners.delete(key);
97+ return runnerClaim(repoPath, labels);
98+}
7899 async function ok(method, path, body, tok) {
79100 const r = await api(method, path, body, tok);
80101 assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
@@ -431,7 +452,7 @@
431452
432453 async function runOneJob(repo) {
433454 const claim = await until("a job to claim", async () => {
434- const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
455+ const r = await runnerClaim(`/repos/${owner}/${repo}`, ["linux"]);
435456 return r.status === 200 ? r.json : null;
436457 });
437458 const r = await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, 180_000);
modifiedapps/diffinery/test/e2e/security.test.mjs+30−7View file
@@ -80,6 +80,27 @@
8080 }
8181 return { status: res.status, json };
8282 }
83+
84+/**
85+ * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner`
86+ * does (personal tokens can't claim jobs): one runner per repository and set of labels.
87+ */
88+const e2eRunners = new Map();
89+async function runnerClaim(repoPath, labels = ["linux"]) {
90+ const key = `${repoPath} ${labels.join(",")}`;
91+ if (!e2eRunners.has(key)) {
92+ const reg = await api("POST", `${repoPath}/actions/runners/registration-token`);
93+ assert.equal(reg.status, 201, JSON.stringify(reg.json));
94+ const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels });
95+ assert.equal(runner.status, 201, JSON.stringify(runner.json));
96+ e2eRunners.set(key, runner.json.token);
97+ }
98+ const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key));
99+ // A repository deleted and created again under the same name needs a new runner.
100+ if (r.status !== 401) return r;
101+ e2eRunners.delete(key);
102+ return runnerClaim(repoPath, labels);
103+}
83104 async function ok(method, path, body, as) {
84105 const r = await api(method, path, body, as);
85106 assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
@@ -1699,7 +1720,7 @@
16991720 await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) });
17001721 const specs = {};
17011722 await until("both jobs claimed", async () => {
1702- const r = await api("POST", `${R}/actions/runners/claim`, { name: "secrets-runner", labels: ["linux"] });
1723+ const r = await runnerClaim(R, ["linux"]);
17031724 if (r.status === 200) {
17041725 const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json();
17051726 specs[spec.jobKey] = Object.keys(spec.secrets).sort();
@@ -1723,7 +1744,7 @@
17231744 const secretsOfNextJob = async () => {
17241745 let names;
17251746 await until("the agent's job", async () => {
1726- const r = await api("POST", `${R}/actions/runners/claim`, { name: "agent-runner", labels: ["linux"] });
1747+ const r = await runnerClaim(R, ["linux"]);
17271748 if (r.status !== 200) return false;
17281749 const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json();
17291750 names = Object.keys(spec.secrets).sort();
@@ -1786,7 +1807,7 @@
17861807 await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) });
17871808 let claim;
17881809 await until("the job claimed", async () => {
1789- const r = await api("POST", `${R}/actions/runners/claim`, { name: "log-runner", labels: ["linux"] });
1810+ const r = await runnerClaim(R, ["linux"]);
17901811 if (r.status === 200) claim = r.json;
17911812 return !!claim;
17921813 });
@@ -1822,7 +1843,7 @@
18221843 await ok("PUT", `/repos/${OWNER}/pkg-other/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) });
18231844 let claim;
18241845 await until("the job claimed", async () => {
1825- const r = await api("POST", `/repos/${OWNER}/pkg-other/actions/runners/claim`, { name: "pkg-runner", labels: ["linux"] });
1846+ const r = await runnerClaim(`/repos/${OWNER}/pkg-other`, ["linux"]);
18261847 if (r.status === 200) claim = r.json;
18271848 return !!claim;
18281849 });
@@ -1853,7 +1874,7 @@
18531874 await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) });
18541875 const tokens = {};
18551876 await until("both jobs claimed", async () => {
1856- const r = await api("POST", `${R}/actions/runners/claim`, { name: "oidc-runner", labels: ["linux"] });
1877+ const r = await runnerClaim(R, ["linux"]);
18571878 if (r.status === 200) {
18581879 const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json();
18591880 const res = await fetch(`${base}/runner/jobs/${r.json.job_id}/oidc?audience=sts.example.com`, { headers: { authorization: `Bearer ${r.json.token}` } });
@@ -1943,6 +1964,8 @@
19431964 // A runner's token claims this repository's jobs and nothing else, with the labels it registered.
19441965 await status(404, "GET", R, undefined, m1.token);
19451966 await status(401, "POST", `/repos/${OWNER}/runner-other/actions/runners/claim`, {}, m1.token);
1967+ // A personal token, even a repository admin's, can't claim jobs: only registered runners do.
1968+ assert.match((await status(403, "POST", `${R}/actions/runners/claim`, { name: "old-runner", labels: ["linux"] })).message, /register this runner/);
19461969 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";
19471970 await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) });
19481971 let first;
@@ -1994,7 +2017,7 @@
19942017 if (write) await ok("PUT", `/repos/guild/${name}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) });
19952018 let names;
19962019 await until(`${name}'s job claimed`, async () => {
1997- const r = await api("POST", `/repos/guild/${name}/actions/runners/claim`, { name: "org-secrets", labels: ["linux"] });
2020+ const r = await runnerClaim(`/repos/guild/${name}`, ["linux"]);
19982021 if (r.status !== 200) return false;
19992022 const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json();
20002023 names = Object.keys(spec.secrets).filter((k) => !/_TOKEN$/.test(k)).sort();
@@ -2128,7 +2151,7 @@
21282151
21292152 const wf = (n) => `name: deploy\non: push\njobs:\n ship${n}:\n runs-on: [self-hosted, linux]\n environment: production\n steps:\n - run: echo ship\n env:\n K: \${{ secrets.DEPLOY_KEY }}\n`;
21302153 const claim = async () => {
2131- const r = await api("POST", `${R}/actions/runners/claim`, { name: "gate-runner", labels: ["linux"] });
2154+ const r = await runnerClaim(R, ["linux"]);
21322155 if (r.status !== 200) return null;
21332156 const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json();
21342157 await fetch(`${base}/runner/jobs/${r.json.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${r.json.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });