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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10217-settings-tokens-gives-claude-code-a-comm → main 6 commits fork 7 threads · 7 unresolved
docs/features/tokens-harness.md +19
| @@ -15,3 +15,22 @@ It appears for every token the code exchange or a refresh minted while the | ||
| 15 | 15 | client is registered. A token from before OAuth tokens were marked is listed as |
| 16 | 16 | hand-made. "Renews itself" means the harness swaps it with its refresh token |
| 17 | 17 | before it lapses; deleting it ends that, see [](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 @@ | ||
| 7 | 7 | * later standard's field names; when one settles, the card follows it. |
| 8 | 8 | */ |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { getRepo } from "./repos.ts"; | |
| 11 | 10 | import { running, type Running } from "../kernel/version.ts"; |
| 11 | import { forgeSource } from "../settings/forge_source.ts"; | |
| 12 | import { repoVisibility } from "./repos.ts"; | |
| 12 | 13 | |
| 13 | 14 | /** The endpoint and the protected-resource metadata that names how to sign in. */ |
| 14 | 15 | export function mcpPointers(origin: string): { url: string; oauth: string } { |
| @@ -30,24 +31,33 @@ export interface HarnessPlugin { | ||
| 30 | 31 | readonly harness: string; |
| 31 | 32 | readonly label: string; |
| 32 | 33 | 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; | |
| 35 | 36 | /** What to set before it runs. */ |
| 36 | 37 | readonly setup: string; |
| 37 | 38 | } |
| 38 | 39 | |
| 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 | |
| 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 |
||
| 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 | } | |
| 41 | 52 | |
| 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" }; | |
| 46 | 56 | } |
| 47 | 57 | |
| 48 | 58 | /** 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`; | |
| 51 | 61 | } |
| 52 | 62 | |
| 53 | 63 | /** The one list the card and /llms.txt both read, so they cannot drift. */ |
| @@ -56,16 +66,16 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [ | ||
| 56 | 66 | harness: "claude-code", |
| 57 | 67 | label: "Claude Code", |
| 58 | 68 | 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`, | |
| 61 | 71 | setup: "set FORGE_URL, then sign in through /mcp with the forge server; or also set FORGE_TOKEN and use the forge-token server", |
| 62 | 72 | }, |
| 63 | 73 | { |
| 64 | 74 | harness: "opencode", |
| 65 | 75 | label: "OpenCode", |
| 66 | 76 | 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`, | |
| 69 | 79 | setup: "set FORGE_URL and FORGE_TOKEN", |
| 70 | 80 | }, |
| 71 | 81 | ]; |
| @@ -75,7 +85,7 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [ | ||
| 75 | 85 | * true is named: who answers, where, over what transport, how to sign in, |
| 76 | 86 | * and the one tool that needs no token. |
| 77 | 87 | */ |
| 78 | export function mcpCard(origin: string, offered: boolean, now: Running = running()) { | |
| 88 | export function mcpCard(origin: string, repo: string | undefined, now: Running = running()) { | |
| 79 | 89 | const { url, oauth } = mcpPointers(origin); |
| 80 | 90 | return { |
| 81 | 91 | name: "forge", |
| @@ -87,11 +97,11 @@ export function mcpCard(origin: string, offered: boolean, now: Running = running | ||
| 87 | 97 | documentation: `${origin}/llms.txt`, |
| 88 | 98 | openapi: `${origin}/contract/openapi.json`, |
| 89 | 99 | tokenFreeTools: [...TOKEN_FREE_TOOLS], |
| 90 | plugins: (offered ? HARNESS_PLUGINS : []).map((p) => ({ | |
| 100 | plugins: (repo === undefined ? [] : HARNESS_PLUGINS).map((p) => ({ | |
| 91 | 101 | harness: p.harness, |
| 92 | 102 | path: p.path, |
| 93 | source: pluginSource(origin), | |
| 94 | install: p.install(origin), | |
| 103 | source: pluginSource(origin, repo!), | |
| 104 | install: p.install(origin, repo!), | |
| 95 | 105 | })), |
| 96 | 106 | }; |
| 97 | 107 | } |
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 = ` | ||
| 233 | 233 | FROM repo JOIN user ON user.id = repo.owner_id |
| 234 | 234 | LEFT JOIN origin ON origin.id = repo.origin_id`; |
| 235 | 235 | |
| 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 | ||
| 236 | 239 | function toRepo(row: RepoRow): Repo { |
| 237 | 240 | return { |
| 238 | 241 | id: row.id, |
| @@ -761,11 +764,22 @@ export function getRepo( | ||
| 761 | 764 | .query< |
| 762 | 765 | RepoRow, |
| 763 | 766 | [string, string] |
| 764 | >(`${SELECT} WHERE user.username = ?1 COLLATE NOCASE AND repo.name = ?2 COLLATE NOCASE`) | |
| 767 | >(`${SELECT} WHERE ${BY_NAME}`) | |
| 765 | 768 | .get(ownerName, name); |
| 766 | 769 | return row ? toRepo(row) : undefined; |
| 767 | 770 | } |
| 768 | 771 | |
| 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 | ||
| 769 | 783 | export function getRepoById(db: Database, repoId: string): Repo | undefined { |
| 770 | 784 | const row = db |
| 771 | 785 | .query<RepoRow, [string]>(`${SELECT} WHERE repo.id = ?1`) |
src/tui/screens/signins.ts +47 −14
| @@ -29,6 +29,8 @@ import { | ||
| 29 | 29 | createToken, |
| 30 | 30 | deleteToken, |
| 31 | 31 | } from "../../actions/users.ts"; |
| 32 | import { marketplaceLines, offeredPluginRepo } from "../../core/mcp_card.ts"; | |
| 33 | import { publicOrigin } from "../../core/public_origin.ts"; | |
| 32 | 34 | import { withinPasswordCap } from "../../core/hash-gate.ts"; |
| 33 | 35 | import { listKeys } from "../../core/keys.ts"; |
| 34 | 36 | import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts"; |
| @@ -90,30 +92,59 @@ export function signInsOf(f: Session): SignIn[] { | ||
| 90 | 92 | ]; |
| 91 | 93 | } |
| 92 | 94 | |
| 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 | ||
| 93 | 103 | /** 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 = ""; | |
| 96 | 106 | return { |
| 97 | 107 | 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 | ], | |
| 99 | 113 | render(area, s) { |
| 100 | 114 | 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
|
||
| 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); | |
| 109 | 122 | 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)); | |
| 111 | 136 | }, |
| 112 | 137 | key(e) { |
| 113 | 138 | if (e.ctrl || e.alt) return true; |
| 114 | 139 | if (e.key === "y") { |
| 115 | 140 | 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."; | |
| 117 | 148 | } else if (e.key === "esc" || e.key === "q") { |
| 118 | 149 | ctx.pop(); |
| 119 | 150 | } |
| @@ -182,7 +213,9 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 182 | 213 | return; |
| 183 | 214 | } |
| 184 | 215 | 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))); | |
| 186 | 219 | }; |
| 187 | 220 | |
| 188 | 221 | /** 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"; | ||
| 47 | 47 | import { isStandInEmail } from "../../core/users.ts"; |
| 48 | 48 | import { cookieValue, isAccountItself, type Ctx } from "../../http/context.ts"; |
| 49 | 49 | 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"; | |
| 50 | 52 | import { sshCommand } from "../../ssh/command.ts"; |
| 51 | 53 | import { |
| 52 | 54 | settingsLayout, |
| @@ -580,8 +582,12 @@ export function sessionsCard( | ||
| 580 | 582 | * and the token just minted when there is one (else a placeholder). |
| 581 | 583 | */ |
| 582 | 584 | 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); | |
| 584 | 586 | 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 |
||
| 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"); | |
| 585 | 591 | return block( |
| 586 | 592 | "Connect your harness", |
| 587 | 593 | html`<p class="muted small mb"> |
| @@ -590,7 +596,12 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html { | ||
| 590 | 596 | </p> |
| 591 | 597 | <p class="small">Claude Code</p> |
| 592 | 598 | <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} | |
| 594 | 605 | <p class="small">OpenCode</p> |
| 595 | 606 | ${copyField( |
| 596 | 607 | "connect-opencode", |
src/web/standards.ts +8 −8
| @@ -39,7 +39,7 @@ import type { Ctx } from "../http/context.ts"; | ||
| 39 | 39 | import { notFound } from "../http/respond.ts"; |
| 40 | 40 | import { SITE_LINE } from "./site_line.ts"; |
| 41 | 41 | 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"; | |
| 43 | 43 | import { running } from "../kernel/version.ts"; |
| 44 | 44 | import { timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 45 | 45 | import { shippedReleases, type Shipped } from "../release/shipped.ts"; |
| @@ -106,7 +106,7 @@ export function securityTxt(ctx: Ctx): Response { | ||
| 106 | 106 | |
| 107 | 107 | /** GET /.well-known/mcp.json — the MCP server card (a draft format; see docs/MCP.md). */ |
| 108 | 108 | 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"); | |
| 110 | 110 | res.headers.set("access-control-allow-origin", "*"); |
| 111 | 111 | return res; |
| 112 | 112 | } |
| @@ -345,7 +345,7 @@ function runningSentence(): string { | ||
| 345 | 345 | } |
| 346 | 346 | |
| 347 | 347 | /** 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 }>(); | |
| 349 | 349 | |
| 350 | 350 | /** |
| 351 | 351 | * 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 } { | ||
| 372 | 372 | const o = ctx.url.origin; |
| 373 | 373 | const tell = sshTellCommand(ctx.app); |
| 374 | 374 | const running = runningSentence(); |
| 375 | const plugins = pluginsOffered(ctx.app.db); | |
| 375 | const plugins = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo); | |
| 376 | 376 | const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db)); |
| 377 | 377 | // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart. |
| 378 | 378 | const signedIn = ctx.auth.user !== undefined; |
| @@ -390,7 +390,7 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } { | ||
| 390 | 390 | } |
| 391 | 391 | |
| 392 | 392 | /** 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 { | |
| 394 | 394 | const mcp = mcpPointers(o); |
| 395 | 395 | return ( |
| 396 | 396 | [ |
| @@ -423,11 +423,11 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 423 | 423 | "## Use it from your harness", |
| 424 | 424 | "", |
| 425 | 425 | `- MCP endpoint: ${mcp.url} (streamable HTTP; OAuth metadata at ${mcp.oauth}). Or send a token as Authorization: Bearer.`, |
| 426 | ...(plugins | |
| 426 | ...(plugins !== undefined | |
| 427 | 427 | ? 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)}\``, | |
| 429 | 429 | ) |
| 430 | : ["- Harness plugins: not offered on this forge (their source is private)"]), | |
| 430 | : ["- Harness plugins: not offered here (no public source)"]), | |
| 431 | 431 | "", |
| 432 | 432 | "## What you can do on any page", |
| 433 | 433 | "", |
test/connect-harness.test.ts +35 −1
| @@ -16,7 +16,7 @@ let base: string; | ||
| 16 | 16 | |
| 17 | 17 | beforeAll(async () => { |
| 18 | 18 | dir = await mkdtemp(join(tmpdir(), "connect-")); |
| 19 | app = createApp({ dataDir: dir, port: 0 }); | |
| 19 | app = createApp({ dataDir: dir, port: 0, forgeRepo: "trevato/forge" }); | |
| 20 | 20 | servers = await serve(app); |
| 21 | 21 | base = servers.url; |
| 22 | 22 | }); |
| @@ -59,3 +59,37 @@ test("lines use the forge's address, a placeholder, then the minted token once", | ||
| 59 | 59 | const later = await (await fetch(`${base}/settings/tokens`, { headers: { cookie } })).text(); |
| 60 | 60 | expect(later).not.toContain(token); |
| 61 | 61 | }); |
| 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 |
||
| 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/"><script>x</script>/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; | ||
| 17 | 17 | |
| 18 | 18 | beforeAll(async () => { |
| 19 | 19 | dataDir = await mkdtemp(join(tmpdir(), "forge-card-plugins-")); |
| 20 | app = createApp({ dataDir, port: 0 }); | |
| 20 | app = createApp({ dataDir, port: 0, forgeRepo: "trevato/forge" }); | |
| 21 | 21 | servers = await serve(app); |
| 22 | 22 | const made0 = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); |
| 23 | 23 | 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 | ||
| 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 | 41 | 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)"); | |
| 43 | 43 | expect(llms).not.toContain("Claude Code plugin"); |
| 44 | 44 | expect(llms).not.toContain("needs that repository to be public"); |
| 45 | 45 | }); |
| @@ -75,3 +75,32 @@ test("llms.txt follows the source going private again, not its cached body", asy | ||
| 75 | 75 | expect(await fetch(`${servers.url}/llms.txt`).then((r) => r.text())).not.toContain("OpenCode plugin"); |
| 76 | 76 | expect(((await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any).plugins).toEqual([]); |
| 77 | 77 | }); |
| 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", () => { | ||
| 68 | 68 | |
| 69 | 69 | test("carries the commit it runs, or null when nothing says", () => { |
| 70 | 70 | 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(); | |
| 73 | 73 | }); |
| 74 | 74 | |
| 75 | 75 | 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", () => { | ||
| 37 | 37 | if (!made.ok) throw new Error(made.error); |
| 38 | 38 | http = forgeHttp(app); |
| 39 | 39 | 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. | |
| 41 | 41 | const query = app.db.query.bind(app.db) as (sql: string) => unknown; |
| 42 | 42 | (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++; | |
| 44 | 44 | return query(sql); |
| 45 | 45 | }; |
| 46 | 46 | }); |
test/settings-timing.test.ts +1 −1
| @@ -48,7 +48,7 @@ afterAll(() => { | ||
| 48 | 48 | /** The most statements a request may run: today's count with room for a read or two. */ |
| 49 | 49 | const MAX_STATEMENTS: Record<string, number> = { |
| 50 | 50 | "/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. |
||
| 52 | 52 | "/settings/password": 10, |
| 53 | 53 | }; |
| 54 | 54 |
test/standards.test.ts +1 −1
| @@ -278,7 +278,7 @@ describe("what the open web expects", () => { | ||
| 278 | 278 | expect(section).toContain(`${base}/.well-known/oauth-protected-resource`); |
| 279 | 279 | expect(section).toContain("streamable HTTP"); |
| 280 | 280 | // 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)"); | |
| 282 | 282 | expect(section).not.toContain("needs that repository to be public"); |
| 283 | 283 | }); |
| 284 | 284 |
test/tui-token-make.test.ts +77 −1
| @@ -60,7 +60,7 @@ describe("making a token in the terminal", () => { | ||
| 60 | 60 | |
| 61 | 61 | beforeAll(async () => { |
| 62 | 62 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-token-make-")); |
| 63 | forge = createApp({ dataDir, port: 0 }); | |
| 63 | forge = createApp({ dataDir, port: 0, forgeRepo: "trevato/forge" }); | |
| 64 | 64 | for (const [username, adminIfFirst] of [["boss", true], ["carol", false], ["bot1", false]] as const) { |
| 65 | 65 | const made = await createUser(forge.db, { |
| 66 | 66 | origin: "seed", |
| @@ -236,4 +236,80 @@ describe("making a token in the terminal", () => { | ||
| 236 | 236 | expect(joined).toContain("y copies it whole"); |
| 237 | 237 | app.stop(); |
| 238 | 238 | }); |
| 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 | }); | |
| 239 | 315 | }); |
This is a second copy of the
getRepolookup 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 togetRepoin repos.ts and call it from here, or change what the tell-gate test counts.