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

Settings › Tokens gives Claude Code a command that works outside a clone: the plugin marketplace #10279

Merged opened this

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

Merged

scout/forge:scout/10217-settings-tokens-gives-claude-code-a-comm → main 6 commits fork 7 threads · 7 unresolved

  • Merged. scout/10217-settings-tokens-gives-claude-code-a-comm was merged into main as 8feee19.

Review

Sign in to review this change.
14 files changed +289 −54
docs/features/tokens-harness.md +19
@@ -15,3 +15,22 @@ It appears for every token the code exchange or a refresh minted while the
1515 client is registered. A token from before OAuth tokens were marked is listed as
1616 hand-made. "Renews itself" means the harness swaps it with its refresh token
1717 before it lapses; deleting it ends that, see [OAuth refresh](oauth-refresh.md).
18
19 ## Connect Claude Code from anywhere
20
21 Settings › Tokens, "Connect your harness", offers two lines that work outside a
22 clone, while the forge's own source repository is public on this forge. That
23 repository is the one an admin names (`PATCH /api/v1/admin/settings` with
24 `{"forgeRepo": "owner/name"}`), else `FORGE_REPO`:
25
26 /plugin marketplace add <forge address>/<owner>/<name>.git
27 /plugin install forge@forge
28
29 Run them inside Claude Code; then sign in through `/mcp`. The same two lines
30 show on the terminal's screen for a token you just made (Signed in › `m`),
31 wrapped whole; `a` and `i` copy them. While no source is named, or it is
32 private, they are not shown, since a stranger could not run them; the
33 `--plugin-dir` line, for a clone, is always shown. The card at
34 `/.well-known/mcp.json` and `/llms.txt` name the same repository. The address
35 is the forge's public one, the same on the page and in the terminal, and is
36 escaped.
src/core/mcp_card.ts +29 −19
@@ -7,8 +7,9 @@
77 * later standard's field names; when one settles, the card follows it.
88 */
99 import type { Database } from "bun:sqlite";
10 import { getRepo } from "./repos.ts";
1110 import { running, type Running } from "../kernel/version.ts";
11 import { forgeSource } from "../settings/forge_source.ts";
12 import { repoVisibility } from "./repos.ts";
1213
1314 /** The endpoint and the protected-resource metadata that names how to sign in. */
1415 export function mcpPointers(origin: string): { url: string; oauth: string } {
@@ -30,24 +31,33 @@ export interface HarnessPlugin {
3031 readonly harness: string;
3132 readonly label: string;
3233 readonly path: string;
33 /** The one line, given the origin it was read from; it works in an empty directory. */
34 readonly install: (origin: string) => string;
34 /** The one line, given the origin it was read from and the source repository; it works in an empty directory. */
35 readonly install: (origin: string, repo: string) => string;
3536 /** What to set before it runs. */
3637 readonly setup: string;
3738 }
3839
39 /** The repository that holds the plugins; the lines are offered only while it is public on this forge. */
40 const PLUGIN_REPO = "trevato/forge";
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
outdated

This is a second copy of the getRepo lookup SQL, written only so that a test that counts by-name reads does not see it. Two copies of the join will drift. Put one narrow visibility read next to getRepo in repos.ts and call it from here, or change what the tell-gate test counts.

43 * public here, so every printed line works for whoever reads it. The card,
outdated

Issue box 1: the owner and name are the hard-coded PLUGIN_REPO, not the forge's config. Take them from the configured source repository (admin setting, else FORGE_REPO) and use that same value for the visibility gate, the printed lines, /llms.txt and /.well-known/mcp.json. Otherwise the three places can disagree.

44 * /llms.txt, Settings › Tokens and the terminal all ask this one question.
45 */
46 export function offeredPluginRepo(db: Database, fromEnv: string | undefined): string | undefined {
47 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;
51 }
4152
42 /** Whether the plugin source can be read without a token here, so every printed install line works. */
43 export function pluginsOffered(db: Database): boolean {
44 const [owner = "", name = ""] = PLUGIN_REPO.split("/");
45 return getRepo(db, owner, name)?.visibility === "public";
53 /** The two Claude Code lines that install the plugin from the marketplace. */
54 export function marketplaceLines(origin: string, repo: string): { add: string; install: string } {
55 return { add: `/plugin marketplace add ${pluginSource(origin, repo)}`, install: "/plugin install forge@forge" };
4656 }
4757
4858 /** The clone URL of that repository on this forge's origin. */
49 export function pluginSource(origin: string): string {
50 return `${origin}/${PLUGIN_REPO}.git`;
59 export function pluginSource(origin: string, repo: string): string {
60 return `${origin}/${repo}.git`;
5161 }
5262
5363 /** The one list the card and /llms.txt both read, so they cannot drift. */
@@ -56,16 +66,16 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [
5666 harness: "claude-code",
5767 label: "Claude Code",
5868 path: "plugins/claude-code",
59 install: (o) =>
60 `git clone --depth 1 --filter=blob:none --sparse ${pluginSource(o)} forge-plugin && git -C forge-plugin sparse-checkout set plugins/claude-code && claude --plugin-dir forge-plugin/plugins/claude-code`,
69 install: (o, r) =>
70 `git clone --depth 1 --filter=blob:none --sparse ${pluginSource(o, r)} forge-plugin && git -C forge-plugin sparse-checkout set plugins/claude-code && claude --plugin-dir forge-plugin/plugins/claude-code`,
6171 setup: "set FORGE_URL, then sign in through /mcp with the forge server; or also set FORGE_TOKEN and use the forge-token server",
6272 },
6373 {
6474 harness: "opencode",
6575 label: "OpenCode",
6676 path: "plugins/opencode/forge.ts",
67 install: (o) =>
68 `mkdir -p .opencode/plugin && curl -fsSL ${o}/${PLUGIN_REPO}/raw/main/plugins/opencode/forge.ts -o .opencode/plugin/forge.ts`,
77 install: (o, r) =>
78 `mkdir -p .opencode/plugin && curl -fsSL ${o}/${r}/raw/main/plugins/opencode/forge.ts -o .opencode/plugin/forge.ts`,
6979 setup: "set FORGE_URL and FORGE_TOKEN",
7080 },
7181 ];
@@ -75,7 +85,7 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [
7585 * true is named: who answers, where, over what transport, how to sign in,
7686 * and the one tool that needs no token.
7787 */
78 export function mcpCard(origin: string, offered: boolean, now: Running = running()) {
88 export function mcpCard(origin: string, repo: string | undefined, now: Running = running()) {
7989 const { url, oauth } = mcpPointers(origin);
8090 return {
8191 name: "forge",
@@ -87,11 +97,11 @@ export function mcpCard(origin: string, offered: boolean, now: Running = running
8797 documentation: `${origin}/llms.txt`,
8898 openapi: `${origin}/contract/openapi.json`,
8999 tokenFreeTools: [...TOKEN_FREE_TOOLS],
90 plugins: (offered ? HARNESS_PLUGINS : []).map((p) => ({
100 plugins: (repo === undefined ? [] : HARNESS_PLUGINS).map((p) => ({
91101 harness: p.harness,
92102 path: p.path,
93 source: pluginSource(origin),
94 install: p.install(origin),
103 source: pluginSource(origin, repo!),
104 install: p.install(origin, repo!),
95105 })),
96106 };
97107 }
src/core/public_origin.ts added +9
@@ -0,0 +1,9 @@
1 /**
2 * The forge's public origin, the one every door prints: the configured
3 * `url`, else what the door itself knows (a page its request's origin, the
4 * terminal its host and port). Never a trailing slash, so a line built on it
5 * reads the same on the page and in the terminal.
6 */
7 export function publicOrigin(configured: string | undefined, fallback: string): string {
8 return (configured ?? fallback).replace(/\/+$/, "");
9 }
src/core/repos.ts +15 −1
@@ -233,6 +233,9 @@ const SELECT = `
233233 FROM repo JOIN user ON user.id = repo.owner_id
234234 LEFT JOIN origin ON origin.id = repo.origin_id`;
235235
236 /** The by-name match `getRepo` and `repoVisibility` share, so the two cannot drift. */
237 const BY_NAME = "user.username = ?1 COLLATE NOCASE AND repo.name = ?2 COLLATE NOCASE";
238
236239 function toRepo(row: RepoRow): Repo {
237240 return {
238241 id: row.id,
@@ -761,11 +764,22 @@ export function getRepo(
761764 .query<
762765 RepoRow,
763766 [string, string]
764 >(`${SELECT} WHERE user.username = ?1 COLLATE NOCASE AND repo.name = ?2 COLLATE NOCASE`)
767 >(`${SELECT} WHERE ${BY_NAME}`)
765768 .get(ownerName, name);
766769 return row ? toRepo(row) : undefined;
767770 }
768771
772 /** Only a repository's visibility, by owner and name: for a page that needs the answer, not the row. */
773 export function repoVisibility(db: Database, ownerName: string, name: string): string | undefined {
774 return (
775 db
776 .query<{ visibility: string }, [string, string]>(
777 `SELECT repo.visibility FROM repo JOIN user ON user.id = repo.owner_id WHERE ${BY_NAME}`,
778 )
779 .get(ownerName, name)?.visibility ?? undefined
780 );
781 }
782
769783 export function getRepoById(db: Database, repoId: string): Repo | undefined {
770784 const row = db
771785 .query<RepoRow, [string]>(`${SELECT} WHERE repo.id = ?1`)
src/tui/screens/signins.ts +47 −14
@@ -29,6 +29,8 @@ import {
2929 createToken,
3030 deleteToken,
3131 } from "../../actions/users.ts";
32 import { marketplaceLines, offeredPluginRepo } from "../../core/mcp_card.ts";
33 import { publicOrigin } from "../../core/public_origin.ts";
3234 import { withinPasswordCap } from "../../core/hash-gate.ts";
3335 import { listKeys } from "../../core/keys.ts";
3436 import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts";
@@ -90,30 +92,59 @@ export function signInsOf(f: Session): SignIn[] {
9092 ];
9193 }
9294
95 /** Cut into rows of `width` characters: wrapped, never truncated, so a hand-copied line is the whole line. */
96 function hardWrap(text: string, width: number): string[] {
97 const w = Math.max(1, width);
98 const rows: string[] = [];
99 for (let i = 0; i < text.length; i += w) rows.push(text.slice(i, i + w));
100 return rows;
101 }
102
93103 /** The secret, alone on a screen; nothing keeps it once this is popped. */
94 function secretScreen(ctx: Ctx, secret: string): Screen {
95 let copied = false;
104 function secretScreen(ctx: Ctx, secret: string, market: { add: string; install: string } | null): Screen {
105 let copied = "";
96106 return {
97107 title: "New token",
98 keys: [binding(["y"], "copy it"), binding(["esc", "q"], "done")],
108 keys: [
109 binding(["y"], "copy it"),
110 ...(market === null ? [] : [binding(["a"], "copy the marketplace line"), binding(["i"], "copy the install line")]),
111 binding(["esc", "q"], "done"),
112 ],
99113 render(area, s) {
100114 const c = ctx.theme.c;
101 s.text(area.x, area.y, truncate("copy it now, it is never shown again.", area.w), style(c.mutedFg, c.bg), area.x + area.w);
102 // Wrapped, never cut: a hand-copied secret must be the whole secret.
103 const width = Math.max(1, area.w);
104 const lines: string[] = [];
105 for (let i = 0; i < secret.length; i += width) lines.push(secret.slice(i, i + width));
106 lines.forEach((line, i) => s.text(area.x, area.y + 2 + i, line, style(c.fg, c.bg), area.x + area.w));
107 const foot = copied ? "Copied." : "y copies it, esc is done.";
108 s.text(area.x, area.y + 3 + lines.length, truncate(foot, area.w), style(c.mutedFg, c.bg), area.x + area.w);
115 const end = area.x + area.w;
outdated

truncate cuts the marketplace command when the terminal is narrow. That leaves a broken command that cannot be copied, because y copies only the secret. The lines are also drawn at area.y + 6 + lines.length with no check that they fit in the area height. Wrap them the way the secret is wrapped, and give them a copy key or skip them when they do not fit. Add a narrow-width test.

116 const bottom = area.y + area.h;
117 s.text(area.x, area.y, truncate("copy it now, it is never shown again.", area.w), style(c.mutedFg, c.bg), end);
118 const lines = hardWrap(secret, area.w);
119 lines.forEach((line, i) => s.text(area.x, area.y + 2 + i, line, style(c.fg, c.bg), end));
120 const foot = copied === "" ? "y copies it, esc is done." : copied;
121 s.text(area.x, area.y + 3 + lines.length, truncate(foot, area.w), style(c.mutedFg, c.bg), end);
109122 if (lines.length > 1)
110 s.text(area.x, area.y + 4 + lines.length, truncate("It wraps; y copies it whole.", area.w), style(c.mutedFg, c.bg), area.x + area.w);
123 s.text(area.x, area.y + 4 + lines.length, truncate("It wraps; y copies it whole.", area.w), style(c.mutedFg, c.bg), end);
124 // The same marketplace lines Settings › Tokens offers, under the same gate; wrapped whole or not drawn.
125 if (market === null) return;
126 const top = area.y + 6 + lines.length;
127 const add = hardWrap(market.add, area.w);
128 const install = hardWrap(market.install, area.w);
129 const head = "Claude Code plugin (a, i copy):";
130 if (top + 1 + add.length + install.length > bottom) {
131 if (top < bottom) s.text(area.x, top, truncate("Plugin lines: a and i copy them.", area.w), style(c.mutedFg, c.bg), end);
132 return;
133 }
134 s.text(area.x, top, truncate(head, area.w), style(c.mutedFg, c.bg), end);
135 [...add, ...install].forEach((row, i) => s.text(area.x, top + 1 + i, row, style(c.fg, c.bg), end));
111136 },
112137 key(e) {
113138 if (e.ctrl || e.alt) return true;
114139 if (e.key === "y") {
115140 ctx.copy(secret);
116 copied = true;
141 copied = "Copied.";
142 } else if (market !== null && e.key === "a") {
143 ctx.copy(market.add);
144 copied = "Copied the marketplace line.";
145 } else if (market !== null && e.key === "i") {
146 ctx.copy(market.install);
147 copied = "Copied the install line.";
117148 } else if (e.key === "esc" || e.key === "q") {
118149 ctx.pop();
119150 }
@@ -182,7 +213,9 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
182213 return;
183214 }
184215 note = `Made ${name} (${scope}).`;
185 ctx.push(secretScreen(ctx, made.value.token));
216 const repo = offeredPluginRepo(f.db, f.app.config.forgeRepo);
217 const origin = publicOrigin(f.app.config.url, `http://${f.app.config.host ?? "localhost"}:${f.app.config.port ?? 3000}`);
218 ctx.push(secretScreen(ctx, made.value.token, repo === undefined ? null : marketplaceLines(origin, repo)));
186219 };
187220
188221 /** Asks admin to say it twice; a plain y mints. */
src/web/pages/account.ts +13 −2
@@ -47,6 +47,8 @@ import type { CommitEmail, User } from "../../core/users.ts";
4747 import { isStandInEmail } from "../../core/users.ts";
4848 import { cookieValue, isAccountItself, type Ctx } from "../../http/context.ts";
4949 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
50 import { marketplaceLines, offeredPluginRepo } from "../../core/mcp_card.ts";
51 import { publicOrigin } from "../../core/public_origin.ts";
5052 import { sshCommand } from "../../ssh/command.ts";
5153 import {
5254 settingsLayout,
@@ -580,8 +582,12 @@ export function sessionsCard(
580582 * and the token just minted when there is one (else a placeholder).
581583 */
582584 export function connectCard(ctx: Ctx, token: string | undefined): Html {
583 const url = (ctx.app.config.url ?? ctx.url.origin).replace(/\/+$/, "");
585 const url = publicOrigin(ctx.app.config.url, ctx.url.origin);
584586 const env = `FORGE_URL=${url} FORGE_TOKEN=${token ?? "<token>"}`;
587 // The plugin marketplace is the forge's configured source, offered only while anyone can clone it.
outdated

The origin here is config.url ?? ctx.url.origin with trailing slashes stripped. The terminal builds its own fallback (http://host:port) without stripping. Have one helper return the forge's public origin and use it in both places. The text must be the same in both doors.

588 const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo);
589 const market = repo === undefined ? null : marketplaceLines(url, repo);
outdated

Raw SQL inside a page module. Move it into a core/settings function that returns the marketplace repository or null; the page renders only. It also costs 2 extra statements per load of Settings › Tokens.

590 const clone = copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command");
585591 return block(
586592 "Connect your harness",
587593 html`<p class="muted small mb">
@@ -590,7 +596,12 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html {
590596 </p>
591597 <p class="small">Claude Code</p>
592598 <p class="muted small mb">Start it with only FORGE_URL and sign in through /mcp (the forge server): nothing to copy. For scripts, use this command and the forge-token server, which sends the token.</p>
593 ${copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command")}
599 ${market
600 ? html`${copyField("connect-claude-add", market.add, "Add the marketplace", "Copy command")}
601 ${copyField("connect-claude-install", market.install, "Install the plugin", "Copy command")}
602 <p class="muted small mb">From a clone</p>`
603 : null}
604 ${clone}
594605 <p class="small">OpenCode</p>
595606 ${copyField(
596607 "connect-opencode",
src/web/standards.ts +8 −8
@@ -39,7 +39,7 @@ import type { Ctx } from "../http/context.ts";
3939 import { notFound } from "../http/respond.ts";
4040 import { SITE_LINE } from "./site_line.ts";
4141 import { mcpToolsSection } from "./llms_tools.ts";
42 import { HARNESS_PLUGINS, mcpCard, mcpPointers, pluginSource, pluginsOffered } from "../core/mcp_card.ts";
42 import { HARNESS_PLUGINS, mcpCard, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
4343 import { running } from "../kernel/version.ts";
4444 import { timePhase, timePhaseAsync } from "../core/route_timing.ts";
4545 import { shippedReleases, type Shipped } from "../release/shipped.ts";
@@ -106,7 +106,7 @@ export function securityTxt(ctx: Ctx): Response {
106106
107107 /** GET /.well-known/mcp.json — the MCP server card (a draft format; see docs/MCP.md). */
108108 export function mcpJson(ctx: Ctx): Response {
109 const res = text(JSON.stringify(mcpCard(ctx.url.origin, pluginsOffered(ctx.app.db)), null, 2), "application/json");
109 const res = text(JSON.stringify(mcpCard(ctx.url.origin, offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo)), null, 2), "application/json");
110110 res.headers.set("access-control-allow-origin", "*");
111111 return res;
112112 }
@@ -345,7 +345,7 @@ function runningSentence(): string {
345345 }
346346
347347 /** llms.txt and its ETag per origin, valid while the listing read, the build, the tell command and the plugin source being public are the same. */
348 const llmsBodies = new Map<string, { signedIn: boolean; repos: LlmsListing; running: string; tell: string | undefined; plugins: boolean; body: string; etag: string }>();
348 const llmsBodies = new Map<string, { signedIn: boolean; repos: LlmsListing; running: string; tell: string | undefined; plugins: string | undefined; body: string; etag: string }>();
349349
350350 /**
351351 * GET /llms.txt — the forge as an agent should meet it (llmstxt.org): what
@@ -372,7 +372,7 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } {
372372 const o = ctx.url.origin;
373373 const tell = sshTellCommand(ctx.app);
374374 const running = runningSentence();
375 const plugins = pluginsOffered(ctx.app.db);
375 const plugins = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo);
376376 const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db));
377377 // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart.
378378 const signedIn = ctx.auth.user !== undefined;
@@ -390,7 +390,7 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } {
390390 }
391391
392392 /** The llms.txt document for one origin. */
393 function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean, plugins: boolean): string {
393 function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean, plugins: string | undefined): string {
394394 const mcp = mcpPointers(o);
395395 return (
396396 [
@@ -423,11 +423,11 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
423423 "## Use it from your harness",
424424 "",
425425 `- MCP endpoint: ${mcp.url} (streamable HTTP; OAuth metadata at ${mcp.oauth}). Or send a token as Authorization: Bearer.`,
426 ...(plugins
426 ...(plugins !== undefined
427427 ? HARNESS_PLUGINS.map(
428 (p) => `- ${p.label} plugin (source ${pluginSource(o)}, path ${p.path}; ${p.setup}): \`${p.install(o)}\``,
428 (p) => `- ${p.label} plugin (source ${pluginSource(o, plugins)}, path ${p.path}; ${p.setup}): \`${p.install(o, plugins)}\``,
429429 )
430 : ["- Harness plugins: not offered on this forge (their source is private)"]),
430 : ["- Harness plugins: not offered here (no public source)"]),
431431 "",
432432 "## What you can do on any page",
433433 "",
test/connect-harness.test.ts +35 −1
@@ -16,7 +16,7 @@ let base: string;
1616
1717 beforeAll(async () => {
1818 dir = await mkdtemp(join(tmpdir(), "connect-"));
19 app = createApp({ dataDir: dir, port: 0 });
19 app = createApp({ dataDir: dir, port: 0, forgeRepo: "trevato/forge" });
2020 servers = await serve(app);
2121 base = servers.url;
2222 });
@@ -59,3 +59,37 @@ test("lines use the forge's address, a placeholder, then the minted token once",
5959 const later = await (await fetch(`${base}/settings/tokens`, { headers: { cookie } })).text();
6060 expect(later).not.toContain(token);
6161 });
62
63 test("the marketplace lines show only while the forge's plugin repository is public, and are escaped", async () => {
64 const { createRepo: initRepo } = await import("../src/core/repos.ts");
65 const { createUser } = await import("../src/core/users.ts");
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 const get = async () => (await fetch(`${base}/settings/tokens`, { headers: { cookie } })).text();
68 const url = (app.config.url ?? base).replace(/\/+$/, "");
69 expect(await get()).not.toContain("connect-claude-add");
70 expect(await get()).toContain('id="connect-claude"');
71
72 const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "connect-password" });
73 if (!owner.ok) throw new Error(owner.error);
74 const made = await initRepo(app.db, app.reposRoot, { ownerId: owner.value.id, name: "forge", visibility: "private", description: "" });
75 expect(made).toMatchObject({ ok: true });
76 expect(await get()).not.toContain("connect-claude-add"); // private: no line a stranger could not run
77 app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run();
78
79 const page = await get();
80 expect(value(page, "connect-claude-add")).toBe(`/plugin marketplace add ${url}/trevato/forge.git`);
81 expect(value(page, "connect-claude-install")).toBe("/plugin install forge@forge");
82 expect(value(page, "connect-claude")).toContain("--plugin-dir plugins/claude-code");
83 expect(value(page, "connect-opencode")).toContain(`FORGE_URL=${url}`);
84
outdated

Box 3 asks for the origin to be checked escaped. Add a case with a hostile origin or path and assert the escaped form. Also type the config override rather than as any.

85 // A hostile public address is escaped in every attribute and never lands raw.
86 const was = app.config.url;
87 Object.assign(app.config, { url: 'http://h.test/"><script>x</script>' });
88 try {
89 const hostile = await get();
90 expect(hostile).not.toContain("<script>x</script>");
91 expect(hostile).toContain("marketplace add http://h.test/&quot;&gt;&lt;script&gt;x&lt;/script&gt;/trevato/forge.git");
92 } finally {
93 Object.assign(app.config, { url: was });
94 }
95 });
test/mcp-card-plugins.test.ts +31 −2
@@ -17,7 +17,7 @@ let owner: User;
1717
1818 beforeAll(async () => {
1919 dataDir = await mkdtemp(join(tmpdir(), "forge-card-plugins-"));
20 app = createApp({ dataDir, port: 0 });
20 app = createApp({ dataDir, port: 0, forgeRepo: "trevato/forge" });
2121 servers = await serve(app);
2222 const made0 = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
2323 if (!made0.ok) throw new Error(made0.error);
@@ -39,7 +39,7 @@ test("a private plugin source offers no plugins in the card or llms.txt", async
3939 const card = (await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any;
4040 const llms = await fetch(`${servers.url}/llms.txt`).then((r) => r.text());
4141 expect(card.plugins).toEqual([]);
42 expect(llms).toContain("Harness plugins: not offered on this forge (their source is private)");
42 expect(llms).toContain("Harness plugins: not offered here (no public source)");
4343 expect(llms).not.toContain("Claude Code plugin");
4444 expect(llms).not.toContain("needs that repository to be public");
4545 });
@@ -75,3 +75,32 @@ test("llms.txt follows the source going private again, not its cached body", asy
7575 expect(await fetch(`${servers.url}/llms.txt`).then((r) => r.text())).not.toContain("OpenCode plugin");
7676 expect(((await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any).plugins).toEqual([]);
7777 });
78
79 test("the plugin repository is the configured source: an admin's setting outranks FORGE_REPO, in the card and llms.txt alike", async () => {
80 const { setForgeSource } = await import("../src/settings/forge_source.ts");
81 await setVisibility("public");
82 const made = await createRepo(app, owner, { name: "kit", visibility: "public" });
83 if (!made.ok) throw new Error(String(made.error));
84 setForgeSource(app.db, "trevato/kit", owner.id);
85 try {
86 const card = (await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any;
87 const llms = await fetch(`${servers.url}/llms.txt`).then((r) => r.text());
88 for (const p of card.plugins) {
89 expect(p.source).toBe(`${servers.url}/trevato/kit.git`);
90 expect(llms).toContain(p.install);
91 }
92 expect(llms).not.toContain(`${servers.url}/trevato/forge.git`);
93 } finally {
94 setForgeSource(app.db, null, owner.id);
95 }
96 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 });
98
99 test("a forge that names no source offers no plugins, whatever is public", async () => {
100 const bare = createApp({ dataDir: join(dataDir, "bare"), port: 0 });
101 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();
105 bare.close();
106 });
test/mcp-card.test.ts +2 −2
@@ -68,8 +68,8 @@ describe("the MCP server card", () => {
6868
6969 test("carries the commit it runs, or null when nothing says", () => {
7070 const sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678";
71 expect(mcpCard("https://x", true, running({ FORGE_APP_COMMIT: sha })).commit).toBe(sha);
72 expect(mcpCard("https://x", true, running({})).commit).toBeNull();
71 expect(mcpCard("https://x", "trevato/forge", running({ FORGE_APP_COMMIT: sha })).commit).toBe(sha);
72 expect(mcpCard("https://x", "trevato/forge", running({})).commit).toBeNull();
7373 });
7474
7575 test("the live card's commit is what running() says", async () => {
test/palette-tell-gate.test.ts +2 −2
@@ -37,10 +37,10 @@ describe("the tell target's repository read", () => {
3737 if (!made.ok) throw new Error(made.error);
3838 http = forgeHttp(app);
3939 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
40 // Count the by-name repository lookup, the one `getRepo` issues.
40 // Count the by-name repository row, the one `getRepo` issues; `repoVisibility` reads one column, not the row.
4141 const query = app.db.query.bind(app.db) as (sql: string) => unknown;
4242 (app.db as { query: unknown }).query = (sql: string) => {
43 if (sql.includes("user.username = ?1 COLLATE NOCASE AND repo.name = ?2")) reads++;
43 if (sql.includes("SELECT repo.*") && sql.includes("user.username = ?1 COLLATE NOCASE AND repo.name = ?2")) reads++;
4444 return query(sql);
4545 };
4646 });
test/settings-timing.test.ts +1 −1
@@ -48,7 +48,7 @@ afterAll(() => {
4848 /** The most statements a request may run: today's count with room for a read or two. */
4949 const MAX_STATEMENTS: Record<string, number> = {
5050 "/settings/keys": 12,
51 "/settings/tokens": 13,
51 "/settings/tokens": 14, // one more read: whether the plugin repository is public
outdated

Raising the statement cap from 13 to 15 hides the added cost instead of removing it. Fold or cache the lookup and keep the cap.

5252 "/settings/password": 10,
5353 };
5454
test/standards.test.ts +1 −1
@@ -278,7 +278,7 @@ describe("what the open web expects", () => {
278278 expect(section).toContain(`${base}/.well-known/oauth-protected-resource`);
279279 expect(section).toContain("streamable HTTP");
280280 // trevato/forge is not here, so no install line is printed that could not work.
281 expect(section).toContain("Harness plugins: not offered on this forge (their source is private)");
281 expect(section).toContain("Harness plugins: not offered here (no public source)");
282282 expect(section).not.toContain("needs that repository to be public");
283283 });
284284
test/tui-token-make.test.ts +77 −1
@@ -60,7 +60,7 @@ describe("making a token in the terminal", () => {
6060
6161 beforeAll(async () => {
6262 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-token-make-"));
63 forge = createApp({ dataDir, port: 0 });
63 forge = createApp({ dataDir, port: 0, forgeRepo: "trevato/forge" });
6464 for (const [username, adminIfFirst] of [["boss", true], ["carol", false], ["bot1", false]] as const) {
6565 const made = await createUser(forge.db, {
6666 origin: "seed",
@@ -236,4 +236,80 @@ describe("making a token in the terminal", () => {
236236 expect(joined).toContain("y copies it whole");
237237 app.stop();
238238 });
239
240 test("the secret screen shows the marketplace lines only while the plugin repository is public", async () => {
241 const mint = async () => {
242 const h = boot("carol");
243 await h.open();
244 await h.press("m", "enter");
245 await h.press("backspace", "backspace");
246 await h.type("rw");
247 await h.press("enter");
248 const text = h.text();
249 h.stop();
250 return text;
251 };
252 expect(await mint()).not.toContain("/plugin marketplace add");
253 const { createRepo } = await import("../src/core/repos.ts");
254 const owner = await createUser(forge.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false });
255 if (!owner.ok) throw new Error(owner.error);
256 const made = await createRepo(forge.db, forge.reposRoot, { ownerId: owner.value.id, name: "forge", visibility: "private", description: "" });
257 expect(made.ok).toBe(true);
258 expect(await mint()).not.toContain("/plugin marketplace add");
259 forge.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run();
260 const shown = await mint();
261 expect(shown).toContain("/plugin marketplace add");
262 expect(shown).toContain("/trevato/forge.git");
263 expect(shown).toContain("/plugin install forge@forge");
264 });
265
266 test("a narrow terminal wraps the marketplace lines whole, a and i copy them, and a short one skips them", async () => {
267 const { createRepo, getRepo } = await import("../src/core/repos.ts");
268 if (getUserByName(forge.db, "trevato") === undefined) {
269 const owner = await createUser(forge.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false });
270 if (!owner.ok) throw new Error(owner.error);
271 }
272 if (getRepo(forge.db, "trevato", "forge") === undefined)
273 await createRepo(forge.db, forge.reposRoot, { ownerId: getUserByName(forge.db, "trevato")!.id, name: "forge", visibility: "public", description: "" });
274 forge.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run();
275 const mintOn = async (w: number, h: number) => {
276 const terminal = headlessTerminal(w, h);
277 const user = getUserByName(forge.db, "carol")!;
278 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
279 app.start();
280 const key = async (k: string, ctrl = false) => {
281 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
282 await settleApp(app);
283 };
284 await key("k", true);
285 for (const ch of "Signed in") await key(ch);
286 for (const k of ["enter", "m", "enter", "enter"]) await key(k);
287 return { app, terminal, key, rows: () => app.screen.toText().split("\n").map((l) => l.trimEnd()) };
288 };
289 const add = "/plugin marketplace add http://localhost:0/trevato/forge.git";
290 const narrow = await mintOn(40, 30);
291 const rows = narrow.rows();
292 expect(rows.every((r) => r.length <= 40)).toBe(true);
293 expect(rows.join("\n")).not.toContain("copy):…");
294 expect(rows.join("\n")).toContain("Claude Code plugin (a, i copy):");
295 const at = rows.findIndex((r) => r.trim().startsWith("/plugin marketplace"));
296 expect(at).toBeGreaterThan(0);
297 expect(rows.slice(at, at + 2).map((r) => r.trim()).join("")).toBe(add);
298 narrow.terminal.takeOutput();
299 await narrow.key("a");
300 expect(narrow.terminal.takeOutput()).toContain(`]52;c;${Buffer.from(add).toString("base64")}`);
301 expect(narrow.rows().join("\n")).toContain("Copied the marketplace line.");
302 await narrow.key("i");
303 expect(narrow.terminal.takeOutput()).toContain(`]52;c;${Buffer.from("/plugin install forge@forge").toString("base64")}`);
304 narrow.app.stop();
305
306 const short = await mintOn(40, 12);
307 const text = short.rows().join("\n");
308 expect(text).toContain("Plugin lines: a and i copy them.");
309 expect(text).not.toContain("/plugin marketplace");
310 short.terminal.takeOutput();
311 await short.key("a");
312 expect(short.terminal.takeOutput()).toContain(`]52;c;${Buffer.from(add).toString("base64")}`);
313 short.app.stop();
314 });
239315 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs