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

Merged opened this

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

Merged

scout/forge:scout/6111-llms-txt-and-api-v1-here-name-ssh-forge → main 2 commits fork

  • Merged. scout/6111-llms-txt-and-api-v1-here-name-ssh-forge was merged into main as cce056e.

Review

Sign in to review this change.
5 files changed +114 −5
README.md +1 −1
@@ -326,7 +326,7 @@ git clone ssh://localhost:2222/you/hello.git # git, same port
326326 ssh -p 2222 localhost tell make the header blue # tell the crew in one line
327327 ```
328328
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.
330330
331331 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`).
332332
src/actions/here.ts +22 −4
@@ -12,6 +12,7 @@
1212 * An action that needs an account is listed for someone without one,
1313 * saying so, because that is the next thing they need to know.
1414 */
15 import { sshTellCommand } from "../ssh/command.ts";
1516 import { mcpPointers } from "../core/mcp_card.ts";
1617 import type { App } from "../app.ts";
1718 import { appsRuntime } from "../apps/runtime.ts";
@@ -42,6 +43,8 @@ export interface Action {
4243 readonly http?: string;
4344 /** The MCP tool that does the same, when there is one. */
4445 readonly mcp?: string;
46 /** The shell command that does the same, when the forge's SSH is on. */
47 readonly ssh?: string;
4548 /** `true`: needs an account the caller does not have; `false`: needs none. */
4649 readonly account?: boolean;
4750 /** `true`: needs a verified email, which this account has not proven yet. */
@@ -123,10 +126,10 @@ export function manifestFor(
123126 you: youFor(viewer, scope, undefined, app),
124127 actions:
125128 page === undefined
126 ? forgeActions(viewer)
129 ? forgeActions(app, viewer, scope)
127130 : [
128131 pageRead(page, path),
129 ...forgeActions(viewer).filter((a) => a.mcp !== page),
132 ...forgeActions(app, viewer, scope).filter((a) => a.mcp !== page),
130133 ],
131134 ...common,
132135 });
@@ -224,7 +227,15 @@ function pageRead(page: "explore" | "search", path: string): Action {
224227 };
225228 }
226229
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;
228239 return [
229240 {
230241 do: "explore apps and repositories",
@@ -246,6 +257,7 @@ function forgeActions(viewer: User | undefined): Action[] {
246257 ...(viewer === undefined
247258 ? [{ do: "use the MCP", http: "POST /mcp", account: true }]
248259 : [{ do: "use the MCP", http: "POST /mcp" }]),
260 ...(tellSsh === undefined ? [] : [{ do: "tell the crew", ssh: tellSsh }]),
249261 ];
250262 }
251263
@@ -297,8 +309,14 @@ function repoActions(
297309 if (record !== undefined && canEnterApp(db, repo, record, viewer))
298310 out.push({ do: "open the app", http: `GET ${r}/app/production/open` });
299311 if (!acts) return out;
312 const tellSsh = sshTellCommand(app);
300313 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 });
302320 // A verdict is a writer's or the crew's; the merge is a person's.
303321 // The same rule `submitReview` asks.
304322 if (
src/ssh/command.ts +11
@@ -21,3 +21,14 @@ export function sshCommand(app: App, login?: string): string | undefined {
2121 const at = login === undefined ? "" : `${login}@`;
2222 return `ssh${port === 22 ? "" : ` -p ${port}`} ${at}${host}`;
2323 }
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" };
2727 import { markPng, type MarkShape } from "../design/mark.ts";
2828 import { metaThemeColor } from "../design/themes.ts";
2929 import { BODY_MAX, TITLE_MAX } from "../core/reports.ts";
30 import { sshTellCommand } from "../ssh/command.ts";
3031 import { llmsRepos, sitemapRepos } from "../core/listings.ts";
3132 import { canRead, getRepo } from "../core/repos.ts";
3233 import type { Ctx } from "../http/context.ts";
@@ -283,6 +284,7 @@ function runningSentence(): string {
283284 export function llmsTxt(ctx: Ctx): Response {
284285 const o = ctx.url.origin;
285286 const mcp = mcpPointers(o);
287 const tell = sshTellCommand(ctx.app);
286288 const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db));
287289 return timePhase(ctx.request, "render", () => text(
288290 [
@@ -313,6 +315,14 @@ export function llmsTxt(ctx: Ctx): Response {
313315 "",
314316 `- 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.`,
315317 "",
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 ]),
316326 "## Find things (no account)",
317327 "",
318328 `- 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs