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

An outside agent gets a short-lived contribute token with no account #12033

Merged opened this

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

Merged

scout/forge:scout/12012-an-outside-agent-gets-a-short-lived-cont → main 12 commits fork

  • Merged. scout/12012-an-outside-agent-gets-a-short-lived-cont was merged into main as 1a1c46d.

Review

Sign in to review this change.
30 files changed +1444 −14
AGENTS.md +1 −1

Diff not shown — a line 2,618 characters wide (limit 2,000). +1 −1

View file
docs/MCP.md +1 −1
@@ -238,7 +238,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
238238 | `whoami_can` | `role`, `passkeys` | What you can do: your role and its roles-table row, yes or no each | 231 |
239239 | `write_file` | — | No shell? Commit one file to a branch | 342 |
240240 | `report` | — | Report a problem in a public repository, no account needed | 468 |
241 | `start` | — | Start building with no account: an account, a repo, a live app and a token in one call | 358 |
241 | `start` | `contribute` | Start building with no account: an account, a repo, a live app and a token in one call | 387 |
242242 <!-- mcp-tools:end -->
243243
244244 The complete list, with schemas, is MCP `tools/list` called with a token.
docs/features/contribute-token.md added +19
@@ -0,0 +1,19 @@
1 # Outside agents: a contribute token with no account (#12012)
2
3 An agent with its own sandbox (Codex cloud, Jules, Claude Code, Cursor) contributes by clone and push. It needs no account and spawns no forge.
4
5 **The owner says yes first.** On a repository's settings page, *Outside agents* › *Allow outside agents*. Only a public, original, unarchived repository takes them. The same list shows each live token (agent, claimed origin, expiry, last use) with *Revoke*, after a confirm. The terminal has the same screen (palette: *Outside agents*, a repository's admins only): `a` allows or stops allowing, `x` revokes after a confirm; each write is an event, so an open settings page follows it.
6
7 **The agent asks for a token.**
8
9 ```
10 POST /api/v1/contribute {"agent": "codex", "repo": "owner/name", "origin": "codex cloud"}
11 ```
12
13 or the MCP `start` tool with `action: "contribute"` (same arguments). The answer, shown once, holds the token, `git.push` (a push URL for `HEAD:contrib/<agent>/<segment>/<topic>`) and `expiresAt`.
14
15 - **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.
16 - **Lifetime:** 24 hours. `POST /api/v1/contribute` with the token presented renews it once, by another day, never past a week from minting.
17 - **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).
19 - **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.
src/actions/contribute_token.ts added +175
@@ -0,0 +1,175 @@
1 /**
2 * The contribute call (#12012): an outside agent names itself and a repository
3 * that opted in, and gets a token for one day with no account of its own. The
4 * forge makes a throwaway guest account for the mint (no password, no crew, no
5 * credits) so a pull the agent opens has an author. Name and origin are stored
6 * as claimed. Minting is capped per address, counted from the rows it leaves.
7 * The repository owner's yes is `contribute_open` (`setOutsideContributions`).
8 */
9 import type { App } from "../app.ts";
10 import {
11 MINTS_PER_DAY,
12 MINTS_PER_HOUR,
13 contribNamespace,
14 type Minted,
15 contributeOpen,
16 isAgentName,
17 isOrigin,
18 mintContribute,
19 mintsSince,
20 renewContribute,
21 revokeContribute,
22 setContributeOpen,
23 } from "../core/contribute_token.ts";
24 import { getRepo, canAdmin, type Repo } from "../core/repos.ts";
25 import { createUser, type User } from "../core/users.ts";
26 import { err, ok, type Result } from "../kernel/result.ts";
27 import { isMirror } from "../origins/write-through.ts";
28 import { isContributionFork } from "../core/contribute.ts";
29 import { nameSlug } from "./start.ts";
30 import { noEmail } from "./users.ts";
31
32 export type ContributeTokenError =
33 | "invalid_agent"
34 | "invalid_origin"
35 | "invalid_repo"
36 | "not_open"
37 | "rate_limited"
38 | "already_renewed"
39 | "failed";
40
41 export const CONTRIBUTE_TOKEN_WORDS: Readonly<Record<ContributeTokenError, string>> = {
42 invalid_agent: "agent is a name of at most 64 letters, digits, . _ -, starting with a letter or digit",
43 invalid_origin: "origin is where you run from, as plain text of at most 200 characters (claimed, not verified)",
44 invalid_repo: "repo is owner/name",
45 not_open: "this repository does not take outside contributions: it is private, archived, a copy, or its owner has not allowed them",
46 rate_limited: "Too many tokens from here; wait and ask again.",
47 already_renewed: "this token was renewed once already; ask for a new one",
48 failed: "The token could not be made; nothing was kept. Try again.",
49 };
50
51 export const CONTRIBUTE_TOKEN_HINT =
52 "send {agent, repo: \"owner/name\", origin?}: agent is your name, origin where you run (both claimed, not verified)";
53
54 export interface ContributeTokenRefusal {
55 readonly error: ContributeTokenError;
56 readonly retryAfter?: number;
57 }
58
59 /**
60 * Whether `repo` takes outside contributions: public, a real original, not archived,
61 * and its owner said yes.
62 */
63 export function takesOutside(app: App, repo: Repo): boolean {
64 return (
65 repo.visibility === "public" &&
66 repo.sealedFor === null &&
67 repo.archivedAt === null &&
68 !isMirror(app, repo) &&
69 !isContributionFork(app.db, repo.id) &&
70 contributeOpen(app.db, repo.id)
71 );
72 }
73
74 export interface ContributeTokenMade extends Minted {
75 readonly repo: string;
76 readonly agent: string;
77 /** The branches this token may push. */
78 readonly branches: string;
79 readonly git: { readonly url: string; readonly push: string };
80 readonly claimed: true;
81 readonly renewable: true;
82 }
83
84 export async function mintContributeToken(
85 app: App,
86 input: { agent?: unknown; origin?: unknown; repo?: unknown },
87 source: string,
88 origin: string,
89 now: Date = new Date(),
90 ): Promise<Result<ContributeTokenMade, ContributeTokenRefusal>> {
91 const refuse = (error: ContributeTokenError, retryAfter?: number) =>
92 err<ContributeTokenRefusal>(retryAfter === undefined ? { error } : { error, retryAfter });
93 if (!isAgentName(input.agent)) return refuse("invalid_agent");
94 const claimed = input.origin === undefined ? "" : input.origin;
95 if (!isOrigin(claimed)) return refuse("invalid_origin");
96 const [owner, name, ...rest] = typeof input.repo === "string" ? input.repo.split("/") : [];
97 if (!owner || !name || rest.length > 0) return refuse("invalid_repo");
98 // A repository that is missing and one that is closed answer alike.
99 const repo = getRepo(app.db, owner, name);
100 if (repo === undefined || !takesOutside(app, repo)) return refuse("not_open");
101 const agent = input.agent;
102
103 const capped = (): boolean =>
104 mintsSince(app.db, now.getTime() - 3_600_000, source) >= MINTS_PER_HOUR ||
105 mintsSince(app.db, now.getTime() - 86_400_000, source) >= MINTS_PER_DAY;
106 if (capped()) return refuse("rate_limited", 3600);
107
108 const base = nameSlug(agent) || "agent";
109 const username = `contrib-${base}-${crypto.randomUUID().slice(0, 6)}`.slice(0, 39);
110 const made = await createUser(app.db, {
111 origin: "start",
112 username,
113 email: noEmail(username),
114 displayName: agent,
115 trusted: true,
116 });
117 if (!made.ok) return refuse("failed");
118 const user: User = made.value;
119 let minted: Minted | undefined;
120 app.db.transaction(() => {
121 // Counted again inside the write: concurrent calls cannot overshoot.
122 if (capped()) return;
123 minted = mintContribute(app.db, { userId: user.id, repoId: repo.id, agent, origin: claimed, source }, now);
124 })();
125 if (minted === undefined) {
126 // The cap held inside the write: the account made for this mint goes with it.
127 app.db.transaction(() => {
128 app.db.query("DELETE FROM email_check WHERE user_id = ?1").run(user.id);
129 app.db.query("DELETE FROM user WHERE id = ?1").run(user.id);
130 })();
131 return refuse("rate_limited", 3600);
132 }
133 const namespace = contribNamespace(agent, minted.id);
134 app.bus.append({ kind: "contribute.token", repoId: repo.id, actorId: user.id, payload: { agent } });
135 const url = `${origin}/${repo.ownerName}/${repo.name}.git`;
136 return ok({
137 ...minted,
138 repo: `${repo.ownerName}/${repo.name}`,
139 agent,
140 branches: `contrib/${namespace}/*`,
141 git: {
142 url,
143 push: `git push ${url.replace("://", `://${user.username}:${minted.token}@`)} HEAD:contrib/${namespace}/<topic>`,
144 },
145 claimed: true,
146 renewable: true,
147 });
148 }
149
150 /** Extend the token the caller holds by another day, once. */
151 export function renewContributeToken(
152 app: App,
153 tokenId: string,
154 now: Date = new Date(),
155 ): Result<{ expiresAt: string }, ContributeTokenRefusal> {
156 const done = renewContribute(app.db, tokenId, now);
157 if (done.ok) return ok({ expiresAt: done.expiresAt });
158 return err({ error: done.error === "already_renewed" ? "already_renewed" : "failed" });
159 }
160
161 /** A repository admin's yes or no to outside contributions. */
162 export function setOutsideContributions(app: App, actor: User | undefined, repo: Repo, on: boolean): Result<{ on: boolean }, "not_found"> {
163 if (!canAdmin(app.db, repo, actor)) return err("not_found");
164 setContributeOpen(app.db, repo.id, on);
165 app.bus.append({ kind: "contribute.open", repoId: repo.id, actorId: actor!.id, payload: { on } });
166 return ok({ on });
167 }
168
169 /** A repository admin revokes one outside agent's token; every door says so on the bus. */
170 export function revokeOutsideToken(app: App, actor: User | undefined, repo: Repo, tokenId: string): Result<{ revoked: true }, "not_found"> {
171 if (!canAdmin(app.db, repo, actor)) return err("not_found");
172 if (!revokeContribute(app.db, repo.id, tokenId)) return err("not_found");
173 app.bus.append({ kind: "contribute.revoked", repoId: repo.id, actorId: actor!.id, payload: { token: tokenId } });
174 return ok({ revoked: true });
175 }
src/actions/pulls.ts +6
@@ -128,6 +128,11 @@ async function proposalRoute(
128128 }
129129
130130 export interface OpenPullInput {
131 /**
132 * Set by the handler for an outside agent's contribute token (`http/api.ts`):
133 * its limits are the token's, not a guest's.
134 */
135 outsideAgent?: boolean;
131136 title: string;
132137 body?: string;
133138 baseBranch: string;
@@ -173,6 +178,7 @@ export async function openPull(
173178 // A guest who cannot write here sends from their own contribution fork, and
174179 // a low number of pulls a day. Asked first, so a mirror's path never starts.
175180 const guest =
181 input.outsideAgent !== true &&
176182 !requireAbility(app, user.value, "make_it_yours").ok &&
177183 (!canWrite(app.db, baseRepo, user.value) || isContributionFork(app.db, baseRepo.id))
178184 ? takeGuestPull(app, user.value, baseRepo, input.headRepo, Date.now())
src/contract/openapi/routes/index.ts +1
@@ -279,6 +279,7 @@ export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts";
279279 export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts";
280280 export { route as postApiV1Builds } from "./postApiV1Builds.ts";
281281 export { route as postApiV1Codes } from "./postApiV1Codes.ts";
282 export { route as postApiV1Contribute } from "./postApiV1Contribute.ts";
282283 export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts";
283284 export { route as postApiV1ConversationFiles } from "./postApiV1ConversationFiles.ts";
284285 export { route as postApiV1ConversationMessages } from "./postApiV1ConversationMessages.ts";
src/contract/openapi/routes/postApiV1Contribute.ts added +20
@@ -0,0 +1,20 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/contribute",
6 summary:
7 "An outside agent's token with no account: for { agent, repo, origin? } (a repository whose owner allows outside agents) answers 201 with a token that lasts 24 hours, may push contrib/<agent>/* there and open pulls from them, shown once; agent and origin are claimed, not verified. Presenting that token renews it, once",
8 auth: false,
9 role: "anonymous",
10 status: 201,
11 body: { agent: "string", repo: "string", origin: "string" },
12 refusals: {
13 invalid_agent: "agent is not an acceptable name (422)",
14 invalid_origin: "origin is not plain text of at most 200 characters (422)",
15 invalid_repo: "repo is not owner/name (422)",
16 not_open: "no such repository, or one that takes no outside contributions (404)",
17 rate_limited: "too many tokens from this address (429)",
18 already_renewed: "the token was renewed once already (409)",
19 },
20 };
src/core/contribute_token.ts added +251
@@ -0,0 +1,251 @@
1 /**
2 * Contribute tokens (#12012): the credential an outside agent with its own
3 * sandbox (Codex cloud, Jules, Claude Code, Cursor) is handed to contribute by
4 * clone and push. It is an ordinary `rw` token for a throwaway guest account the
5 * forge makes, limited by `http/contribute_gate.ts` to: reading public
6 * repositories, pushing `contrib/<agent>/…` branches on the one repository that
7 * opted in, opening and reading its own pulls there. It expires in a day,
8 * renews once, and the agent's name and origin are kept as claimed, never
9 * verified. `allows` is the scope check: plain data in, a yes or no out.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { isSafeRef } from "../git/guard.ts";
14 import { sha256 } from "../kernel/hash.ts";
15 import { createToken } from "./tokens.ts";
16
17 export interface ContributeScope {
18 readonly tokenId: string;
19 readonly repoId: string;
20 readonly agent: string;
21 /** `<agent>/<segment>`: the branches under `contrib/` this token alone may touch. */
22 readonly namespace: string;
23 }
24
25 /**
26 * A segment of the push namespace that only this token has. The agent name is
27 * claimed, so two tokens may share one; the segment (from the token's random id)
28 * keeps one from force-pushing over or deleting the other's branches.
29 */
30 export const contribSegment = (tokenId: string): string => sha256(`contribute-segment:${tokenId}`).slice(0, 8);
31
32 export const contribNamespace = (agent: string, tokenId: string): string => `${agent}/${contribSegment(tokenId)}`;
33
34 export const CONTRIBUTE_TTL_MS = 24 * 3600_000;
35 /** A renewal never carries a token past this long after it was minted. */
36 export const CONTRIBUTE_MAX_LIFE_MS = 7 * 24 * 3600_000;
37 export const MINTS_PER_HOUR = 5;
38 export const MINTS_PER_DAY = 20;
39 /**
40 * Pulls one token may have open, and may open in a day; and all of a repository's
41 * outside tokens together in a day.
42 */
43 export const OPEN_PULLS_PER_TOKEN = 5;
44 export const PULLS_PER_TOKEN_DAY = 20;
45 export const PULLS_PER_REPO_DAY = 30;
46 export const AGENT_MAX = 64;
47 export const ORIGIN_MAX = 200;
48
49 /** An agent's claimed name: letters, digits, . _ - ; it starts with a letter or digit. */
50 export const isAgentName = (name: unknown): name is string =>
51 typeof name === "string" && name.length <= AGENT_MAX && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && !/\.\./.test(name);
52
53 /** Where the agent says it runs from: short plain text, nothing a page could misread. */
54 export const isOrigin = (origin: unknown): origin is string =>
55 typeof origin === "string" && origin.length <= ORIGIN_MAX && !/[\x00-\x1f\x7f<>]/.test(origin);
56
57 /** Whether `ref` (full `refs/heads/…`) is a branch under `contrib/<agent>/`. */
58 export function inContribNamespace(ref: string, agent: string): boolean {
59 const prefix = `refs/heads/contrib/${agent}/`;
60 if (!ref.startsWith(prefix) || ref.length > 200 || !isSafeRef(ref)) return false;
61 return ref
62 .slice(prefix.length)
63 .split("/")
64 .every((part) => /^[A-Za-z0-9._-]+$/.test(part) && !/^\.+$/.test(part));
65 }
66
67 /** A pull's head branch name (no `refs/heads/`) inside the namespace. */
68 export const headInContribNamespace = (head: string, agent: string): boolean =>
69 inContribNamespace(`refs/heads/${head}`, agent);
70
71 /**
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.
75 */
76 export function pullCapReached(
77 db: Database,
78 repoId: string,
79 userId: string,
80 now: Date,
81 ): "token_open" | "token_day" | "repo_day" | undefined {
82 const count = (sql: string, ...args: string[]): number =>
83 db.query<{ n: number }, string[]>(sql).get(...args)?.n ?? 0;
84 const since = new Date(now.getTime() - 86_400_000).toISOString();
85 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;
96 }
97
98 /** What a request is, as the scope check reads it. */
99 export interface ContributeAsk {
100 readonly method: string;
101 readonly path: string;
102 /** `service` of a git info/refs request. */
103 readonly service?: string | null;
104 /** The pull number the path names, when that pull is the token's own. */
105 readonly ownPull?: boolean;
106 }
107
108 /** Forge-wide reads open to the token. */
109 const FORGE_READS = new Set(["/api/v1/here", "/api/v1/search", "/api/v1/explore", "/api/v1/openapi.json", "/api/v1/contribute"]);
110
111 /**
112 * Whether the token may make this request. `repo` is the one it was minted
113 * for. Allow-listed: a route added tomorrow is closed to it until written here.
114 * Handlers still apply the guest account's own access on top, so a private
115 * repository stays hidden.
116 */
117 export function allows(scope: ContributeScope, repo: { owner: string; name: string }, ask: ContributeAsk): boolean {
118 const read = ask.method === "GET" || ask.method === "HEAD";
119 const path = ask.path.toLowerCase();
120 if (read && FORGE_READS.has(path)) return true;
121 if (ask.method === "POST" && path === "/api/v1/contribute") return true; // renews
122 const at = `/${repo.owner}/${repo.name}`.toLowerCase();
123 const api = `/api/v1/repos${at}`;
124 // The one repository: push, open a pull, and update or read its own.
125 if (read && path === `${at}.git/info/refs` && ask.service === "git-receive-pack") return true;
126 // Any public repository may be read: its API, and a fetch.
127 if (read && path.startsWith("/api/v1/repos/")) return true;
128 if (read && /^\/[^/]+\/[^/]+\.git\/info\/refs$/.test(path)) return ask.service === "git-upload-pack";
129 if (ask.method === "POST" && /^\/[^/]+\/[^/]+\.git\/git-upload-pack$/.test(path)) return true;
130 if (ask.method === "POST" && path === `${at}.git/git-receive-pack`) return true;
131 if (ask.method === "POST" && path === `${api}/pulls`) return true;
132 if ((ask.method === "PATCH" || ask.method === "POST") && ask.ownPull === true && path.startsWith(`${api}/pulls/`)) return true;
133 return false;
134 }
135
136 /** The pull number a path names under `repo`'s pulls, or undefined. */
137 export function pullNumberIn(path: string, repo: { owner: string; name: string }): number | undefined {
138 const head = `/api/v1/repos/${repo.owner}/${repo.name}/pulls/`.toLowerCase();
139 const lower = path.toLowerCase();
140 if (!lower.startsWith(head)) return undefined;
141 const m = /^([1-9][0-9]{0,8})(?:\/|$)/.exec(lower.slice(head.length));
142 return m ? Number(m[1]) : undefined;
143 }
144
145 // ---- the rows ----
146
147 /** A source (an address) is kept only as a hash: enough to count, never to read. */
148 const sourceKey = (source: string): string => sha256(`contribute-source:${source}`).slice(0, 24);
149
150 export const contributeOpen = (db: Database, repoId: string): boolean =>
151 db.query("SELECT 1 FROM contribute_open WHERE repo_id = ?1").get(repoId) !== null;
152
153 export function setContributeOpen(db: Database, repoId: string, on: boolean): void {
154 if (on) db.query("INSERT OR IGNORE INTO contribute_open (repo_id) VALUES (?1)").run(repoId);
155 else db.query("DELETE FROM contribute_open WHERE repo_id = ?1").run(repoId);
156 }
157
158 export function mintsSince(db: Database, since: number, source: string): number {
159 return (
160 db
161 .query<{ n: number }, [string, number]>("SELECT COUNT(*) AS n FROM contribute_mint WHERE source = ?1 AND made_at >= ?2")
162 .get(sourceKey(source), since)?.n ?? 0
163 );
164 }
165
166 export interface Minted {
167 readonly id: string;
168 readonly token: string;
169 readonly expiresAt: string;
170 }
171
172 /** Mint the token for `userId` on `repoId`, and count it against `source`. */
173 export function mintContribute(
174 db: Database,
175 input: { userId: string; repoId: string; agent: string; origin: string; source: string },
176 now: Date,
177 ): Minted {
178 const expiresAt = new Date(now.getTime() + CONTRIBUTE_TTL_MS).toISOString();
179 const minted = createToken(db, input.userId, `contribute:${input.agent}`, "rw", { expiresAt });
180 db.query(
181 "INSERT INTO contribute_token (token_id, repo_id, agent, origin, minted_at) VALUES (?1, ?2, ?3, ?4, ?5)",
182 ).run(minted.id, input.repoId, input.agent, input.origin, now.toISOString());
183 db.query("INSERT INTO contribute_mint (source, made_at) VALUES (?1, ?2)").run(sourceKey(input.source), now.getTime());
184 // The ledger only needs the last day.
185 db.query("DELETE FROM contribute_mint WHERE made_at < ?1").run(now.getTime() - 2 * 24 * 3600_000);
186 return { ...minted, expiresAt };
187 }
188
189 /** Extend a token by another day, once, never past a week from its minting. */
190 export function renewContribute(
191 db: Database,
192 tokenId: string,
193 now: Date,
194 ): { ok: true; expiresAt: string } | { ok: false; error: "already_renewed" | "not_found" } {
195 const row = db
196 .query<{ minted_at: string; renewed: number }, [string]>("SELECT minted_at, renewed FROM contribute_token WHERE token_id = ?1")
197 .get(tokenId);
198 if (row === null) return { ok: false, error: "not_found" };
199 if (row.renewed !== 0) return { ok: false, error: "already_renewed" };
200 const until = Math.min(now.getTime() + CONTRIBUTE_TTL_MS, Date.parse(row.minted_at) + CONTRIBUTE_MAX_LIFE_MS);
201 const expiresAt = new Date(until).toISOString();
202 db.transaction(() => {
203 db.query("UPDATE token SET expires_at = ?1 WHERE id = ?2").run(expiresAt, tokenId);
204 db.query("UPDATE contribute_token SET renewed = 1 WHERE token_id = ?1").run(tokenId);
205 })();
206 return { ok: true, expiresAt };
207 }
208
209 export interface ContributeTokenInfo {
210 readonly id: string;
211 readonly agent: string;
212 /** As the agent claimed it; never verified. */
213 readonly origin: string;
214 readonly mintedAt: string;
215 readonly expiresAt: string;
216 readonly lastUsedAt: string | null;
217 readonly renewed: boolean;
218 }
219
220 /** The live tokens on a repository: unexpired, newest first. */
221 export function listContribute(db: Database, repoId: string, now: Date): ContributeTokenInfo[] {
222 return db
223 .query<
224 { id: string; agent: string; origin: string; minted_at: string; expires_at: string; last_used_at: string | null; renewed: number },
225 [string, string]
226 >(
227 `SELECT c.token_id AS id, c.agent, c.origin, c.minted_at, t.expires_at, t.last_used_at, c.renewed
228 FROM contribute_token c JOIN token t ON t.id = c.token_id
229 WHERE c.repo_id = ?1 AND t.expires_at > ?2 ORDER BY c.minted_at DESC, c.token_id DESC`,
230 )
231 .all(repoId, now.toISOString())
232 .map((r) => ({
233 id: r.id,
234 agent: r.agent,
235 origin: r.origin,
236 mintedAt: r.minted_at,
237 expiresAt: r.expires_at,
238 lastUsedAt: r.last_used_at,
239 renewed: r.renewed === 1,
240 }));
241 }
242
243 /** Revoke one token of `repoId`; false when it is not one of that repository's. */
244 export function revokeContribute(db: Database, repoId: string, tokenId: string): boolean {
245 const own = db
246 .query("SELECT 1 FROM contribute_token WHERE token_id = ?1 AND repo_id = ?2")
247 .get(tokenId, repoId);
248 if (own === null) return false;
249 db.query("DELETE FROM token WHERE id = ?1").run(tokenId);
250 return db.query("SELECT 1 FROM token WHERE id = ?1").get(tokenId) === null;
251 }
src/core/event_audience.ts +2
@@ -270,6 +270,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
270270 kind === "repo.source_added" ||
271271 kind === "repo.source_removed" ||
272272 kind === "repo.crew_outsiders" ||
273 // An outside agent's token and the owner's yes to them are the admins' (#12012).
274 kind.startsWith("contribute.") ||
273275 // A valve turn names a repository and the owner's budget: for those who administer it (`actions/valves.ts`).
274276 kind === "valve.turned" ||
275277 // A clog starting names the forge's own pile-up: the owner's to hear (#6524).
src/core/migration-pins/20261114010000-contribute-tokens.pin added +1
@@ -0,0 +1,1 @@
1 554897fea4417afac1f59ee8109d32bfad31859aee147c859236f3e96c1802af
src/core/migrations/20261114010000-contribute-tokens.ts added +28
@@ -0,0 +1,28 @@
1 // Outside agents' short-lived contribute tokens (#12012). `contribute_open` is a repository
2 // owner's yes to them; `contribute_token` names what an ordinary token is limited to (the
3 // repository, the agent name and origin as claimed, whether it has been renewed); `contribute_mint`
4 // counts mints per hashed address. Deleting a repository deletes its tokens. New tables only.
5 export default `
6 CREATE TABLE contribute_open (
7 repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE
8 );
9 CREATE TABLE contribute_token (
10 token_id TEXT PRIMARY KEY REFERENCES token(id) ON DELETE CASCADE,
11 repo_id TEXT NOT NULL,
12 agent TEXT NOT NULL,
13 origin TEXT NOT NULL,
14 minted_at TEXT NOT NULL,
15 renewed INTEGER NOT NULL DEFAULT 0
16 );
17 CREATE INDEX contribute_token_repo ON contribute_token(repo_id);
18 CREATE TABLE contribute_mint (
19 source TEXT NOT NULL,
20 made_at INTEGER NOT NULL
21 );
22 CREATE INDEX contribute_mint_source ON contribute_mint(source, made_at);
23
24 CREATE TRIGGER contribute_token_repo_gone BEFORE DELETE ON repo
25 BEGIN
26 DELETE FROM token WHERE id IN (SELECT token_id FROM contribute_token WHERE repo_id = OLD.id);
27 END;
28 `;
src/core/tokens.ts +20 −2
@@ -9,6 +9,7 @@ import { sha256 } from "../kernel/hash.ts";
99 import { id, secret } from "../kernel/id.ts";
1010 import { err, ok, type Result } from "../kernel/result.ts";
1111 import type { Proposal } from "./proposal_token.ts";
12 import { contribNamespace, type ContributeScope } from "./contribute_token.ts";
1213 import type { RunScope } from "./run_token.ts";
1314 import { getUserById, isActive, type User } from "./users.ts";
1415
@@ -71,7 +72,7 @@ export function verifyToken(
7172 db: Database,
7273 presented: string,
7374 ): Result<
74 { user: User; scope: TokenScope; name: string; proposal?: Proposal; run?: RunScope; via?: string },
75 { user: User; scope: TokenScope; name: string; proposal?: Proposal; run?: RunScope; via?: string; contribute?: ContributeScope },
7576 "invalid_token"
7677 > {
7778 const row = db
@@ -89,15 +90,19 @@ export function verifyToken(
8990 r_repo: string | null;
9091 r_fork: string | null;
9192 v_via: string | null;
93 c_repo: string | null;
94 c_agent: string | null;
9295 },
9396 [string]
9497 >(
9598 `SELECT t.id, t.user_id, t.name, t.scope, t.last_used_at, t.expires_at,
9699 p.repo_id AS p_repo, p.child AS p_child, p.origin AS p_origin,
97 r.repo_id AS r_repo, r.fork_id AS r_fork, v.via AS v_via
100 r.repo_id AS r_repo, r.fork_id AS r_fork, v.via AS v_via,
101 c.repo_id AS c_repo, c.agent AS c_agent
98102 FROM token t LEFT JOIN proposal_token p ON p.token_id = t.id
99103 LEFT JOIN run_token r ON r.token_id = t.id
100104 LEFT JOIN via_token v ON v.token_id = t.id
105 LEFT JOIN contribute_token c ON c.token_id = t.id
101106 WHERE t.token_hash = ?1`,
102107 )
103108 .get(sha256(presented));
@@ -139,6 +144,19 @@ export function verifyToken(
139144 scope: row.scope,
140145 run: { repoId: row.r_repo, forkId: row.r_fork! },
141146 });
147 // An outside agent's token is bound to one repository (`http/contribute_gate.ts`).
148 if (row.c_repo !== null)
149 return ok({
150 user,
151 name: row.name,
152 scope: row.scope,
153 contribute: {
154 tokenId: row.id,
155 repoId: row.c_repo,
156 agent: row.c_agent!,
157 namespace: contribNamespace(row.c_agent!, row.id),
158 },
159 });
142160 // The forge's own agent, speaking for this person (`core/via_token.ts`).
143161 if (row.v_via !== null) return ok({ user, name: row.name, scope: row.scope, via: row.v_via });
144162 return ok({ user, name: row.name, scope: row.scope });
src/git/hook-protocol.ts +2
@@ -17,6 +17,8 @@ export interface HookRequest {
1717 readonly actorId: string;
1818 /** A proposal token's child: every ref must be inside `child/<name>/`. */
1919 readonly proposalChild?: string;
20 /** An outside agent's name: every ref must be inside `contrib/<agent>/`. */
21 readonly contribAgent?: string;
2022 readonly updates: readonly RefUpdate[];
2123 /**
2224 * Quarantine environment forwarded from the hook process. Any git command
src/git/hooks.ts +6
@@ -62,6 +62,8 @@ export function hookEnv(input: {
6262 actorId: string;
6363 /** Set for a proposal token: the one namespace its push may touch. */
6464 proposalChild?: string;
65 /** Set for an outside agent's contribute token: the one namespace its push may touch. */
66 contribAgent?: string;
6567 }): Record<string, string> {
6668 const self = selfInvocation();
6769 const env: Record<string, string> = {
@@ -73,6 +75,7 @@ export function hookEnv(input: {
7375 };
7476 if (input.proposalChild !== undefined)
7577 env.FORGE_PROPOSAL_CHILD = input.proposalChild;
78 if (input.contribAgent !== undefined) env.FORGE_CONTRIB_AGENT = input.contribAgent;
7679 if (self.entry !== undefined) env.FORGE_ENTRY = self.entry;
7780 return env;
7881 }
@@ -127,6 +130,9 @@ export async function runHookClient(hook: HookName): Promise<never> {
127130 ...(process.env.FORGE_PROPOSAL_CHILD !== undefined
128131 ? { proposalChild: process.env.FORGE_PROPOSAL_CHILD }
129132 : {}),
133 ...(process.env.FORGE_CONTRIB_AGENT !== undefined
134 ? { contribAgent: process.env.FORGE_CONTRIB_AGENT }
135 : {}),
130136 updates: parseHookStdin(stdin),
131137 gitEnv,
132138 }),
src/http/api.ts +47 −2
@@ -193,6 +193,12 @@ import {
193193 pullProposalOf,
194194 recordPullProposal,
195195 } from "../core/proposal_token.ts";
196 import { OPEN_PULLS_PER_TOKEN, headInContribNamespace, pullCapReached } from "../core/contribute_token.ts";
197
198 /** Tokens with a pull being opened now: one at a time, so the count cannot be raced. */
199 const contributePulling = new Set<string>();
200 import { takesOutside } from "../actions/contribute_token.ts";
201 import { contributeMint } from "./api/contribute_token.ts";
196202 import {
197203 getUserByName,
198204 hiddenFrom,
@@ -1326,6 +1332,9 @@ export function apiRoutes(router: Router<Handler>): void {
13261332 return timePhase(ctx.request, "render", () => json(body));
13271333 });
13281334
1335 // An outside agent's short-lived token (#12012): mint, or renew the one presented.
1336 router.add("POST", "/api/v1/contribute", contributeMint);
1337
13291338 boardRoutes(router);
13301339 displayRoutes(router);
13311340
@@ -2657,6 +2666,10 @@ export function apiRoutes(router: Router<Handler>): void {
26572666 if (denied) return denied;
26582667 const repo = loadRepo(ctx);
26592668 if (!repo) return notFound();
2669 // An outside agent's token opens a pull from its own contrib/<agent>/… branch of this
2670 // repository, never from another repository, and never past what it was minted for.
2671 const outside = ctx.auth.contribute;
2672 if (outside !== undefined && (outside.repoId !== repo.id || !takesOutside(ctx.app, repo))) return forbidden();
26602673 // A proposal token's body is small, and its pull comes from a branch of
26612674 // its own child's namespace in this repository, never from another one.
26622675 const proposal = ctx.auth.proposal;
@@ -2669,6 +2682,15 @@ export function apiRoutes(router: Router<Handler>): void {
26692682 : await boundedBody(ctx.request, PROPOSAL_BODY_MAX);
26702683 if (input === "too_large")
26712684 return apiError("too_large", 413, `at most ${PROPOSAL_BODY_MAX} bytes`);
2685 if (
2686 outside !== undefined &&
2687 (str(input.headRepo) !== "" || !headInContribNamespace(str(input.head), outside.namespace))
2688 )
2689 return apiError(
2690 "invalid_head",
2691 422,
2692 `a contribute pull comes from a contrib/${outside.namespace}/… branch of this repository`,
2693 );
26722694 if (
26732695 proposal !== undefined &&
26742696 (str(input.headRepo) !== "" ||
@@ -2695,15 +2717,38 @@ export function apiRoutes(router: Router<Handler>): void {
26952717 if (run !== undefined && headRepo.id !== run.forkId && headRepo.id !== run.repoId)
26962718 return notFound();
26972719 }
2698 const created = await openPull(ctx.app, ctx.auth.user, repo, {
2699 title: str(input.title),
2720 // An outside agent opens a few pulls: each starts checks and a preview on the
2721 // 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, {
2740 title: str(input.title),
27002741 body: str(input.body),
27012742 baseBranch: str(input.base),
27022743 headBranch: str(input.head),
27032744 ...(headRepo ? { headRepo } : {}),
27042745 // A person's deliberate yes to fixing a sealed report from a public branch.
27052746 ...(input.confirmPublic === true ? { confirmPublic: true } : {}),
2747 ...(outside === undefined ? {} : { outsideAgent: true }),
27062748 });
2749 } finally {
2750 if (outside !== undefined) contributePulling.delete(outside.tokenId);
2751 }
27072752 if (!created.ok && created.error === "sealed_in_public")
27082753 return apiError(
27092754 "sealed_in_public",
src/http/api/contribute_token.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * `POST /api/v1/contribute` (#12012): an outside agent names itself and a
3 * repository that takes outside contributions and gets a token for a day, with
4 * no account. With a contribute token presented, the same call renews that
5 * token once instead. The rules and limits are `actions/contribute_token.ts`.
6 * A browser page from another site is refused: an agent is not one.
7 */
8 import {
9 CONTRIBUTE_TOKEN_HINT,
10 CONTRIBUTE_TOKEN_WORDS,
11 mintContributeToken,
12 renewContributeToken,
13 } from "../../actions/contribute_token.ts";
14 import { REQUEST_MAX } from "../../core/reports.ts";
15 import { crossOrigin, type Handler } from "../context.ts";
16 import { clientBucket } from "../peer.ts";
17 import { apiError, boundedBody, json, tryAgain } from "../respond.ts";
18
19 export const contributeMint: Handler = async (ctx) => {
20 if (crossOrigin(ctx.request, ctx.url, ctx.app.log))
21 return apiError("forbidden", 403, "send this call from your agent, not from a page on another site");
22 const noStore = { "cache-control": "no-store" };
23 const held = ctx.auth.contribute;
24 if (held !== undefined) {
25 const renewed = renewContributeToken(ctx.app, held.tokenId);
26 if (renewed.ok) return json({ renewed: true, expiresAt: renewed.value.expiresAt }, 200, noStore);
27 return apiError(renewed.error.error, 409, CONTRIBUTE_TOKEN_WORDS[renewed.error.error]);
28 }
29 // Any other credential is not an outside agent's: the call is for those without one.
30 if (ctx.auth.user !== undefined) return apiError("forbidden", 403, "you are signed in: use your own token to push");
31 const input = await boundedBody(ctx.request, REQUEST_MAX);
32 if (input === "too_large") return apiError("too_large", 413, CONTRIBUTE_TOKEN_HINT);
33 const made = await mintContributeToken(ctx.app, input, clientBucket(ctx.request), ctx.url.origin);
34 if (made.ok) return json(made.value, 201, noStore);
35 const refusal = made.error;
36 const said = CONTRIBUTE_TOKEN_WORDS[refusal.error];
37 if (refusal.error === "rate_limited") return tryAgain("rate_limited", 429, said, refusal.retryAfter ?? 3600);
38 if (refusal.error === "not_open") return apiError("not_open", 404, said);
39 if (refusal.error === "failed") return apiError("failed", 500, said);
40 return apiError(refusal.error, 422, `${said}; ${CONTRIBUTE_TOKEN_HINT}`);
41 };
src/http/context.ts +3
@@ -3,6 +3,7 @@
33 * route params, and authentication resolved once as plain data.
44 */
55
6 import type { ContributeScope } from "../core/contribute_token.ts";
67 import type { Proposal } from "../core/proposal_token.ts";
78 import type { App } from "../app.ts";
89 import type { RunScope } from "../core/run_token.ts";
@@ -29,6 +30,8 @@ export interface Auth {
2930 /** Set for a crew run's token: the repositories it may reach
3031 * (`http/run_gate.ts`). */
3132 readonly run?: RunScope;
33 /** Set for an outside agent's contribute token (`http/contribute_gate.ts`). */
34 readonly contribute?: ContributeScope;
3235 /** Set for a via token: the forge's own agent, speaking for this person in
3336 * this conversation (`conversation:<id>`, `core/via_token.ts`). */
3437 readonly through?: string;
src/http/contribute_gate.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * The one gate every contribute-token request passes, before any handler
3 * (#12012). The token (`core/contribute_token.ts`) reads public repositories,
4 * pushes and opens pulls on the one repository it was minted for, and reads or
5 * updates its own pulls there; `allows` is the list, and everything else
6 * answers `forbidden`. Its requests are limited per token per minute. Which
7 * refs a push may touch is decided at the ref update (`handleHook`), and which
8 * branch a pull may come from, in the pulls handler.
9 */
10
11 import type { App } from "../app.ts";
12 import { allows, pullNumberIn } from "../core/contribute_token.ts";
13 import { getPull } from "../core/pulls.ts";
14 import { getRepoById } from "../core/repos.ts";
15 import { RateLimiter } from "../core/ratelimit.ts";
16 import type { Auth } from "./context.ts";
17 import { apiError, tryAgain } from "./respond.ts";
18
19 const limiters = new WeakMap<App, RateLimiter>();
20
21 /** Requests per token per minute. Not switched off by FORGE_RATE_LIMIT=0. */
22 export const CONTRIBUTE_PER_MINUTE = 120;
23
24 function limiter(app: App): RateLimiter {
25 let one = limiters.get(app);
26 if (one === undefined) {
27 one = new RateLimiter({ perMinute: CONTRIBUTE_PER_MINUTE, adminPerMinute: CONTRIBUTE_PER_MINUTE, enabled: true });
28 limiters.set(app, one);
29 }
30 return one;
31 }
32
33 const refuse = (): Response =>
34 apiError(
35 "forbidden",
36 403,
37 "a contribute token reads public repositories, pushes contrib/<agent>/<token>/… branches on its repository and opens or updates its own pulls there",
38 );
39
40 /** The refusal for this request, or undefined when it may proceed. */
41 export function contributeGate(app: App, request: Request, url: URL, auth: Auth): Response | undefined {
42 const scope = auth.contribute;
43 if (scope === undefined) return undefined;
44 const repo = getRepoById(app.db, scope.repoId);
45 if (repo === undefined) return refuse();
46 const verdict = limiter(app).take(scope.tokenId, "rw");
47 if (!verdict.allowed)
48 return tryAgain(
49 "rate_limited",
50 429,
51 `too many requests: ${verdict.limit} per minute per token; retry in ${verdict.retryAfter}s`,
52 verdict.retryAfter,
53 );
54 const at = { owner: repo.ownerName, name: repo.name };
55 const number = pullNumberIn(url.pathname, at);
56 const pull = number === undefined ? undefined : getPull(app.db, repo.id, number);
57 const ownPull = pull !== undefined && pull.authorId === auth.user?.id;
58 const ok = allows(scope, at, {
59 method: request.method,
60 path: url.pathname,
61 service: url.searchParams.get("service"),
62 ownPull,
63 });
64 return ok ? undefined : refuse();
65 }
src/http/mcp.ts +12 −1
@@ -34,6 +34,7 @@ import { TOKEN_FREE_TOOLS } from "../core/mcp_card.ts";
3434 import { proofOf } from "./report_filer.ts";
3535 import { fileReport } from "../actions/reports.ts";
3636 import { START_HINT, START_WORDS, startBuilding } from "../actions/start.ts";
37 import { CONTRIBUTE_TOKEN_HINT, CONTRIBUTE_TOKEN_WORDS, mintContributeToken } from "../actions/contribute_token.ts";
3738 import { REPORT_RECEIVED } from "../actions/report_words.ts";
3839 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
3940 import { everydayOf } from "../core/everyday.ts";
@@ -317,8 +318,8 @@ export const START_TOOL = {
317318 description: { type: "string", maxLength: 2000 },
318319 agent: { type: "string", maxLength: 64 },
319320 code: { type: "string", maxLength: 100 },
321 action: { type: "string", enum: ["contribute"] },
320322 },
321 required: ["name"],
322323 },
323324 } as const;
324325
@@ -408,6 +409,16 @@ async function startAnswer(ctx: Ctx, id: unknown, args: Args): Promise<unknown>
408409 id,
409410 result: { content: [{ type: "text", text: t }], ...(isError ? { isError } : {}) },
410411 });
412 if (args["action"] === "contribute") {
413 const made = await mintContributeToken(ctx.app, args, clientBucket(ctx.request), ctx.url.origin);
414 if (made.ok) return text(JSON.stringify(made.value));
415 const said = CONTRIBUTE_TOKEN_WORDS[made.error.error];
416 return text(
417 `${made.error.error}: ${said}${made.error.retryAfter === undefined ? "" : ` (try again in ${made.error.retryAfter} seconds)`} ${CONTRIBUTE_TOKEN_HINT}`,
418 true,
419 );
420 }
421 if (typeof args["name"] !== "string") return text(`invalid_name: ${START_WORDS.invalid_name} ${START_HINT}`, true);
411422 const started = await startBuilding(ctx.app, args, clientBucket(ctx.request), ctx.url.origin);
412423 if (started.ok) return text(JSON.stringify(started.value));
413424 const e = started.error;
src/http/server.ts +26 −2
@@ -498,11 +498,14 @@ import { targetForShortCode } from "../origins/oauth.ts";
498498 import { apiRoutes } from "./api.ts";
499499 import { pageParamRefused } from "./cursor_lists.ts";
500500 import { inNamespace } from "../core/proposal_token.ts";
501 import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts";
501 import { inContribNamespace } from "../core/contribute_token.ts";
502 import { takesOutside } from "../actions/contribute_token.ts";
503 import { contributeOpenSet, contributeTokenRevoke, proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts";
502504 import { adminGate } from "./admin_gate.ts";
503505 import { movedResponse } from "./moved.ts";
504506 import { originGate } from "./origin_gate.ts";
505507 import { proposalGate } from "./proposal_gate.ts";
508 import { contributeGate } from "./contribute_gate.ts";
506509 import { runGate } from "./run_gate.ts";
507510 import { viaGate } from "./via_gate.ts";
508511 import { checksRecheckRoutes } from "./checks_recheck.ts";
@@ -693,6 +696,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
693696 tokenName: verified.value.name,
694697 ...(verified.value.proposal ? { proposal: verified.value.proposal } : {}),
695698 ...(verified.value.run ? { run: verified.value.run } : {}),
699 ...(verified.value.contribute ? { contribute: verified.value.contribute } : {}),
696700 ...(verified.value.via ? { through: verified.value.via } : {}),
697701 };
698702 }
@@ -721,6 +725,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
721725 ? { proposal: verified.value.proposal }
722726 : {}),
723727 ...(verified.value.run ? { run: verified.value.run } : {}),
728 ...(verified.value.contribute ? { contribute: verified.value.contribute } : {}),
724729 ...(verified.value.via ? { through: verified.value.via } : {}),
725730 };
726731 }
@@ -1213,6 +1218,7 @@ export function buildRouter(): Router<Handler> {
12131218 repoId,
12141219 actorId: ctx.auth.user?.id ?? "",
12151220 ...(ctx.auth.proposal ? { proposalChild: ctx.auth.proposal.child } : {}),
1221 ...(ctx.auth.contribute ? { contribAgent: ctx.auth.contribute.namespace } : {}),
12161222 }),
12171223 ...cap,
12181224 },
@@ -1582,6 +1588,8 @@ export function buildRouter(): Router<Handler> {
15821588 "/:owner/:name/settings/proposal-tokens/:id/delete",
15831589 proposalTokenRevoke,
15841590 );
1591 router.add("POST", "/:owner/:name/settings/contribute-tokens/open", contributeOpenSet);
1592 router.add("POST", "/:owner/:name/settings/contribute-tokens/:id/delete", contributeTokenRevoke);
15851593 router.add("GET", "/:owner/:name/settings/app", appSettingsPage);
15861594 router.add("GET", "/:owner/:name/settings/app/traffic", appSourcesPage);
15871595 router.add("GET", "/:owner/:name/settings/app/statement", statementPage);
@@ -1787,8 +1795,11 @@ async function withGitAccess(
17871795 if (write) {
17881796 if (!ctx.auth.user) return gitChallenge();
17891797 if (isPacksMirror(repo) && canRead(ctx.app.db, repo, ctx.auth.user)) return gitRefused(ctx, MIRROR_PUSH_REFUSED);
1798 // An outside agent's token may push its one repository;
1799 // which refs is the hook's (`contribAgent`).
1800 const outside = ctx.auth.contribute?.repoId === repo.id && takesOutside(ctx.app, repo);
17901801 if (
1791 !canWrite(ctx.app.db, repo, ctx.auth.user) ||
1802 !(outside || canWrite(ctx.app.db, repo, ctx.auth.user)) ||
17921803 !scopeAllows(ctx.auth.scope ?? "ro", "rw")
17931804 ) {
17941805 return notFound();
@@ -2108,6 +2119,16 @@ async function handleHook(app: App, request: Request): Promise<Response> {
21082119 { status: 403 },
21092120 );
21102121 }
2122 // An outside agent's token pushes inside `contrib/<agent>/` and nowhere else.
2123 if (
2124 input.contribAgent !== undefined &&
2125 !inContribNamespace(update.ref, input.contribAgent)
2126 ) {
2127 return new Response(
2128 `${update.ref}: this token pushes only contrib/${input.contribAgent}/… branches`,
2129 { status: 403 },
2130 );
2131 }
21112132 // The pull namespace belongs to the forge, never to pushers.
21122133 if (update.ref.startsWith("refs/pull/")) {
21132134 return new Response(`${update.ref}: refs/pull/* is reserved`, {
@@ -3019,6 +3040,9 @@ async function answerGet(
30193040 // request it makes ends here, before the limiter or any handler.
30203041 const proposalRefusal = proposalGate(app, request, url, auth);
30213042 if (proposalRefusal !== undefined) return proposalRefusal;
3043 // An outside agent's token reaches public reads and its one repository.
3044 const contributeRefusal = contributeGate(app, request, url, auth);
3045 if (contributeRefusal !== undefined) return contributeRefusal;
30223046 // A crew run's token reaches its own repository and nothing else.
30233047 const runRefusal = runGate(app, request, url, auth);
30243048 if (runRefusal !== undefined) return runRefusal;
src/tui/screens/outsideagents.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * Outside agents, in the terminal (#12012): the settings page's block
3 * (`web/pages/proposal-tokens.ts`) from the same actions
4 * (`actions/contribute_token.ts`), gated the same way: a repository's admins.
5 * `a` allows outside agents or stops allowing them; `x` revokes the token under
6 * the cursor, after a confirm. Each write is an event on the bus, so an open
7 * settings page redraws, and this screen follows a write made there.
8 */
9
10 import { revokeOutsideToken, setOutsideContributions, takesOutside } from "../../actions/contribute_token.ts";
11 import { contributeOpen, listContribute, type ContributeTokenInfo } from "../../core/contribute_token.ts";
12 import { canAdmin, type Repo } from "../../core/repos.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import type { Command } from "../app/palette.ts";
16 import type { Session } from "../session.ts";
17 import { style } from "../term/style.ts";
18 import { truncate } from "../term/width.ts";
19 import { fill, len, split } from "../ui/layout.ts";
20 import { ListState, confirmScreen, drawList, later, live, type ListRow } from "./common.ts";
21
22 /** Settings › Outside agents' gate: an admin of the repository. */
23 export const mayManageOutside = (f: Session, repo: Repo): boolean => f.user !== undefined && canAdmin(f.db, repo, f.user);
24
25 /**
26 * The live tokens, for an admin; none for anyone else, and none while outside
27 * agents are not allowed.
28 */
29 export function outsideRows(f: Session, repo: Repo): ContributeTokenInfo[] {
30 return mayManageOutside(f, repo) && contributeOpen(f.db, repo.id) ? listContribute(f.db, repo.id, new Date()) : [];
31 }
32
33 export function outsideAgentsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
34 const state = new ListState();
35 const where = `${repo.ownerName}/${repo.name}`;
36 const screen: Screen = {
37 title: `${where} — Outside agents`,
38 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "allow or stop allowing"), binding(["x"], "revoke token")],
39 async load(signal) {
40 live(f, signal, { repoId: repo.id, where: (e) => e.kind.startsWith("contribute.") }, () => ctx.invalidate());
41 },
42 render(area, s) {
43 const c = ctx.theme.c;
44 const may = mayManageOutside(f, repo);
45 const open = contributeOpen(f.db, repo.id);
46 const [head, list] = split(area, "v", [len(1), fill(1)]);
47 const said = !may
48 ? "An admin of this repository manages outside agents."
49 : !open
50 ? "Outside agents may not ask for a token. a allows them."
51 : takesOutside(f.app, repo)
52 ? "Outside agents may ask for a day's token. a stops allowing them; x revokes."
53 : "Allowed, but not offered now: only a public, original, unarchived repository takes them.";
54 s.text(head!.x, head!.y, truncate(said, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
55 const rows: ListRow[] = outsideRows(f, repo).map((t) => ({
56 title: t.agent,
57 note: `${t.origin === "" ? "no origin given" : t.origin} · claimed, not verified`,
58 meta: `expires ${t.expiresAt.slice(0, 16).replace("T", " ")}`,
59 }));
60 drawList(s, list!, c, state, rows);
61 },
62 key(e) {
63 const all = outsideRows(f, repo);
64 if (state.key(e, all.length)) return true;
65 if (e.ctrl || e.alt || (e.key !== "a" && e.key !== "x")) return false;
66 if (!mayManageOutside(f, repo)) {
67 ctx.toast("An admin of this repository manages outside agents.", "danger");
68 return true;
69 }
70 if (e.key === "a") {
71 const on = !contributeOpen(f.db, repo.id);
72 const set = setOutsideContributions(f.app, f.user, repo, on);
73 ctx.toast(
74 !set.ok
75 ? "An admin of this repository manages outside agents."
76 : on
77 ? "Outside agents may ask for a token."
78 : "Outside agents may no longer ask. Tokens already made work until revoked.",
79 set.ok ? undefined : "danger",
80 );
81 ctx.invalidate();
82 return true;
83 }
84 const at = all[state.index];
85 if (at === undefined) return true;
86 void later(ctx, () =>
87 confirmScreen(ctx, screen, {
88 title: "Revoke token",
89 body: [`${at.agent}'s token stops working now. Pulls it opened stay.`],
90 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Revoke" }],
91 selected: 0,
92 onChoose: (i) => {
93 if (i !== 1) return;
94 const done = revokeOutsideToken(f.app, f.user, repo, at.id);
95 ctx.toast(done.ok ? `${at.agent}'s token revoked.` : "No such token.", done.ok ? undefined : "danger");
96 ctx.invalidate();
97 },
98 }),
99 );
100 return true;
101 },
102 };
103 return screen;
104 }
105
106 /** The palette's command while a repository the viewer administers is open. */
107 export function outsideAgentsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
108 if (repo === undefined || !mayManageOutside(f, repo)) return [];
109 return [
110 {
111 id: "outside-agents",
112 title: `${repo.ownerName}/${repo.name} — Outside agents`,
113 hint: "allow outside agents' day tokens, see and revoke the live ones",
114 run: () => ctx.push(outsideAgentsScreen(f, ctx, repo)),
115 },
116 ];
117 }
src/tui/screens/workspace.ts +2
@@ -171,6 +171,7 @@ import { variantsCommands } from "./variants.ts";
171171 import { autonomyCommands } from "./autonomy.ts";
172172 import { trafficCommands } from "./trafficview.ts";
173173 import { chaptersCommands } from "./chapters.ts";
174 import { outsideAgentsCommands } from "./outsideagents.ts";
174175 import { schedulesCommands } from "./schedules.ts";
175176 import { landCommands } from "./landasis.ts";
176177 import { shiftCommands } from "./subtitle_shift.ts";
@@ -2003,6 +2004,7 @@ export function workspaceScreen(
20032004 ...autonomyCommands(f, ctx, repo),
20042005 ...trafficCommands(f, ctx, repo),
20052006 ...schedulesCommands(f, ctx, repo),
2007 ...outsideAgentsCommands(f, ctx, repo),
20062008 ...landCommands(f, ctx, repo),
20072009 ...shiftCommands(f, ctx, repo, () => screen),
20082010 ...dataImportCommands(f, ctx, repo),
src/web/pages/outside-agents-live.ts added +22
@@ -0,0 +1,22 @@
1 /**
2 * Settings: the outside agents' block (#12012) follows tokens made or revoked from any
3 * door without a reload. Its own stream, only on a settings page that has the block, so
4 * the repository page's script carries none of it.
5 */
6 export const outsideAgentsJs = /* js */ `
7 (function () {
8 if (!document.getElementById('contribute-tokens')) return;
9 var es = new EventSource(location.pathname.split('/').slice(0, 3).join('/') + '/stream');
10 function refresh() {
11 fetch(location.pathname).then(function (r) { return r.text(); }).then(function (t) {
12 var h = document.createElement('div');
13 h.innerHTML = t;
14 var mine = document.getElementById('contribute-tokens'), next = h.querySelector('#contribute-tokens');
15 if (mine && next && mine.innerHTML !== next.innerHTML) mine.replaceWith(next);
16 }).catch(function () {});
17 }
18 ['contribute.open', 'contribute.token', 'contribute.revoked'].forEach(function (k) {
19 es.addEventListener(k, refresh);
20 });
21 })();
22 `;
src/web/pages/proposal-tokens.ts +68 −2
@@ -6,21 +6,60 @@
66 */
77
88 import { listProposals, mintProposal, revokeProposal } from "../../actions/proposal_tokens.ts";
9 import { canWrite, type Repo } from "../../core/repos.ts";
9 import { revokeOutsideToken, setOutsideContributions, takesOutside } from "../../actions/contribute_token.ts";
10 import { contributeOpen, listContribute } from "../../core/contribute_token.ts";
11 import { canAdmin, canWrite, type Repo } from "../../core/repos.ts";
1012 import { isAccountItself, type Ctx } from "../../http/context.ts";
1113 import { loadRepo, repoBase, repoNotFound, NOT_YOURS_TO_DO, forbiddenPage } from "../chrome.ts";
1214 import { field, readForm } from "../form.ts";
1315 import { html, type Html } from "../html.ts";
16 import { liveMount } from "../live.ts";
1417 import { flashTo } from "../layout.ts";
1518 import { btn, confirmAction, field as formField, time } from "../ui.ts";
1619
20 /**
21 * Outside agents' contribute tokens (#12012): the owner's yes, and each live token
22 * with Revoke (after a confirm). Admins only.
23 */
24 function contributeTokensSection(ctx: Ctx, repo: Repo): Html | null {
25 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return null;
26 const base = repoBase(repo);
27 const open = contributeOpen(ctx.app.db, repo.id);
28 const tokens = open ? listContribute(ctx.app.db, repo.id, new Date()) : [];
29 return html`<section class="settings-block mb" id="contribute-tokens">
30 <form method="post" action="${base}/settings/contribute-tokens/open">
31 <input type="hidden" name="on" value="${open ? "0" : "1"}" />
32 <p class="muted"><strong>Outside agents</strong> with their own sandbox may ask for a token that lasts a day, push <code>contrib/&lt;name&gt;/…</code> branches here and open pulls, with no account. Names and origins are claimed, not verified.${open && !takesOutside(ctx.app, repo)
33 ? " Not offered now: only a public, original, unarchived repository takes them."
34 : ""} ${btn(open ? "Stop allowing" : "Allow", { variant: open ? "secondary" : "primary", size: "sm" })}</p>
35 </form>
36 ${tokens.length === 0
37 ? null
38 : html`<dl class="status-rows">
39 ${tokens.map(
40 (t) => html`<div class="status-row">
41 <dt>${t.agent}</dt>
42 <dd>
43 <span class="mono">${t.origin === "" ? "no origin given" : t.origin}</span>
44 <span class="muted">· claimed, not verified · expires ${time(t.expiresAt)} · last used ${t.lastUsedAt ? time(t.lastUsedAt) : "never"}</span>
45 </dd>
46 <dd class="status-act">
47 ${confirmAction({ action: `${base}/settings/contribute-tokens/${t.id}/delete`, verb: "Revoke", size: "sm" })}
48 </dd>
49 </div>`,
50 )}
51 </dl>`}
52 </section>`;
53 }
54
1755 /** The section, or null for someone who cannot write here. */
1856 export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null {
1957 if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return null;
58 const outside = contributeTokensSection(ctx, repo);
2059 const base = repoBase(repo);
2160 const listed = listProposals(ctx.app, ctx.auth.user, repo);
2261 const tokens = listed.ok ? listed.value : [];
23 return html`<section class="settings-block" id="proposal-tokens">
62 return html`${outside}${outside === null ? null : liveMount({ repo: base })}<section class="settings-block" id="proposal-tokens">
2463 <h2>Proposal tokens</h2>
2564 <p class="muted">Lets one child forge fork this code, push <code>child/&lt;name&gt;/…</code> branches and open pulls. Lasts 30 days.</p>
2665 ${tokens.length === 0
@@ -95,3 +134,30 @@ export async function proposalTokenRevoke(ctx: Ctx): Promise<Response> {
95134 ? { message: "Proposal token revoked." }
96135 : { tone: "danger", message: "No such proposal token." });
97136 }
137
138 // POST /:owner/:name/settings/contribute-tokens/open
139 export async function contributeOpenSet(ctx: Ctx): Promise<Response> {
140 const repo = loadRepo(ctx);
141 if (!repo) return repoNotFound(ctx);
142 const form = await readForm(ctx, "rw");
143 if (form instanceof Response) return form;
144 const on = field(form, "on") === "1";
145 const set = setOutsideContributions(ctx.app, ctx.auth.user, repo, on);
146 if (!set.ok) return forbiddenPage(ctx, NOT_YOURS_TO_DO);
147 return flashTo(ctx, `${repoBase(repo)}/settings`, {
148 message: on ? "Outside agents may ask for a token for this repository." : "Outside agents may no longer ask for a token. Tokens already made still work until revoked.",
149 });
150 }
151
152 // POST /:owner/:name/settings/contribute-tokens/:id/delete
153 export async function contributeTokenRevoke(ctx: Ctx): Promise<Response> {
154 const repo = loadRepo(ctx);
155 if (!repo) return repoNotFound(ctx);
156 const form = await readForm(ctx, "rw");
157 if (form instanceof Response) return form;
158 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return forbiddenPage(ctx, NOT_YOURS_TO_DO);
159 const revoked = revokeOutsideToken(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
160 return flashTo(ctx, `${repoBase(repo)}/settings`, revoked.ok
161 ? { message: "Outside agent's token revoked." }
162 : { tone: "danger", message: "No such token." });
163 }
src/web/pages/search.ts +9
@@ -667,6 +667,15 @@ const DESTRUCTIVE: {
667667 phrase: "",
668668 }),
669669 },
670 {
671 pattern: /^\/([^/]+)\/([^/]+)\/settings\/contribute-tokens\/([^/]+)\/delete$/,
672 describe: (m) => ({
673 verb: "Revoke",
674 title: "Revoke this outside agent's token?",
675 detail: `The agent can no longer push contrib/<name>/… branches or open pulls on ${m[1]}/${m[2]}. Pulls it already opened stay.`,
676 phrase: "",
677 }),
678 },
670679 {
671680 pattern: /^\/([^/]+)\/([^/]+)\/settings\/mirror\/stop$/,
672681 describe: (m, ctx) => {
src/web/pages/settings.ts +3 −1
@@ -111,6 +111,8 @@ import {
111111 tlEvent,
112112 } from "../ui.ts";
113113 import { cutWaveFor } from "../../actions/cut_wave.ts";
114 import { liveClientJs } from "../live.ts";
115 import { outsideAgentsJs } from "./outside-agents-live.ts";
114116 import { proposalTokensSection } from "./proposal-tokens.ts";
115117 import { secretsSection } from "./secrets.ts";
116118 import { checkMemoryAt } from "../../apps/check-memory.ts";
@@ -204,7 +206,7 @@ export function settingsPageResponse(
204206 crumbs: crumbsFor(repo),
205207 palette: repoPalette(ctx, repo),
206208 ...(styleCss !== undefined ? { styleCss } : {}),
207 scriptJs: teamJs,
209 scriptJs: `${teamJs}\n${liveClientJs}\n${outsideAgentsJs}`,
208210 body: html`${repoHeader(ctx, repo, "settings", { compact: true })}
209211 ${settingsLayout(sections(repoBase(repo), active), body)}`,
210212 },
test/contribute-token.test.ts added +373
@@ -0,0 +1,373 @@
1 /**
2 * An outside agent's short-lived contribute token (#12012): mint, renew, revoke,
3 * expiry, refusal for a repository that does not take outside contributions,
4 * the scope check, and the doors (HTTP, git push, a pull, MCP).
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { mintContributeToken, renewContributeToken, setOutsideContributions } from "../src/actions/contribute_token.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import {
14 CONTRIBUTE_MAX_LIFE_MS,
15 CONTRIBUTE_TTL_MS,
16 MINTS_PER_HOUR,
17 OPEN_PULLS_PER_TOKEN,
18 PULLS_PER_REPO_DAY,
19 contribNamespace,
20 contribSegment,
21 allows,
22 headInContribNamespace,
23 inContribNamespace,
24 pullCapReached,
25 isAgentName,
26 listContribute,
27 revokeContribute,
28 } from "../src/core/contribute_token.ts";
29 import type { Repo } from "../src/core/repos.ts";
30 import { verifyToken } from "../src/core/tokens.ts";
31 import { createUser, type User } from "../src/core/users.ts";
32 import { serve, type Servers } from "../src/http/server.ts";
33 import { gitClientEnv } from "./helpers/git-client.ts";
34 import { quietLogs } from "./helpers/globals.ts";
35
36 quietLogs();
37
38 const scope = { tokenId: "t", repoId: "r", agent: "codex", namespace: "codex/abcd1234" };
39 const lib = { owner: "alice", name: "lib" };
40
41 describe("the scope check", () => {
42 const ask = (method: string, path: string, extra: object = {}) => allows(scope, lib, { method, path, ...extra });
43
44 test("reads public things, pushes and opens pulls on its one repository, and nothing else", () => {
45 expect(ask("GET", "/api/v1/repos/bob/other")).toBe(true);
46 expect(ask("GET", "/bob/other.git/info/refs", { service: "git-upload-pack" })).toBe(true);
47 expect(ask("POST", "/bob/other.git/git-upload-pack")).toBe(true);
48 expect(ask("GET", "/alice/lib.git/info/refs", { service: "git-receive-pack" })).toBe(true);
49 expect(ask("POST", "/alice/lib.git/git-receive-pack")).toBe(true);
50 expect(ask("POST", "/api/v1/repos/alice/lib/pulls")).toBe(true);
51 expect(ask("PATCH", "/api/v1/repos/alice/lib/pulls/3", { ownPull: true })).toBe(true);
52
53 expect(ask("PATCH", "/api/v1/repos/alice/lib/pulls/3")).toBe(false); // not its own
54 expect(ask("POST", "/bob/other.git/git-receive-pack")).toBe(false);
55 expect(ask("GET", "/bob/other.git/info/refs", { service: "git-receive-pack" })).toBe(false);
56 expect(ask("POST", "/api/v1/repos/bob/other/pulls")).toBe(false);
57 expect(ask("POST", "/api/v1/repos/alice/lib/proposal-tokens")).toBe(false);
58 expect(ask("DELETE", "/api/v1/repos/alice/lib")).toBe(false);
59 expect(ask("GET", "/api/v1/user")).toBe(false);
60 expect(ask("POST", "/mcp")).toBe(false);
61 });
62
63 test("its branches are contrib/<agent>/ and hostile names are outside it", () => {
64 expect(inContribNamespace("refs/heads/contrib/codex/abcd1234/fix-1", "codex/abcd1234")).toBe(true);
65 expect(headInContribNamespace("contrib/codex/abcd1234/a/b", "codex/abcd1234")).toBe(true);
66 for (const ref of [
67 "refs/heads/main",
68 "refs/heads/contrib/other/x",
69 "refs/heads/contrib/codex/abcd1234",
70 "refs/heads/contrib/codex/fix-1", // no segment: another token's ground
71 "refs/heads/contrib/codex/ffff0000/fix-1", // another token's segment
72 "refs/heads/contrib/codex/abcd1234/../../main",
73 "refs/heads/contrib/codex/abcd1234/a b",
74 "refs/tags/contrib/codex/abcd1234/x",
75 "refs/pull/1/head",
76 ])
77 expect(inContribNamespace(ref, "codex/abcd1234")).toBe(false);
78 for (const bad of ["", "-x", "a/b", "a b", "a..b", "x".repeat(65)]) expect(isAgentName(bad)).toBe(false);
79 expect(isAgentName("Codex_cloud-2")).toBe(true);
80 });
81 });
82
83 describe("minting, renewing, revoking", () => {
84 let dataDir: string;
85 let app: App;
86 let servers: Servers;
87 let alice: User;
88 let pub: Repo;
89 let closed: Repo;
90 let work: string;
91 const now = new Date("2026-11-14T00:00:00Z");
92
93 const git = async (args: string[], cwd = work) => {
94 const proc = Bun.spawn(["git", "-c", "credential.helper=", ...args], {
95 cwd,
96 env: gitClientEnv({ GIT_TERMINAL_PROMPT: "0" }),
97 stdout: "pipe",
98 stderr: "pipe",
99 });
100 const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
101 return { code: await proc.exited, out, err };
102 };
103 const post = (path: string, body: unknown, token?: string, headers: Record<string, string> = {}) =>
104 fetch(`${servers.url}${path}`, {
105 method: "POST",
106 headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers },
107 body: JSON.stringify(body),
108 });
109
110 beforeAll(async () => {
111 dataDir = await mkdtemp(join(tmpdir(), "forge-contrib-token-"));
112 app = createApp({ dataDir, port: 0, host: "forge.test" });
113 servers = await serve(app);
114 const made = await createUser(app.db, {
115 origin: "seed",
116 trusted: true,
117 username: "alice",
118 email: "[email protected]",
119 password: "a-good-passphrase",
120 adminIfFirst: true,
121 });
122 if (!made.ok) throw new Error(made.error);
123 alice = made.value;
124 for (const [name, visibility] of [["lib", "public"], ["secret", "private"]] as const) {
125 const r = await createRepo(app, alice, { name, visibility });
126 if (!r.ok) throw new Error(String(r.error));
127 if (name === "lib") pub = r.value;
128 else closed = r.value;
129 const seeded = await commitToBranch(app, alice, r.value, {
130 branch: "main",
131 message: "Start",
132 files: [{ path: "README.md", content: "hi\n" }],
133 });
134 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
135 }
136 work = join(dataDir, "work");
137 expect((await git(["clone", `${servers.url}/alice/lib.git`, work], dataDir)).code).toBe(0);
138 await git(["config", "user.email", "[email protected]"]);
139 await git(["config", "user.name", "codex"]);
140 await Bun.write(join(work, "fix.md"), "a fix\n");
141 await git(["add", "."]);
142 await git(["commit", "-m", "A fix"]);
143 });
144
145 afterAll(async () => {
146 await servers.stop();
147 await rm(dataDir, { recursive: true, force: true });
148 });
149
150 const mint = (repo: string, agent = "codex", source = "203.0.113.1", at = now) =>
151 mintContributeToken(app, { agent, origin: "codex cloud, claimed", repo }, source, "https://forge.test", at);
152
153 test("a repository that does not allow outside contributions is refused, and so is every other that cannot", async () => {
154 // Not opted in yet.
155 const first = await mint("alice/lib", "codex", "198.51.100.9");
156 expect(first.ok).toBe(false);
157 expect(!first.ok && first.error.error).toBe("not_open");
158 // A missing one answers alike.
159 const none = await mint("alice/nope", "codex", "198.51.100.9");
160 expect(!none.ok && none.error.error).toBe("not_open");
161 // A private repository does not take them even when the owner said yes.
162 setOutsideContributions(app, alice, closed, true);
163 const priv = await mint("alice/secret", "codex", "198.51.100.9");
164 expect(!priv.ok && priv.error.error).toBe("not_open");
165 // Bad input is named.
166 const bad = await mintContributeToken(app, { agent: "a b", repo: "alice/lib" }, "198.51.100.9", "https://forge.test", now);
167 expect(!bad.ok && bad.error.error).toBe("invalid_agent");
168 const badOrigin = await mintContributeToken(app, { agent: "x", origin: "<script>", repo: "alice/lib" }, "198.51.100.9", "https://forge.test", now);
169 expect(!badOrigin.ok && badOrigin.error.error).toBe("invalid_origin");
170 });
171
172 test("an owner's yes lets it mint; the token expires, renews once, and is revoked", async () => {
173 expect(setOutsideContributions(app, alice, pub, true).ok).toBe(true);
174 const made = await mint("alice/lib");
175 if (!made.ok) throw new Error(JSON.stringify(made.error));
176 expect(made.value.branches).toBe(`contrib/codex/${contribSegment(made.value.id)}/*`);
177 expect(Date.parse(made.value.expiresAt) - now.getTime()).toBe(CONTRIBUTE_TTL_MS);
178
179 const verified = verifyToken(app.db, made.value.token);
180 expect(verified.ok && verified.value.contribute).toEqual({
181 tokenId: made.value.id,
182 repoId: pub.id,
183 agent: "codex",
184 namespace: contribNamespace("codex", made.value.id),
185 });
186 expect(verified.ok && verified.value.user.isAdmin).toBe(false);
187
188 // Listed with what it claimed.
189 const listed = listContribute(app.db, pub.id, now);
190 expect(listed.map((t) => [t.agent, t.origin])).toEqual([["codex", "codex cloud, claimed"]]);
191
192 // Renews once, a day on.
193 const later = new Date(now.getTime() + 20 * 3600_000);
194 const renewed = renewContributeToken(app, made.value.id, later);
195 expect(renewed.ok && Date.parse(renewed.value.expiresAt)).toBe(later.getTime() + CONTRIBUTE_TTL_MS);
196 expect(Date.parse((renewed.ok && renewed.value.expiresAt) || "")).toBeLessThanOrEqual(now.getTime() + CONTRIBUTE_MAX_LIFE_MS);
197 const again = renewContributeToken(app, made.value.id, later);
198 expect(!again.ok && again.error.error).toBe("already_renewed");
199
200 // Expiry is seen: not listed after, and the token no longer verifies.
201 app.db.query("UPDATE token SET expires_at = ?1 WHERE id = ?2").run(new Date(Date.now() - 1000).toISOString(), made.value.id);
202 expect(verifyToken(app.db, made.value.token).ok).toBe(false);
203 expect(listContribute(app.db, pub.id, new Date()).length).toBe(0);
204
205 // Revoke: another repository's id cannot revoke it; its own can.
206 const second = await mint("alice/lib", "jules", "203.0.113.2");
207 if (!second.ok) throw new Error("mint");
208 expect(revokeContribute(app.db, closed.id, second.value.id)).toBe(false);
209 expect(verifyToken(app.db, second.value.token).ok).toBe(true);
210 expect(revokeContribute(app.db, pub.id, second.value.id)).toBe(true);
211 expect(verifyToken(app.db, second.value.token).ok).toBe(false);
212 });
213
214 test("minting is limited per address", async () => {
215 for (let i = 0; i < MINTS_PER_HOUR; i++) expect((await mint("alice/lib", "busy", "192.0.2.7")).ok).toBe(true);
216 const over = await mint("alice/lib", "busy", "192.0.2.7");
217 expect(!over.ok && over.error.error).toBe("rate_limited");
218 expect((await mint("alice/lib", "busy", "192.0.2.8")).ok).toBe(true);
219 });
220
221 test("over HTTP: mint with no account, push under contrib/<agent>/ only, open a pull, renew once", async () => {
222 const res = await post("/api/v1/contribute", { agent: "cursor", repo: "alice/lib", origin: "cursor cloud" });
223 expect(res.status).toBe(201);
224 const made = (await res.json()) as { token: string; git: { url: string }; expiresAt: string };
225 expect(made.token).toStartWith("forge_");
226 expect(res.headers.get("cache-control")).toBe("no-store");
227
228 const closedRes = await post("/api/v1/contribute", { agent: "cursor", repo: "alice/secret" });
229 expect(closedRes.status).toBe(404);
230 expect((await post("/api/v1/contribute", { agent: "", repo: "alice/lib" })).status).toBe(422);
231
232 const verifiedMade = verifyToken(app.db, made.token) as { ok: true; value: { user: User; contribute: { namespace: string } } };
233 const user = verifiedMade.value.user.username;
234 const ns = verifiedMade.value.contribute.namespace;
235 const remote = (ref: string) => git(["push", `${servers.url.replace("://", `://${user}:${made.token}@`)}/alice/lib.git`, `HEAD:${ref}`]);
236 const toMain = await remote("main");
237 expect(toMain.code).not.toBe(0);
238 expect(toMain.err).toContain(`this token pushes only contrib/${ns}/`);
239 expect((await remote("refs/heads/contrib/other/x")).code).not.toBe(0);
240 expect((await remote(`refs/heads/contrib/${ns}/../../main`)).code).not.toBe(0);
241 // Another repository is not its own.
242 const elsewhere = await git(["push", `${servers.url.replace("://", `://${user}:${made.token}@`)}/alice/secret.git`, `HEAD:refs/heads/contrib/${ns}/x`]);
243 expect(elsewhere.code).not.toBe(0);
244 const landed = await remote(`refs/heads/contrib/${ns}/fix`);
245 expect(landed.err).not.toContain("refused");
246 expect(landed.code).toBe(0);
247
248 const auth = { authorization: `Bearer ${made.token}` };
249 const opened = await post("/api/v1/repos/alice/lib/pulls", { title: "A fix", base: "main", head: `contrib/${ns}/fix` }, made.token);
250 expect(opened.status).toBe(201);
251 const pull = (await opened.json()) as { number: number };
252 // Its head must be its own namespace.
253 expect((await post("/api/v1/repos/alice/lib/pulls", { title: "x", base: "main", head: "main" }, made.token)).status).toBe(422);
254 // It reads its pull back, checks included, but may not merge or reach anything else.
255 const read = await fetch(`${servers.url}/api/v1/repos/alice/lib/pulls/${pull.number}`, { headers: auth });
256 expect(read.status).toBe(200);
257 expect((await post(`/api/v1/repos/alice/lib/pulls/${pull.number}/merge`, {}, made.token)).status).not.toBe(200);
258 expect((await fetch(`${servers.url}/api/v1/tokens`, { headers: auth })).status).toBe(403);
259 expect((await post("/mcp", { jsonrpc: "2.0", id: 1, method: "tools/list" }, made.token)).status).toBe(403);
260
261 // Renews once through the same call.
262 const renewed = await post("/api/v1/contribute", {}, made.token);
263 expect(renewed.status).toBe(200);
264 expect(((await renewed.json()) as { renewed: boolean }).renewed).toBe(true);
265 expect((await post("/api/v1/contribute", {}, made.token)).status).toBe(409);
266
267 // The owner revokes it from the settings list: the token stops working.
268 const listed = listContribute(app.db, pub.id, new Date()).find((t) => t.agent === "cursor")!;
269 expect(revokeContribute(app.db, pub.id, listed.id)).toBe(true);
270 expect((await fetch(`${servers.url}/api/v1/repos/alice/lib`, { headers: auth })).status).toBe(200); // public, as anyone
271 expect((await post("/api/v1/repos/alice/lib/pulls", { title: "y", base: "main", head: `contrib/${ns}/fix` }, made.token)).status).toBe(401);
272 });
273
274 test("an owner's no ends the offer and the token's push", async () => {
275 const res = await post("/api/v1/contribute", { agent: "late", repo: "alice/lib" }, undefined);
276 const made = (await res.json()) as { token: string };
277 setOutsideContributions(app, alice, pub, false);
278 expect((await post("/api/v1/contribute", { agent: "again", repo: "alice/lib" })).status).toBe(404);
279 expect((await post("/api/v1/repos/alice/lib/pulls", { title: "z", base: "main", head: "contrib/late/x" }, made.token)).status).toBe(403);
280 setOutsideContributions(app, alice, pub, true);
281 });
282
283 test("the start tool's contribute action mints through MCP with no token", async () => {
284 const res = await post("/mcp", {
285 jsonrpc: "2.0",
286 id: 1,
287 method: "tools/call",
288 params: { name: "start", arguments: { action: "contribute", agent: "jules", repo: "alice/lib", origin: "jules.google" } },
289 }, undefined, { accept: "application/json, text/event-stream" });
290 const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
291 expect(body.result.isError).toBeUndefined();
292 const made = JSON.parse(body.result.content[0]!.text) as { token: string; branches: string };
293 expect(made.branches).toMatch(/^contrib\/jules\/[0-9a-f]{8}\/\*$/);
294 expect(made.token).toStartWith("forge_");
295 });
296
297 test("two tokens that claim the same name cannot push over, or delete, each other's branches", async () => {
298 setOutsideContributions(app, alice, pub, true);
299 const a = await mint("alice/lib", "twin", "203.0.113.50");
300 const b = await mint("alice/lib", "twin", "203.0.113.51");
301 if (!a.ok || !b.ok) throw new Error("mint");
302 const nsA = contribNamespace("twin", a.value.id);
303 const nsB = contribNamespace("twin", b.value.id);
304 expect(nsA).not.toBe(nsB);
305 const as = (t: typeof a.value) => {
306 const verified = verifyToken(app.db, t.token) as { ok: true; value: { user: User } };
307 return `${servers.url.replace("://", `://${verified.value.user.username}:${t.token}@`)}/alice/lib.git`;
308 };
309 expect((await git(["push", as(a.value), `HEAD:refs/heads/contrib/${nsA}/work`])).code).toBe(0);
310 await git(["commit", "--allow-empty", "-m", "Another head"]);
311 // B pushes into A's namespace, force-pushes over its branch, and deletes it: all refused.
312 expect((await git(["push", "--force", as(b.value), `HEAD:refs/heads/contrib/${nsA}/work`])).code).not.toBe(0);
313 expect((await git(["push", as(b.value), `:refs/heads/contrib/${nsA}/work`])).code).not.toBe(0);
314 // The bare claimed name, with no segment, is nobody's.
315 expect((await git(["push", as(b.value), "HEAD:refs/heads/contrib/twin/work"])).code).not.toBe(0);
316 expect((await git(["push", as(b.value), `HEAD:refs/heads/contrib/${nsB}/work`])).code).toBe(0);
317 // A's branch is still there, and A may delete its own.
318 const heads = (await git(["ls-remote", as(a.value)])).out;
319 expect(heads).toContain(`refs/heads/contrib/${nsA}/work`);
320 expect((await git(["push", as(a.value), `:refs/heads/contrib/${nsA}/work`])).code).toBe(0);
321 });
322
323 test("a token opens at most a few pulls, and a repository takes a day's worth from all of them", async () => {
324 setOutsideContributions(app, alice, pub, true);
325 const made = await mint("alice/lib", "spam", "203.0.113.60");
326 if (!made.ok) throw new Error("mint");
327 const ns = contribNamespace("spam", made.value.id);
328 const verified = verifyToken(app.db, made.value.token) as { ok: true; value: { user: User } };
329 const url = `${servers.url.replace("://", `://${verified.value.user.username}:${made.value.token}@`)}/alice/lib.git`;
330 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);
332 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);
334 const over = await open(OPEN_PULLS_PER_TOKEN);
335 expect(over.status).toBe(429);
336 expect(over.headers.get("retry-after")).not.toBeNull();
337 // Counted from the rows: closing one frees a place.
338 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);
340
341 // The repository's own day: pulls by many outside tokens together.
342 const others: User[] = [];
343 for (let i = 0; others.length < Math.ceil(PULLS_PER_REPO_DAY / 4); i++) {
344 const m = await mint("alice/lib", `many${i}`, `198.18.0.${i}`);
345 if (!m.ok) throw new Error("mint");
346 others.push((verifyToken(app.db, m.value.token) as { ok: true; value: { user: User } }).value.user);
347 }
348 let n = 1000;
349 for (const u of others)
350 for (let k = 0; k < 4; k++)
351 app.db
352 .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, ?2, ?3, 'pull', 'x', ?4, 'closed')")
353 .run(`seed-${n}`, pub.id, n++, u.id);
354 expect(pullCapReached(app.db, pub.id, others[0]!.id, new Date())).toBe("repo_day");
355 const late = await mint("alice/lib", "late2", "198.19.0.1");
356 if (!late.ok) throw new Error("mint");
357 const lateUser = (verifyToken(app.db, late.value.token) as { ok: true; value: { user: User } }).value.user;
358 expect(pullCapReached(app.db, pub.id, lateUser.id, new Date())).toBe("repo_day");
359 // A day later the count is clear.
360 expect(pullCapReached(app.db, pub.id, lateUser.id, new Date(Date.now() + 2 * 86_400_000))).toBeUndefined();
361 });
362
363 test("a mint that loses the race to the cap leaves no account behind", async () => {
364 const source = "203.0.113.77";
365 for (let i = 0; i < MINTS_PER_HOUR - 1; i++) expect((await mint("alice/lib", "pre", source)).ok).toBe(true);
366 const users = () => app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM user").get()!.n;
367 const before = users();
368 const both = await Promise.all([mint("alice/lib", "race", source), mint("alice/lib", "race", source)]);
369 expect(both.filter((r) => r.ok).length).toBe(1);
370 // One account for the one token, none for the refused mint.
371 expect(users()).toBe(before + 1);
372 });
373 });
test/fuzz-mcp.test.ts +3 −2
@@ -45,7 +45,7 @@ const RANDOM_CASES = 4;
4545
4646 type Schema = { type?: string | string[]; properties?: Record<string, Schema>; required?: string[]; items?: Schema; enum?: unknown[] };
4747
48 const BIG = "x".repeat(10 * 1024);
48 const BIG = "x".repeat(8 * 1024);
4949 const nested = (depth: number): unknown => (depth === 0 ? { a: "../" } : { a: nested(depth - 1), b: [nested(0)] });
5050
5151 /** Hostile values in no particular type, for any field. */
@@ -101,7 +101,8 @@ function wrongFor(schema: Schema): unknown[] {
101101 function cases(name: string, schema: Schema, r: Rand): unknown[] {
102102 const props = Object.entries(schema.properties ?? {});
103103 const all: unknown[] = [undefined, {}, "not-an-object", [1, 2], null];
104 // Every field at once: each wrong type, a 10 kB string, a `../` path.
104 // Every field at once: each wrong type, an 8 kB string, a `../` path.
105 // All fields at once must stay under the 64 KiB anonymous body cap.
105106 for (const pickAt of [0, 3, 4]) {
106107 const args: Record<string, unknown> = {};
107108 for (const [k, s] of props) args[k] = wrongFor(s)[pickAt % wrongFor(s).length];
test/signup-gate-parent.test.ts +1
@@ -110,6 +110,7 @@ async function callers(): Promise<Record<string, string[]>> {
110110 test("every caller of createUser says where its account comes from", async () => {
111111 // A new way in is added here on purpose: a stranger's door, or a named exemption.
112112 expect(await callers()).toEqual({
113 "src/actions/contribute_token.ts": ["start"],
113114 "src/actions/crew.ts": ["agent"],
114115 "src/actions/first_admin.ts": ["first-admin"],
115116 "src/actions/spawn.ts": ["spawn", "spawn"],
test/tui-outside-agents.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * Outside agents in the terminal (#12012): the settings page's block, from the
3 * same actions and the same gate (a repository's admins). A write made here
4 * reaches the bus, which is what redraws an open settings page.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { mintContributeToken } from "../src/actions/contribute_token.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { contributeOpen, listContribute } from "../src/core/contribute_token.ts";
13 import { createRepo, type Repo } from "../src/core/repos.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { createUser, getUserByName, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { outsideAgentsCommands, outsideAgentsScreen } from "../src/tui/screens/outsideagents.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { adminCookie } from "./helpers/admin.ts";
21 import { settleApp } from "./helpers/tui.ts";
22
23 let dir: string;
24 let app: App;
25 let servers: Servers;
26 let admin: User;
27 let bob: User;
28 let repo: Repo;
29 let adminHeader: string;
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-outside-tui-"));
33 app = createApp({ dataDir: dir, port: 0 });
34 servers = await serve(app);
35 adminHeader = await adminCookie(app, { username: "trevato", email: "[email protected]", password: "a-good-passphrase" });
36 admin = getUserByName(app.db, "trevato")!;
37 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
38 if (!made.ok) throw new Error(made.error);
39 bob = made.value;
40 createSession(app.db, bob.id);
41 const r = await createRepo(app.db, app.reposRoot, { ownerId: admin.id, name: "lib", visibility: "public", description: "", defaultBranch: "main" });
42 if (!r.ok) throw new Error(r.error);
43 repo = r.value;
44 });
45 afterAll(async () => {
46 await servers.stop();
47 app.close();
48 await rm(dir, { recursive: true, force: true });
49 });
50
51 const key = (k: string) => ({ type: "key" as const, key: k, ctrl: false, alt: false, shift: false });
52 function open(user: User) {
53 const { app: tui, session } = createForgeApp(
54 app,
55 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
56 { terminal: headlessTerminal(120, 40), exitHooks: false },
57 );
58 tui.start();
59 tui.ctx.push(outsideAgentsScreen(session, tui.ctx, repo));
60 return { tui, session };
61 }
62 const web = async (path: string, cookie: string) => await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
63
64 test("an admin allows, sees a token, and revokes it, and each write is an event the page follows", async () => {
65 const events: string[] = [];
66 const off = app.bus.subscribe((e) => {
67 if (e.repoId === repo.id) events.push(e.kind);
68 });
69 const { tui, session } = open(admin);
70 await settleApp(tui);
71 expect(tui.screen.toText()).toContain("Outside agents may not ask for a token");
72 expect(await web("/trevato/lib/settings", adminHeader)).toContain("Allow");
73
74 tui.dispatch(key("a"));
75 await settleApp(tui);
76 expect(contributeOpen(app.db, repo.id)).toBe(true);
77 expect(events).toContain("contribute.open");
78 expect(await web("/trevato/lib/settings", adminHeader)).toContain("Stop allowing");
79
80 const made = await mintContributeToken(app, { agent: "codex", origin: "codex cloud", repo: "trevato/lib" }, "203.0.113.5", "https://forge.test");
81 if (!made.ok) throw new Error(JSON.stringify(made.error));
82 tui.ctx.invalidate();
83 await settleApp(tui);
84 expect(tui.screen.toText()).toContain("codex");
85 expect(tui.screen.toText()).toContain("claimed, not verified");
86
87 // Revoke asks first: cancelling keeps the token, confirming ends it.
88 tui.dispatch(key("x"));
89 await settleApp(tui);
90 expect(tui.screen.toText()).toContain("Revoke token");
91 tui.dispatch(key("esc"));
92 await settleApp(tui);
93 expect(listContribute(app.db, repo.id, new Date()).length).toBe(1);
94 tui.dispatch(key("x"));
95 await settleApp(tui);
96 tui.dispatch(key("right"));
97 tui.dispatch(key("enter"));
98 await settleApp(tui);
99 expect(listContribute(app.db, repo.id, new Date()).length).toBe(0);
100 expect(events).toContain("contribute.revoked");
101
102 tui.dispatch(key("a"));
103 await settleApp(tui);
104 expect(contributeOpen(app.db, repo.id)).toBe(false);
105 expect(outsideAgentsCommands(session, tui.ctx, repo).length).toBe(1);
106 off();
107 tui.stop?.();
108 });
109
110 test("only a repository's admin has the screen's keys and its palette entry", async () => {
111 const { tui, session } = open(bob);
112 await settleApp(tui);
113 expect(outsideAgentsCommands(session, tui.ctx, repo)).toEqual([]);
114 expect(tui.screen.toText()).toContain("An admin of this repository manages outside agents.");
115 tui.dispatch(key("a"));
116 await settleApp(tui);
117 expect(contributeOpen(app.db, repo.id)).toBe(false);
118 tui.stop?.();
119 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs