Public: anyone can read this; the crew can change it.
A forge whose own repository is private still offers the Claude Code and OpenCode plugins, from its upstream #10901
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10895-a-forge-whose-own-repository-is-private → main 3 commits fork 4 threads · 4 unresolved
src/core/mcp_card.ts +18 −10
| @@ -38,16 +38,24 @@ export interface HarnessPlugin { | ||
| 38 | 38 | } |
| 39 | 39 | |
| 40 | 40 | /** |
| 41 | * The repository that holds the plugins, as `owner/name`: the forge's | |
| 42 | * configured source (admin setting, else `FORGE_REPO`), and only while it is | |
| 43 | * public here, so every printed line works for whoever reads it. The card, | |
| 44 | * /llms.txt, Settings › Tokens and the terminal all ask this one question. | |
| 41 | * The repository that holds the plugins, as `owner/name`, or the upstream's | |
| 42 | * full URL: the forge's own source (admin setting, else `FORGE_REPO`) while it | |
| 43 | * is public here, else `upstream`, so every printed line works for whoever | |
| 44 | * reads it. Undefined only when neither is available. The card, /llms.txt, | |
| 45 | * Settings › Tokens and the terminal all ask this one question. | |
| 45 | 46 | */ |
| 46 | export function offeredPluginRepo(db: Database, fromEnv: string | undefined): string | undefined { | |
| 47 | export function offeredPluginRepo(db: Database, fromEnv: string | undefined, upstream: string | undefined): string | undefined { | |
| 47 | 48 | const repo = forgeSource(db, fromEnv); |
| 48 | if (repo === undefined) return undefined; | |
| 49 | const [owner = "", name = ""] = repo.split("/"); | |
| 50 | return repoVisibility(db, owner, name) === "public" ? repo : undefined; | |
| 49 | if (repo !== undefined) { | |
| 50 | const [owner = "", name = ""] = repo.split("/"); | |
| 51 | if (repoVisibility(db, owner, name) === "public") return repo; | |
| 52 | } | |
outdated
This always returns a string, so the 'not offered' path in standards.ts is dead and box 1's 'undefined only when neither is available' is not met. Narrow the type and remove the dead branches, or make the upstream optional. |
||
| 53 | return upstream; | |
| 54 | } | |
| 55 | ||
| 56 | /** Where a plugin repository lives: `owner/name` is on this forge's origin, a full URL is its own. */ | |
outdated
This is the third copy of the upstream URL (see src/web/studio/home.ts:686 and standards.ts). Export a single constant and use it everywhere. |
||
| 57 | function pluginBase(origin: string, repo: string): string { | |
| 58 | return repo.startsWith("https://") ? repo : `${origin}/${repo}`; | |
| 51 | 59 | } |
| 52 | 60 | |
| 53 | 61 | /** The two Claude Code lines that install the plugin from the marketplace. */ |
| @@ -57,7 +65,7 @@ export function marketplaceLines(origin: string, repo: string): { add: string; i | ||
| 57 | 65 | |
| 58 | 66 | /** The clone URL of that repository on this forge's origin. */ |
| 59 | 67 | export function pluginSource(origin: string, repo: string): string { |
| 60 | return `${origin}/${repo}.git`; | |
| 68 | return `${pluginBase(origin, repo)}.git`; | |
| 61 | 69 | } |
| 62 | 70 | |
| 63 | 71 | /** The one list the card and /llms.txt both read, so they cannot drift. */ |
| @@ -75,7 +83,7 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [ | ||
| 75 | 83 | label: "OpenCode", |
| 76 | 84 | path: "plugins/opencode/forge.ts", |
| 77 | 85 | install: (o, r) => |
| 78 | `mkdir -p .opencode/plugin && curl -fsSL ${o}/${r}/raw/main/plugins/opencode/forge.ts -o .opencode/plugin/forge.ts`, | |
| 86 | `mkdir -p .opencode/plugin && curl -fsSL ${pluginBase(o, r)}/raw/main/plugins/opencode/forge.ts -o .opencode/plugin/forge.ts`, | |
| 79 | 87 | setup: "set FORGE_URL and FORGE_TOKEN", |
| 80 | 88 | }, |
| 81 | 89 | ]; |
src/core/upstream.ts added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | /** The public repository every forge comes from; the one place its address is written. */ | |
| 2 | export const UPSTREAM_REPO = "https://forge.open-platform.sh/trevato/forge"; |
src/tui/screens/signins.ts +2 −1
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | * ended from here. |
| 22 | 22 | */ |
| 23 | 23 | |
| 24 | import { UPSTREAM_REPO } from "../../core/upstream.ts"; | |
| 24 | 25 | import { accountSignIns, endAccountSession } from "../../actions/signins.ts"; |
| 25 | 26 | import { |
| 26 | 27 | CHANGE_PASSWORD_WORDS, |
| @@ -213,7 +214,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 213 | 214 | return; |
| 214 | 215 | } |
| 215 | 216 | note = `Made ${name} (${scope}).`; |
| 216 | const repo = offeredPluginRepo(f.db, f.app.config.forgeRepo); | |
| 217 | const repo = offeredPluginRepo(f.db, f.app.config.forgeRepo, UPSTREAM_REPO); | |
| 217 | 218 | const origin = publicOrigin(f.app.config.url, `http://${f.app.config.host ?? "localhost"}:${f.app.config.port ?? 3000}`); |
| 218 | 219 | ctx.push(secretScreen(ctx, made.value.token, repo === undefined ? null : marketplaceLines(origin, repo))); |
| 219 | 220 | }; |
src/web/pages/account.ts +2 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * has been read once. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { UPSTREAM_REPO } from "../../core/upstream.ts"; | |
| 12 | 13 | import { resendVerification } from "../../actions/verify.ts"; |
| 13 | 14 | import { clientAddress } from "../../http/peer.ts"; |
| 14 | 15 | import { emailVerified } from "../../core/verify.ts"; |
| @@ -718,7 +719,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html { | ||
| 718 | 719 | const url = publicOrigin(ctx.app.config.url, ctx.url.origin); |
| 719 | 720 | const env = `FORGE_URL=${url} FORGE_TOKEN=${token ?? "<token>"}`; |
| 720 | 721 | // The plugin marketplace is the forge's configured source, offered only while anyone can clone it. |
| 721 | const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo); | |
| 722 | const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO); | |
Non-blocking: stale comment; the marketplace is now the forge own source while public, else the upstream. |
||
| 722 | 723 | const market = repo === undefined ? null : marketplaceLines(url, repo); |
| 723 | 724 | const clone = copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command"); |
| 724 | 725 | return block( |
src/web/standards.ts +5 −4
| @@ -25,6 +25,7 @@ | ||
| 25 | 25 | * FORGE_SECURITY_CONTACT. |
| 26 | 26 | */ |
| 27 | 27 | |
| 28 | import { UPSTREAM_REPO } from "../core/upstream.ts"; | |
| 28 | 29 | import { CRAWL_DELAY_S, DEEP_SEGMENTS } from "../http/crawl.ts"; |
| 29 | 30 | import { markPng, type MarkShape } from "../design/mark.ts"; |
| 30 | 31 | import { metaThemeColor } from "../design/themes.ts"; |
| @@ -109,7 +110,7 @@ export function securityTxt(ctx: Ctx): Response { | ||
| 109 | 110 | |
| 110 | 111 | /** GET /.well-known/mcp.json — the MCP server card (a draft format; see docs/MCP.md). */ |
| 111 | 112 | export function mcpJson(ctx: Ctx): Response { |
| 112 | const res = text(JSON.stringify(mcpCard(ctx.url.origin, offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo)), null, 2), "application/json"); | |
| 113 | const res = text(JSON.stringify(mcpCard(ctx.url.origin, offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO)), null, 2), "application/json"); | |
| 113 | 114 | res.headers.set("access-control-allow-origin", "*"); |
| 114 | 115 | return res; |
| 115 | 116 | } |
| @@ -378,7 +379,7 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } { | ||
| 378 | 379 | const o = ctx.url.origin; |
| 379 | 380 | const tell = sshTellCommand(ctx.app); |
| 380 | 381 | const running = runningSentence(); |
| 381 | const plugins = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo); | |
| 382 | const plugins = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO); | |
| 382 | 383 | const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db)); |
| 383 | 384 | // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart. |
| 384 | 385 | const signedIn = ctx.auth.user !== undefined; |
| @@ -411,8 +412,8 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 411 | 412 | "If you are an assistant helping a person: say plainly what you can and cannot do from where you are. If you cannot reach this forge or hold an account on it, give the person these steps to do by hand.", |
| 412 | 413 | "", |
| 413 | 414 | `- Help build a public app here: each one is a repository at ${o}/{owner}/{name}. A person makes an account at ${o}/signup, then follows "Offer a change" below. If /signup answers 403, new accounts here are by invitation for now: send a suggestion through "Report a problem" below, or work from a forge of your own.`, |
| 414 | `- Get your own forge: \`git clone https://forge.open-platform.sh/trevato/forge.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, free within limits while hosting is not sold: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`, | |
| 415 | "- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: https://forge.open-platform.sh/trevato/forge/raw/main/docs/ORIGINS.md", | |
| 415 | `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, free within limits while hosting is not sold: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`, | |
| 416 | `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`, | |
| 416 | 417 | `- Connect an assistant that can use tools: add a custom connector with the URL ${mcp.url}. It signs in with OAuth; only the report tool works without an account.`, |
| 417 | 418 | "", |
| 418 | 419 | "## Report a problem (no account)", |
src/web/studio/home.ts +2 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * best of what is public (`actions/front.ts`). |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { UPSTREAM_REPO } from "../../core/upstream.ts"; | |
| 9 | 10 | import { foundryHref } from "../../actions/foundry_door.ts"; |
| 10 | 11 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 11 | 12 | import { liveLine } from "../pages/releases.ts"; |
| @@ -683,7 +684,7 @@ function doorPulse(days: FrontPage["pulse"]["days"]): Html | null { | ||
| 683 | 684 | </figure>`; |
| 684 | 685 | } |
| 685 | 686 | |
| 686 | const FORGE_REPO = "https://forge.open-platform.sh/trevato/forge"; | |
| 687 | const FORGE_REPO = UPSTREAM_REPO; | |
| 687 | 688 | |
| 688 | 689 | /** |
| 689 | 690 | * One press for each shipped pack (#9572): what it is, and the way to its page, |
test/connect-harness.test.ts +2 −2
| @@ -66,14 +66,14 @@ test("the marketplace lines show only while the forge's plugin repository is pub | ||
| 66 | 66 | const cookie = ((await fetch(`${base}/signin`, { method: "POST", body: new URLSearchParams({ username: "ada", password: "connect-password" }), redirect: "manual" })).headers.get("set-cookie") ?? "").split(";")[0]!; |
| 67 | 67 | const get = async () => (await fetch(`${base}/settings/tokens`, { headers: { cookie } })).text(); |
| 68 | 68 | const url = (app.config.url ?? base).replace(/\/+$/, ""); |
| 69 | expect(await get()).not.toContain("connect-claude-add"); | |
| 69 | expect(value(await get(), "connect-claude-add")).toBe("/plugin marketplace add https://forge.open-platform.sh/trevato/forge.git"); // no own source: the upstream's | |
| 70 | 70 | expect(await get()).toContain('id="connect-claude"'); |
| 71 | 71 | |
| 72 | 72 | const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "connect-password" }); |
| 73 | 73 | if (!owner.ok) throw new Error(owner.error); |
| 74 | 74 | const made = await initRepo(app.db, app.reposRoot, { ownerId: owner.value.id, name: "forge", visibility: "private", description: "" }); |
| 75 | 75 | expect(made).toMatchObject({ ok: true }); |
| 76 | expect(await get()).not.toContain("connect-claude-add"); // private: no line a stranger could not run | |
| 76 | expect(value(await get(), "connect-claude-add")).toBe("/plugin marketplace add https://forge.open-platform.sh/trevato/forge.git"); // private: the upstream's, which a stranger can run | |
| 77 | 77 | app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run(); |
| 78 | 78 | |
| 79 | 79 | const page = await get(); |
test/llms-anonymous.test.ts +1 −1
| @@ -46,7 +46,7 @@ const section = (body: string): string[] => { | ||
| 46 | 46 | test("the anonymous text is small, varies by reader, and lists report plus one line", async () => { |
| 47 | 47 | const res = await fetch(`${servers.url}/llms.txt`); |
| 48 | 48 | const body = await res.text(); |
| 49 | expect(Buffer.byteLength(body)).toBeLessThan(5 * 1024); | |
| 49 | expect(Buffer.byteLength(body)).toBeLessThan(6 * 1024); | |
| 50 | 50 | expect(res.headers.get("vary")).toContain("Cookie"); |
| 51 | 51 | expect(res.headers.get("vary")).toContain("Authorization"); |
| 52 | 52 | const listed = section(body); |
test/mcp-card-plugins.test.ts +26 −10
| @@ -35,12 +35,16 @@ const setVisibility = async (visibility: "public" | "private") => { | ||
| 35 | 35 | if (!r.ok) throw new Error(String(r.error)); |
| 36 | 36 | }; |
| 37 | 37 | |
| 38 | test("a private plugin source offers no plugins in the card or llms.txt", async () => { | |
| 38 | test("a private own repository offers the upstream's plugins, in the card and llms.txt alike", async () => { | |
| 39 | 39 | const card = (await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any; |
| 40 | 40 | const llms = await fetch(`${servers.url}/llms.txt`).then((r) => r.text()); |
| 41 | expect(card.plugins).toEqual([]); | |
| 42 | expect(llms).toContain("Harness plugins: not offered here (no public source)"); | |
| 43 | expect(llms).not.toContain("Claude Code plugin"); | |
| 41 | expect(card.plugins.map((p: any) => p.harness).sort()).toEqual(["claude-code", "opencode"]); | |
| 42 | for (const p of card.plugins) { | |
| 43 | expect(p.source).toBe("https://forge.open-platform.sh/trevato/forge.git"); | |
| 44 | expect(llms).toContain(p.install); | |
| 45 | } | |
| 46 | expect(llms).not.toContain("not offered here"); | |
| 47 | expect(llms).not.toContain(`${servers.url}/trevato/forge.git`); | |
| 44 | 48 | expect(llms).not.toContain("needs that repository to be public"); |
| 45 | 49 | }); |
| 46 | 50 | |
| @@ -72,8 +76,10 @@ test("llms.txt follows the source going private again, not its cached body", asy | ||
| 72 | 76 | await setVisibility("public"); |
| 73 | 77 | expect(await fetch(`${servers.url}/llms.txt`).then((r) => r.text())).toContain("OpenCode plugin"); |
| 74 | 78 | await setVisibility("private"); |
| 75 | expect(await fetch(`${servers.url}/llms.txt`).then((r) => r.text())).not.toContain("OpenCode plugin"); | |
| 76 | expect(((await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any).plugins).toEqual([]); | |
| 79 | const text = await fetch(`${servers.url}/llms.txt`).then((r) => r.text()); | |
| 80 | expect(text).toContain("OpenCode plugin"); | |
| 81 | expect(text).not.toContain(`${servers.url}/trevato/forge.git`); | |
| 82 | expect(((await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any).plugins[0].source).toBe("https://forge.open-platform.sh/trevato/forge.git"); | |
| 77 | 83 | }); |
| 78 | 84 | |
| 79 | 85 | test("the plugin repository is the configured source: an admin's setting outranks FORGE_REPO, in the card and llms.txt alike", async () => { |
| @@ -96,11 +102,21 @@ test("the plugin repository is the configured source: an admin's setting outrank | ||
| 96 | 102 | expect(((await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any).plugins[0].source).toBe(`${servers.url}/trevato/forge.git`); |
| 97 | 103 | }); |
| 98 | 104 | |
| 99 | test("a forge that names no source offers no plugins, whatever is public", async () => { | |
| 105 | test("the upstream stands in while the own repository is private or unset, and with neither nothing is offered", async () => { | |
| 100 | 106 | const bare = createApp({ dataDir: join(dataDir, "bare"), port: 0 }); |
| 101 | 107 | const { offeredPluginRepo } = await import("../src/core/mcp_card.ts"); |
| 102 | expect(offeredPluginRepo(bare.db, bare.config.forgeRepo)).toBeUndefined(); | |
| 103 | expect(offeredPluginRepo(app.db, "trevato/forge")).toBe("trevato/forge"); | |
| 104 | expect(offeredPluginRepo(app.db, "trevato/nothing")).toBeUndefined(); | |
| 108 | const { UPSTREAM_REPO } = await import("../src/core/upstream.ts"); | |
| 109 | await setVisibility("private"); | |
| 110 | expect(offeredPluginRepo(bare.db, bare.config.forgeRepo, UPSTREAM_REPO)).toBe(UPSTREAM_REPO); | |
| 111 | expect(offeredPluginRepo(app.db, "trevato/forge", UPSTREAM_REPO)).toBe(UPSTREAM_REPO); // private here | |
| 112 | expect(offeredPluginRepo(app.db, "trevato/nothing", UPSTREAM_REPO)).toBe(UPSTREAM_REPO); | |
| 113 | await setVisibility("public"); | |
| 114 | expect(offeredPluginRepo(app.db, "trevato/forge", UPSTREAM_REPO)).toBe("trevato/forge"); | |
| 115 | await setVisibility("private"); | |
| 116 | // Neither source: no repository, which llms.txt prints as the "not offered" line. | |
| 117 | expect(offeredPluginRepo(bare.db, undefined, undefined)).toBeUndefined(); | |
| 118 | expect(offeredPluginRepo(app.db, "trevato/forge", undefined)).toBeUndefined(); | |
| 119 | const { mcpCard } = await import("../src/core/mcp_card.ts"); | |
| 120 | expect(mcpCard("http://x.test", undefined).plugins).toEqual([]); | |
| 105 | 121 | bare.close(); |
| 106 | 122 | }); |
test/standards.test.ts +2 −2
| @@ -277,8 +277,8 @@ describe("what the open web expects", () => { | ||
| 277 | 277 | expect(section).toContain(`${base}/mcp`); |
| 278 | 278 | expect(section).toContain(`${base}/.well-known/oauth-protected-resource`); |
| 279 | 279 | expect(section).toContain("streamable HTTP"); |
| 280 | // trevato/forge is not here, so no install line is printed that could not work. | |
| 281 | expect(section).toContain("Harness plugins: not offered here (no public source)"); | |
| 280 | // trevato/forge is not here, so the lines come from the public upstream. | |
| 281 | expect(section).toContain("Claude Code plugin (source https://forge.open-platform.sh/trevato/forge.git"); | |
| 282 | 282 | expect(section).not.toContain("needs that repository to be public"); |
| 283 | 283 | }); |
| 284 | 284 |
test/tui-token-make.test.ts +3 −2
| @@ -249,16 +249,17 @@ describe("making a token in the terminal", () => { | ||
| 249 | 249 | h.stop(); |
| 250 | 250 | return text; |
| 251 | 251 | }; |
| 252 | expect(await mint()).not.toContain("/plugin marketplace add"); | |
| 252 | expect(await mint()).toContain(`/plugin marketplace add https://forge.open-platform.sh/trevato/forge.git`); // no own source: the upstream's | |
| 253 | 253 | const { createRepo } = await import("../src/core/repos.ts"); |
| 254 | 254 | const owner = await createUser(forge.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false }); |
| 255 | 255 | if (!owner.ok) throw new Error(owner.error); |
| 256 | 256 | const made = await createRepo(forge.db, forge.reposRoot, { ownerId: owner.value.id, name: "forge", visibility: "private", description: "" }); |
| 257 | 257 | expect(made.ok).toBe(true); |
| 258 | expect(await mint()).not.toContain("/plugin marketplace add"); | |
| 258 | expect(await mint()).toContain(`/plugin marketplace add https://forge.open-platform.sh/trevato/forge.git`); // private here: still the upstream's | |
| 259 | 259 | forge.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run(); |
| 260 | 260 | const shown = await mint(); |
| 261 | 261 | expect(shown).toContain("/plugin marketplace add"); |
| 262 | expect(shown).not.toContain("forge.open-platform.sh"); | |
| 262 | 263 | expect(shown).toContain("/trevato/forge.git"); |
| 263 | 264 | expect(shown).toContain("/plugin install forge@forge"); |
| 264 | 265 | }); |
The comment is run together and still says 'only while it is public here', which is no longer true. Rewrite it.