Public: anyone can read this; the crew can change it.
/llms.txt and /api/v1/here name `ssh <forge> tell` when the forge's SSH is on, and say nothing about it when it is off #6171
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6111-llms-txt-and-api-v1-here-name-ssh-forge → main 2 commits fork
5 files
changed +114
−5
README.md +1 −1
| @@ -326,7 +326,7 @@ git clone ssh://localhost:2222/you/hello.git # git, same port | ||
| 326 | 326 | ssh -p 2222 localhost tell make the header blue # tell the crew in one line |
| 327 | 327 | |
| 328 | 328 | |
| 329 | `tell <words>` takes the rest of the command line as given (no shell expansion; control characters are refused) and tells the crew in the forge's own repository, as the account the key belongs to, through the same action as the web composer. It prints the told line and the full address of the issue, and exits 0; an unknown key, nothing said, too much, or an unverified email prints the reason on stderr, records nothing and exits 1. (Quote the words if your local shell would expand them.) | |
| 329 | `tell <words>` takes the rest of the command line as given (no shell expansion; control characters are refused) and tells the crew in the forge's own repository, as the account the key belongs to, through the same action as the web composer. It prints the told line and the full address of the issue, and exits 0; an unknown key, nothing said, too much, or an unverified email prints the reason on stderr, records nothing and exits 1. (Quote the words if your local shell would expand them.) When SSH is on, `/llms.txt` and `/api/v1/here` (the `ssh` field of the "tell the crew" action) name this exact command, so an agent finds it without being taught; with SSH off they say nothing about it. | |
| 330 | 330 | |
| 331 | 331 | An app's open pulls, newest five first, one line each (`#n title · green|red|running|none`), need no tty: run `pulls` on the app's login — `ssh <app>@<forge> pulls`, with the app's page printing the exact command as `FORGE_APP_SSH` (the terminal is off when that is unset). A private app answers only a key its gate admits; sealed pulls show to their author, writers and the crew (`src/apps/door_pulls.ts`). |
| 332 | 332 |
src/actions/here.ts +22 −4
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * An action that needs an account is listed for someone without one, |
| 13 | 13 | * saying so, because that is the next thing they need to know. |
| 14 | 14 | */ |
| 15 | import { sshTellCommand } from "../ssh/command.ts"; | |
| 15 | 16 | import { mcpPointers } from "../core/mcp_card.ts"; |
| 16 | 17 | import type { App } from "../app.ts"; |
| 17 | 18 | import { appsRuntime } from "../apps/runtime.ts"; |
| @@ -42,6 +43,8 @@ export interface Action { | ||
| 42 | 43 | readonly http?: string; |
| 43 | 44 | /** The MCP tool that does the same, when there is one. */ |
| 44 | 45 | readonly mcp?: string; |
| 46 | /** The shell command that does the same, when the forge's SSH is on. */ | |
| 47 | readonly ssh?: string; | |
| 45 | 48 | /** `true`: needs an account the caller does not have; `false`: needs none. */ |
| 46 | 49 | readonly account?: boolean; |
| 47 | 50 | /** `true`: needs a verified email, which this account has not proven yet. */ |
| @@ -123,10 +126,10 @@ export function manifestFor( | ||
| 123 | 126 | you: youFor(viewer, scope, undefined, app), |
| 124 | 127 | actions: |
| 125 | 128 | page === undefined |
| 126 | ? forgeActions(viewer) | |
| 129 | ? forgeActions(app, viewer, scope) | |
| 127 | 130 | : [ |
| 128 | 131 | pageRead(page, path), |
| 129 | ...forgeActions(viewer).filter((a) => a.mcp !== page), | |
| 132 | ...forgeActions(app, viewer, scope).filter((a) => a.mcp !== page), | |
| 130 | 133 | ], |
| 131 | 134 | ...common, |
| 132 | 135 | }); |
| @@ -224,7 +227,15 @@ function pageRead(page: "explore" | "search", path: string): Action { | ||
| 224 | 227 | }; |
| 225 | 228 | } |
| 226 | 229 | |
| 227 | function forgeActions(viewer: User | undefined): Action[] { | |
| 230 | function forgeActions( | |
| 231 | app: App, | |
| 232 | viewer: User | undefined, | |
| 233 | scope: TokenScope | undefined, | |
| 234 | ): Action[] { | |
| 235 | const tellSsh = | |
| 236 | viewer !== undefined && (scope === undefined || scopeAllows(scope, "rw")) | |
| 237 | ? sshTellCommand(app) | |
| 238 | : undefined; | |
| 228 | 239 | return [ |
| 229 | 240 | { |
| 230 | 241 | do: "explore apps and repositories", |
| @@ -246,6 +257,7 @@ function forgeActions(viewer: User | undefined): Action[] { | ||
| 246 | 257 | ...(viewer === undefined |
| 247 | 258 | ? [{ do: "use the MCP", http: "POST /mcp", account: true }] |
| 248 | 259 | : [{ do: "use the MCP", http: "POST /mcp" }]), |
| 260 | ...(tellSsh === undefined ? [] : [{ do: "tell the crew", ssh: tellSsh }]), | |
| 249 | 261 | ]; |
| 250 | 262 | } |
| 251 | 263 | |
| @@ -297,8 +309,14 @@ function repoActions( | ||
| 297 | 309 | if (record !== undefined && canEnterApp(db, repo, record, viewer)) |
| 298 | 310 | out.push({ do: "open the app", http: `GET ${r}/app/production/open` }); |
| 299 | 311 | if (!acts) return out; |
| 312 | const tellSsh = sshTellCommand(app); | |
| 300 | 313 | if (mayDirectCrew(db, repo, viewer)) |
| 301 | out.push({ do: "tell the crew", mcp: "tell", ...unproven }); | |
| 314 | out.push({ | |
| 315 | do: "tell the crew", | |
| 316 | mcp: "tell", | |
| 317 | ...(tellSsh === undefined ? {} : { ssh: tellSsh }), | |
| 318 | ...unproven, | |
| 319 | }); | |
| 302 | 320 | // A verdict is a writer's or the crew's; the merge is a person's. |
| 303 | 321 | // The same rule `submitReview` asks. |
| 304 | 322 | if ( |
src/ssh/command.ts +11
| @@ -21,3 +21,14 @@ export function sshCommand(app: App, login?: string): string | undefined { | ||
| 21 | 21 | const at = login === undefined ? "" : `${login}@`; |
| 22 | 22 | return `ssh${port === 22 ? "" : ` -p ${port}`} ${at}${host}`; |
| 23 | 23 | } |
| 24 | ||
| 25 | /** | |
| 26 | * The one-line tell, `ssh … tell <words>`, built from the forge's own | |
| 27 | * address. Undefined when SSH is off, and when the forge is itself hosted as | |
| 28 | * an app (its address then names an app login, which has no `tell`). | |
| 29 | */ | |
| 30 | export function sshTellCommand(app: App): string | undefined { | |
| 31 | if (app.config.appSsh !== undefined) return undefined; | |
| 32 | const base = sshCommand(app); | |
| 33 | return base === undefined ? undefined : `${base} tell <words>`; | |
| 34 | } |
src/web/standards.ts +10
| @@ -27,6 +27,7 @@ import releases from "../core/releases.json" with { type: "json" }; | ||
| 27 | 27 | import { markPng, type MarkShape } from "../design/mark.ts"; |
| 28 | 28 | import { metaThemeColor } from "../design/themes.ts"; |
| 29 | 29 | import { BODY_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 30 | import { sshTellCommand } from "../ssh/command.ts"; | |
| 30 | 31 | import { llmsRepos, sitemapRepos } from "../core/listings.ts"; |
| 31 | 32 | import { canRead, getRepo } from "../core/repos.ts"; |
| 32 | 33 | import type { Ctx } from "../http/context.ts"; |
| @@ -283,6 +284,7 @@ function runningSentence(): string { | ||
| 283 | 284 | export function llmsTxt(ctx: Ctx): Response { |
| 284 | 285 | const o = ctx.url.origin; |
| 285 | 286 | const mcp = mcpPointers(o); |
| 287 | const tell = sshTellCommand(ctx.app); | |
| 286 | 288 | const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db)); |
| 287 | 289 | return timePhase(ctx.request, "render", () => text( |
| 288 | 290 | [ |
| @@ -313,6 +315,14 @@ export function llmsTxt(ctx: Ctx): Response { | ||
| 313 | 315 | "", |
| 314 | 316 | `- GET ${o}/api/v1/here?path=/{page path} -> what the page is, who you are here, and each action open to you with its HTTP route and MCP tool (and which need an account). Every page links to it: rel="alternate" type="application/json". MCP: the tool "here" with the page URL.`, |
| 315 | 317 | "", |
| 318 | ...(tell === undefined | |
| 319 | ? [] | |
| 320 | : [ | |
| 321 | "## Tell the crew from a shell", | |
| 322 | "", | |
| 323 | `- With an account and an SSH key added under Settings -> Keys: \`${tell}\` tells the crew in one line and prints where it went.`, | |
| 324 | "", | |
| 325 | ]), | |
| 316 | 326 | "## Find things (no account)", |
| 317 | 327 | "", |
| 318 | 328 | `- Explore repositories: GET ${o}/api/v1/explore?q=kind:app+sort:forks+garden. MCP: the tool "explore" with {"q"}. In q, kind: is app, person or forge; sort: is score, forks, ships, use, fresh or name; any other word is free text matched against name, owner and description.`, |
test/ssh-tell-discovery.test.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** /llms.txt and /api/v1/here name `ssh <forge> tell` when SSH is on, and are silent when off (#6111). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | ||
| 12 | let dir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let token: string; | |
| 16 | let roToken: string; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dir = await mkdtemp(join(tmpdir(), "forge-tell-disc-")); | |
| 20 | app = createApp({ dataDir: dir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | const made = await createUser(app.db, { | |
| 23 | origin: "seed", | |
| 24 | trusted: true, | |
| 25 | username: "alice", | |
| 26 | email: "[email protected]", | |
| 27 | password: "a-good-passphrase", | |
| 28 | adminIfFirst: true, | |
| 29 | }); | |
| 30 | if (!made.ok) throw new Error(made.error); | |
| 31 | token = createToken(app.db, made.value.id, "t", "rw").token; | |
| 32 | roToken = createToken(app.db, made.value.id, "ro", "ro").token; | |
| 33 | const repo = await createRepo(app, made.value, { name: "hello", visibility: "public" }); | |
| 34 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 35 | }); | |
| 36 | afterAll(async () => { | |
| 37 | await servers.stop?.(); | |
| 38 | await rm(dir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | const llms = () => fetch(`${servers.url}/llms.txt`).then((r) => r.text()); | |
| 42 | const here = async (path = "/", tok = token) => | |
| 43 | (await fetch(`${servers.url}/api/v1/here?path=${path}`, { | |
| 44 | headers: { authorization: `Bearer ${tok}` }, | |
| 45 | }).then((r) => r.json())) as { actions: { do: string; ssh?: string }[] }; | |
| 46 | ||
| 47 | test("SSH off: neither mentions it", async () => { | |
| 48 | app.config.sshPort = 0; | |
| 49 | expect(await llms()).not.toContain("tell <words>"); | |
| 50 | expect((await here()).actions.some((a) => a.ssh !== undefined)).toBe(false); | |
| 51 | }); | |
| 52 | ||
| 53 | test("SSH on: both give the exact command from the forge's own address", async () => { | |
| 54 | app.config.sshPort = 2222; | |
| 55 | const cmd = `ssh -p 2222 ${app.config.host ?? "localhost"} tell <words>`; | |
| 56 | expect(await llms()).toContain(`\`${cmd}\``); | |
| 57 | const a = (await here()).actions.find((x) => x.do === "tell the crew"); | |
| 58 | expect(a?.ssh).toBe(cmd); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a repository page puts ssh on its tell action for who may direct the crew; a read-only token gets none anywhere", async () => { | |
| 62 | app.config.sshPort = 2222; | |
| 63 | const cmd = `ssh -p 2222 ${app.config.host ?? "localhost"} tell <words>`; | |
| 64 | const repoPage = await here("/alice/hello"); | |
| 65 | expect(repoPage.actions.find((x) => x.do === "tell the crew")?.ssh).toBe(cmd); | |
| 66 | expect((await here("/", roToken)).actions.some((a) => a.ssh !== undefined)).toBe(false); | |
| 67 | expect((await here("/alice/hello", roToken)).actions.some((a) => a.ssh !== undefined)).toBe(false); | |
| 68 | app.config.sshPort = 0; | |
| 69 | expect((await here("/alice/hello")).actions.some((a) => a.ssh !== undefined)).toBe(false); | |
| 70 | }); |
new above
offline