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

Merged opened this in answer to

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

Merged

torque/forge:torque/11837-an-agent-that-only-wants-to-contribute-w → main 7 commits fork 3 threads · 3 unresolved

  • Merged. torque/11837-an-agent-that-only-wants-to-contribute-w was merged into main as 53fc2b4.

Review

Sign in to review this change.
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`;
152152 | `next_up` | — | The ranked list the crew builds from; `issue` + `to` (top, not-now, after:N) moves one | 349 |
153153 | `notify` | — | Read, set or clear your phone notify URL | 146 |
154154 | `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 |
156156 | `org_create` | — | Make an organization | 151 |
157157 | `org_teams` | `create`, `add_member`, `members`, `metrics`, `remove_member`, `remove` | An org's teams: create, add member with a role, list members, remove | 682 |
158158 | `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`;
199199 | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 324 |
200200 | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 |
201201 | `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 |
203203 | `spend` | — | Report what your session spent outside forge runs: who, model, tokens or dollars, issue | 470 |
204204 | `ssh_keys` | `list`, `add`, `remove` | Your SSH keys; add: key, remove: id | 250 |
205205 | `steer` | — | Say a word to one crew run by its id | 224 |
docs/README.md +1
@@ -22,6 +22,7 @@ platform for organizations.
2222
2323 - [Run it](REFERENCE.md#run-it): start a forge and create the first admin.
2424 - [On your machine](ON-YOUR-MACHINE.md): the local copy, offline work and the binary.
25 - [Send a change without a forge](features/send-a-change.md): fork, source archive, patch, pull.
2526
2627 ## Connect forges
2728
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 scripts/fast.ts) && bun test $files # 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=$(mktemp -d) 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);
outdated

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.

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 @@
1717 * no page. `ro` tokens read; `rw` tokens act.
1818 */
1919
20 import { readSource, SOURCE_PREFIX, SOURCE_TEMPLATE } from "./mcp/source_resource.ts";
2021 import { CARD_KINDS, CARD_MIME, CARD_OF_TOOL, CARD_SHELL, CARD_URI, cardFor } from "../web/studio/cards.ts";
2122 import { getRepo } from "../core/repos.ts";
2223 import { scopeAllows } from "../core/tokens.ts";
@@ -177,8 +178,16 @@ function rpcOne(
177178 mimeType: CARD_MIME,
178179 })),
179180 });
181 case "resources/templates/list":
182 return reply({
183 resourceTemplates: [{ uriTemplate: SOURCE_TEMPLATE, name: "forge-source", mimeType: "application/gzip" }],
184 });
180185 case "resources/read": {
181186 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 );
182191 const kind = CARD_KINDS.find((k) => CARD_URI(k) === uri);
183192 if (kind === undefined) return refuse(-32002, `no resource ${uri}`);
184193 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 @@
11 import { openPull } from "../../../actions/pulls.ts";
2 import { proposeChange, validChecks, validFiles, type ProposeChangeError } from "../../../actions/propose_change.ts";
23 import { VERIFY_FIRST } from "../../../actions/verify_words.ts";
34 import { GUEST_REFUSALS, isGuestRefusal } from "../../../core/contribute.ts";
45 import { getRepo } from "../../../core/repos.ts";
56 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
67
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
713 /** The MCP door to `POST /api/v1/repos/:owner/:name/pulls`: the same action, the same refusals. */
814 export const tool: Tool = {
915 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",
1117 description: "Open a pull request, from a branch or from your fork.",
1218 inputSchema: {
1319 type: "object",
@@ -18,13 +24,49 @@ export const tool: Tool = {
1824 base: { type: "string" },
1925 body: { type: "string" },
2026 headRepo: { type: "string" },
27 patch: { type: "string" },
28 files: { type: "array", items: { type: "object" } },
29 checks: { type: "array", items: { type: "object" } },
2130 },
22 required: ["repo", "title", "head"],
31 required: ["repo", "title"],
2332 },
2433 writes: true,
2534 async run(ctx, user, args) {
2635 const repo = repoArg(ctx, user, args);
2736 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");
2870 const headSpec = str(args["headRepo"]);
2971 const [owner, name, ...rest] = headSpec.split("/");
3072 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";
12 import { spawnDoor } from "../../../spawn/door.ts";
23 import { FOLLOW_SHORT } from "../../../spawn/follow_words.ts";
34 import { good, bad, type Tool, APP_ARG, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } from "../shared.ts";
45
56 export const tool: Tool = {
67 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",
89 description:
910 "Spawn a child forge. Plans first.",
1011 inputSchema: {
@@ -50,6 +51,7 @@ export const tool: Tool = {
5051 type: "boolean",
5152 description: "Stay an owner.",
5253 },
54 purpose: { type: "string", description: "\"contribute\" makes no forge." },
5355 dryRun: { type: "boolean" },
5456 reveal: REVEAL_ARG,
5557 },
@@ -67,6 +69,7 @@ export const tool: Tool = {
6769 const door = await spawnDoor(ctx, user, args);
6870 if (door.status >= 400)
6971 return bad(String(door.body["error"]), String(door.body["message"]));
72 if (door.body["created"] === false) return good(door.body);
7073 const made = door.body["dryRun"] === false;
7174 const { claimUrl, ...rest } = door.body;
7275 const reveal = args["reveal"] === true && typeof claimUrl === "string";
@@ -77,6 +80,7 @@ export const tool: Tool = {
7780 ? "This is the plan; nothing was made. Send dryRun: false to spawn."
7881 : `Spawned ${String(door.body["name"])} at ${String(door.body["url"])}. ${reveal ? LINK_REVEALED : LINK_KEPT}${reached}`,
7982 ...rest,
83 ...(made ? {} : { where: WHERE_A_FORGE_LIVES }),
8084 ...(reveal ? { claimUrl } : {}),
8185 });
8286 },
src/http/server.ts +2
@@ -547,6 +547,7 @@ import { flushAccess, recordAccess } from "./access_log.ts";
547547 import { carryPeer, clientAddress, notePeer, wasInternal, wasViaEdge, type ProxyTrust } from "./peer.ts";
548548 import { isProbe } from "../kernel/probe.ts";
549549 import { strangerCode } from "./stranger_code.ts";
550 import { sourceArchive } from "./archive.ts";
550551 import { takeStrangerRead } from "./stranger_reads.ts";
551552 import { parseCidrs } from "../kernel/cidr.ts";
552553 import {
@@ -1480,6 +1481,7 @@ export function buildRouter(): Router<Handler> {
14801481 router.add("GET", "/:owner/:name/tree/:ref/*path", strangerCode(treePage));
14811482 router.add("GET", "/:owner/:name/blob/:ref/*path", strangerCode(blobPage));
14821483 router.add("GET", "/:owner/:name/raw/:ref/*path", strangerCode(rawBlob));
1484 router.add("GET", "/:owner/:name/archive/:file", strangerCode(sourceArchive));
14831485 router.add("GET", "/:owner/:name/media/:ref/*path", strangerCode(mediaBlob));
14841486 // Every file path at a ref, for the fuzzy finder. JSON, `canRead`-gated.
14851487 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 @@
66 * the caller says `false` — returns the plan and changes nothing.
77 */
88
9 import { contributeAnswer, wantsToContribute } from "./contribute_words.ts";
910 import { nameShapeVerdict } from "./name_verdict.ts";
1011 import { linkRequestVisitor } from "../perf/visitor_link.ts";
1112 import { startWatch, probeOf, type Probe } from "./live.ts";
@@ -188,6 +189,8 @@ export async function spawnDoor(
188189 const paid = opts.paid === true;
189190 const anon = user === undefined;
190191 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() };
191194 // A plan is free of children but not of work — it reads every catalog
192195 // app it names — so a stranger's are counted per address and per forge.
193196 if (anon && !paid) {
src/system/telemetry.ts +4 −1
@@ -5,6 +5,7 @@
55 * node's history. Everything here is counts, rates and names of public
66 * things — no sealed value, no private repository's content, no person.
77 */
8 import { CONTRIBUTE_ONE_LINE, CONTRIBUTE_PATH } from "../spawn/contribute_words.ts";
89 import { readRepeats } from "../flow/repeats.ts";
910 import { readVerdicts } from "../flow/culprits.ts";
1011 import type { App } from "../app.ts";
@@ -262,11 +263,13 @@ export function welcome(app: App, now = new Date()) {
262263 path: `/${i.owner}/${i.name}/issues/${i.number}`,
263264 })),
264265 propose: [
266 CONTRIBUTE_ONE_LINE,
267 ...CONTRIBUTE_PATH,
265268 "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.",
266269 "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.",
267270 "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.",
268271 "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.",
270273 "Anyone may propose. The bar decides what merges. A person lands it; an agent never does.",
271274 ],
272275 telemetry:
src/tui/forge.ts +2
@@ -18,6 +18,7 @@ import { sourceFor } from "../view/source.ts";
1818 import { fleetCommand } from "./screens/fleet.ts";
1919 import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
21 import { sendChangeCommand } from "./screens/send_change.ts";
2122 import { interruptedOutCommand } from "./screens/interrupted_out.ts";
2223 import { getForgeCommand } from "./screens/getforge.ts";
2324 import { startForgeCommand } from "./screens/startforge.ts";
@@ -250,6 +251,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
250251 run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"),
251252 },
252253 packsCommand(session, ctx),
254 sendChangeCommand(ctx),
253255 interruptedOutCommand(session, ctx),
254256 getForgeCommand(session, ctx),
255257 ...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
478478 "",
479479 "## Offer a change",
480480 "",
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"}.`,
482482 "",
483483 "## Use it from your harness",
484484 "",
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("&lt;script&gt;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";
285285 const NOT_WALKED: Record<string, string> = {
286286 "/:owner/-/teams/:team": "needs an organization and a team",
287287 "/: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",
288289 "/:owner/:name/bets/:id": "needs a proposed bet",
289290 "/:owner/:name/builds/:label/cart": "needs a pinned build with a cart",
290291 "/: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;
5050 const NOT_PAGES = new Set([
5151 "/events", "/system/stream", "/settings/teams/stream", "/fleet/graph/live",
5252 "/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
5454 ]);
5555
5656 /** 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", () => {
5151 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5252 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
5353 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
54 ["send-change", "Send a change", "fork, patch, pull: no forge of your own"],
5455 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
5556 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
5657 ["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",
210211 ["explore", "Explore", "on the web"],
211212 ["explore-feed", "Explore feed", "Atom address"],
212213 ["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"],
213215 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
214216 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
215217 ["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"
298300 ["explore", "Explore", "on the web"],
299301 ["explore-feed", "Explore feed", "Atom address"],
300302 ["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"],
301304 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
302305 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
303306 ["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", () => {
381384 ["explore", "Explore", "on the web"],
382385 ["explore-feed", "Explore feed", "Atom address"],
383386 ["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"],
384388 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
385389 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
386390 ["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> = {
6868 "/docs/:file": "a file name of the documentation folder; the seed supplies no :file",
6969 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
7070 "/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",
7172 "/:owner/:name/bets/:id": BET,
7273 "/:owner/:name/chapters/:chapter": "needs a chapter, which the seed does not draft (test/chapters.test.ts covers it)",
7374 "/:owner/:name/builds/:label/cart": CART,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs