Public: anyone can read this; the crew can change it.

A contribute token pushes only to its own contrib branch, and the push prints the pull it opened #12047

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/12013-a-contribute-token-pushes-only-to-its-ow → main 5 commits fork

  • Merged. torque/12013-a-contribute-token-pushes-only-to-its-ow was merged into main as 8a4b216.

Review

Sign in to review this change.
10 files changed +543 −58
docs/features/contribute-token.md +3 −1
@@ -15,5 +15,7 @@ or the MCP `start` tool with `action: "contribute"` (same arguments). The answer
1515 - **Scope:** read public repositories; push `contrib/<agent>/<segment>/*` on that one repository; open pulls from those branches (`POST /api/v1/repos/<owner>/<name>/pulls`, `head: "contrib/<agent>/<segment>/<topic>"`); read and update its own pulls, checks included. Everything else answers 403 (`src/http/contribute_gate.ts`, `allows` in `src/core/contribute_token.ts`). It never reaches the default branch and a person merges.
1616 - **Lifetime:** 24 hours. `POST /api/v1/contribute` with the token presented renews it once, by another day, never past a week from minting.
1717 - **Own branches:** the agent name is claimed, so two tokens may share one. The `<segment>` is derived from the token's own id and is in the `branches` and `git.push` the mint answers; the pre-receive hook refuses any ref outside it, so one token cannot push over or delete another's branches.
18 - **Limits:** 5 tokens an hour and 20 a day per address (hashed), 120 requests a minute per token; at most 5 pulls open per token, 20 opened a day per token and 30 a day from all of a repository's outside tokens (counted from the pull rows; over it answers 429).
18 - **Limits:** 5 tokens an hour and 20 a day per address (hashed), 120 requests a minute per token; at most 5 pulls open per token, 20 opened a day per token, 25 a day per claimed agent name (`PULLS_PER_AGENT_DAY`, so minting more tokens buys nothing) and 30 a day from all of a repository's outside tokens (counted from the pull rows; over it answers 429).
1919 - **Claimed, not verified:** the agent name and origin are what the agent said. The forge makes a throwaway guest account per token (no password, no crew, no credits) so a pull has an author; the account stays after the token ends.
20
21 **The push opens the pull (#12013).** A push of a new `contrib/<agent>/<segment>/<topic>` branch opens a pull on the default branch (the tip commit's first line is the title, its whole message the body); a later push to the branch updates that pull. The push output prints, as `remote:` lines, the pull's URL and `checks (plain text)`, the URL of `GET /api/v1/repos/<owner>/<name>/pulls/<n>/checks?format=text` (`state:`, `head:`, `required:`). Refused with one line: a push to the default branch or another token's branch (the line names the branches the token may push), a token past its expiry (401), a push over 5 MiB (`CONTRIBUTE_PUSH_MAX_BYTES`; git says "pack exceeds maximum allowed size (5.00 MiB)"), and a new branch once the token has 5 pulls open has opened 20 in a day, the agent name has opened 25, or the repository has taken 30 in a day (the line carries the number). A push that creates more new branches than the token has room to open pulls is refused whole with both numbers. Opens are serialized per token (`inTurn`), shared by the API and the push, with the caps read inside the turn; a branch that loses a race to a cap is still pushed and the output says no pull was opened. Code: `src/actions/contribute_push.ts`; test: `test/contribute-push.test.ts`.
src/actions/contribute_push.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * What an outside agent's push does beyond landing a branch (#12013). Before it
3 * lands, a new branch is refused with the number when the token's pull caps are
4 * spent. After it lands, a new `contrib/…` branch opens a pull (the tip commit's
5 * message is its title and body) and a later push finds the same pull; the lines
6 * returned are what the push prints back to the agent: the pull's URL and the
7 * plain-text URL of its checks.
8 */
9
10 import type { App } from "../app.ts";
11 import { capWords, inTurn, pullCapReached, pullRoom } from "../core/contribute_token.ts";
12 import { MAX_BODY, MAX_TITLE } from "../core/issues.ts";
13 import { pullForBranch } from "../core/pulls.ts";
14 import type { Repo } from "../core/repos.ts";
15 import type { User } from "../core/users.ts";
16 import { runGit } from "../git/env.ts";
17 import { ZERO_OID, type RefUpdate } from "../git/hook-protocol.ts";
18 import { repoDir } from "../git/repos.ts";
19 import { openPull } from "./pulls.ts";
20
21 const HEADS = "refs/heads/";
22
23 const errorText = (error: unknown): string => (typeof error === "string" ? error : error instanceof Error ? error.message : JSON.stringify(error));
24
25 /** Branches this push creates: the ones that would open a pull. */
26 const created = (updates: readonly RefUpdate[]): RefUpdate[] =>
27 updates.filter((u) => u.ref.startsWith(HEADS) && ZERO_OID.test(u.oldOid) && !ZERO_OID.test(u.newOid));
28
29 /**
30 * The one plain line refusing this push because its pull caps are spent, or because
31 * it creates more branches than there is room for pulls; undefined otherwise.
32 */
33 export function contribPushRefusal(app: App, repo: Repo, userId: string, updates: readonly RefUpdate[], now = new Date()): string | undefined {
34 const fresh = created(updates).length;
35 if (fresh === 0) return undefined;
36 const { room, capped } = pullRoom(app.db, repo.id, userId, now);
37 if (capped !== undefined) return `not pushed: ${capWords(capped)}`;
38 if (fresh > room)
39 return `not pushed: this push creates ${fresh} new branches and this token may open ${room} more pull${room === 1 ? "" : "s"} now; push fewer branches at a time`;
40 return undefined;
41 }
42
43 /** The commit's message split into a title (its first line) and the whole message as the body. */
44 export function pullWords(message: string, branch: string): { title: string; body: string } {
45 const text = message.replace(/\r\n?/g, "\n").trim();
46 const title = (text.split("\n", 1)[0] ?? "").trim().slice(0, MAX_TITLE) || branch;
47 return { title, body: text.slice(0, MAX_BODY) };
48 }
49
50 /** Open or find the pull of every branch the push moved; the lines the push prints. */
51 export async function contribPushPulls(
52 app: App,
53 repo: Repo,
54 actor: User | undefined,
55 updates: readonly RefUpdate[],
56 origin: string,
57 ): Promise<string[]> {
58 const lines: string[] = [];
59 if (actor === undefined) return lines;
60 const at = `${origin}/${repo.ownerName}/${repo.name}`;
61 const show = (number: number, verb: string): void => {
62 lines.push(`${verb} pull #${number}: ${at}/pulls/${number}`);
63 lines.push(`checks (plain text): ${origin}/api/v1/repos/${repo.ownerName}/${repo.name}/pulls/${number}/checks?format=text`);
64 };
65 for (const update of updates) {
66 if (!update.ref.startsWith(HEADS) || ZERO_OID.test(update.newOid)) continue;
67 const branch = update.ref.slice(HEADS.length);
68 const known = pullForBranch(app.db, repo.id, branch);
69 if (known !== undefined && known.state === "open" && known.mergedAt === null) {
70 show(known.number, "updated");
71 continue;
72 }
73 const log = await runGit(["log", "-1", "--format=%B", update.newOid], { cwd: repoDir(app.reposRoot, repo.ownerName, repo.name) });
74 const words = pullWords(log.ok ? log.value : "", branch);
75 // One open at a time per token, with the caps read inside the turn (as the API's does).
76 const opened = await inTurn(actor.id, async () => {
77 const capped = pullCapReached(app.db, repo.id, actor.id, new Date());
78 if (capped !== undefined) return capped;
79 return openPull(app, actor, repo, {
80 title: words.title,
81 body: words.body,
82 baseBranch: repo.defaultBranch,
83 headBranch: branch,
84 outsideAgent: true,
85 });
86 });
87 if (typeof opened === "string") lines.push(`${branch} is pushed but no pull was opened: ${capWords(opened)}`);
88 else if (opened.ok) show(opened.value.number, "opened");
89 else lines.push(`${branch} is pushed but no pull was opened (${errorText(opened.error)}): open one with POST /api/v1/repos/${repo.ownerName}/${repo.name}/pulls`);
90 }
91 return lines;
92 }
src/contract/openapi/routes/getApiV1ReposOwnerNamePullsNumberChecks.ts +2 −1
@@ -3,8 +3,9 @@ import { REPO, type Route } from "../shared.ts";
33 export const route: Route = {
44 method: "get",
55 path: `${REPO}/pulls/{number}/checks`,
6 summary: "A pull's checks at its current head; answers { head, state, required, run, runs } with state `none` when no run exists",
6 summary: "A pull's checks at its current head; answers { head, state, required, run, runs } with state `none` when no run exists; `?format=text` answers three plain-text lines (`state:`, `head:`, `required:`) instead",
77 auth: false,
88 role: "anonymous",
9 query: { format: "string" },
910 reply: { head: "string", state: "string", required: "boolean", run: "object", runs: "array" },
1011 };
src/core/contribute_token.ts +67 −19
@@ -42,7 +42,11 @@ export const MINTS_PER_DAY = 20;
4242 */
4343 export const OPEN_PULLS_PER_TOKEN = 5;
4444 export const PULLS_PER_TOKEN_DAY = 20;
45 /** Pulls all the tokens that claim one agent name may open on a repository in a day. */
46 export const PULLS_PER_AGENT_DAY = 25;
4547 export const PULLS_PER_REPO_DAY = 30;
48 /** The most one push of an outside agent's may carry (git's `receive.maxInputSize`): 5 MiB. */
49 export const CONTRIBUTE_PUSH_MAX_BYTES = 5 * 1024 * 1024;
4650 export const AGENT_MAX = 64;
4751 export const ORIGIN_MAX = 200;
4852
@@ -68,31 +72,75 @@ export function inContribNamespace(ref: string, agent: string): boolean {
6872 export const headInContribNamespace = (head: string, agent: string): boolean =>
6973 inContribNamespace(`refs/heads/${head}`, agent);
7074
75 export type PullCap = "token_open" | "token_day" | "agent_day" | "repo_day";
76
7177 /**
72 * Why this token's user may not open another pull on `repoId` now, or undefined.
73 * Counted from the pull rows themselves: open ones by this author, and the day's
74 * by this author and by every outside token's author on the repository.
78 * How many more pulls this token's user may open on `repoId` now, and the cap that
79 * binds (undefined while room is left). Counted from the pull rows themselves: open
80 * ones by this author, and the day's by this author, by every token of the same
81 * claimed agent name, and by every outside token's author on the repository.
7582 */
76 export function pullCapReached(
77 db: Database,
78 repoId: string,
79 userId: string,
80 now: Date,
81 ): "token_open" | "token_day" | "repo_day" | undefined {
83 export function pullRoom(db: Database, repoId: string, userId: string, now: Date): { room: number; capped?: PullCap } {
8284 const count = (sql: string, ...args: string[]): number =>
8385 db.query<{ n: number }, string[]>(sql).get(...args)?.n ?? 0;
8486 const since = new Date(now.getTime() - 86_400_000).toISOString();
8587 const mine = "SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'pull' AND author_id = ?2";
86 if (count(`${mine} AND state = 'open'`, repoId, userId) >= OPEN_PULLS_PER_TOKEN) return "token_open";
87 if (count(`${mine} AND created_at >= ?3`, repoId, userId, since) >= PULLS_PER_TOKEN_DAY) return "token_day";
88 const all = count(
89 `SELECT COUNT(*) AS n FROM issue
90 WHERE repo_id = ?1 AND kind = 'pull' AND created_at >= ?2
91 AND author_id IN (SELECT t.user_id FROM token t JOIN contribute_token c ON c.token_id = t.id WHERE c.repo_id = ?1)`,
92 repoId,
93 since,
94 );
95 return all >= PULLS_PER_REPO_DAY ? "repo_day" : undefined;
88 const outsiders = "SELECT t.user_id FROM token t JOIN contribute_token c ON c.token_id = t.id WHERE c.repo_id = ?1";
89 const left: [number, PullCap][] = [
90 [OPEN_PULLS_PER_TOKEN - count(`${mine} AND state = 'open'`, repoId, userId), "token_open"],
91 [PULLS_PER_TOKEN_DAY - count(`${mine} AND created_at >= ?3`, repoId, userId, since), "token_day"],
92 [
93 PULLS_PER_AGENT_DAY -
94 count(
95 `SELECT COUNT(*) AS n FROM issue
96 WHERE repo_id = ?1 AND kind = 'pull' AND created_at >= ?3
97 AND author_id IN (${outsiders} AND c.agent = (SELECT c2.agent FROM token t2 JOIN contribute_token c2 ON c2.token_id = t2.id WHERE t2.user_id = ?2 AND c2.repo_id = ?1 LIMIT 1))`,
98 repoId,
99 userId,
100 since,
101 ),
102 "agent_day",
103 ],
104 [
105 PULLS_PER_REPO_DAY -
106 count(
107 `SELECT COUNT(*) AS n FROM issue
108 WHERE repo_id = ?1 AND kind = 'pull' AND created_at >= ?2 AND author_id IN (${outsiders})`,
109 repoId,
110 since,
111 ),
112 "repo_day",
113 ],
114 ];
115 let binding = left[0]!;
116 for (const l of left) if (l[0] < binding[0]) binding = l;
117 return binding[0] > 0 ? { room: binding[0] } : { room: 0, capped: binding[1] };
118 }
119
120 /** Why this token's user may not open another pull on `repoId` now, or undefined. */
121 export const pullCapReached = (db: Database, repoId: string, userId: string, now: Date): PullCap | undefined =>
122 pullRoom(db, repoId, userId, now).capped;
123
124 /** The plain refusal for a pull cap, with the number it holds to. */
125 export function capWords(capped: PullCap): string {
126 if (capped === "repo_day")
127 return `this repository takes at most ${PULLS_PER_REPO_DAY} pulls a day from outside agents and has taken them; try tomorrow`;
128 if (capped === "agent_day")
129 return `this agent name has opened ${PULLS_PER_AGENT_DAY} pulls in a day here (the most it may); try again tomorrow`;
130 if (capped === "token_open") return `this token has ${OPEN_PULLS_PER_TOKEN} pulls open (the most it may); wait for one to close`;
131 return `this token has opened ${PULLS_PER_TOKEN_DAY} pulls in a day (the most it may); try again tomorrow`;
132 }
133
134 const turns = new Map<string, Promise<unknown>>();
135 /** Run `fn` after every earlier call with the same key has finished: one at a time per token. */
136 export function inTurn<T>(key: string, fn: () => Promise<T>): Promise<T> {
137 const run = (turns.get(key) ?? Promise.resolve()).then(fn, fn);
138 const tail = run.catch(() => undefined);
139 turns.set(key, tail);
140 void tail.then(() => {
141 if (turns.get(key) === tail) turns.delete(key);
142 });
143 return run;
96144 }
97145
98146 /** What a request is, as the scope check reads it. */
src/git/hook-protocol.ts +2
@@ -19,6 +19,8 @@ export interface HookRequest {
1919 readonly proposalChild?: string;
2020 /** An outside agent's name: every ref must be inside `contrib/<agent>/`. */
2121 readonly contribAgent?: string;
22 /** The forge's address as the pusher reached it, for the URLs a push prints. */
23 readonly pushOrigin?: string;
2224 readonly updates: readonly RefUpdate[];
2325 /**
2426 * Quarantine environment forwarded from the hook process. Any git command
src/git/hooks.ts +4
@@ -64,6 +64,8 @@ export function hookEnv(input: {
6464 proposalChild?: string;
6565 /** Set for an outside agent's contribute token: the one namespace its push may touch. */
6666 contribAgent?: string;
67 /** The address the pusher reached the forge at: a contribute push prints URLs on it. */
68 pushOrigin?: string;
6769 }): Record<string, string> {
6870 const self = selfInvocation();
6971 const env: Record<string, string> = {
@@ -76,6 +78,7 @@ export function hookEnv(input: {
7678 if (input.proposalChild !== undefined)
7779 env.FORGE_PROPOSAL_CHILD = input.proposalChild;
7880 if (input.contribAgent !== undefined) env.FORGE_CONTRIB_AGENT = input.contribAgent;
81 if (input.pushOrigin !== undefined) env.FORGE_PUSH_ORIGIN = input.pushOrigin;
7982 if (self.entry !== undefined) env.FORGE_ENTRY = self.entry;
8083 return env;
8184 }
@@ -133,6 +136,7 @@ export async function runHookClient(hook: HookName): Promise<never> {
133136 ...(process.env.FORGE_CONTRIB_AGENT !== undefined
134137 ? { contribAgent: process.env.FORGE_CONTRIB_AGENT }
135138 : {}),
139 ...(process.env.FORGE_PUSH_ORIGIN !== undefined ? { pushOrigin: process.env.FORGE_PUSH_ORIGIN } : {}),
136140 updates: parseHookStdin(stdin),
137141 gitEnv,
138142 }),
src/http/api.ts +27 −32
@@ -193,10 +193,8 @@ import {
193193 pullProposalOf,
194194 recordPullProposal,
195195 } from "../core/proposal_token.ts";
196 import { OPEN_PULLS_PER_TOKEN, headInContribNamespace, pullCapReached } from "../core/contribute_token.ts";
196 import { capWords, headInContribNamespace, inTurn, pullCapReached } from "../core/contribute_token.ts";
197197
198 /** Tokens with a pull being opened now: one at a time, so the count cannot be raced. */
199 const contributePulling = new Set<string>();
200198 import { takesOutside } from "../actions/contribute_token.ts";
201199 import { contributeMint } from "./api/contribute_token.ts";
202200 import {
@@ -2719,36 +2717,28 @@ export function apiRoutes(router: Router<Handler>): void {
27192717 }
27202718 // An outside agent opens a few pulls: each starts checks and a preview on the
27212719 // owner's repository. Counted from the pull rows, one open at a time per token.
2722 if (outside !== undefined) {
2723 const capped = pullCapReached(ctx.app.db, repo.id, ctx.auth.user!.id, new Date());
2724 if (capped !== undefined || contributePulling.has(outside.tokenId))
2725 return tryAgain(
2726 "rate_limited",
2727 429,
2728 capped === "repo_day"
2729 ? "this repository has taken as many pulls from outside agents as it takes in a day; try tomorrow"
2730 : capped === "token_open"
2731 ? `this token has ${OPEN_PULLS_PER_TOKEN} pulls open; wait for one to close`
2732 : "too many pulls opened by this token; try again later",
2733 capped === undefined ? 1 : 3600,
2734 );
2735 contributePulling.add(outside.tokenId);
2736 }
2737 let created: Awaited<ReturnType<typeof openPull>>;
2738 try {
2739 created = await openPull(ctx.app, ctx.auth.user, repo, {
2720 const open = () =>
2721 openPull(ctx.app, ctx.auth.user!, repo, {
27402722 title: str(input.title),
2741 body: str(input.body),
2742 baseBranch: str(input.base),
2743 headBranch: str(input.head),
2744 ...(headRepo ? { headRepo } : {}),
2745 // A person's deliberate yes to fixing a sealed report from a public branch.
2746 ...(input.confirmPublic === true ? { confirmPublic: true } : {}),
2747 ...(outside === undefined ? {} : { outsideAgent: true }),
2748 });
2749 } finally {
2750 if (outside !== undefined) contributePulling.delete(outside.tokenId);
2751 }
2723 body: str(input.body),
2724 baseBranch: str(input.base),
2725 headBranch: str(input.head),
2726 ...(headRepo ? { headRepo } : {}),
2727 // A person's deliberate yes to fixing a sealed report from a public branch.
2728 ...(input.confirmPublic === true ? { confirmPublic: true } : {}),
2729 ...(outside === undefined ? {} : { outsideAgent: true }),
2730 });
2731 // An outside token opens one pull at a time (the same turn a push's opens take),
2732 // its caps read inside the turn so the count cannot be raced.
2733 const opening =
2734 outside === undefined
2735 ? await open()
2736 : await inTurn(ctx.auth.user!.id, async () => {
2737 const capped = pullCapReached(ctx.app.db, repo.id, ctx.auth.user!.id, new Date());
2738 return capped ?? (await open());
2739 });
2740 if (typeof opening === "string") return tryAgain("rate_limited", 429, capWords(opening), 3600);
2741 const created = opening;
27522742 if (!created.ok && created.error === "sealed_in_public")
27532743 return apiError(
27542744 "sealed_in_public",
@@ -4302,6 +4292,11 @@ export function apiRoutes(router: Router<Handler>): void {
43024292 return { head: oid, runs: checksForHead(ctx.app.db, found.pull.id, oid) };
43034293 });
43044294 const latest = runs[0];
4295 // `?format=text`: three plain lines, the URL a contribute push prints.
4296 if (ctx.url.searchParams.get("format") === "text")
4297 return new Response(`state: ${latest?.state ?? "none"}\nhead: ${head}\nrequired: ${found.repo.requireChecks ? "yes" : "no"}\n`, {
4298 headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
4299 });
43054300 return timePhase(ctx.request, "render", () =>
43064301 json({
43074302 head,
src/http/server.ts +46 −2
@@ -97,6 +97,8 @@ import { readOnlyRefusal } from "./read_only.ts";
9797 import { running } from "../kernel/version.ts";
9898 import { runGit } from "../git/env.ts";
9999 import { noteRefusedInternal } from "../kernel/internal_refused.ts";
100 import { CONTRIBUTE_PUSH_MAX_BYTES } from "../core/contribute_token.ts";
101 import { contribPushPulls, contribPushRefusal } from "../actions/contribute_push.ts";
100102 import { pushCap, storageChanged, storageRefusal, storageRefusedText, storageSentence } from "../core/storage.ts";
101103 import { hookEnv, ZERO_OID, type HookRequest } from "../git/hooks.ts";
102104 import { repoDir } from "../git/repos.ts";
@@ -1218,7 +1220,9 @@ export function buildRouter(): Router<Handler> {
12181220 repoId,
12191221 actorId: ctx.auth.user?.id ?? "",
12201222 ...(ctx.auth.proposal ? { proposalChild: ctx.auth.proposal.child } : {}),
1221 ...(ctx.auth.contribute ? { contribAgent: ctx.auth.contribute.namespace } : {}),
1223 ...(ctx.auth.contribute
1224 ? { contribAgent: ctx.auth.contribute.namespace, pushOrigin: publicUrl(ctx.request, ctx.app.config.url).origin }
1225 : {}),
12221226 }),
12231227 ...cap,
12241228 },
@@ -1816,6 +1820,22 @@ async function withGitAccess(
18161820 const refused = await storageRefusal(ctx.app, user, repo.ownerId, repo);
18171821 if (refused !== undefined) return gitRefused(ctx, storageRefusedText(refused));
18181822 const cap = await pushCap(ctx.app, user, repo.ownerId, repo);
1823 // An outside agent's push is small: git stops reading past the cap and says the number.
1824 if (ctx.auth.contribute !== undefined) {
1825 // Merged into whatever pushCap set: its own cap is read by key, ours appended at COUNT
1826 // (a later value of a key wins in git).
1827 const n = Number(cap.GIT_CONFIG_COUNT ?? "0");
1828 let room = CONTRIBUTE_PUSH_MAX_BYTES;
1829 for (let i = 0; i < n; i++)
1830 if (cap[`GIT_CONFIG_KEY_${i}`] === "receive.maxInputSize")
1831 room = Math.min(room, Number(cap[`GIT_CONFIG_VALUE_${i}`] ?? Infinity));
1832 // index-pack (unpackLimit 1), not unpack-objects, is the one whose refusal names the size.
1833 cap[`GIT_CONFIG_KEY_${n}`] = "receive.maxInputSize";
1834 cap[`GIT_CONFIG_VALUE_${n}`] = String(room);
1835 cap[`GIT_CONFIG_KEY_${n + 1}`] = "receive.unpackLimit";
1836 cap[`GIT_CONFIG_VALUE_${n + 1}`] = "1";
1837 cap.GIT_CONFIG_COUNT = String(n + 2);
1838 }
18191839 const served = await serve(dir, repo.id, cap);
18201840 return ctx.request.method === "POST" && served.body !== null
18211841 ? new Response(afterBody(served.body, () => storageChanged(ctx.app, repo.ownerId)), served)
@@ -2129,6 +2149,12 @@ async function handleHook(app: App, request: Request): Promise<Response> {
21292149 { status: 403 },
21302150 );
21312151 }
2152 // New branches of an outside agent's open pulls: the push is refused, with the
2153 // number, when its caps are spent or it makes more branches than there is room for.
2154 if (input.contribAgent !== undefined && actor !== undefined) {
2155 const spent = contribPushRefusal(app, repo, actor.id, input.updates);
2156 if (spent !== undefined) return new Response(spent, { status: 403 });
2157 }
21322158 // The pull namespace belongs to the forge, never to pushers.
21332159 if (update.ref.startsWith("refs/pull/")) {
21342160 return new Response(`${update.ref}: refs/pull/* is reserved`, {
@@ -2226,6 +2252,24 @@ async function handleHook(app: App, request: Request): Promise<Response> {
22262252 );
22272253 }
22282254
2255 // An outside agent's push opens its pull (or finds it) and prints where it is.
2256 let printed = "";
2257 if (input.contribAgent !== undefined) {
2258 const origin = (() => {
2259 try {
2260 return new URL(input.pushOrigin ?? "").origin;
2261 } catch {
2262 return app.config.url !== undefined ? new URL(app.config.url).origin : `http://localhost:${app.config.port}`;
2263 }
2264 })();
2265 const actor = input.actorId ? getUserById(app.db, input.actorId) : undefined;
2266 printed = (await contribPushPulls(app, repo, actor, input.updates, origin)
2267 .catch((error) => {
2268 app.log.warn("could not open the outside agent's pull", { repo: `${repo.ownerName}/${repo.name}`, error: String(error) });
2269 return [];
2270 })).join("\n");
2271 }
2272
22292273 // post-receive: the push is durable — record it and wake listeners.
22302274 app.bus.append({
22312275 kind: "push",
@@ -2268,7 +2312,7 @@ async function handleHook(app: App, request: Request): Promise<Response> {
22682312 error: String(error),
22692313 }),
22702314 );
2271 return new Response("");
2315 return new Response(printed);
22722316 }
22732317
22742318 // ---- wiring ----
test/contribute-push.test.ts added +290
@@ -0,0 +1,290 @@
1 /**
2 * An outside agent's push (#12013): accepted on its own contrib branch over
3 * https, it opens a pull (the commit message is the body) and prints the pull's
4 * URL and the plain-text check URL; a later push updates the same pull; a push
5 * to main, to another token's branch, or after expiry is refused in one line;
6 * the size cap and the pull caps refuse with their numbers.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { mintContributeToken, setOutsideContributions } from "../src/actions/contribute_token.ts";
13 import { pullWords } from "../src/actions/contribute_push.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { createApp, type App } from "../src/app.ts";
16 import {
17 CONTRIBUTE_PUSH_MAX_BYTES,
18 OPEN_PULLS_PER_TOKEN,
19 PULLS_PER_AGENT_DAY,
20 PULLS_PER_REPO_DAY,
21 PULLS_PER_TOKEN_DAY,
22 contribNamespace,
23 inTurn,
24 } from "../src/core/contribute_token.ts";
25 import { verifyToken } from "../src/core/tokens.ts";
26 import { createUser, type User } from "../src/core/users.ts";
27 import { serve, type Servers } from "../src/http/server.ts";
28 import { gitClientEnv } from "./helpers/git-client.ts";
29 import { quietLogs } from "./helpers/globals.ts";
30
31 quietLogs();
32
33 describe("the pull's words", () => {
34 test("the first line is the title and the whole message the body", () => {
35 expect(pullWords("Fix the bug\n\nIt was off by one.\n", "contrib/x/y/z")).toEqual({
36 title: "Fix the bug",
37 body: "Fix the bug\n\nIt was off by one.",
38 });
39 expect(pullWords("", "contrib/x/y/z").title).toBe("contrib/x/y/z");
40 });
41 });
42
43 describe("one open at a time per token", () => {
44 test("calls with one key run in order, never together; other keys are not held", async () => {
45 const log: string[] = [];
46 let release!: () => void;
47 const held = new Promise<void>((resolve) => (release = resolve));
48 const job = (name: string, wait?: Promise<void>) => async () => {
49 log.push(`${name}:start`);
50 await wait;
51 log.push(`${name}:end`);
52 };
53 // "a" holds its turn until "c" (another key) has run and "b" is queued behind it.
54 const all = Promise.all([
55 inTurn("k", job("a", held)),
56 inTurn("k", job("b")),
57 inTurn("other", job("c")).then(() => release()),
58 ]);
59 await all;
60 expect(log.indexOf("a:end")).toBeLessThan(log.indexOf("b:start"));
61 expect(log.indexOf("c:start")).toBeLessThan(log.indexOf("a:end"));
62 });
63 test("a failing call does not block the next", async () => {
64 await inTurn("f", async () => Promise.reject(new Error("x"))).catch(() => undefined);
65 expect(await inTurn("f", async () => 7)).toBe(7);
66 });
67 });
68
69 describe("pushing with a contribute token", () => {
70 let dataDir: string;
71 let app: App;
72 let servers: Servers;
73 let work: string;
74 let seq = 0;
75
76 const git = async (args: string[], cwd = work) => {
77 const proc = Bun.spawn(["git", "-c", "credential.helper=", ...args], {
78 cwd,
79 env: gitClientEnv({ GIT_TERMINAL_PROMPT: "0" }),
80 stdout: "pipe",
81 stderr: "pipe",
82 });
83 const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
84 return { code: await proc.exited, out, err };
85 };
86
87 beforeAll(async () => {
88 dataDir = await mkdtemp(join(tmpdir(), "forge-contrib-push-"));
89 app = createApp({ dataDir, port: 0, host: "forge.test" });
90 servers = await serve(app);
91 const made = await createUser(app.db, {
92 origin: "seed",
93 trusted: true,
94 username: "alice",
95 email: "[email protected]",
96 password: "a-good-passphrase",
97 adminIfFirst: true,
98 });
99 if (!made.ok) throw new Error(made.error);
100 const r = await createRepo(app, made.value, { name: "lib", visibility: "public" });
101 if (!r.ok) throw new Error(String(r.error));
102 const seeded = await commitToBranch(app, made.value, r.value, {
103 branch: "main",
104 message: "Start",
105 files: [{ path: "README.md", content: "hi\n" }],
106 });
107 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
108 setOutsideContributions(app, made.value, r.value, true);
109 work = join(dataDir, "work");
110 expect((await git(["clone", `${servers.url}/alice/lib.git`, work], dataDir)).code).toBe(0);
111 await git(["config", "user.email", "[email protected]"]);
112 await git(["config", "user.name", "codex"]);
113 });
114
115 afterAll(async () => {
116 await servers.stop();
117 await rm(dataDir, { recursive: true, force: true });
118 });
119
120 const token = async (agent: string, expired = false) => {
121 const m = await mintContributeToken(app, { agent, origin: "test", repo: "alice/lib" }, `203.0.113.${++seq}`, "https://forge.test");
122 if (!m.ok) throw new Error(`mint: ${JSON.stringify(m.error)}`);
123 const v = verifyToken(app.db, m.value.token) as { ok: true; value: { user: User } };
124 if (expired) app.db.query("UPDATE token SET expires_at = ?1 WHERE id = ?2").run(new Date(Date.now() - 1000).toISOString(), m.value.id);
125 return {
126 ns: contribNamespace(agent, m.value.id),
127 id: m.value.id,
128 userId: v.value.user.id,
129 url: `${servers.url.replace("://", `://${v.value.user.username}:${m.value.token}@`)}/alice/lib.git`,
130 };
131 };
132 const commit = async (message: string, file = `f${++seq}.md`, content = "x\n") => {
133 await Bun.write(join(work, file), content);
134 await git(["add", "."]);
135 await git(["commit", "-m", message]);
136 };
137 const plain = (err: string) => err.split("\n").filter((l) => l.startsWith("remote:"));
138
139 test("a new contrib branch opens a pull, prints its URLs, and a later push updates it", async () => {
140 const t = await token("codex");
141 await commit("Add a fix\n\nIt explains itself.");
142 const first = await git(["push", t.url, `HEAD:contrib/${t.ns}/fix`]);
143 expect(first.code).toBe(0);
144 const pull = /remote: opened pull #(\d+): (http\S+\/alice\/lib\/pulls\/\d+)/.exec(first.err);
145 expect(pull).not.toBeNull();
146 const checks = /remote: checks \(plain text\): (http\S+\/pulls\/\d+\/checks\?format=text)/.exec(first.err);
147 expect(checks).not.toBeNull();
148 // The printed URLs lead where they say: path on the server the test talks to.
149 const page = await fetch(`${servers.url}${new URL(pull![2]!).pathname}`);
150 expect(page.status).toBe(200);
151 const text = await fetch(`${servers.url}${new URL(checks![1]!).pathname}${new URL(checks![1]!).search}`);
152 expect(text.status).toBe(200);
153 expect(text.headers.get("content-type")).toContain("text/plain");
154 expect(await text.text()).toContain("state:");
155 const api = (await (await fetch(`${servers.url}/api/v1/repos/alice/lib/pulls/${pull![1]}`)).json()) as { title: string; body: string };
156 expect(api.title).toBe("Add a fix");
157 expect(api.body).toContain("It explains itself.");
158
159 await commit("More");
160 const second = await git(["push", t.url, `HEAD:contrib/${t.ns}/fix`]);
161 expect(second.code).toBe(0);
162 expect(second.err).toContain(`remote: updated pull #${pull![1]}: `);
163 expect(second.err).not.toContain("opened pull");
164 });
165
166 test("a push to main, to another token's branch, or after expiry is refused in one line", async () => {
167 const t = await token("codex");
168 const other = await token("codex");
169 await commit("Wrong places");
170 const toMain = await git(["push", t.url, "HEAD:main"]);
171 expect(toMain.code).not.toBe(0);
172 expect(plain(toMain.err).join("\n")).toContain(`this token pushes only contrib/${t.ns}/`);
173 const elsewhere = await git(["push", t.url, `HEAD:contrib/${other.ns}/fix`]);
174 expect(elsewhere.code).not.toBe(0);
175 expect(plain(elsewhere.err).join("\n")).toContain(`this token pushes only contrib/${t.ns}/`);
176 // A token past its expiry.
177 const old = await token("codex", true);
178 const expired = await git(["push", old.url, `HEAD:contrib/${old.ns}/fix`]);
179 expect(expired.code).not.toBe(0);
180 expect(expired.err).not.toContain("opened pull");
181 expect(expired.err).toContain("Authentication failed");
182 });
183
184 test("a push over the size cap is refused with the number", async () => {
185 const t = await token("big");
186 // Random bytes, so the pack cannot shrink below the cap.
187 const noise = crypto.getRandomValues(new Uint8Array(1024 * 1024));
188 const big = new Uint8Array(CONTRIBUTE_PUSH_MAX_BYTES + 1024 * 1024);
189 for (let i = 0; i < big.length; i += noise.length) {
190 big.set(crypto.getRandomValues(noise).subarray(0, Math.min(noise.length, big.length - i)), i);
191 }
192 const base = (await git(["rev-parse", "HEAD"])).out.trim();
193 try {
194 await Bun.write(join(work, "big.bin"), big);
195 await git(["add", "."]);
196 await git(["commit", "-m", "Too much"]);
197 const pushed = await git(["push", t.url, `HEAD:contrib/${t.ns}/big`]);
198 expect(pushed.code).not.toBe(0);
199 expect(pushed.err).toContain("5.00 MiB");
200 expect(CONTRIBUTE_PUSH_MAX_BYTES).toBe(5 * 1024 * 1024);
201 } finally {
202 await git(["reset", "--hard", base]);
203 }
204 });
205
206 test("a token's pull cap refuses the next new branch with the number", async () => {
207 const t = await token("spam");
208 for (let i = 0; i < OPEN_PULLS_PER_TOKEN; i++) {
209 await commit(`Change ${i}`);
210 const ok = await git(["push", t.url, `HEAD:contrib/${t.ns}/p${i}`]);
211 expect(ok.code).toBe(0);
212 expect(ok.err).toContain("opened pull");
213 }
214 await commit("One too many");
215 const over = await git(["push", t.url, `HEAD:contrib/${t.ns}/more`]);
216 expect(over.code).not.toBe(0);
217 expect(over.err).toContain(`${OPEN_PULLS_PER_TOKEN} pulls open`);
218 // A branch already pulled may still be pushed to.
219 expect((await git(["push", t.url, `HEAD:contrib/${t.ns}/p0`, "--force"])).code).toBe(0);
220 });
221
222 test("one push may not create more branches than the token has pulls to open", async () => {
223 const t = await token("wide");
224 await commit("Wide");
225 const refs = Array.from({ length: OPEN_PULLS_PER_TOKEN + 1 }, (_, i) => `HEAD:contrib/${t.ns}/w${i}`);
226 const pushed = await git(["push", t.url, ...refs]);
227 expect(pushed.code).not.toBe(0);
228 expect(pushed.err).toContain(`creates ${OPEN_PULLS_PER_TOKEN + 1} new branches and this token may open ${OPEN_PULLS_PER_TOKEN} more pulls`);
229 expect((await git(["ls-remote", t.url])).out).not.toContain(`contrib/${t.ns}/w0`);
230 });
231
232 describe("the day's caps", () => {
233 const seed = (userId: string, n: number) => {
234 const repoId = (app.db.query("SELECT id FROM repo WHERE name = 'lib'").get() as { id: string }).id;
235 for (let i = 0; i < n; i++)
236 app.db
237 .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, ?2, ?3, 'pull', 'x', ?4, 'closed')")
238 .run(`cap-${userId}-${i}`, repoId, 5000 + ++seq, userId);
239 };
240 // Pulls of earlier tests are a day old here: only the seeded ones count.
241 beforeAll(() => {
242 app.db.query("UPDATE issue SET created_at = ?1 WHERE kind = 'pull'").run(new Date(Date.now() - 3 * 86_400_000).toISOString());
243 });
244 const clear = () => app.db.query("DELETE FROM issue WHERE id LIKE 'cap-%'").run();
245 const refused = async (t: { url: string; ns: string }) => {
246 await commit("Over");
247 const pushed = await git(["push", t.url, `HEAD:contrib/${t.ns}/over`]);
248 expect(pushed.code).not.toBe(0);
249 return pushed.err;
250 };
251
252 test("a token that opened its day's pulls is refused with the number", async () => {
253 const t = await token("daily");
254 try {
255 seed(t.userId, PULLS_PER_TOKEN_DAY);
256 expect(await refused(t)).toContain(`this token has opened ${PULLS_PER_TOKEN_DAY} pulls in a day`);
257 } finally {
258 clear();
259 }
260 });
261
262 test("the tokens of one agent name share a day, however many are minted", async () => {
263 const a = await token("loop");
264 const b = await token("loop");
265 try {
266 seed(a.userId, PULLS_PER_TOKEN_DAY - 1);
267 seed(b.userId, PULLS_PER_AGENT_DAY - PULLS_PER_TOKEN_DAY + 1);
268 expect(await refused(b)).toContain(`this agent name has opened ${PULLS_PER_AGENT_DAY} pulls in a day`);
269 // Another name is not held to it.
270 const other = await token("loop2");
271 await commit("Fine");
272 expect((await git(["push", other.url, `HEAD:contrib/${other.ns}/ok`])).code).toBe(0);
273 } finally {
274 clear();
275 }
276 });
277
278 test("a repository takes at most a day's worth from all outside tokens", async () => {
279 const t = await token("one");
280 try {
281 // Fillers of distinct names, each under its own caps, together at the repository's.
282 for (let left = PULLS_PER_REPO_DAY, i = 0; left > 0; i++, left -= PULLS_PER_TOKEN_DAY - 1)
283 seed((await token(`filler${i}`)).userId, Math.min(left, PULLS_PER_TOKEN_DAY - 1));
284 expect(await refused(t)).toContain(`takes at most ${PULLS_PER_REPO_DAY} pulls a day`);
285 } finally {
286 clear();
287 }
288 });
289 });
290 });
test/contribute-token.test.ts +10 −3
@@ -328,15 +328,22 @@ describe("minting, renewing, revoking", () => {
328328 const verified = verifyToken(app.db, made.value.token) as { ok: true; value: { user: User } };
329329 const url = `${servers.url.replace("://", `://${verified.value.user.username}:${made.value.token}@`)}/alice/lib.git`;
330330 const refs = Array.from({ length: OPEN_PULLS_PER_TOKEN + 1 }, (_, i) => `HEAD:refs/heads/contrib/${ns}/p${i}`);
331 expect((await git(["push", url, ...refs])).code).toBe(0);
331 // One push of six branches is refused whole, with the number it has room for.
332 const wide = await git(["push", url, ...refs]);
333 expect(wide.code).not.toBe(0);
334 expect(wide.err).toContain(`creates ${OPEN_PULLS_PER_TOKEN + 1} new branches`);
335 const pushed = await git(["push", url, ...refs.slice(0, OPEN_PULLS_PER_TOKEN)]);
336 expect(pushed.code).toBe(0);
337 expect(pushed.err.match(/remote: opened pull/g)?.length).toBe(OPEN_PULLS_PER_TOKEN);
332338 const open = (i: number) => post("/api/v1/repos/alice/lib/pulls", { title: `P${i}`, base: "main", head: `contrib/${ns}/p${i}` }, made.value.token);
333 for (let i = 0; i < OPEN_PULLS_PER_TOKEN; i++) expect((await open(i)).status).toBe(201);
334339 const over = await open(OPEN_PULLS_PER_TOKEN);
335340 expect(over.status).toBe(429);
336341 expect(over.headers.get("retry-after")).not.toBeNull();
337342 // Counted from the rows: closing one frees a place.
338343 app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND author_id = ?2 AND number = (SELECT MIN(number) FROM issue WHERE repo_id = ?1 AND author_id = ?2)").run(pub.id, verified.value.user.id);
339 expect((await open(OPEN_PULLS_PER_TOKEN)).status).toBe(201);
344 const freed = await git(["push", url, refs[OPEN_PULLS_PER_TOKEN]!]);
345 expect(freed.code).toBe(0);
346 expect(freed.err).toContain("remote: opened pull");
340347
341348 // The repository's own day: pulls by many outside tokens together.
342349 const others: User[] = [];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs