Public: anyone can read this; the crew can change it.
An outside agent gets a short-lived contribute token with no account #12033
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12012-an-outside-agent-gets-a-short-lived-cont → main 12 commits fork
30 files
changed +1444
−14
AGENTS.md +1 −1
Diff not shown — a line 2,618 characters wide (limit 2,000). +1 −1
View filedocs/MCP.md +1 −1
| @@ -238,7 +238,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 238 | 238 | | `whoami_can` | `role`, `passkeys` | What you can do: your role and its roles-table row, yes or no each | 231 | |
| 239 | 239 | | `write_file` | — | No shell? Commit one file to a branch | 342 | |
| 240 | 240 | | `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 | | |
| 242 | 242 | <!-- mcp-tools:end --> |
| 243 | 243 | |
| 244 | 244 | 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( | ||
| 128 | 128 | } |
| 129 | 129 | |
| 130 | 130 | 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; | |
| 131 | 136 | title: string; |
| 132 | 137 | body?: string; |
| 133 | 138 | baseBranch: string; |
| @@ -173,6 +178,7 @@ export async function openPull( | ||
| 173 | 178 | // A guest who cannot write here sends from their own contribution fork, and |
| 174 | 179 | // a low number of pulls a day. Asked first, so a mirror's path never starts. |
| 175 | 180 | const guest = |
| 181 | input.outsideAgent !== true && | |
| 176 | 182 | !requireAbility(app, user.value, "make_it_yours").ok && |
| 177 | 183 | (!canWrite(app.db, baseRepo, user.value) || isContributionFork(app.db, baseRepo.id)) |
| 178 | 184 | ? 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"; | ||
| 279 | 279 | export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts"; |
| 280 | 280 | export { route as postApiV1Builds } from "./postApiV1Builds.ts"; |
| 281 | 281 | export { route as postApiV1Codes } from "./postApiV1Codes.ts"; |
| 282 | export { route as postApiV1Contribute } from "./postApiV1Contribute.ts"; | |
| 282 | 283 | export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts"; |
| 283 | 284 | export { route as postApiV1ConversationFiles } from "./postApiV1ConversationFiles.ts"; |
| 284 | 285 | 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 { | ||
| 270 | 270 | kind === "repo.source_added" || |
| 271 | 271 | kind === "repo.source_removed" || |
| 272 | 272 | 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.") || | |
| 273 | 275 | // A valve turn names a repository and the owner's budget: for those who administer it (`actions/valves.ts`). |
| 274 | 276 | kind === "valve.turned" || |
| 275 | 277 | // 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"; | ||
| 9 | 9 | import { id, secret } from "../kernel/id.ts"; |
| 10 | 10 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 11 | 11 | import type { Proposal } from "./proposal_token.ts"; |
| 12 | import { contribNamespace, type ContributeScope } from "./contribute_token.ts"; | |
| 12 | 13 | import type { RunScope } from "./run_token.ts"; |
| 13 | 14 | import { getUserById, isActive, type User } from "./users.ts"; |
| 14 | 15 | |
| @@ -71,7 +72,7 @@ export function verifyToken( | ||
| 71 | 72 | db: Database, |
| 72 | 73 | presented: string, |
| 73 | 74 | ): 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 }, | |
| 75 | 76 | "invalid_token" |
| 76 | 77 | > { |
| 77 | 78 | const row = db |
| @@ -89,15 +90,19 @@ export function verifyToken( | ||
| 89 | 90 | r_repo: string | null; |
| 90 | 91 | r_fork: string | null; |
| 91 | 92 | v_via: string | null; |
| 93 | c_repo: string | null; | |
| 94 | c_agent: string | null; | |
| 92 | 95 | }, |
| 93 | 96 | [string] |
| 94 | 97 | >( |
| 95 | 98 | `SELECT t.id, t.user_id, t.name, t.scope, t.last_used_at, t.expires_at, |
| 96 | 99 | 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 | |
| 98 | 102 | FROM token t LEFT JOIN proposal_token p ON p.token_id = t.id |
| 99 | 103 | LEFT JOIN run_token r ON r.token_id = t.id |
| 100 | 104 | LEFT JOIN via_token v ON v.token_id = t.id |
| 105 | LEFT JOIN contribute_token c ON c.token_id = t.id | |
| 101 | 106 | WHERE t.token_hash = ?1`, |
| 102 | 107 | ) |
| 103 | 108 | .get(sha256(presented)); |
| @@ -139,6 +144,19 @@ export function verifyToken( | ||
| 139 | 144 | scope: row.scope, |
| 140 | 145 | run: { repoId: row.r_repo, forkId: row.r_fork! }, |
| 141 | 146 | }); |
| 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 | }); | |
| 142 | 160 | // The forge's own agent, speaking for this person (`core/via_token.ts`). |
| 143 | 161 | if (row.v_via !== null) return ok({ user, name: row.name, scope: row.scope, via: row.v_via }); |
| 144 | 162 | return ok({ user, name: row.name, scope: row.scope }); |
src/git/hook-protocol.ts +2
| @@ -17,6 +17,8 @@ export interface HookRequest { | ||
| 17 | 17 | readonly actorId: string; |
| 18 | 18 | /** A proposal token's child: every ref must be inside `child/<name>/`. */ |
| 19 | 19 | readonly proposalChild?: string; |
| 20 | /** An outside agent's name: every ref must be inside `contrib/<agent>/`. */ | |
| 21 | readonly contribAgent?: string; | |
| 20 | 22 | readonly updates: readonly RefUpdate[]; |
| 21 | 23 | /** |
| 22 | 24 | * Quarantine environment forwarded from the hook process. Any git command |
src/git/hooks.ts +6
| @@ -62,6 +62,8 @@ export function hookEnv(input: { | ||
| 62 | 62 | actorId: string; |
| 63 | 63 | /** Set for a proposal token: the one namespace its push may touch. */ |
| 64 | 64 | proposalChild?: string; |
| 65 | /** Set for an outside agent's contribute token: the one namespace its push may touch. */ | |
| 66 | contribAgent?: string; | |
| 65 | 67 | }): Record<string, string> { |
| 66 | 68 | const self = selfInvocation(); |
| 67 | 69 | const env: Record<string, string> = { |
| @@ -73,6 +75,7 @@ export function hookEnv(input: { | ||
| 73 | 75 | }; |
| 74 | 76 | if (input.proposalChild !== undefined) |
| 75 | 77 | env.FORGE_PROPOSAL_CHILD = input.proposalChild; |
| 78 | if (input.contribAgent !== undefined) env.FORGE_CONTRIB_AGENT = input.contribAgent; | |
| 76 | 79 | if (self.entry !== undefined) env.FORGE_ENTRY = self.entry; |
| 77 | 80 | return env; |
| 78 | 81 | } |
| @@ -127,6 +130,9 @@ export async function runHookClient(hook: HookName): Promise<never> { | ||
| 127 | 130 | ...(process.env.FORGE_PROPOSAL_CHILD !== undefined |
| 128 | 131 | ? { proposalChild: process.env.FORGE_PROPOSAL_CHILD } |
| 129 | 132 | : {}), |
| 133 | ...(process.env.FORGE_CONTRIB_AGENT !== undefined | |
| 134 | ? { contribAgent: process.env.FORGE_CONTRIB_AGENT } | |
| 135 | : {}), | |
| 130 | 136 | updates: parseHookStdin(stdin), |
| 131 | 137 | gitEnv, |
| 132 | 138 | }), |
src/http/api.ts +47 −2
| @@ -193,6 +193,12 @@ import { | ||
| 193 | 193 | pullProposalOf, |
| 194 | 194 | recordPullProposal, |
| 195 | 195 | } 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"; | |
| 196 | 202 | import { |
| 197 | 203 | getUserByName, |
| 198 | 204 | hiddenFrom, |
| @@ -1326,6 +1332,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1326 | 1332 | return timePhase(ctx.request, "render", () => json(body)); |
| 1327 | 1333 | }); |
| 1328 | 1334 | |
| 1335 | // An outside agent's short-lived token (#12012): mint, or renew the one presented. | |
| 1336 | router.add("POST", "/api/v1/contribute", contributeMint); | |
| 1337 | ||
| 1329 | 1338 | boardRoutes(router); |
| 1330 | 1339 | displayRoutes(router); |
| 1331 | 1340 | |
| @@ -2657,6 +2666,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2657 | 2666 | if (denied) return denied; |
| 2658 | 2667 | const repo = loadRepo(ctx); |
| 2659 | 2668 | 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(); | |
| 2660 | 2673 | // A proposal token's body is small, and its pull comes from a branch of |
| 2661 | 2674 | // its own child's namespace in this repository, never from another one. |
| 2662 | 2675 | const proposal = ctx.auth.proposal; |
| @@ -2669,6 +2682,15 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2669 | 2682 | : await boundedBody(ctx.request, PROPOSAL_BODY_MAX); |
| 2670 | 2683 | if (input === "too_large") |
| 2671 | 2684 | 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 | ); | |
| 2672 | 2694 | if ( |
| 2673 | 2695 | proposal !== undefined && |
| 2674 | 2696 | (str(input.headRepo) !== "" || |
| @@ -2695,15 +2717,38 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2695 | 2717 | if (run !== undefined && headRepo.id !== run.forkId && headRepo.id !== run.repoId) |
| 2696 | 2718 | return notFound(); |
| 2697 | 2719 | } |
| 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), | |
| 2700 | 2741 | body: str(input.body), |
| 2701 | 2742 | baseBranch: str(input.base), |
| 2702 | 2743 | headBranch: str(input.head), |
| 2703 | 2744 | ...(headRepo ? { headRepo } : {}), |
| 2704 | 2745 | // A person's deliberate yes to fixing a sealed report from a public branch. |
| 2705 | 2746 | ...(input.confirmPublic === true ? { confirmPublic: true } : {}), |
| 2747 | ...(outside === undefined ? {} : { outsideAgent: true }), | |
| 2706 | 2748 | }); |
| 2749 | } finally { | |
| 2750 | if (outside !== undefined) contributePulling.delete(outside.tokenId); | |
| 2751 | } | |
| 2707 | 2752 | if (!created.ok && created.error === "sealed_in_public") |
| 2708 | 2753 | return apiError( |
| 2709 | 2754 | "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 @@ | ||
| 3 | 3 | * route params, and authentication resolved once as plain data. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import type { ContributeScope } from "../core/contribute_token.ts"; | |
| 6 | 7 | import type { Proposal } from "../core/proposal_token.ts"; |
| 7 | 8 | import type { App } from "../app.ts"; |
| 8 | 9 | import type { RunScope } from "../core/run_token.ts"; |
| @@ -29,6 +30,8 @@ export interface Auth { | ||
| 29 | 30 | /** Set for a crew run's token: the repositories it may reach |
| 30 | 31 | * (`http/run_gate.ts`). */ |
| 31 | 32 | readonly run?: RunScope; |
| 33 | /** Set for an outside agent's contribute token (`http/contribute_gate.ts`). */ | |
| 34 | readonly contribute?: ContributeScope; | |
| 32 | 35 | /** Set for a via token: the forge's own agent, speaking for this person in |
| 33 | 36 | * this conversation (`conversation:<id>`, `core/via_token.ts`). */ |
| 34 | 37 | 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"; | ||
| 34 | 34 | import { proofOf } from "./report_filer.ts"; |
| 35 | 35 | import { fileReport } from "../actions/reports.ts"; |
| 36 | 36 | import { START_HINT, START_WORDS, startBuilding } from "../actions/start.ts"; |
| 37 | import { CONTRIBUTE_TOKEN_HINT, CONTRIBUTE_TOKEN_WORDS, mintContributeToken } from "../actions/contribute_token.ts"; | |
| 37 | 38 | import { REPORT_RECEIVED } from "../actions/report_words.ts"; |
| 38 | 39 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 39 | 40 | import { everydayOf } from "../core/everyday.ts"; |
| @@ -317,8 +318,8 @@ export const START_TOOL = { | ||
| 317 | 318 | description: { type: "string", maxLength: 2000 }, |
| 318 | 319 | agent: { type: "string", maxLength: 64 }, |
| 319 | 320 | code: { type: "string", maxLength: 100 }, |
| 321 | action: { type: "string", enum: ["contribute"] }, | |
| 320 | 322 | }, |
| 321 | required: ["name"], | |
| 322 | 323 | }, |
| 323 | 324 | } as const; |
| 324 | 325 | |
| @@ -408,6 +409,16 @@ async function startAnswer(ctx: Ctx, id: unknown, args: Args): Promise<unknown> | ||
| 408 | 409 | id, |
| 409 | 410 | result: { content: [{ type: "text", text: t }], ...(isError ? { isError } : {}) }, |
| 410 | 411 | }); |
| 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); | |
| 411 | 422 | const started = await startBuilding(ctx.app, args, clientBucket(ctx.request), ctx.url.origin); |
| 412 | 423 | if (started.ok) return text(JSON.stringify(started.value)); |
| 413 | 424 | const e = started.error; |
src/http/server.ts +26 −2
| @@ -498,11 +498,14 @@ import { targetForShortCode } from "../origins/oauth.ts"; | ||
| 498 | 498 | import { apiRoutes } from "./api.ts"; |
| 499 | 499 | import { pageParamRefused } from "./cursor_lists.ts"; |
| 500 | 500 | 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"; | |
| 502 | 504 | import { adminGate } from "./admin_gate.ts"; |
| 503 | 505 | import { movedResponse } from "./moved.ts"; |
| 504 | 506 | import { originGate } from "./origin_gate.ts"; |
| 505 | 507 | import { proposalGate } from "./proposal_gate.ts"; |
| 508 | import { contributeGate } from "./contribute_gate.ts"; | |
| 506 | 509 | import { runGate } from "./run_gate.ts"; |
| 507 | 510 | import { viaGate } from "./via_gate.ts"; |
| 508 | 511 | import { checksRecheckRoutes } from "./checks_recheck.ts"; |
| @@ -693,6 +696,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 693 | 696 | tokenName: verified.value.name, |
| 694 | 697 | ...(verified.value.proposal ? { proposal: verified.value.proposal } : {}), |
| 695 | 698 | ...(verified.value.run ? { run: verified.value.run } : {}), |
| 699 | ...(verified.value.contribute ? { contribute: verified.value.contribute } : {}), | |
| 696 | 700 | ...(verified.value.via ? { through: verified.value.via } : {}), |
| 697 | 701 | }; |
| 698 | 702 | } |
| @@ -721,6 +725,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 721 | 725 | ? { proposal: verified.value.proposal } |
| 722 | 726 | : {}), |
| 723 | 727 | ...(verified.value.run ? { run: verified.value.run } : {}), |
| 728 | ...(verified.value.contribute ? { contribute: verified.value.contribute } : {}), | |
| 724 | 729 | ...(verified.value.via ? { through: verified.value.via } : {}), |
| 725 | 730 | }; |
| 726 | 731 | } |
| @@ -1213,6 +1218,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1213 | 1218 | repoId, |
| 1214 | 1219 | actorId: ctx.auth.user?.id ?? "", |
| 1215 | 1220 | ...(ctx.auth.proposal ? { proposalChild: ctx.auth.proposal.child } : {}), |
| 1221 | ...(ctx.auth.contribute ? { contribAgent: ctx.auth.contribute.namespace } : {}), | |
| 1216 | 1222 | }), |
| 1217 | 1223 | ...cap, |
| 1218 | 1224 | }, |
| @@ -1582,6 +1588,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1582 | 1588 | "/:owner/:name/settings/proposal-tokens/:id/delete", |
| 1583 | 1589 | proposalTokenRevoke, |
| 1584 | 1590 | ); |
| 1591 | router.add("POST", "/:owner/:name/settings/contribute-tokens/open", contributeOpenSet); | |
| 1592 | router.add("POST", "/:owner/:name/settings/contribute-tokens/:id/delete", contributeTokenRevoke); | |
| 1585 | 1593 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); |
| 1586 | 1594 | router.add("GET", "/:owner/:name/settings/app/traffic", appSourcesPage); |
| 1587 | 1595 | router.add("GET", "/:owner/:name/settings/app/statement", statementPage); |
| @@ -1787,8 +1795,11 @@ async function withGitAccess( | ||
| 1787 | 1795 | if (write) { |
| 1788 | 1796 | if (!ctx.auth.user) return gitChallenge(); |
| 1789 | 1797 | 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); | |
| 1790 | 1801 | if ( |
| 1791 | !canWrite(ctx.app.db, repo, ctx.auth.user) || | |
| 1802 | !(outside || canWrite(ctx.app.db, repo, ctx.auth.user)) || | |
| 1792 | 1803 | !scopeAllows(ctx.auth.scope ?? "ro", "rw") |
| 1793 | 1804 | ) { |
| 1794 | 1805 | return notFound(); |
| @@ -2108,6 +2119,16 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 2108 | 2119 | { status: 403 }, |
| 2109 | 2120 | ); |
| 2110 | 2121 | } |
| 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 | } | |
| 2111 | 2132 | // The pull namespace belongs to the forge, never to pushers. |
| 2112 | 2133 | if (update.ref.startsWith("refs/pull/")) { |
| 2113 | 2134 | return new Response(`${update.ref}: refs/pull/* is reserved`, { |
| @@ -3019,6 +3040,9 @@ async function answerGet( | ||
| 3019 | 3040 | // request it makes ends here, before the limiter or any handler. |
| 3020 | 3041 | const proposalRefusal = proposalGate(app, request, url, auth); |
| 3021 | 3042 | 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; | |
| 3022 | 3046 | // A crew run's token reaches its own repository and nothing else. |
| 3023 | 3047 | const runRefusal = runGate(app, request, url, auth); |
| 3024 | 3048 | 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"; | ||
| 171 | 171 | import { autonomyCommands } from "./autonomy.ts"; |
| 172 | 172 | import { trafficCommands } from "./trafficview.ts"; |
| 173 | 173 | import { chaptersCommands } from "./chapters.ts"; |
| 174 | import { outsideAgentsCommands } from "./outsideagents.ts"; | |
| 174 | 175 | import { schedulesCommands } from "./schedules.ts"; |
| 175 | 176 | import { landCommands } from "./landasis.ts"; |
| 176 | 177 | import { shiftCommands } from "./subtitle_shift.ts"; |
| @@ -2003,6 +2004,7 @@ export function workspaceScreen( | ||
| 2003 | 2004 | ...autonomyCommands(f, ctx, repo), |
| 2004 | 2005 | ...trafficCommands(f, ctx, repo), |
| 2005 | 2006 | ...schedulesCommands(f, ctx, repo), |
| 2007 | ...outsideAgentsCommands(f, ctx, repo), | |
| 2006 | 2008 | ...landCommands(f, ctx, repo), |
| 2007 | 2009 | ...shiftCommands(f, ctx, repo, () => screen), |
| 2008 | 2010 | ...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 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | 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"; | |
| 10 | 12 | import { isAccountItself, type Ctx } from "../../http/context.ts"; |
| 11 | 13 | import { loadRepo, repoBase, repoNotFound, NOT_YOURS_TO_DO, forbiddenPage } from "../chrome.ts"; |
| 12 | 14 | import { field, readForm } from "../form.ts"; |
| 13 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | import { liveMount } from "../live.ts"; | |
| 14 | 17 | import { flashTo } from "../layout.ts"; |
| 15 | 18 | import { btn, confirmAction, field as formField, time } from "../ui.ts"; |
| 16 | 19 | |
| 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/<name>/…</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 | ||
| 17 | 55 | /** The section, or null for someone who cannot write here. */ |
| 18 | 56 | export function proposalTokensSection(ctx: Ctx, repo: Repo): Html | null { |
| 19 | 57 | if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return null; |
| 58 | const outside = contributeTokensSection(ctx, repo); | |
| 20 | 59 | const base = repoBase(repo); |
| 21 | 60 | const listed = listProposals(ctx.app, ctx.auth.user, repo); |
| 22 | 61 | 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"> | |
| 24 | 63 | <h2>Proposal tokens</h2> |
| 25 | 64 | <p class="muted">Lets one child forge fork this code, push <code>child/<name>/…</code> branches and open pulls. Lasts 30 days.</p> |
| 26 | 65 | ${tokens.length === 0 |
| @@ -95,3 +134,30 @@ export async function proposalTokenRevoke(ctx: Ctx): Promise<Response> { | ||
| 95 | 134 | ? { message: "Proposal token revoked." } |
| 96 | 135 | : { tone: "danger", message: "No such proposal token." }); |
| 97 | 136 | } |
| 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: { | ||
| 667 | 667 | phrase: "", |
| 668 | 668 | }), |
| 669 | 669 | }, |
| 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 | }, | |
| 670 | 679 | { |
| 671 | 680 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/mirror\/stop$/, |
| 672 | 681 | describe: (m, ctx) => { |
src/web/pages/settings.ts +3 −1
| @@ -111,6 +111,8 @@ import { | ||
| 111 | 111 | tlEvent, |
| 112 | 112 | } from "../ui.ts"; |
| 113 | 113 | import { cutWaveFor } from "../../actions/cut_wave.ts"; |
| 114 | import { liveClientJs } from "../live.ts"; | |
| 115 | import { outsideAgentsJs } from "./outside-agents-live.ts"; | |
| 114 | 116 | import { proposalTokensSection } from "./proposal-tokens.ts"; |
| 115 | 117 | import { secretsSection } from "./secrets.ts"; |
| 116 | 118 | import { checkMemoryAt } from "../../apps/check-memory.ts"; |
| @@ -204,7 +206,7 @@ export function settingsPageResponse( | ||
| 204 | 206 | crumbs: crumbsFor(repo), |
| 205 | 207 | palette: repoPalette(ctx, repo), |
| 206 | 208 | ...(styleCss !== undefined ? { styleCss } : {}), |
| 207 | scriptJs: teamJs, | |
| 209 | scriptJs: `${teamJs}\n${liveClientJs}\n${outsideAgentsJs}`, | |
| 208 | 210 | body: html`${repoHeader(ctx, repo, "settings", { compact: true })} |
| 209 | 211 | ${settingsLayout(sections(repoBase(repo), active), body)}`, |
| 210 | 212 | }, |
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; | ||
| 45 | 45 | |
| 46 | 46 | type Schema = { type?: string | string[]; properties?: Record<string, Schema>; required?: string[]; items?: Schema; enum?: unknown[] }; |
| 47 | 47 | |
| 48 | const BIG = "x".repeat(10 * 1024); | |
| 48 | const BIG = "x".repeat(8 * 1024); | |
| 49 | 49 | const nested = (depth: number): unknown => (depth === 0 ? { a: "../" } : { a: nested(depth - 1), b: [nested(0)] }); |
| 50 | 50 | |
| 51 | 51 | /** Hostile values in no particular type, for any field. */ |
| @@ -101,7 +101,8 @@ function wrongFor(schema: Schema): unknown[] { | ||
| 101 | 101 | function cases(name: string, schema: Schema, r: Rand): unknown[] { |
| 102 | 102 | const props = Object.entries(schema.properties ?? {}); |
| 103 | 103 | 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. | |
| 105 | 106 | for (const pickAt of [0, 3, 4]) { |
| 106 | 107 | const args: Record<string, unknown> = {}; |
| 107 | 108 | 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[]>> { | ||
| 110 | 110 | test("every caller of createUser says where its account comes from", async () => { |
| 111 | 111 | // A new way in is added here on purpose: a stranger's door, or a named exemption. |
| 112 | 112 | expect(await callers()).toEqual({ |
| 113 | "src/actions/contribute_token.ts": ["start"], | |
| 113 | 114 | "src/actions/crew.ts": ["agent"], |
| 114 | 115 | "src/actions/first_admin.ts": ["first-admin"], |
| 115 | 116 | "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 | }); |
new above
offline