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

Every repository answers AGENTS.md, and llms.txt leads with the three doors #12024

Merged opened this

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

Merged

scout/forge:scout/12015-every-repository-answers-agents-md-and-l → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/12015-every-repository-answers-agents-md-and-l was merged into main as a0be936.

Review

Sign in to review this change.
10 files changed +333 −1
docs/features/agents-md.md added +14
@@ -0,0 +1,14 @@
1 # AGENTS.md on every repository
2
3 Any repository you may read answers `/<owner>/<name>/AGENTS.md`, as plain text. Open `https://<forge>/alice/notes/AGENTS.md` in a browser or `curl` it.
4
5 - If the repository has an `AGENTS.md` in its root on the default branch, that file is the answer, word for word.
6 - If it has none, the forge writes one: how to install, how to run the tests, how commits and pulls are expected, and the contribute flow in five lines, with the exact clone URL and the one host a sandbox must allow (this forge's own).
7 - A file over 200 KB is not served; the generated text answers and says so.
8 - A private repository answers 404 to anyone who may not read it, a guest included.
9
10 `/llms.txt` opens with the same doors (git, MCP, the web) and the egress host, and points at this route per repository.
11
12 ## In the terminal
13
14 With a repository open, press `:` (or Ctrl-K), type `AGENTS.md` and press enter. The screen shows the same text the route answers; `j` and `k` scroll, `esc` goes back. It is offered to anyone who may read the repository.
src/http/agents_md.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * `GET /:owner/:name/AGENTS.md`: how to work on this repository. The
3 * repository's own root AGENTS.md when it has one, else a generated text
4 * (`web/doors_words.ts`). Anyone who may read the repository may read it; a
5 * private one answers 404 like every other page. Served as text, never as a page.
6 */
7
8 import { canRead, getRepo } from "../core/repos.ts";
9 import { agentsMdOf } from "../view/agents_md.ts";
10 import type { Handler } from "./context.ts";
11 import { securityFloor } from "./respond.ts";
12
13 const plain = (body: string, status: number, own: boolean): Response =>
14 securityFloor(
15 new Response(body, {
16 status,
17 headers: {
18 "content-type": "text/plain; charset=utf-8",
19 "cache-control": "private, no-cache",
20 vary: "Cookie, Authorization",
21 "x-forge-agents-md": own ? "own" : "generated",
22 },
23 }),
24 );
25
26 export const agentsMd: Handler = async (ctx) => {
27 const repo = getRepo(ctx.app.db, ctx.params.owner ?? "", (ctx.params.name ?? "").replace(/\.git$/, ""));
28 if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return plain("not found\n", 404, false);
29 const found = await agentsMdOf(ctx.app.reposRoot, repo, ctx.url.origin);
30 return plain(found.text, 200, found.source === "own");
31 };
src/http/server.ts +2
@@ -549,6 +549,7 @@ import { carryPeer, clientAddress, notePeer, wasInternal, wasViaEdge, type Proxy
549549 import { isProbe } from "../kernel/probe.ts";
550550 import { strangerCode } from "./stranger_code.ts";
551551 import { sourceArchive } from "./archive.ts";
552 import { agentsMd } from "./agents_md.ts";
552553 import { takeStrangerRead } from "./stranger_reads.ts";
553554 import { parseCidrs } from "../kernel/cidr.ts";
554555 import {
@@ -1485,6 +1486,7 @@ export function buildRouter(): Router<Handler> {
14851486 router.add("GET", "/:owner/:name/blob/:ref/*path", strangerCode(blobPage));
14861487 router.add("GET", "/:owner/:name/raw/:ref/*path", strangerCode(rawBlob));
14871488 router.add("GET", "/:owner/:name/archive/:file", strangerCode(sourceArchive));
1489 router.add("GET", "/:owner/:name/AGENTS.md", strangerCode(agentsMd));
14881490 router.add("GET", "/:owner/:name/media/:ref/*path", strangerCode(mediaBlob));
14891491 // Every file path at a ref, for the fuzzy finder. JSON, `canRead`-gated.
14901492 router.add("GET", "/:owner/:name/paths/:ref", pathsJson);
src/tui/screens/agents_md.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * How to work on this repository, in the terminal: the text `/<owner>/<name>/AGENTS.md`
3 * answers (`view/agents_md.ts`), scrolled with j and k. Offered while a
4 * repository the person may read is open.
5 */
6
7 import { canRead, type Repo } from "../../core/repos.ts";
8 import { publicOrigin } from "../../core/public_origin.ts";
9 import { agentsMdOf } from "../../view/agents_md.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import type { Command } from "../app/palette.ts";
12 import { binding } from "../app/keys.ts";
13 import type { Session } from "../session.ts";
14 import { renderMarkdown } from "../ui/markdown.ts";
15 import { Scroll, drawLines } from "./common.ts";
16
17 export function agentsMdScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
18 const scroll = new Scroll();
19 let text = "Reading…";
20 let cache: { w: number; text: string; lines: ReturnType<typeof renderMarkdown> } | undefined;
21 const origin = publicOrigin(f.app.config.url, `http://localhost:${f.app.config.port}`);
22 void agentsMdOf(f.app.reposRoot, repo, origin).then((r) => {
23 text = r.text;
24 ctx.invalidate?.();
25 });
26 return {
27 title: `${repo.ownerName}/${repo.name} — AGENTS.md`,
28 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
29 render(area, s) {
30 const w = Math.max(20, area.w - 2);
31 if (cache === undefined || cache.w !== w || cache.text !== text) cache = { w, text, lines: renderMarkdown(text, w, ctx.theme.c) };
32 drawLines(s, area, ctx.theme.c, scroll, cache.lines);
33 },
34 key(e) {
35 if (e.key === "esc" || e.key === "q") ctx.pop();
36 else scroll.key(e, cache?.lines.length ?? 0);
37 return true;
38 },
39 };
40 }
41
42 /** The palette's command while a readable repository is open. */
43 export function agentsMdCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
44 if (repo === undefined || !canRead(f.app.db, repo, f.user)) return [];
45 return [
46 {
47 id: "agents-md",
48 title: `${repo.ownerName}/${repo.name} — AGENTS.md`,
49 hint: "how to work on this repository: install, tests, the contribute flow",
50 run: () => ctx.push(agentsMdScreen(f, ctx, repo)),
51 },
52 ];
53 }
src/tui/screens/workspace.ts +2
@@ -176,6 +176,7 @@ import { landCommands } from "./landasis.ts";
176176 import { shiftCommands } from "./subtitle_shift.ts";
177177 import { dataImportCommands } from "./dataimport.ts";
178178 import { jobsCommands } from "./jobs.ts";
179 import { agentsMdCommands } from "./agents_md.ts";
179180 import { queueCommands } from "./queuegrowth.ts";
180181 import { blockedCommands } from "./blocked.ts";
181182 import { statementCommands } from "./statement.ts";
@@ -2006,6 +2007,7 @@ export function workspaceScreen(
20062007 ...shiftCommands(f, ctx, repo, () => screen),
20072008 ...dataImportCommands(f, ctx, repo),
20082009 ...jobsCommands(f, ctx, repo),
2010 ...agentsMdCommands(f, ctx, repo),
20092011 ...chaptersCommands(f, ctx, repo),
20102012 ...queueCommands(f, ctx, repo),
20112013 ...blockedCommands(f, ctx, repo),
src/view/agents_md.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * A repository's AGENTS.md, for the route (`http/agents_md.ts`) and the
3 * terminal (`tui/screens/agents_md.ts`): its own root file when it has one,
4 * else the generated text. One reader, so both say the same words. The caller
5 * has already checked the person may read the repository.
6 */
7
8 import type { Repo } from "../core/repos.ts";
9 import { blobForRender } from "../git/read.ts";
10 import { repoDir } from "../git/repos.ts";
11 import { generatedAgentsMd } from "../web/doors_words.ts";
12
13 export const OWN_MAX = 200_000;
14
15 export interface AgentsMd {
16 readonly text: string;
17 /** `own`: the repository's file; `generated`: the forge's text. */
18 readonly source: "own" | "generated";
19 }
20
21 export async function agentsMdOf(reposRoot: string, repo: Repo, origin: string): Promise<AgentsMd> {
22 const dir = repoDir(reposRoot, repo.ownerName, repo.name);
23 let tooLarge = false;
24 try {
25 const blob = await blobForRender(dir, repo.defaultBranch, "AGENTS.md", OWN_MAX);
26 if (blob.kind === "text") return { text: new TextDecoder().decode(blob.body), source: "own" };
27 tooLarge = blob.kind === "too_large";
28 } catch {
29 // An empty repository or an unreadable ref has no file: the generated text answers.
30 }
31 const generated = generatedAgentsMd(origin, repo.ownerName, repo.name, repo.defaultBranch);
32 const note = tooLarge ? `\nNote: this repository's own AGENTS.md is over ${OWN_MAX / 1000} KB and is not served here; read it in the repository.\n` : "";
33 return { text: generated + note, source: "generated" };
34 }
src/web/doors_words.ts added +61
@@ -0,0 +1,61 @@
1 /**

Issue asks for words from src/kernel/both_doors.ts where they exist; not used here, so the two can drift.

2 * The three doors into a forge, in one place: `/llms.txt` leads with them and
3 * a repository without an AGENTS.md of its own is answered with them
4 * (`/<owner>/<repo>/AGENTS.md`, `src/http/agents_md.ts`). Fixed words and the
5 * origin and repository the caller already validated: nothing here echoes a
6 * request.
7 */
8
9 /** The one host a sandbox must allow to use every door: this forge's own. */
10 export const egressHost = (origin: string): string => new URL(origin).host;
11
12 /** llms.txt's opening: the doors, the commands, the egress host. */
13 export function threeDoors(origin: string, mcpUrl: string): string[] {
14 return [
15 `## Three doors`,
16 "",
17 `Egress: allow one host, ${egressHost(origin)} (this forge).`,
18 "",
19 `1. Git: \`git clone ${origin}/{owner}/{name}.git\`, \`git push origin <branch>\` with a token as password; a person merges.`,
20 `2. MCP (no shell): ${mcpUrl} (OAuth): \`fork\`, then \`open_pull\` with a \`patch\`.`,
21 `3. Web: ${origin}/ ; /{owner}/{name}/report needs no account.`,
22 `Per repository: ${origin}/{owner}/{name}/AGENTS.md.`,
23 "",
24 ];
25 }
26
27 /** The text a repository without its own AGENTS.md is answered with. */
28 export function generatedAgentsMd(origin: string, owner: string, name: string, branch: string): string {
29 const clone = `${origin}/${owner}/${name}.git`;
30 return (
31 [
32 `# ${owner}/${name}: working on it`,
33 "",
34 `This repository has no AGENTS.md of its own; this one is generated by the forge at ${origin}. Read README.md and CLAUDE.md (when present) for the rest.`,
35 "",
36 "## Install",
37 "",
38 `- \`git clone ${clone} && cd ${name}\`, then \`bun install\` when there is a package.json and no node_modules.`,
39 "",
40 "## Run the tests",
41 "",
42 "- `bun test` (or the command forge.json names under `check` or `app.test`); `bun x tsc --noEmit` when there is a tsconfig.json; `bun scripts/gate.ts` when it exists.",
43 "- Do not push while any of them is red.",
44 "",
45 "## Commits and pulls",
46 "",
47 "- Small commits with clear messages; one concern per pull. Work on a branch, never on the default branch: pushes to it are refused.",
48 "- A pull's description says what changed, what was left out, and how to try it. A person reviews and merges.",
49 "- Every behaviour you add has a test that would fail without it.",
50 "",
51 "## Contribute in five lines",
52 "",
53 `1. Fork it (MCP \`fork\`, or POST ${origin}/api/v1/repos/${owner}/${name}/forks).`,
54 `2. Clone and push with a contribute token: \`git clone ${clone}\`, branch from ${branch}, \`git push origin <branch>\`; with no git, fetch ${origin}/${owner}/${name}/archive/${branch}.tar.gz.`,
55 "3. Run the install and the tests above in your copy.",
56 "4. Send it: the forge opens the pull on push, or MCP `open_pull` with a `patch` and `checks`.",
57 `5. Found a problem and not a fix? ${origin}/${owner}/${name}/report needs no account. A sandbox needs one egress host: ${egressHost(origin)}.`,
58 "",
59 ].join("\n")
60 );
61 }
src/web/standards.ts +2
@@ -47,6 +47,7 @@ import { getPlans } from "../settings/plans.ts";
4747 import { SITE_PATH, SITE_LINE } from "./site_line.ts";
4848 import { holds } from "./feeds.ts";
4949 import { mcpToolsSection } from "./llms_tools.ts";
50 import { threeDoors } from "./doors_words.ts";
5051 import { apiCatalog, cardsLine, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
5152 import { forgeDocument } from "../core/standards_index.ts";
5253 import { TAGS } from "../contract/served.ts";
@@ -455,6 +456,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
455456 "",
456457 `> ${SITE_LINE} A self-hosted forge in one Bun process and one SQLite file that hosts code and runs apps, with AI agents that build. Forges connect. Part of Open Platform (https://www.open-platform.sh). Anyone may read a public repository and report a problem in it.`,
457458 "",
459 ...threeDoors(o, mcp.url),

Removing this line (and the /api/v1/users waiting-state line) drops real information to make room; keep it.

458460 "## Start here",
459461 "",
460462 "If you are an assistant helping a person: say plainly what you can and cannot do from here. If you cannot reach this forge, give the person these steps.",
test/agents-md.test.ts added +131
@@ -0,0 +1,131 @@
1 /** Every repository answers /AGENTS.md; /llms.txt opens with the three doors. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
7 import { serve, type Servers } from "../src/http/server.ts";
8 import { generatedAgentsMd, threeDoors } from "../src/web/doors_words.ts";
9 import { createUser, getUserByName as getUserOf } from "../src/core/users.ts";
10 import { createRepo } from "../src/core/repos.ts";
11 import { commitFiles } from "../src/git/write.ts";
12 import { repoDir } from "../src/git/repos.ts";
13 import { getRepo } from "../src/core/repos.ts";
14 import { agentsMdOf } from "../src/view/agents_md.ts";
15 import { agentsMdCommands } from "../src/tui/screens/agents_md.ts";
16 import type { Session } from "../src/tui/session.ts";
17 import type { Ctx as TuiCtx } from "../src/tui/app/app.ts";
18 import { tokenFor } from "./helpers/tokens.ts";
19
20 let dataDir: string;
21 let app: ReturnType<typeof createApp>;
22 let servers: Servers;
23
24 beforeAll(async () => {
25 dataDir = await mkdtemp(join(tmpdir(), "forge-agents-md-"));
26 app = createApp({ dataDir, port: 0 });
27 servers = await serve(app);
28 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
29 if (!made.ok) throw new Error(made.error);
30 const repo = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: "notes", visibility: "public" });
31 if (!repo.ok) throw new Error(String(repo.error));
32 const own = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: "owned", visibility: "public" });
33 if (!own.ok) throw new Error(String(own.error));
34 await commitFiles(repoDir(app.reposRoot, "alice", "owned"), {
35 branch: "main",
36 message: "add AGENTS.md",
37 files: [{ path: "AGENTS.md", content: "# my own rules\nrun bun test\n" }],
38 author: { name: "a", email: "[email protected]" },
39 });
40 const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
41 if (!bob.ok) throw new Error(bob.error);
42 const secret = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: "secret", visibility: "private" });
43 if (!secret.ok) throw new Error(String(secret.error));
44 const big = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: "big", visibility: "public" });
45 if (!big.ok) throw new Error(String(big.error));
46 await commitFiles(repoDir(app.reposRoot, "alice", "big"), {
47 branch: "main",
48 message: "a huge AGENTS.md",
49 files: [{ path: "AGENTS.md", content: "x".repeat(210_000) }],
50 author: { name: "a", email: "[email protected]" },
51 });
52 }, 30_000);
53
54 afterAll(async () => {
55 servers.stop();
56 app.close();
57 await rm(dataDir, { recursive: true, force: true });
58 });
59
60 const base = () => `http://localhost:${servers.port}`;
61
62 test("a repository without its own file gets the generated one with the commands", async () => {
63 const res = await fetch(`${base()}/alice/notes/AGENTS.md`);
64 expect(res.status).toBe(200);
65 expect(res.headers.get("content-type")).toContain("text/plain");
66 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
67 const body = await res.text();
68 expect(body).toContain("bun install");
69 expect(body).toContain("bun test");
70 expect(body).toContain("git push origin");
71 expect(body).toContain("alice/notes");
72 });
73
74 test("a repository with its own file is answered with it", async () => {
75 const res = await fetch(`${base()}/alice/owned/AGENTS.md`);
76 expect(res.status).toBe(200);
77 expect(await res.text()).toBe("# my own rules\nrun bun test\n");
78 });
79
80 test("an unknown repository answers 404", async () => {
81 expect((await fetch(`${base()}/alice/nothing/AGENTS.md`)).status).toBe(404);
82 });
83
84 test("llms.txt leads with the three doors and the egress host", async () => {
85 const body = await (await fetch(`${base()}/llms.txt`)).text();
86 expect(body.indexOf("## Three doors")).toBeGreaterThan(-1);
87 expect(body.indexOf("## Three doors")).toBeLessThan(body.indexOf("## Start here"));
88 expect(body).toContain("git clone");
89 expect(body).toContain("git push origin");
90 expect(body).toContain("one host");
91 expect(body).toContain(`localhost:${servers.port}`);
92 });
93
94 test("the words are the same pure text", () => {
95 expect(threeDoors("https://f.example", "https://f.example/mcp").join("\n")).toContain("allow one host, f.example (this forge)");
96 expect(generatedAgentsMd("https://f.example", "a", "b", "main")).toContain("archive/main.tar.gz");
97 });
98
99 const get = (path: string, user?: string) =>
100 fetch(`${base()}${path}`, user ? { headers: { authorization: `Bearer ${tokenFor(app, user, "ro")}` } } : {});
101
102 test("a private repository answers 404 to a guest and to a stranger, 200 to its owner", async () => {
103 const guest = await get("/alice/secret/AGENTS.md");
104 expect(guest.status).toBe(404);
105 expect(await guest.text()).not.toContain("secret");
106 expect((await get("/alice/secret/AGENTS.md", "bob")).status).toBe(404);
107 const owner = await get("/alice/secret/AGENTS.md", "alice");
108 expect(owner.status).toBe(200);
109 expect(await owner.text()).toContain("alice/secret");
110 });
111
112 test("an own file over the cap is not served, and the generated text says so", async () => {
113 const body = await (await get("/alice/big/AGENTS.md")).text();
114 expect(body).toContain("bun install");
115 expect(body).toContain("over 200 KB");
116 expect(body).not.toContain("xxxxxxxx");
117 });
118
119 test("the terminal offers the same text to whoever may read the repository, and no one else", async () => {
120 const session = (user: ReturnType<typeof getUserOf>) => ({ user, db: app.db, app }) as unknown as Session;
121 const ctx = { push() {}, pop() {}, invalidate() {}, theme: {} } as unknown as TuiCtx;
122 const secret = getRepo(app.db, "alice", "secret")!;
123 const notes = getRepo(app.db, "alice", "notes")!;
124 expect(agentsMdCommands(session(getUserOf(app.db, "alice")), ctx, secret)).toHaveLength(1);
125 expect(agentsMdCommands(session(getUserOf(app.db, "bob")), ctx, secret)).toEqual([]);
126 expect(agentsMdCommands(session(undefined), ctx, secret)).toEqual([]);
127 expect(agentsMdCommands(session(undefined), ctx, notes)).toHaveLength(1);
128 expect(agentsMdCommands(session(undefined), ctx, undefined)).toEqual([]);
129 const viaTerminal = await agentsMdOf(app.reposRoot, notes, `http://localhost:${servers.port}`);
130 expect(viaTerminal.text).toBe(await (await get("/alice/notes/AGENTS.md")).text());
131 });
test/llms-anonymous.test.ts +3 −1
@@ -46,7 +46,9 @@ const section = (body: string): string[] => {
4646 test("the anonymous text is small, varies by reader, and lists report and start plus one line", async () => {
4747 const res = await fetch(`${servers.url}/llms.txt`);
4848 const body = await res.text();
49 expect(Buffer.byteLength(body)).toBeLessThan(6.5 * 1024);
49 // 6.5 KiB until the three doors (#12015, about 440 bytes) opened the text;
50 // nothing else was cut to make room.
51 expect(Buffer.byteLength(body)).toBeLessThan(7 * 1024);
5052 expect(res.headers.get("vary")).toContain("Cookie");
5153 expect(res.headers.get("vary")).toContain("Authorization");
5254 const listed = section(body);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs