Public: anyone can read this; the crew can change it.
An agent that only wants to contribute works from its own runtime and a fork:… #11849
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11837-an-agent-that-only-wants-to-contribute-w → main 7 commits fork 3 threads · 3 unresolved
24 files
changed +1024
−8
docs/MCP.md +2 −2
| @@ -152,7 +152,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 152 | 152 | | `next_up` | — | The ranked list the crew builds from; `issue` + `to` (top, not-now, after:N) moves one | 349 | |
| 153 | 153 | | `notify` | — | Read, set or clear your phone notify URL | 146 | |
| 154 | 154 | | `now` | — | What the crew does across your apps: who, on what, since when, cost; what awaits review | 252 | |
| 155 | | `open_pull` | — | Open a pull request, from a branch or from your fork | 327 | | |
| 155 | | `open_pull` | — | Open a pull request, from a branch or from your fork | 449 | | |
| 156 | 156 | | `org_create` | — | Make an organization | 151 | |
| 157 | 157 | | `org_teams` | `create`, `add_member`, `members`, `metrics`, `remove_member`, `remove` | An org's teams: create, add member with a role, list members, remove | 682 | |
| 158 | 158 | | `orgs` | — | Your orgs; with org, its teams | 170 | |
| @@ -199,7 +199,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 199 | 199 | | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 324 | |
| 200 | 200 | | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 | |
| 201 | 201 | | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 | |
| 202 | | `spawn` | — | Spawn a child forge | 871 | | |
| 202 | | `spawn` | — | Spawn a child forge | 946 | | |
| 203 | 203 | | `spend` | — | Report what your session spent outside forge runs: who, model, tokens or dollars, issue | 470 | |
| 204 | 204 | | `ssh_keys` | `list`, `add`, `remove` | Your SSH keys; add: key, remove: id | 250 | |
| 205 | 205 | | `steer` | — | Say a word to one crew run by its id | 224 | |
docs/README.md +1
| @@ -22,6 +22,7 @@ platform for organizations. | ||
| 22 | 22 | |
| 23 | 23 | - [](REFERENCE.md#run-it): start a forge and create the first admin. |
| 24 | 24 | - [](ON-YOUR-MACHINE.md): the local copy, offline work and the binary. |
| 25 | - [](features/send-a-change.md): fork, source archive, patch, pull. | |
| 25 | 26 | |
| 26 | 27 | ## Connect forges |
| 27 | 28 |
docs/features/send-a-change.md added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | # Send a change without a forge of your own | |
| 2 | ||
| 3 | This page is for an agent or a person who wants to change a forge's code and has only a short-lived sandbox: no inbound connections, maybe no git credential, maybe no outbound network. The path costs the host no forge: `spawn` with `purpose: "contribute"` makes nothing and answers these steps; `/llms.txt`, `/agents`, the welcome answer and the terminal's *Send a change* screen say them too. | |
| 4 | ||
| 5 | The steps: | |
| 6 | ||
| 7 | sh | |
| 8 | # 1. fork (MCP `fork`, or POST /api/v1/repos/{owner}/{name}/forks): the fork is yours, public, and bounded by the guest limits | |
| 9 | # 2. the source at a commit, no git credential, plain HTTPS (a full 40-hex commit id never changes and is cached forever) | |
| 10 | mkdir work && curl -fL https://<forge>/<owner>/<name>/archive/<branch-or-commit>.tar.gz | tar xz -C work --strip-components=1 | |
| 11 | cd work | |
| 12 | # 3. nothing to install: the forge has no dependencies beyond Bun | |
| 13 | bun scripts/gate.ts && bun x tsc --noEmit # `bun install` first only if typecheck lacks types | |
| 14 | files= && bun test # the tests the change touches | |
| 15 | # 4. see it work, for your own use: loopback only, data in a temp directory, no crew | |
| 16 | FORGE_DATA= FORGE_APP_ENV=preview FORGE_HOST=127.0.0.1 FORGE_SSH_PORT=0 PORT=3000 bun src/main.ts & | |
| 17 | curl -s http://127.0.0.1:3000/ | |
| 18 | ||
| 19 | ||
| 20 | `FORGE_APP_ENV=preview` holds the crew off, so nothing there spends a model's | |
| 21 | budget; `FORGE_HOST=127.0.0.1` keeps it off the network. | |
| 22 | ||
| 23 | **A sandbox with no outbound network.** Read the source through the MCP | |
| 24 | instead: the resource `forge://source/{owner}/{name}@{ref}` returns the | |
| 25 | archive (`?path=src/dir` for part of it), base64 in the resource's `blob`; | |
| 26 | decode it into the sandbox's files and carry on from step 3. A sandbox with | |
| 27 | neither can still change the code by reading files with the MCP's | |
| 28 | `read_file` and proposing by hand: the forge's own check is the one that runs. | |
| 29 | ||
| 30 | **Sending the work back, without git.** MCP `open_pull` with `title`, | |
| 31 | `head` (the branch to make on your fork, optional) and either `patch` (a | |
| 32 | unified diff of text files, at most 256 KiB) or `files` (`[{ path, content }]`, | |
| 33 | `content: null` deletes). It commits to a branch on your fork with you as | |
| 34 | author and opens the pull. Say what you ran in `checks` (`{ name, result: | |
| 35 | pass|fail|skipped, detail? }`, at most ten): they are shown on the pull as | |
| 36 | what you claim; the forge runs its own once a person approves the head. | |
| 37 | ||
| 38 | **A forge to keep** is another matter, and `spawn` says what each costs whom: | |
| 39 | *hosted here* (the host pays a cell), *on your machine* (`bun src/main.ts`, | |
| 40 | your hardware), *on a cloud account* (your operator's account, from the forge's | |
| 41 | Cloud page). | |
| 42 | ||
| 43 | ## Limits | |
| 44 | ||
| 45 | The archive route (`/:owner/:name/archive/<ref>.tar.gz`) is open to anyone who may read the repository, so it is bounded: a few builds a minute per source address, a few at once on the forge, and 16 MiB of gzip. A larger tree goes by git or `?path=` for one directory. |
src/actions/propose_change.ts added +202
| @@ -0,0 +1,202 @@ | ||
| 1 | /** | |
| 2 | * Send a change back without git: a patch or a set of files, in one call. | |
| 3 | * | |
| 4 | * An agent in a chat sandbox has a working copy and no push credential. It | |
| 5 | * sends the change as text; the forge forks the repository to the sender | |
| 6 | * (the guest fork limits apply, as for `fork`), writes a branch there with | |
| 7 | * the sender as the commit's author, and opens a pull from that fork. Nothing | |
| 8 | * here runs the change: a guest's pull waits for a person's approval before | |
| 9 | * any check (`core/pull_trust.ts`), and its check then runs as any check does. The checks the sender says it ran are written | |
| 10 | * into the pull's description as the sender's word, never as a verdict. | |
| 11 | */ | |
| 12 | ||
| 13 | import { mkdir } from "node:fs/promises"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import type { App } from "../app.ts"; | |
| 16 | import { getForkOf, getRepoById, type Repo } from "../core/repos.ts"; | |
| 17 | import { getPull, type Pull } from "../core/pulls.ts"; | |
| 18 | import type { GuestRefusal } from "../core/contribute.ts"; | |
| 19 | import type { User } from "../core/users.ts"; | |
| 20 | import { runGit } from "../git/env.ts"; | |
| 21 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; | |
| 22 | import { repoDir } from "../git/repos.ts"; | |
| 23 | import { MAX_PATCH_BYTES, patchToFiles, type PatchError } from "../git/patch_files.ts"; | |
| 24 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 25 | import { editFiles, type EditFile } from "./contents.ts"; | |
| 26 | import { guestWaits } from "../origins/guest.ts"; | |
| 27 | import { openPull } from "./pulls.ts"; | |
| 28 | import { forkRepo } from "./repos.ts"; | |
| 29 | ||
| 30 | /** Bounds of one call: a proposal by text is small; a large change goes by git. */ | |
| 31 | export const MAX_FILES_SENT = 50; | |
| 32 | export const MAX_FILES_BYTES = 512 * 1024; | |
| 33 | export const MAX_TITLE = 200; | |
| 34 | export const MAX_BODY = 8_000; | |
| 35 | export const MAX_CHECKS = 10; | |
| 36 | ||
| 37 | export interface ReportedCheck { | |
| 38 | readonly name: string; | |
| 39 | readonly result: "pass" | "fail" | "skipped"; | |
| 40 | readonly detail?: string; | |
| 41 | } | |
| 42 | ||
| 43 | /** A file as sent: whole text, or `null` to delete it. */ | |
| 44 | export interface SentFile { | |
| 45 | readonly path: string; | |
| 46 | readonly content: string | null; | |
| 47 | } | |
| 48 | ||
| 49 | /** `raw` as files, or `invalid`: at most `MAX_FILES_SENT`, each a safe path and text or null, in `MAX_FILES_BYTES` together. */ | |
| 50 | export function validFiles(raw: unknown): Result<SentFile[], "invalid"> { | |
| 51 | if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_FILES_SENT) return err("invalid"); | |
| 52 | const out: SentFile[] = []; | |
| 53 | let bytes = 0; | |
| 54 | for (const f of raw) { | |
| 55 | if (typeof f !== "object" || f === null) return err("invalid"); | |
| 56 | const { path, content } = f as Record<string, unknown>; | |
| 57 | if (typeof path !== "string" || !isSafeTreePath(path)) return err("invalid"); | |
| 58 | if (content !== null && typeof content !== "string") return err("invalid"); | |
| 59 | bytes += content === null ? 0 : Buffer.byteLength(content, "utf8"); | |
| 60 | if (bytes > MAX_FILES_BYTES) return err("invalid"); | |
| 61 | out.push({ path, content }); | |
| 62 | } | |
| 63 | return ok(out); | |
| 64 | } | |
| 65 | ||
| 66 | export interface ProposeChangeInput { | |
| 67 | readonly title: string; | |
| 68 | readonly body?: string; | |
| 69 | readonly base?: string; | |
| 70 | readonly branch?: string; | |
| 71 | readonly files?: readonly SentFile[]; | |
| 72 | readonly patch?: string; | |
| 73 | readonly checks?: readonly ReportedCheck[]; | |
| 74 | } | |
| 75 | ||
| 76 | export interface Proposed { | |
| 77 | readonly pull: Pull; | |
| 78 | readonly fork: Repo; | |
| 79 | readonly branch: string; | |
| 80 | readonly commit: string; | |
| 81 | readonly waitsForApproval: boolean; | |
| 82 | } | |
| 83 | ||
| 84 | export type ProposeChangeError = | |
| 85 | | "invalid" | |
| 86 | | "not_found" | |
| 87 | | "unknown_base" | |
| 88 | | "branch_exists" | |
| 89 | | { kind: "patch"; reason: PatchError } | |
| 90 | | { kind: "fork"; reason: string } | |
| 91 | | { kind: "edit"; reason: string } | |
| 92 | | { kind: "pull"; reason: string; branch: string; fork: string }; | |
| 93 | ||
| 94 | /** What the sender says it ran, as lines for the pull's description. */ | |
| 95 | export function checksBlock(checks: readonly ReportedCheck[]): string { | |
| 96 | if (checks.length === 0) return ""; | |
| 97 | const lines = checks.map((c) => `- ${c.name}: ${c.result}${c.detail ? ` (${c.detail})` : ""}`); | |
| 98 | return `\n\nChecks the sender says it ran before sending (its word; the forge runs its own):\n${lines.join("\n")}`; | |
| 99 | } | |
| 100 | ||
| 101 | export function validChecks(raw: unknown): Result<ReportedCheck[], "invalid"> { | |
| 102 | if (raw === undefined) return ok([]); | |
| 103 | if (!Array.isArray(raw) || raw.length > MAX_CHECKS) return err("invalid"); | |
| 104 | const out: ReportedCheck[] = []; | |
| 105 | for (const c of raw) { | |
| 106 | if (typeof c !== "object" || c === null) return err("invalid"); | |
| 107 | const { name, result, detail } = c as Record<string, unknown>; | |
| 108 | if (typeof name !== "string" || name.trim() === "" || name.length > 60) return err("invalid"); | |
| 109 | if (result !== "pass" && result !== "fail" && result !== "skipped") return err("invalid"); | |
| 110 | if (detail !== undefined && (typeof detail !== "string" || detail.length > 200)) return err("invalid"); | |
| 111 | out.push({ name: name.replace(/\s+/g, " ").trim(), result, ...(detail === undefined || detail === "" ? {} : { detail: detail.replace(/\s+/g, " ") }) }); | |
| 112 | } | |
| 113 | return ok(out); | |
| 114 | } | |
| 115 | ||
| 116 | /** Why a fork could not be made, as this action's own error: the fork's own words for the few that are ours, the refusal itself otherwise. */ | |
| 117 | function forkFailure(e: Awaited<ReturnType<typeof forkRepo>> extends infer R ? (R extends { ok: false; error: infer E } ? E : never) : never): ProposeChangeError | GuestRefusal | "unverified" | "unauthorized" | "repository_limit" | "storage_limit" | "refused" | "not_member" { | |
| 118 | switch (e) { | |
| 119 | case "invalid_name": | |
| 120 | case "name_taken": | |
| 121 | case "already_forked": | |
| 122 | case "fork_failed": | |
| 123 | case "limit": | |
| 124 | return { kind: "fork", reason: e.replace(/_/g, " ") }; | |
| 125 | case "not_found": | |
| 126 | return "not_found"; | |
| 127 | default: | |
| 128 | return e; | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | export async function proposeChange( | |
| 133 | app: App, | |
| 134 | user: User, | |
| 135 | upstream: Repo, | |
| 136 | input: ProposeChangeInput, | |
| 137 | ): Promise<Result<Proposed, ProposeChangeError | GuestRefusal | "unverified" | "unauthorized" | "repository_limit" | "storage_limit" | "refused" | "not_member">> { | |
| 138 | const title = input.title.trim(); | |
| 139 | if (title === "" || title.length > MAX_TITLE) return err("invalid"); | |
| 140 | const body = input.body ?? ""; | |
| 141 | if (body.length > MAX_BODY) return err("invalid"); | |
| 142 | const hasFiles = input.files !== undefined && input.files.length > 0; | |
| 143 | const hasPatch = input.patch !== undefined && input.patch !== ""; | |
| 144 | if (hasFiles === hasPatch) return err("invalid"); | |
| 145 | if (hasFiles) { | |
| 146 | if (!validFiles(input.files).ok) return err("invalid"); | |
| 147 | } else if (Buffer.byteLength(input.patch!, "utf8") > MAX_PATCH_BYTES) return err({ kind: "patch", reason: "too_large" }); | |
| 148 | const base = (input.base ?? "").trim() || upstream.defaultBranch; | |
| 149 | const branch = (input.branch ?? "").trim() || `change-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; | |
| 150 | if (!isSafeRef(base) || !isSafeRef(branch) || branch.startsWith("refs/")) return err("invalid"); | |
| 151 | ||
| 152 | const upstreamDir = repoDir(app.reposRoot, upstream.ownerName, upstream.name); | |
| 153 | const baseOid = await runGit(["rev-parse", "--verify", "--quiet", `refs/heads/${base}^{commit}`], { cwd: upstreamDir }); | |
| 154 | if (!baseOid.ok) return err("unknown_base"); | |
| 155 | ||
| 156 | let fork = getForkOf(app.db, user.id, upstream.id); | |
| 157 | if (fork === undefined) { | |
| 158 | const made = await forkRepo(app, user, upstream); | |
| 159 | if (!made.ok) return err(forkFailure(made.error)); | |
| 160 | fork = made.value; | |
| 161 | } | |
| 162 | const forkDir = repoDir(app.reposRoot, fork.ownerName, fork.name); | |
| 163 | // A fork made months ago is a snapshot: bring the base up to the upstream's | |
| 164 | // when that is a fast-forward, and start from the upstream's commit either way. | |
| 165 | await runGit(["fetch", "--no-tags", upstreamDir, `refs/heads/${base}:refs/forge-upstream/${base}`], { cwd: forkDir }); | |
| 166 | const fresh = await runGit(["rev-parse", "--verify", "--quiet", `refs/forge-upstream/${base}^{commit}`], { cwd: forkDir }); | |
| 167 | if (!fresh.ok) return err({ kind: "fork", reason: "the fork could not read the base" }); | |
| 168 | const start = fresh.value; | |
| 169 | ||
| 170 | let files: readonly EditFile[]; | |
| 171 | if (hasFiles) files = input.files!; | |
| 172 | else { | |
| 173 | const scratch = join(app.config.dataDir, "scratch"); | |
| 174 | await mkdir(scratch, { recursive: true }); | |
| 175 | const made = await patchToFiles(forkDir, start, input.patch!, scratch); | |
| 176 | if (!made.ok) return err({ kind: "patch", reason: made.error }); | |
| 177 | files = made.value; | |
| 178 | } | |
| 179 | ||
| 180 | const edited = await editFiles(app, user, fork, { newBranch: branch, base: start, files, message: title }); | |
| 181 | if (!edited.ok) { | |
| 182 | const e = edited.error; | |
| 183 | if (typeof e === "object" && e.kind === "branch_failed" && e.reason === "exists") return err("branch_exists"); | |
| 184 | return err({ kind: "edit", reason: typeof e === "string" ? e : e.kind }); | |
| 185 | } | |
| 186 | const opened = await openPull(app, user, upstream, { | |
| 187 | title, | |
| 188 | body: `${body}${checksBlock(input.checks ?? [])}`, | |
| 189 | baseBranch: base, | |
| 190 | headBranch: branch, | |
| 191 | headRepo: fork, | |
| 192 | }); | |
| 193 | if (!opened.ok) return err(typeof opened.error === "string" ? { kind: "pull", reason: opened.error, branch, fork: `${fork.ownerName}/${fork.name}` } : { kind: "pull", reason: "refused", branch, fork: `${fork.ownerName}/${fork.name}` }); | |
| 194 | const pull = getPull(app.db, upstream.id, opened.value.number) ?? opened.value; | |
| 195 | return ok({ | |
| 196 | pull, | |
| 197 | fork: getRepoById(app.db, fork.id) ?? fork, | |
| 198 | branch, | |
| 199 | commit: edited.value.commit.commitOid, | |
| 200 | waitsForApproval: guestWaits(app.db, pull), | |
| 201 | }); | |
| 202 | } |
src/git/archive.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * A repository at one commit as a gzipped tar: the working copy an agent in a | |
| 3 | * sandbox with no git credential takes (`/:owner/:name/archive/<ref>.tar.gz`, | |
| 4 | * and the MCP resource of the same bytes). `git archive` writes the tree, so | |
| 5 | * nothing of `.git`, no history and no credential is in it. Bounded: a tree | |
| 6 | * over `MAX_ARCHIVE_BYTES` is refused rather than streamed. | |
| 7 | */ | |
| 8 | ||
| 9 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 10 | import { runGitRaw } from "./env.ts"; | |
| 11 | import { resolveOid } from "./merge.ts"; | |
| 12 | ||
| 13 | export const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024; | |
| 14 | ||
| 15 | /** The newest few archives by commit: a commit's tree never changes, so a held one is always right. */ | |
| 16 | const held = new Map<string, Uint8Array>(); | |
| 17 | const HELD_MAX = 3; | |
| 18 | const HELD_BYTES = 24 * 1024 * 1024; | |
| 19 | ||
| 20 | export type ArchiveError = "not_found" | "too_large"; | |
| 21 | ||
| 22 | export async function archiveAt( | |
| 23 | dir: string, | |
| 24 | ref: string, | |
| 25 | prefix: string, | |
| 26 | /** A directory or file of the tree to take alone; the whole tree when absent. */ | |
| 27 | path?: string, | |
| 28 | /** Refuse (`too_large`) a gzip over this many bytes. */ | |
| 29 | limit: number = MAX_ARCHIVE_BYTES, | |
| 30 | ): Promise<Result<{ oid: string; bytes: Uint8Array }, ArchiveError>> { | |
| 31 | const oid = await resolveOid(dir, ref); | |
| 32 | if (oid === undefined) return err("not_found"); | |
| 33 | const key = `${dir}\0${oid}\0${path ?? ""}`; | |
| 34 | const hit = held.get(key); | |
| 35 | if (hit !== undefined) return hit.length > limit ? err("too_large") : ok({ oid, bytes: hit }); | |
| 36 | const tar = await runGitRaw(["--literal-pathspecs", "archive", "--format=tar", `--prefix=${prefix}/`, oid, ...(path === undefined ? [] : ["--", path])], { | |
| 37 | cwd: dir, | |
| 38 | maxBytes: MAX_ARCHIVE_BYTES, | |
| 39 | timeoutMs: 20_000, | |
| 40 | }); | |
| 41 | if (!tar.ok) return err(tar.error.code === -2 ? "too_large" : "not_found"); | |
| 42 | const bytes = Bun.gzipSync(new Uint8Array(tar.value)); | |
| 43 | if (bytes.length > limit) return err("too_large"); | |
| 44 | if (bytes.length <= HELD_BYTES) { | |
| 45 | held.set(key, bytes); | |
| 46 | while (held.size > HELD_MAX) held.delete(held.keys().next().value as string); | |
| 47 | } | |
| 48 | return ok({ oid, bytes }); | |
| 49 | } |
src/git/patch_files.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * A unified diff turned into the files it produces, without a work tree. | |
| 3 | * | |
| 4 | * The patch is applied to a scratch index read from `base` (`git apply | |
| 5 | * --cached`, the patch on stdin, never in an argument), and the difference | |
| 6 | * between that index and `base` is read back as whole files. Those files go | |
| 7 | * through the ordinary edit path (`actions/contents.ts`), which owns the | |
| 8 | * limits, the commit and its author, so a patch gets no door a file edit | |
| 9 | * does not have. Text only: a binary patch, a symlink, a submodule or a path | |
| 10 | * that is not a safe tree path is refused. Modes other than the plain file | |
| 11 | * are dropped (an executable bit does not travel in a patch here). | |
| 12 | */ | |
| 13 | ||
| 14 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 17 | import { runGit, runGitRaw } from "./env.ts"; | |
| 18 | import { isSafeTreePath } from "./guard.ts"; | |
| 19 | import type { FileWrite } from "./write.ts"; | |
| 20 | ||
| 21 | /** The most patch bytes one call takes. */ | |
| 22 | export const MAX_PATCH_BYTES = 256 * 1024; | |
| 23 | ||
| 24 | export type PatchError = | |
| 25 | | "too_large" | |
| 26 | | "empty" | |
| 27 | | "does_not_apply" | |
| 28 | | "unsupported" | |
| 29 | | "bad_path"; | |
| 30 | ||
| 31 | /** `dir` is a bare repository, `base` a commit in it, `scratch` a directory the caller may write. */ | |
| 32 | export async function patchToFiles( | |
| 33 | dir: string, | |
| 34 | base: string, | |
| 35 | patch: string, | |
| 36 | scratch: string, | |
| 37 | ): Promise<Result<FileWrite[], PatchError>> { | |
| 38 | if (Buffer.byteLength(patch, "utf8") > MAX_PATCH_BYTES) return err("too_large"); | |
| 39 | if (patch.trim() === "") return err("empty"); | |
| 40 | if (patch.includes("\0")) return err("unsupported"); | |
| 41 | const work = await mkdtemp(join(scratch, "patch-")); | |
| 42 | const env = { GIT_INDEX_FILE: join(work, "index") }; | |
| 43 | try { | |
| 44 | const read = await runGit(["read-tree", base], { cwd: dir, env }); | |
| 45 | if (!read.ok) return err("does_not_apply"); | |
| 46 | const text = patch.endsWith("\n") ? patch : `${patch}\n`; | |
| 47 | const applied = await runGit(["apply", "--cached", "--whitespace=nowarn", "-"], { cwd: dir, env, stdin: text }); | |
| 48 | if (!applied.ok) return err(/binary/i.test(applied.error.stderr) ? "unsupported" : "does_not_apply"); | |
| 49 | const raw = await runGitRaw(["diff", "--cached", "--raw", "-z", "--no-renames", "--no-abbrev", base], { cwd: dir, env }); | |
| 50 | if (!raw.ok) return err("does_not_apply"); | |
| 51 | const fields = new TextDecoder().decode(raw.value).split("\0"); | |
| 52 | const files: FileWrite[] = []; | |
| 53 | for (let i = 0; i + 1 < fields.length; i += 2) { | |
| 54 | const meta = (fields[i] ?? "").split(" "); | |
| 55 | const path = fields[i + 1] ?? ""; | |
| 56 | const [, newMode, , newOid, status] = meta; | |
| 57 | if (!isSafeTreePath(path)) return err("bad_path"); | |
| 58 | if (status === "D") { | |
| 59 | files.push({ path, content: null }); | |
| 60 | continue; | |
| 61 | } | |
| 62 | if (newMode !== "100644" && newMode !== "100755") return err("unsupported"); | |
| 63 | const blob = await runGitRaw(["cat-file", "blob", newOid ?? ""], { cwd: dir, env }); | |
| 64 | if (!blob.ok) return err("does_not_apply"); | |
| 65 | try { | |
| 66 | files.push({ path, content: new TextDecoder("utf-8", { fatal: true }).decode(blob.value) }); | |
| 67 | } catch { | |
| 68 | return err("unsupported"); | |
| 69 | } | |
| 70 | } | |
| 71 | return files.length === 0 ? err("empty") : ok(files); | |
| 72 | } finally { | |
| 73 | await rm(work, { recursive: true, force: true }); | |
| 74 | } | |
| 75 | } |
src/http/archive.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * `GET /:owner/:name/archive/<ref>.tar.gz`: the source at a commit, for an | |
| 3 | * agent that has HTTPS and no git. Anyone who may read the repository may | |
| 4 | * take it; a private one answers 404 like every other page. At a full commit | |
| 5 | * id the bytes never change and are cached as such. | |
| 6 | */ | |
| 7 | ||
| 8 | import { getRepo, canRead } from "../core/repos.ts"; | |
| 9 | import { archiveAt } from "../git/archive.ts"; | |
| 10 | import { ARCHIVE_HTTP_BYTES, takeArchive } from "./archive_limit.ts"; | |
| 11 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; | |
| 12 | import { repoDir } from "../git/repos.ts"; | |
| 13 | import type { Handler } from "./context.ts"; | |
| 14 | import { securityFloor } from "./respond.ts"; | |
| 15 | ||
| 16 | const FULL_OID = /^[0-9a-f]{40}$/; | |
| 17 | const SUFFIX = ".tar.gz"; | |
| 18 | ||
| 19 | export const sourceArchive: Handler = async (ctx) => { | |
| 20 | const gone = () => securityFloor(new Response("not found\n", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } })); | |
| 21 | const repo = getRepo(ctx.app.db, ctx.params.owner ?? "", (ctx.params.name ?? "").replace(/\.git$/, "")); | |
| 22 | if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return gone(); | |
| 23 | const file = ctx.params.file ?? ""; | |
| 24 | if (!file.endsWith(SUFFIX)) return gone(); | |
| 25 | const ref = file.slice(0, -SUFFIX.length); | |
| 26 | if (!isSafeRef(ref) && !FULL_OID.test(ref)) return gone(); | |
| 27 | const path = new URL(ctx.request.url).searchParams.get("path") ?? undefined; | |
| 28 | if (path !== undefined && !isSafeTreePath(path)) return gone(); | |
| 29 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); | |
| 30 | const turn = takeArchive(ctx.request); | |
| 31 | if (!turn.ok) | |
| 32 | return securityFloor( | |
| 33 | new Response(turn.why === "rate" ? "too many archives from this address: wait a minute\n" : "busy: try again shortly\n", { | |
| 34 | status: turn.why === "rate" ? 429 : 503, | |
| 35 | headers: { "content-type": "text/plain; charset=utf-8", "retry-after": String(turn.retryAfter) }, | |
| 36 | }), | |
| 37 | ); | |
| 38 | const made = await archiveAt(dir, ref, `${repo.name}-${ref.slice(0, 12)}`, path, ARCHIVE_HTTP_BYTES).finally(turn.done); | |
| 39 | if (!made.ok) | |
| 40 | return securityFloor( | |
| 41 | new Response(made.error === "too_large" ? "too large to archive: use git\n" : "not found\n", { | |
| 42 | status: made.error === "too_large" ? 413 : 404, | |
| 43 | headers: { "content-type": "text/plain; charset=utf-8" }, | |
| 44 | }), | |
| 45 | ); | |
| 46 | const pinned = FULL_OID.test(ref) && repo.visibility === "public"; | |
| 47 | return securityFloor( | |
| 48 | new Response(made.value.bytes, { | |
| 49 | headers: { | |
| 50 | "content-type": "application/gzip", | |
| 51 | "content-disposition": `attachment; filename="${repo.name}-${made.value.oid.slice(0, 12)}.tar.gz"`, | |
| 52 | "x-forge-commit": made.value.oid, | |
| 53 | "cache-control": pinned ? "public, max-age=31536000, immutable" : "no-cache", | |
| 54 | etag: `"${made.value.oid}"`, | |
| 55 | }, | |
| 56 | }), | |
| 57 | ); | |
| 58 | }; |
src/http/archive_limit.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * The archive route is open to strangers and each build is a `git archive` | |
| 3 | * plus a gzip in this process, so it is bounded twice: a source may start | |
| 4 | * `ARCHIVES_PER_MINUTE` builds a sliding minute (the source is the peer as | |
| 5 | * `http/peer.ts` resolves it from the socket, an IPv6 client by its /64, | |
| 6 | * never a header the client sets), and at most `ARCHIVES_AT_ONCE` run in the | |
| 7 | * forge together. A refusal is not counted. In memory, bounded by the | |
| 8 | * number of sources seen in a minute (pruned on every take). | |
| 9 | */ | |
| 10 | ||
| 11 | import { clientBucket } from "./peer.ts"; | |
| 12 | ||
| 13 | export const ARCHIVES_PER_MINUTE = 6; | |
| 14 | export const ARCHIVES_AT_ONCE = 2; | |
| 15 | /** Gzip bytes the HTTP route serves; a larger tree goes by git or `?path=`. */ | |
| 16 | export const ARCHIVE_HTTP_BYTES = 16 * 1024 * 1024; | |
| 17 | ||
| 18 | const WINDOW_MS = 60_000; | |
| 19 | const seen = new Map<string, number[]>(); | |
| 20 | let running = 0; | |
| 21 | ||
| 22 | export type Take = { ok: true; done: () => void } | { ok: false; why: "rate" | "busy"; retryAfter: number }; | |
| 23 | ||
| 24 | /** Admit one build from `request`'s source, or say why not; call `done` when it ends. */ | |
| 25 | export function takeArchive(request: Request, now: number = Date.now()): Take { | |
| 26 | for (const [k, at] of seen) { | |
| 27 | const live = at.filter((t) => now - t < WINDOW_MS); | |
| 28 | if (live.length === 0) seen.delete(k); | |
| 29 | else seen.set(k, live); | |
| 30 | } | |
| 31 | const key = clientBucket(request); | |
| 32 | const mine = seen.get(key) ?? []; | |
| 33 | if (mine.length >= ARCHIVES_PER_MINUTE) return { ok: false, why: "rate", retryAfter: Math.max(1, Math.ceil((WINDOW_MS - (now - mine[0]!)) / 1000)) }; | |
| 34 | if (running >= ARCHIVES_AT_ONCE) return { ok: false, why: "busy", retryAfter: 5 }; | |
| 35 | seen.set(key, [...mine, now]); | |
| 36 | running++; | |
| 37 | let ended = false; | |
| 38 | return { | |
| 39 | ok: true, | |
| 40 | done: () => { | |
| 41 | if (ended) return; | |
| 42 | ended = true; | |
| 43 | running--; | |
| 44 | }, | |
| 45 | }; | |
| 46 | } | |
| 47 | ||
| 48 | /** Test seam: forget every source and the count in flight. */ | |
| 49 | export function forgetArchives(): void { | |
| 50 | seen.clear(); | |
| 51 | running = 0; | |
| 52 | } |
src/http/mcp.ts +9
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | * no page. `ro` tokens read; `rw` tokens act. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { readSource, SOURCE_PREFIX, SOURCE_TEMPLATE } from "./mcp/source_resource.ts"; | |
| 20 | 21 | import { CARD_KINDS, CARD_MIME, CARD_OF_TOOL, CARD_SHELL, CARD_URI, cardFor } from "../web/studio/cards.ts"; |
| 21 | 22 | import { getRepo } from "../core/repos.ts"; |
| 22 | 23 | import { scopeAllows } from "../core/tokens.ts"; |
| @@ -177,8 +178,16 @@ function rpcOne( | ||
| 177 | 178 | mimeType: CARD_MIME, |
| 178 | 179 | })), |
| 179 | 180 | }); |
| 181 | case "resources/templates/list": | |
| 182 | return reply({ | |
| 183 | resourceTemplates: [{ uriTemplate: SOURCE_TEMPLATE, name: "forge-source", mimeType: "application/gzip" }], | |
| 184 | }); | |
| 180 | 185 | case "resources/read": { |
| 181 | 186 | const uri = str(params["uri"]); |
| 187 | if (uri.startsWith(SOURCE_PREFIX)) | |
| 188 | return readSource(ctx, user, uri).then((r) => | |
| 189 | r.ok ? reply({ contents: [{ uri: r.uri, mimeType: r.mimeType, blob: r.blob }] }) : refuse(-32002, r.message), | |
| 190 | ); | |
| 182 | 191 | const kind = CARD_KINDS.find((k) => CARD_URI(k) === uri); |
| 183 | 192 | if (kind === undefined) return refuse(-32002, `no resource ${uri}`); |
| 184 | 193 | return reply({ |
src/http/mcp/source_resource.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * The repository's source as an MCP resource, for a runtime with no outbound | |
| 3 | * network: `forge://source/<owner>/<name>@<ref>[?path=<dir>]`, a base64 gzip | |
| 4 | * tar of the tree at that ref (the bytes `/:owner/:name/archive/<ref>.tar.gz` | |
| 5 | * serves). Small by rule: a resource travels inside a conversation, so the | |
| 6 | * whole of a large repository is refused with the advice to ask for a | |
| 7 | * directory with `path`. Each read is admitted like the HTTP route: the | |
| 8 | * per-source minute and the at-once cap in `http/archive_limit.ts`. | |
| 9 | */ | |
| 10 | ||
| 11 | import { canRead, getRepo } from "../../core/repos.ts"; | |
| 12 | import type { User } from "../../core/users.ts"; | |
| 13 | import { archiveAt } from "../../git/archive.ts"; | |
| 14 | import { isSafeRef, isSafeTreePath } from "../../git/guard.ts"; | |
| 15 | import { repoDir } from "../../git/repos.ts"; | |
| 16 | import { takeArchive } from "../archive_limit.ts"; | |
| 17 | import type { Ctx } from "../context.ts"; | |
| 18 | ||
| 19 | export const SOURCE_PREFIX = "forge://source/"; | |
| 20 | export const SOURCE_TEMPLATE = `${SOURCE_PREFIX}{owner}/{name}@{ref}{?path}`; | |
| 21 | /** The most gzip bytes one resource carries. */ | |
| 22 | export const SOURCE_RESOURCE_BYTES = 4 * 1024 * 1024; | |
| 23 | ||
| 24 | export type SourceRead = | |
| 25 | | { ok: true; uri: string; mimeType: string; blob: string } | |
| 26 | | { ok: false; message: string }; | |
| 27 | ||
| 28 | const SHAPE = /^forge:\/\/source\/([^/@?]+)\/([^/@?]+)@([^?]+)(?:\?path=(.+))?$/; | |
| 29 | ||
| 30 | export async function readSource(ctx: Ctx, user: User | undefined, uri: string): Promise<SourceRead> { | |
| 31 | const m = SHAPE.exec(uri); | |
| 32 | if (m === null) return { ok: false, message: `no resource ${uri}` }; | |
| 33 | const [, owner = "", name = "", rawRef = "", rawPath] = m; | |
| 34 | let ref: string; | |
| 35 | let path: string | undefined; | |
| 36 | try { | |
| 37 | ref = decodeURIComponent(rawRef); | |
| 38 | path = rawPath === undefined ? undefined : decodeURIComponent(rawPath); | |
| 39 | } catch { | |
| 40 | return { ok: false, message: `no resource ${uri}` }; | |
| 41 | } | |
| 42 | const repo = getRepo(ctx.app.db, owner, name); | |
| 43 | if (!repo || !canRead(ctx.app.db, repo, user) || !isSafeRef(ref) || (path !== undefined && !isSafeTreePath(path))) | |
| 44 | return { ok: false, message: `no resource ${uri}` }; | |
| 45 | // The same admission as the HTTP route (http/archive_limit.ts): one bucket | |
outdated
High: this build is not admitted through takeArchive, unlike the HTTP route. Add the per-source and at-once bounds here, with a test that a seventh read in a minute is refused. |
||
| 46 | // per source address, shared with /archive, and the forge-wide at-once cap. | |
| 47 | const turn = takeArchive(ctx.request); | |
| 48 | if (!turn.ok) | |
| 49 | return { | |
| 50 | ok: false, | |
| 51 | message: turn.why === "rate" ? `too many archives from this address: retry after ${turn.retryAfter} s` : `busy: retry after ${turn.retryAfter} s`, | |
| 52 | }; | |
| 53 | const made = await archiveAt(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), ref, `${repo.name}-${ref.slice(0, 12)}`, path, SOURCE_RESOURCE_BYTES).finally(turn.done); | |
| 54 | if (!made.ok) | |
| 55 | return { | |
| 56 | ok: false, | |
| 57 | message: made.error === "too_large" ? `too large for a resource (over ${SOURCE_RESOURCE_BYTES} bytes gzipped): ask for a directory with ?path=src/…` : `no resource ${uri}`, | |
| 58 | }; | |
| 59 | return { ok: true, uri, mimeType: "application/gzip", blob: Buffer.from(made.value.bytes).toString("base64") }; | |
| 60 | } |
src/http/mcp/tools/open_pull.ts +44 −2
| @@ -1,13 +1,19 @@ | ||
| 1 | 1 | import { openPull } from "../../../actions/pulls.ts"; |
| 2 | import { proposeChange, validChecks, validFiles, type ProposeChangeError } from "../../../actions/propose_change.ts"; | |
| 2 | 3 | import { VERIFY_FIRST } from "../../../actions/verify_words.ts"; |
| 3 | 4 | import { GUEST_REFUSALS, isGuestRefusal } from "../../../core/contribute.ts"; |
| 4 | 5 | import { getRepo } from "../../../core/repos.ts"; |
| 5 | 6 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 6 | 7 | |
| 8 | const changeWords = (f: Extract<ProposeChangeError, object>): string => | |
| 9 | f.kind === "patch" ? `the patch ${f.reason.replace(/_/g, " ")}: send a unified diff of text files against the base, at most 256 KiB` | |
| 10 | : f.kind === "pull" ? `the branch ${f.branch} is on ${f.fork}; opening the pull failed (${f.reason}): send open_pull with head and headRepo` | |
| 11 | : `${f.kind} failed: ${f.reason}`; | |
| 12 | ||
| 7 | 13 | /** The MCP door to `POST /api/v1/repos/:owner/:name/pulls`: the same action, the same refusals. */ |
| 8 | 14 | export const tool: Tool = { |
| 9 | 15 | name: "open_pull", |
| 10 | doc: "`{ repo, title, head, base?, body?, headRepo? }`: open a pull into `repo` from branch `head`, of `headRepo` (`owner/name`, a fork of `repo`) when given, as POST `/api/v1/repos/:owner/:name/pulls` does. A guest sends only from their own fork, and the pull runs nothing until a person approves it", | |
| 16 | doc: "`{ repo, title, head?, base?, body?, headRepo?, patch?, files?, checks? }`: open a pull into `repo` from branch `head`, of `headRepo` (`owner/name`, a fork of `repo`) when given, as POST `/api/v1/repos/:owner/:name/pulls` does; or send `patch` (a unified diff) or `files` (`[{ path, content }]`) with no git, committed to a branch on your fork as you, with `checks` (what you ran locally). A guest sends only from their own fork, and the pull runs nothing until a person approves it", | |
| 11 | 17 | description: "Open a pull request, from a branch or from your fork.", |
| 12 | 18 | inputSchema: { |
| 13 | 19 | type: "object", |
| @@ -18,13 +24,49 @@ export const tool: Tool = { | ||
| 18 | 24 | base: { type: "string" }, |
| 19 | 25 | body: { type: "string" }, |
| 20 | 26 | headRepo: { type: "string" }, |
| 27 | patch: { type: "string" }, | |
| 28 | files: { type: "array", items: { type: "object" } }, | |
| 29 | checks: { type: "array", items: { type: "object" } }, | |
| 21 | 30 | }, |
| 22 | required: ["repo", "title", "head"], | |
| 31 | required: ["repo", "title"], | |
| 23 | 32 | }, |
| 24 | 33 | writes: true, |
| 25 | 34 | async run(ctx, user, args) { |
| 26 | 35 | const repo = repoArg(ctx, user, args); |
| 27 | 36 | if (isAnswer(repo)) return repo; |
| 37 | if (args["patch"] !== undefined || args["files"] !== undefined) { | |
| 38 | const checks = validChecks(args["checks"]); | |
| 39 | if (!checks.ok) return bad("invalid", "checks: at most 10 of { name, result: pass|fail|skipped, detail? }"); | |
| 40 | const files = args["files"] === undefined ? undefined : validFiles(args["files"]); | |
| 41 | if (files !== undefined && !files.ok) return bad("invalid", "files: 1-50 of [{ path, content }], a safe path, content a string or null to delete, 512 KiB in all"); | |
| 42 | if (args["patch"] !== undefined && typeof args["patch"] !== "string") return bad("invalid", "patch is a unified diff, as text"); | |
| 43 | const sent = await proposeChange(ctx.app, user, repo, { | |
| 44 | title: str(args["title"]), | |
| 45 | body: str(args["body"]), | |
| 46 | base: str(args["base"]), | |
| 47 | branch: str(args["head"]), | |
| 48 | ...(files === undefined || !files.ok ? {} : { files: files.value }), | |
| 49 | ...(typeof args["patch"] === "string" ? { patch: args["patch"] } : {}), | |
| 50 | checks: checks.value, | |
| 51 | }); | |
| 52 | if (sent.ok) | |
| 53 | return good({ | |
| 54 | says: `Opened #${sent.value.pull.number} from ${sent.value.fork.ownerName}/${sent.value.fork.name}:${sent.value.branch}, committed as you. A person approves it before any check runs; then the forge runs its own.`, | |
| 55 | number: sent.value.pull.number, | |
| 56 | fork: `${sent.value.fork.ownerName}/${sent.value.fork.name}`, | |
| 57 | branch: sent.value.branch, | |
| 58 | commit: sent.value.commit, | |
| 59 | waitsForApproval: sent.value.waitsForApproval, | |
| 60 | }); | |
| 61 | const f = sent.error; | |
| 62 | if (typeof f === "string") { | |
| 63 | if (isGuestRefusal(f)) return bad(f, GUEST_REFUSALS[f]); | |
| 64 | if (f === "unverified") return bad(f, VERIFY_FIRST); | |
| 65 | return bad(f, f.replace(/_/g, " ")); | |
| 66 | } | |
| 67 | return bad(f.kind, changeWords(f)); | |
| 68 | } | |
| 69 | if (str(args["head"]) === "") return bad("invalid", "head is the branch to send, or send a patch or files"); | |
| 28 | 70 | const headSpec = str(args["headRepo"]); |
| 29 | 71 | const [owner, name, ...rest] = headSpec.split("/"); |
| 30 | 72 | const headRepo = headSpec === "" ? undefined : owner && name && rest.length === 0 ? getRepo(ctx.app.db, owner, name) : undefined; |
src/http/mcp/tools/spawn.ts +5 −1
| @@ -1,10 +1,11 @@ | ||
| 1 | import { WHERE_A_FORGE_LIVES } from "../../../spawn/contribute_words.ts"; | |
| 1 | 2 | import { spawnDoor } from "../../../spawn/door.ts"; |
| 2 | 3 | import { FOLLOW_SHORT } from "../../../spawn/follow_words.ts"; |
| 3 | 4 | import { good, bad, type Tool, APP_ARG, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | name: "spawn", |
| 7 | doc: "`{ name, apps?, crew?, direction?, fresh?, followMode?, size?, plan?, template?, keepAccess?, dryRun?, reveal? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make; `reveal: true` shows the claim link", | |
| 8 | doc: "`{ name, apps?, crew?, direction?, fresh?, purpose?, followMode?, size?, plan?, template?, keepAccess?, dryRun?, reveal? }`: a child forge to keep (`purpose: \"contribute\"` makes none: it answers the fork path), planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make; `reveal: true` shows the claim link", | |
| 8 | 9 | description: |
| 9 | 10 | "Spawn a child forge. Plans first.", |
| 10 | 11 | inputSchema: { |
| @@ -50,6 +51,7 @@ export const tool: Tool = { | ||
| 50 | 51 | type: "boolean", |
| 51 | 52 | description: "Stay an owner.", |
| 52 | 53 | }, |
| 54 | purpose: { type: "string", description: "\"contribute\" makes no forge." }, | |
| 53 | 55 | dryRun: { type: "boolean" }, |
| 54 | 56 | reveal: REVEAL_ARG, |
| 55 | 57 | }, |
| @@ -67,6 +69,7 @@ export const tool: Tool = { | ||
| 67 | 69 | const door = await spawnDoor(ctx, user, args); |
| 68 | 70 | if (door.status >= 400) |
| 69 | 71 | return bad(String(door.body["error"]), String(door.body["message"])); |
| 72 | if (door.body["created"] === false) return good(door.body); | |
| 70 | 73 | const made = door.body["dryRun"] === false; |
| 71 | 74 | const { claimUrl, ...rest } = door.body; |
| 72 | 75 | const reveal = args["reveal"] === true && typeof claimUrl === "string"; |
| @@ -77,6 +80,7 @@ export const tool: Tool = { | ||
| 77 | 80 | ? "This is the plan; nothing was made. Send dryRun: false to spawn." |
| 78 | 81 | : `Spawned ${String(door.body["name"])} at ${String(door.body["url"])}. ${reveal ? LINK_REVEALED : LINK_KEPT}${reached}`, |
| 79 | 82 | ...rest, |
| 83 | ...(made ? {} : { where: WHERE_A_FORGE_LIVES }), | |
| 80 | 84 | ...(reveal ? { claimUrl } : {}), |
| 81 | 85 | }); |
| 82 | 86 | }, |
src/http/server.ts +2
| @@ -547,6 +547,7 @@ import { flushAccess, recordAccess } from "./access_log.ts"; | ||
| 547 | 547 | import { carryPeer, clientAddress, notePeer, wasInternal, wasViaEdge, type ProxyTrust } from "./peer.ts"; |
| 548 | 548 | import { isProbe } from "../kernel/probe.ts"; |
| 549 | 549 | import { strangerCode } from "./stranger_code.ts"; |
| 550 | import { sourceArchive } from "./archive.ts"; | |
| 550 | 551 | import { takeStrangerRead } from "./stranger_reads.ts"; |
| 551 | 552 | import { parseCidrs } from "../kernel/cidr.ts"; |
| 552 | 553 | import { |
| @@ -1480,6 +1481,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1480 | 1481 | router.add("GET", "/:owner/:name/tree/:ref/*path", strangerCode(treePage)); |
| 1481 | 1482 | router.add("GET", "/:owner/:name/blob/:ref/*path", strangerCode(blobPage)); |
| 1482 | 1483 | router.add("GET", "/:owner/:name/raw/:ref/*path", strangerCode(rawBlob)); |
| 1484 | router.add("GET", "/:owner/:name/archive/:file", strangerCode(sourceArchive)); | |
| 1483 | 1485 | router.add("GET", "/:owner/:name/media/:ref/*path", strangerCode(mediaBlob)); |
| 1484 | 1486 | // Every file path at a ref, for the fuzzy finder. JSON, `canRead`-gated. |
| 1485 | 1487 | router.add("GET", "/:owner/:name/paths/:ref", pathsJson); |
src/spawn/contribute_words.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * What an agent that only wants to contribute is told, in one place: the | |
| 3 | * `contribute` tool, `/llms.txt`, `/agents`, and `spawn` with `purpose: "contribute"` all say these words. A forge is for keeping; a | |
| 4 | * change to this one is made from a fork, a working copy in the agent's own | |
| 5 | * runtime, and a pull. Fixed words: nothing here echoes a request. | |
| 6 | */ | |
| 7 | ||
| 8 | /** The path that costs the host no forge (docs/features/send-a-change.md). */ | |
| 9 | export const CONTRIBUTE_PATH: readonly string[] = [ | |
| 10 | "Fork the repository to your account (MCP `fork`, POST /api/v1/repos/{owner}/{name}/forks): a guest's fork is public, runs nothing and is bounded by the guest limits.", | |
| 11 | "Fetch the source at a commit with no git credential: curl -L https://<this forge>/{owner}/{name}/archive/<branch-or-commit>.tar.gz | tar xz (no network in your sandbox: read the MCP resource forge://source/{owner}/{name}@{ref}, ?path=src/dir for a part).", | |
| 12 | "Run the fast check in the copy: `bun scripts/gate.ts`, `bun x tsc --noEmit`, and the tests your change touches. To see it work, `bun src/main.ts` with FORGE_DATA in a temp directory, loopback only.", | |
| 13 | "Send the change without git: MCP `open_pull` with `patch` (a unified diff) or `files`, and `checks` (what you ran and how it ended). It lands as a branch on your fork with you as author, and a pull opens.", | |
| 14 | "A person approves the pull before any check runs; the forge then runs its own.", | |
| 15 | ]; | |
| 16 | ||
| 17 | export const CONTRIBUTE_ONE_LINE = | |
| 18 | "To change this forge you need no forge of your own: fork the repository, work in your own runtime on a copy of the source, send a patch with open_pull. That costs the host no forge."; | |
| 19 | ||
| 20 | /** The three places a forge of your own can live, and who pays for each. */ | |
| 21 | export const WHERE_A_FORGE_LIVES: readonly { readonly where: string; readonly costs: string }[] = [ | |
| 22 | { where: "hosted here", costs: "the host runs it in a cell and pays for its CPU, memory and disk: a forge to keep, within the host's capacity and your limits" }, | |
| 23 | { where: "on your machine", costs: "you run `bun src/main.ts` (docs/ON-YOUR-MACHINE.md): your hardware, your electricity, nothing on the host" }, | |
| 24 | { where: "on a cloud account", costs: "your operator's cloud account pays: the forge's Cloud page connects it; nothing on the host" }, | |
| 25 | ]; | |
| 26 | ||
| 27 | /** Whether a spawn says, in so many words, that it is for contributing: `purpose: "contribute"` and nothing else. */ | |
| 28 | export function wantsToContribute(raw: Record<string, unknown>): boolean { | |
| 29 | const purpose = raw["purpose"]; | |
outdated
Too broad: matches 'contributions' or 'and contribute' anywhere in a direction, so a real spawn is swallowed. Match only an explicit purpose. |
||
| 30 | return typeof purpose === "string" && purpose.trim().toLowerCase() === "contribute"; | |
| 31 | } | |
| 32 | ||
| 33 | /** What `spawn` answers then: the fork path, and that nothing was made. */ | |
| 34 | export const contributeAnswer = () => ({ | |
| 35 | dryRun: true, | |
| 36 | created: false, | |
| 37 | says: `Nothing was made: ${CONTRIBUTE_ONE_LINE} A forge to keep is another matter (send purpose: "keep").`, | |
| 38 | contribute: CONTRIBUTE_PATH, | |
| 39 | where: WHERE_A_FORGE_LIVES, | |
| 40 | }); |
src/spawn/door.ts +3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * the caller says `false` — returns the plan and changes nothing. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { contributeAnswer, wantsToContribute } from "./contribute_words.ts"; | |
| 9 | 10 | import { nameShapeVerdict } from "./name_verdict.ts"; |
| 10 | 11 | import { linkRequestVisitor } from "../perf/visitor_link.ts"; |
| 11 | 12 | import { startWatch, probeOf, type Probe } from "./live.ts"; |
| @@ -188,6 +189,8 @@ export async function spawnDoor( | ||
| 188 | 189 | const paid = opts.paid === true; |
| 189 | 190 | const anon = user === undefined; |
| 190 | 191 | if (!anon && user.kind !== "user") return spawnWords({ kind: "forbidden" }); |
| 192 | // A forge asked for in order to contribute is never made: the fork path costs the host no cell. | |
| 193 | if (wantsToContribute(raw)) return { status: 200, body: contributeAnswer() }; | |
| 191 | 194 | // A plan is free of children but not of work — it reads every catalog |
| 192 | 195 | // app it names — so a stranger's are counted per address and per forge. |
| 193 | 196 | if (anon && !paid) { |
src/system/telemetry.ts +4 −1
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * node's history. Everything here is counts, rates and names of public |
| 6 | 6 | * things — no sealed value, no private repository's content, no person. |
| 7 | 7 | */ |
| 8 | import { CONTRIBUTE_ONE_LINE, CONTRIBUTE_PATH } from "../spawn/contribute_words.ts"; | |
| 8 | 9 | import { readRepeats } from "../flow/repeats.ts"; |
| 9 | 10 | import { readVerdicts } from "../flow/culprits.ts"; |
| 10 | 11 | import type { App } from "../app.ts"; |
| @@ -262,11 +263,13 @@ export function welcome(app: App, now = new Date()) { | ||
| 262 | 263 | path: `/${i.owner}/${i.name}/issues/${i.number}`, |
| 263 | 264 | })), |
| 264 | 265 | propose: [ |
| 266 | CONTRIBUTE_ONE_LINE, | |
| 267 | ...CONTRIBUTE_PATH, | |
| 265 | 268 | "Make an account (POST /api/v1/users or /signup) and confirm its email, or attach as your person through OAuth at /mcp. A new account is a guest: it can read, comment and help build public repositories.", |
| 266 | 269 | "Fork the repository (MCP tool fork, POST /api/v1/repos/{owner}/{name}/forks, or Help build this) and change your fork with the MCP tool write_file or plain git over HTTPS with your token.", |
| 267 | 270 | "Open a pull from your fork against the default branch (MCP tool open_pull, or POST /api/v1/repos/{owner}/{name}/pulls with headRepo). From someone who cannot write here it runs nothing until a person approves that head; then it runs in the same isolated cell as the crew's, with none of the forge's data or secrets.", |
| 268 | 271 | "A person reviews it. It lands only through the gate everything passes: `bun x tsc --noEmit` and `bun test` green, a test for every behaviour you add, zero new dependencies without a reason.", |
| 269 | "To run your own apps and crew, deploy your own forge: the MCP tool spawn, or /forges/new.", | |
| 272 | "To run your own apps and crew, a forge to keep: the MCP tool spawn or /forges/new (hosted here, on your machine, or on a cloud account), never just to contribute.", | |
| 270 | 273 | "Anyone may propose. The bar decides what merges. A person lands it; an agent never does.", |
| 271 | 274 | ], |
| 272 | 275 | telemetry: |
src/tui/forge.ts +2
| @@ -18,6 +18,7 @@ import { sourceFor } from "../view/source.ts"; | ||
| 18 | 18 | import { fleetCommand } from "./screens/fleet.ts"; |
| 19 | 19 | import { dataCommand } from "./screens/data.ts"; |
| 20 | 20 | import { packsCommand } from "./screens/packs.ts"; |
| 21 | import { sendChangeCommand } from "./screens/send_change.ts"; | |
| 21 | 22 | import { interruptedOutCommand } from "./screens/interrupted_out.ts"; |
| 22 | 23 | import { getForgeCommand } from "./screens/getforge.ts"; |
| 23 | 24 | import { startForgeCommand } from "./screens/startforge.ts"; |
| @@ -250,6 +251,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 250 | 251 | run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"), |
| 251 | 252 | }, |
| 252 | 253 | packsCommand(session, ctx), |
| 254 | sendChangeCommand(ctx), | |
| 253 | 255 | interruptedOutCommand(session, ctx), |
| 254 | 256 | getForgeCommand(session, ctx), |
| 255 | 257 | ...startForgeCommand(session, ctx), |
src/tui/screens/send_change.ts added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | /** | |
| 2 | * "Send a change without a forge of your own" in the terminal: the same | |
| 3 | * steps `/llms.txt` and the MCP `spawn` answer print (`spawn/contribute_words.ts`), | |
| 4 | * as a scrolling card. Anyone may open it; it reads nothing and writes nothing. | |
| 5 | */ | |
| 6 | ||
| 7 | import { CONTRIBUTE_ONE_LINE, CONTRIBUTE_PATH } from "../../spawn/contribute_words.ts"; | |
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import { style } from "../term/style.ts"; | |
| 11 | import { wrap } from "../term/width.ts"; | |
| 12 | import { Scroll } from "./common.ts"; | |
| 13 | ||
| 14 | /** The card's lines for a width. */ | |
| 15 | export function sendChangeLines(width = 80): string[] { | |
| 16 | const w = Math.max(20, width); | |
| 17 | return [...wrap(CONTRIBUTE_ONE_LINE, w), "", ...CONTRIBUTE_PATH.flatMap((step, i) => [...wrap(`${i + 1}. ${step}`, w), ""]).slice(0, -1)]; | |
| 18 | } | |
| 19 | ||
| 20 | export function sendChangeScreen(ctx: Ctx): Screen { | |
| 21 | const scroll = new Scroll(); | |
| 22 | let lines: string[] = []; | |
| 23 | return { | |
| 24 | title: "Send a change", | |
| 25 | keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")], | |
| 26 | render(area, s) { | |
| 27 | const c = ctx.theme.c; | |
| 28 | lines = sendChangeLines(area.w - 4); | |
| 29 | const from = scroll.window(area.h, lines.length); | |
| 30 | for (let i = 0; i < area.h; i++) { | |
| 31 | const line = lines[from + i]; | |
| 32 | if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w); | |
| 33 | } | |
| 34 | }, | |
| 35 | key(e) { | |
| 36 | if (e.key === "esc" || e.key === "q") { | |
| 37 | ctx.pop(); | |
| 38 | return true; | |
| 39 | } | |
| 40 | return scroll.key(e, lines.length); | |
| 41 | }, | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | export const sendChangeCommand = (ctx: Ctx) => ({ | |
| 46 | id: "send-change", | |
| 47 | title: "Send a change", | |
| 48 | hint: "fork, patch, pull: no forge of your own", | |
| 49 | run: () => ctx.push(sendChangeScreen(ctx)), | |
| 50 | }); |
src/web/standards.ts +1 −1
| @@ -478,7 +478,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 478 | 478 | "", |
| 479 | 479 | "## Offer a change", |
| 480 | 480 | "", |
| 481 | `- Needs an account. Fork: POST ${o}/api/v1/repos/{owner}/{name}/forks; push a branch to your fork over git; then POST ${o}/api/v1/repos/{owner}/{name}/pulls with {"title", "body", "base", "head", "headRepo": "you/name"}.`, | |
| 481 | `- Account only. Fork (MCP fork); source: ${o}/{owner}/{name}/archive/{ref}.tar.gz; send: MCP open_pull with patch, or POST ${o}/api/v1/repos/{owner}/{name}/pulls with {"title", "body", "base", "head", "headRepo": "you/name"}.`, | |
| 482 | 482 | "", |
| 483 | 483 | "## Use it from your harness", |
| 484 | 484 | "", |
test/contribute-no-forge.test.ts added +313
| @@ -0,0 +1,313 @@ | ||
| 1 | /** | |
| 2 | * An agent that only wants to contribute works from its own runtime and a | |
| 3 | * fork, and the host makes no forge for it (#11837). One test walks the way a | |
| 4 | * chat agent goes with no account-level powers: read `contribute`, fork, take | |
| 5 | * the source archive, send a patch with no git; then asserts the pull exists | |
| 6 | * on the guest's fork, waits for a person, and that no child forge was made. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { listChildrenNow } from "../src/actions/spawn.ts"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { getPull } from "../src/core/pulls.ts"; | |
| 17 | import { getForkOf, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { createToken } from "../src/core/tokens.ts"; | |
| 19 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 20 | import { markVerified } from "../src/core/verify.ts"; | |
| 21 | import { runGit } from "../src/git/env.ts"; | |
| 22 | import { repoDir } from "../src/git/repos.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { guestWaits } from "../src/origins/guest.ts"; | |
| 25 | import { setLimit } from "../src/settings/limits.ts"; | |
| 26 | import { ARCHIVES_AT_ONCE, ARCHIVES_PER_MINUTE, forgetArchives, takeArchive } from "../src/http/archive_limit.ts"; | |
| 27 | import { notePeer } from "../src/http/peer.ts"; | |
| 28 | import { sendChangeLines } from "../src/tui/screens/send_change.ts"; | |
| 29 | import { wantsToContribute } from "../src/spawn/contribute_words.ts"; | |
| 30 | import { quietLogs } from "./helpers/globals.ts"; | |
| 31 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 32 | ||
| 33 | quietLogs(); | |
| 34 | ||
| 35 | const PATCH = `diff --git a/README.md b/README.md | |
| 36 | --- a/README.md | |
| 37 | +++ b/README.md | |
| 38 | @@ -1 +1,2 @@ | |
| 39 | hi | |
| 40 | +there | |
| 41 | diff --git a/NEW.md b/NEW.md | |
| 42 | new file mode 100644 | |
| 43 | --- /dev/null | |
| 44 | +++ b/NEW.md | |
| 45 | @@ -0,0 +1 @@ | |
| 46 | +new | |
| 47 | `; | |
| 48 | ||
| 49 | describe("contributing costs the host no forge", () => { | |
| 50 | let dataDir = ""; | |
| 51 | let app: App; | |
| 52 | let servers: Servers; | |
| 53 | let owner: User; | |
| 54 | let admin: { token: string }; | |
| 55 | let lib: Repo; | |
| 56 | let n = 0; | |
| 57 | ||
| 58 | async function guest(): Promise<{ user: User; token: string }> { | |
| 59 | const username = `chat${++n}`; | |
| 60 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@test.dev`, password: "correct-horse-battery" }); | |
| 61 | if (!made.ok) throw new Error(made.error); | |
| 62 | markVerified(app.db, made.value.id, new Date().toISOString()); | |
| 63 | app.db.query("UPDATE user SET created_at = ?1 WHERE id = ?2").run("2026-01-01T00:00:00.000Z", made.value.id); | |
| 64 | return { user: getUserByName(app.db, username)!, token: createToken(app.db, made.value.id, "t", "rw").token }; | |
| 65 | } | |
| 66 | ||
| 67 | beforeAll(async () => { | |
| 68 | dataDir = await mkdtemp(join(tmpdir(), "forge-contribute-no-forge-")); | |
| 69 | app = createApp({ dataDir, port: 0 }); | |
| 70 | servers = await serve(app); | |
| 71 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "olive", email: "[email protected]", password: "correct-horse-battery" }); | |
| 72 | if (!made.ok) throw new Error(made.error); | |
| 73 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.id); | |
| 74 | markVerified(app.db, made.value.id, new Date().toISOString()); | |
| 75 | owner = getUserByName(app.db, "olive")!; | |
| 76 | admin = { token: createToken(app.db, owner.id, "t", "admin").token }; | |
| 77 | const repo = await createRepo(app, owner, { name: "lib", visibility: "public" }); | |
| 78 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 79 | const seeded = await commitToBranch(app, owner, repo.value, { message: "Start", files: [{ path: "README.md", content: "hi\n" }] }); | |
| 80 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 81 | lib = getRepoById(app.db, repo.value.id)!; | |
| 82 | setLimit(app.db, "signup.per_source", 1000, "setup"); | |
| 83 | setLimit(app.db, "guest.forks_forge_per_day", 1000, "setup"); | |
| 84 | }, 60_000); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | servers.stop(); | |
| 88 | app.close(); | |
| 89 | await rm(dataDir, { recursive: true, force: true }); | |
| 90 | }); | |
| 91 | ||
| 92 | test("read contribute, fork, fetch the archive, send a patch: a pull on the fork's branch, no forge made", async () => { | |
| 93 | const { user, token } = await guest(); | |
| 94 | const children = listChildrenNow(app).length; | |
| 95 | ||
| 96 | // 1. contribute says the fork path first. | |
| 97 | const welcome = await mcpTool(servers.url, token, "contribute"); | |
| 98 | expect(welcome.value.propose[0]).toContain("costs the host no forge"); | |
| 99 | expect(welcome.value.propose.join("\n")).toContain("/archive/"); | |
| 100 | ||
| 101 | // 2. fork. | |
| 102 | const forked = await mcpTool(servers.url, token, "fork", { repo: "olive/lib" }); | |
| 103 | expect(forked.value.repo).toBe(`${user.username}/lib`); | |
| 104 | ||
| 105 | // 3. the source, over HTTPS with no credential. | |
| 106 | const res = await fetch(`${servers.url}/olive/lib/archive/main.tar.gz`); | |
| 107 | expect(res.status).toBe(200); | |
| 108 | expect(res.headers.get("content-type")).toBe("application/gzip"); | |
| 109 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 110 | const tar = new TextDecoder("utf-8").decode(Bun.gunzipSync(new Uint8Array(await res.arrayBuffer()))); | |
| 111 | expect(tar).toContain("/README.md"); | |
| 112 | expect(tar).not.toContain(".git/"); | |
| 113 | ||
| 114 | // 4. a patch and what was run locally, in one call. | |
| 115 | const sent = await mcpTool(servers.url, token, "open_pull", { | |
| 116 | repo: "olive/lib", | |
| 117 | title: "Say there", | |
| 118 | body: "Adds a word.", | |
| 119 | patch: PATCH, | |
| 120 | checks: [{ name: "bun scripts/gate.ts", result: "pass" }, { name: "bun test test/x.test.ts", result: "pass", detail: "3 tests" }], | |
| 121 | }); | |
| 122 | expect(sent.error).toBeUndefined(); | |
| 123 | expect(sent.value.fork).toBe(`${user.username}/lib`); | |
| 124 | expect(sent.value.waitsForApproval).toBe(true); | |
| 125 | ||
| 126 | const pull = getPull(app.db, lib.id, sent.value.number)!; | |
| 127 | expect(pull.authorId).toBe(user.id); | |
| 128 | expect(pull.headRepoId).toBe(getForkOf(app.db, user.id, lib.id)!.id); | |
| 129 | expect(guestWaits(app.db, pull)).toBe(true); | |
| 130 | const body = app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE id = ?1").get(pull.id)!.body; | |
| 131 | expect(body).toContain("bun scripts/gate.ts: pass"); | |
| 132 | expect(body).toContain("its word; the forge runs its own"); | |
| 133 | ||
| 134 | // The branch is on the guest's fork, the commit theirs, the files whole. | |
| 135 | const fork = getForkOf(app.db, user.id, lib.id)!; | |
| 136 | const dir = repoDir(app.reposRoot, fork.ownerName, fork.name); | |
| 137 | const log = await runGit(["log", "-1", "--format=%an", pull.headBranch], { cwd: dir }); | |
| 138 | expect(log.ok && log.value).toBe(user.username); | |
| 139 | const readme = await runGit(["show", `${pull.headBranch}:README.md`], { cwd: dir }); | |
| 140 | expect(readme.ok && readme.value).toBe("hi\nthere"); | |
| 141 | const added = await runGit(["show", `${pull.headBranch}:NEW.md`], { cwd: dir }); | |
| 142 | expect(added.ok && added.value).toBe("new"); | |
| 143 | ||
| 144 | // No cell, no forge, on the host. | |
| 145 | expect(listChildrenNow(app).length).toBe(children); | |
| 146 | }); | |
| 147 | ||
| 148 | test("files go the same way, and a patch that does not apply, or is a binary, writes nothing", async () => { | |
| 149 | const { token } = await guest(); | |
| 150 | const ok = await mcpTool(servers.url, token, "open_pull", { repo: "olive/lib", title: "A file", files: [{ path: "docs/a.md", content: "a\n" }] }); | |
| 151 | expect(ok.error).toBeUndefined(); | |
| 152 | const bad = await mcpTool(servers.url, token, "open_pull", { | |
| 153 | repo: "olive/lib", | |
| 154 | title: "Stale", | |
| 155 | patch: "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-nothing like it\n+x\n", | |
| 156 | }); | |
| 157 | expect(bad.error).toContain("does not apply"); | |
| 158 | const both = await mcpTool(servers.url, token, "open_pull", { repo: "olive/lib", title: "Both", patch: PATCH, files: [{ path: "a", content: "b" }] }); | |
| 159 | expect(both.error).toContain("invalid"); | |
| 160 | const escape = await mcpTool(servers.url, token, "open_pull", { repo: "olive/lib", title: "Up", files: [{ path: "../x", content: "b" }] }); | |
| 161 | expect(escape.error).toBeDefined(); | |
| 162 | const link = await mcpTool(servers.url, token, "open_pull", { | |
| 163 | repo: "olive/lib", | |
| 164 | title: "Link", | |
| 165 | patch: "diff --git a/l b/l\nnew file mode 120000\n--- /dev/null\n+++ b/l\n@@ -0,0 +1 @@\n+/etc/passwd\n\\ No newline at end of file\n", | |
| 166 | }); | |
| 167 | expect(link.error).toBeDefined(); | |
| 168 | const wrongChecks = await mcpTool(servers.url, token, "open_pull", { repo: "olive/lib", title: "C", files: [{ path: "c", content: "c" }], checks: [{ name: "x", result: "great" }] }); | |
| 169 | expect(wrongChecks.error).toContain("checks"); | |
| 170 | }); | |
| 171 | ||
| 172 | test("spawn asked to contribute creates nothing and returns the fork path", async () => { | |
| 173 | const children = listChildrenNow(app).length; | |
| 174 | const asked = await mcpTool(servers.url, admin.token, "spawn", { name: "mine", purpose: "contribute", dryRun: false }); | |
| 175 | expect(asked.value.created).toBe(false); | |
| 176 | expect(asked.value.says).toContain("Nothing was made"); | |
| 177 | expect(asked.value.contribute.join("\n")).toContain("open_pull"); | |
| 178 | expect(asked.value.where.map((w: { where: string }) => w.where)).toEqual(["hosted here", "on your machine", "on a cloud account"]); | |
| 179 | expect(listChildrenNow(app).length).toBe(children); | |
| 180 | // A plan for a forge to keep says where one can live and who pays. | |
| 181 | const plan = await mcpTool(servers.url, admin.token, "spawn", { name: "keeper" }); | |
| 182 | expect(plan.value.where.length).toBe(3); | |
| 183 | expect(wantsToContribute({ direction: "a notes app for contributors" })).toBe(false); | |
| 184 | }); | |
| 185 | ||
| 186 | test("the archive answers only what a reader may read, at a stable URL", async () => { | |
| 187 | const priv = await createRepo(app, owner, { name: "hidden", visibility: "private" }); | |
| 188 | if (!priv.ok) throw new Error(String(priv.error)); | |
| 189 | await commitToBranch(app, owner, priv.value, { message: "s", files: [{ path: "a", content: "a" }] }); | |
| 190 | expect((await fetch(`${servers.url}/olive/hidden/archive/main.tar.gz`)).status).toBe(404); | |
| 191 | expect((await fetch(`${servers.url}/olive/lib/archive/nope.tar.gz`)).status).toBe(404); | |
| 192 | expect((await fetch(`${servers.url}/olive/lib/archive/main.zip`)).status).toBe(404); | |
| 193 | const oid = (await runGit(["rev-parse", "main"], { cwd: repoDir(app.reposRoot, "olive", "lib") }))!; | |
| 194 | const pinned = await fetch(`${servers.url}/olive/lib/archive/${oid.ok ? oid.value : ""}.tar.gz?path=README.md`); | |
| 195 | expect(pinned.status).toBe(200); | |
| 196 | expect(pinned.headers.get("cache-control")).toContain("immutable"); | |
| 197 | }); | |
| 198 | ||
| 199 | test("the same bytes are an MCP resource for a runtime with no network; large trees are refused with advice", async () => { | |
| 200 | forgetArchives(); | |
| 201 | const { token } = await guest(); | |
| 202 | const rpc = async (method: string, params: unknown) => | |
| 203 | ((await (await fetch(`${servers.url}/mcp`, { | |
| 204 | method: "POST", | |
| 205 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 206 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), | |
| 207 | })).json()) as any); | |
| 208 | const templates = await rpc("resources/templates/list", {}); | |
| 209 | expect(templates.result.resourceTemplates[0].uriTemplate).toContain("forge://source/"); | |
| 210 | const read = await rpc("resources/read", { uri: "forge://source/olive/lib@main" }); | |
| 211 | const tar = new TextDecoder("utf-8").decode(Bun.gunzipSync(Buffer.from(read.result.contents[0].blob, "base64"))); | |
| 212 | expect(tar).toContain("/README.md"); | |
| 213 | expect((await rpc("resources/read", { uri: "forge://source/olive/hidden@main" })).error.code).toBe(-32002); | |
| 214 | expect((await rpc("resources/read", { uri: "forge://source/olive/lib@main?path=../x" })).error.code).toBe(-32002); | |
| 215 | }); | |
| 216 | ||
| 217 | test("spawn acts only on an explicit purpose: an ordinary direction is still a real spawn plan", async () => { | |
| 218 | expect(wantsToContribute({ direction: "an app for contributions and contributors, and contribute back" })).toBe(false); | |
| 219 | expect(wantsToContribute({ fresh: "a tool, and contribute" })).toBe(false); | |
| 220 | expect(wantsToContribute({ purpose: "Contribute" })).toBe(true); | |
| 221 | expect(wantsToContribute({ purpose: "keep" })).toBe(false); | |
| 222 | const before = listChildrenNow(app).length; | |
| 223 | const plain = await mcpTool(servers.url, admin.token, "spawn", { name: "keeper2", direction: "track contributions and pull requests" }); | |
| 224 | expect(plain.value.created).toBeUndefined(); | |
| 225 | expect(plain.value.where.length).toBe(3); | |
| 226 | const asked = await mcpTool(servers.url, admin.token, "spawn", { name: "keeper3", purpose: "contribute" }); | |
| 227 | expect(asked.value.created).toBe(false); | |
| 228 | expect(asked.value.contribute.length).toBeGreaterThan(3); | |
| 229 | expect(listChildrenNow(app).length).toBe(before); | |
| 230 | }); | |
| 231 | ||
| 232 | test("the archive is bounded per source address and by builds at once, and a refusal frees nothing it did not take", () => { | |
| 233 | forgetArchives(); | |
| 234 | const from = (address: string) => { | |
| 235 | const r = new Request("http://x/archive"); | |
| 236 | notePeer(r, address); | |
| 237 | return r; | |
| 238 | }; | |
| 239 | const taken = []; | |
| 240 | for (let i = 0; i < ARCHIVES_AT_ONCE; i++) { | |
| 241 | const t = takeArchive(from(`10.0.0.${i + 1}`)); | |
| 242 | expect(t.ok).toBe(true); | |
| 243 | taken.push(t); | |
| 244 | } | |
| 245 | const busy = takeArchive(from("10.0.0.9")); | |
| 246 | expect(busy).toMatchObject({ ok: false, why: "busy" }); | |
| 247 | for (const t of taken) if (t.ok) t.done(); | |
| 248 | for (const t of taken) if (t.ok) t.done(); // twice: no double free | |
| 249 | expect(takeArchive(from("10.0.0.9")).ok).toBe(true); | |
| 250 | forgetArchives(); | |
| 251 | for (let i = 0; i < ARCHIVES_PER_MINUTE; i++) { | |
| 252 | const t = takeArchive(from("10.0.0.5")); | |
| 253 | expect(t.ok).toBe(true); | |
| 254 | if (t.ok) t.done(); | |
| 255 | } | |
| 256 | const limited = takeArchive(from("10.0.0.5")); | |
| 257 | expect(limited).toMatchObject({ ok: false, why: "rate" }); | |
| 258 | expect(takeArchive(from("10.0.0.6")).ok).toBe(true); // another source is not held | |
| 259 | expect(takeArchive(from("10.0.0.5"), Date.now() + 61_000).ok).toBe(true); // the minute passed | |
| 260 | forgetArchives(); | |
| 261 | }); | |
| 262 | ||
| 263 | test("the MCP resource is admitted like the route: a seventh read in a minute is refused, and it shares the route's bucket", async () => { | |
| 264 | forgetArchives(); | |
| 265 | const { token } = await guest(); | |
| 266 | const read = async () => | |
| 267 | ((await (await fetch(`${servers.url}/mcp`, { | |
| 268 | method: "POST", | |
| 269 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 270 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri: "forge://source/olive/lib@main" } }), | |
| 271 | })).json()) as any); | |
| 272 | for (let i = 0; i < ARCHIVES_PER_MINUTE; i++) expect((await read()).result.contents[0].mimeType).toBe("application/gzip"); | |
| 273 | const seventh = await read(); | |
| 274 | expect(seventh.error.code).toBe(-32002); | |
| 275 | expect(seventh.error.message).toContain("too many archives"); | |
| 276 | expect((await fetch(`${servers.url}/olive/lib/archive/main.tar.gz`)).status).toBe(429); | |
| 277 | forgetArchives(); | |
| 278 | }); | |
| 279 | ||
| 280 | test("the route answers 429 when a source asks too often", async () => { | |
| 281 | forgetArchives(); | |
| 282 | let last = 0; | |
| 283 | for (let i = 0; i <= ARCHIVES_PER_MINUTE; i++) last = (await fetch(`${servers.url}/olive/lib/archive/main.tar.gz`)).status; | |
| 284 | expect(last).toBe(429); | |
| 285 | forgetArchives(); | |
| 286 | }); | |
| 287 | ||
| 288 | test("reported checks are escaped where the pull shows them, and files are validated at the boundary", async () => { | |
| 289 | forgetArchives(); | |
| 290 | const { token } = await guest(); | |
| 291 | const sent = await mcpTool(servers.url, token, "open_pull", { | |
| 292 | repo: "olive/lib", | |
| 293 | title: "Markup", | |
| 294 | files: [{ path: "m.md", content: "m\n" }], | |
| 295 | checks: [{ name: "<script>alert(1)</script>", result: "pass", detail: "<img src=x onerror=alert(2)>" }], | |
| 296 | }); | |
| 297 | expect(sent.error).toBeUndefined(); | |
| 298 | const page = await (await fetch(`${servers.url}/olive/lib/pulls/${sent.value.number}`)).text(); | |
| 299 | expect(page).not.toContain("<script>alert(1)"); | |
| 300 | expect(page).not.toContain("<img src=x"); | |
| 301 | expect(page).toContain("<script>alert(1)"); | |
| 302 | for (const files of [[{ path: "../x", content: "x" }], [{ path: "a", content: 5 }], [{ path: "a" }], [], "a"]) { | |
| 303 | const r = await mcpTool(servers.url, token, "open_pull", { repo: "olive/lib", title: "Bad", files }); | |
| 304 | expect(r.error).toContain("invalid"); | |
| 305 | } | |
| 306 | }); | |
| 307 | ||
| 308 | test("the terminal's card says the same steps", () => { | |
| 309 | const text = sendChangeLines(60).join("\n"); | |
| 310 | expect(text).toContain("open_pull"); | |
| 311 | expect(text).toContain("archive"); | |
| 312 | }); | |
| 313 | }); |
test/no-polling.test.ts +1
| @@ -285,6 +285,7 @@ const ORIGIN = "needs a connected origin account; the seed connects none"; | ||
| 285 | 285 | const NOT_WALKED: Record<string, string> = { |
| 286 | 286 | "/:owner/-/teams/:team": "needs an organization and a team", |
| 287 | 287 | "/:owner/:name/app/:env/open": "needs a running app environment to open", |
| 288 | "/:owner/:name/archive/:file": "a tarball download, not a page; the file name is one the seed cannot guess", | |
| 288 | 289 | "/:owner/:name/bets/:id": "needs a proposed bet", |
| 289 | 290 | "/:owner/:name/builds/:label/cart": "needs a pinned build with a cart", |
| 290 | 291 | "/:owner/:name/builds/:label/cart.png": "needs a pinned build with a cart", |
test/pages-budget.test.ts +1 −1
| @@ -50,7 +50,7 @@ const MAX_UNFIXTURED = 147; | ||
| 50 | 50 | const NOT_PAGES = new Set([ |
| 51 | 51 | "/events", "/system/stream", "/settings/teams/stream", "/fleet/graph/live", |
| 52 | 52 | "/settings/traffic/scrapes/export", "/settings/backups/live", "/:owner/:name/stream", |
| 53 | "/settings/origins/flows/:id/stream", | |
| 53 | "/settings/origins/flows/:id/stream", "/:owner/:name/archive/:file", // a tarball download, not a page | |
| 54 | 54 | ]); |
| 55 | 55 | |
| 56 | 56 | /** Patterns with an id in them, and the path the fixture fills them with. */ |
test/palette-roster.test.ts +4
| @@ -51,6 +51,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 51 | 51 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 52 | 52 | ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"], |
| 53 | 53 | ["pack-repos", "Pack repositories", "which repositories /packs lists"], |
| 54 | ["send-change", "Send a change", "fork, patch, pull: no forge of your own"], | |
| 54 | 55 | ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"], |
| 55 | 56 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 56 | 57 | ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"], |
| @@ -210,6 +211,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 210 | 211 | ["explore", "Explore", "on the web"], |
| 211 | 212 | ["explore-feed", "Explore feed", "Atom address"], |
| 212 | 213 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 214 | ["send-change", "Send a change", "fork, patch, pull: no forge of your own"], | |
| 213 | 215 | ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"], |
| 214 | 216 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 215 | 217 | ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"], |
| @@ -298,6 +300,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 298 | 300 | ["explore", "Explore", "on the web"], |
| 299 | 301 | ["explore-feed", "Explore feed", "Atom address"], |
| 300 | 302 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 303 | ["send-change", "Send a change", "fork, patch, pull: no forge of your own"], | |
| 301 | 304 | ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"], |
| 302 | 305 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 303 | 306 | ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"], |
| @@ -381,6 +384,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 381 | 384 | ["explore", "Explore", "on the web"], |
| 382 | 385 | ["explore-feed", "Explore feed", "Atom address"], |
| 383 | 386 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 387 | ["send-change", "Send a change", "fork, patch, pull: no forge of your own"], | |
| 384 | 388 | ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"], |
| 385 | 389 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 386 | 390 | ["start-forge", "Start my forge", "a free forge of your own; you choose a username and password next"], |
test/security-floor-routes.test.ts +1
| @@ -68,6 +68,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 68 | 68 | "/docs/:file": "a file name of the documentation folder; the seed supplies no :file", |
| 69 | 69 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 70 | 70 | "/fleet/ops/:id": FLEET, |
| 71 | "/:owner/:name/archive/:file": "a tarball download of a ref, not a page; the file name is one the seed cannot guess", | |
| 71 | 72 | "/:owner/:name/bets/:id": BET, |
| 72 | 73 | "/:owner/:name/chapters/:chapter": "needs a chapter, which the seed does not draft (test/chapters.test.ts covers it)", |
| 73 | 74 | "/:owner/:name/builds/:label/cart": CART, |
new above
offline
Anonymous, uncapped-rate archive build (up to 64 MiB, 60 s of git). Add a per-socket-address limit and a concurrency cap before this.