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

The page manifest says what you can do here as who you are: /api/v1/here answers differently for a stranger, a member and an admin, and a test pins each #10596

Merged opened this

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

Merged

scout/forge:scout/10384-the-page-manifest-says-what-you-can-do-h → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/10384-the-page-manifest-says-what-you-can-do-h was merged into main as 6792ab3.

Review

Sign in to review this change.
10 files changed +276 −51
docs/MCP.md +15
@@ -260,6 +260,21 @@ answers
260260 (decision card) sends them to `say` with the first ask's `repo`, `number`
261261 and `text`. Empty words send nothing. `asks` rows carry `repo`
262262 (`owner/name`) beside `number` for this.
263 - **The manifest's shape.** `GET /api/v1/here?path=/owner/name` (or the
264 `here` tool with the page URL) answers `{ here, you, can, mcp, docs }`.
265 `you.as` is `anonymous`, `reader`, `writer`, `owner` or `agent`; `can` is
266 the list of verbs open to that caller, each
267 `{ do, http?, mcp?, noMcp?, ssh? }`, where `mcp` is the tool that does the
268 same in the page button's words (`merge a pull` → `merge`, `approve a pull`
outdated

Drop "(actions is gone: the list is can)"; that is history, not shape. Wrap this paragraph's long lines.

269 → `review`, `change settings` → `repo_settings`, `comment` → `say`); a verb
270 with no tool says why in `noMcp`, never silent. Each line is the answer of
271 the check its route asks, so a verb the caller cannot do is absent, not
272 listed and refused; one that needs an account carries `account: true`. A private
273 repository answers byte for byte as a missing one (404).
274 `test/here-roles.test.ts` pins a stranger, a signed-in non-member, a writer
275 and an owner, and that every verb names a tool or says why not. The 50 ms
276 budget is held by a bounded statement count; the `here` phase is read from
277 Server-Timing and bounded at the flake floor (`WALL_CLOCK_FLOOR_MS`).
263278 - **Contract.** `GET /contract/openapi.json` is OpenAPI 3.1 for the routes
264279 the manifest (`here`) names, plus `POST /mcp`. It also describes planning: `GET …/map`, `POST …/plan`,
265280 `POST …/issues/{number}/dependencies` and
src/actions/here.ts +34 −11
@@ -47,6 +47,8 @@ export interface Action {
4747 readonly http?: string;
4848 /** The MCP tool that does the same, when there is one. */
4949 readonly mcp?: string;
50 /** Why no MCP tool does the same, when none does: say so, never stay silent. */
51 readonly noMcp?: string;
5052 /** The arguments that tool takes here, plain data (`{ q }` for a filter). */
5153 readonly mcpArgs?: Readonly<Record<string, string>>;
5254 /** The shell command that does the same, when the forge's SSH is on. */
@@ -84,13 +86,19 @@ export interface Manifest {
8486 /** Only when the credential limits it: a read-only token acts on nothing. */
8587 readonly scope?: "ro";
8688 };
87 readonly actions: readonly Action[];
89 /** What this caller can do here: the verbs open to them, none they would be refused. */
90 readonly can: readonly Action[];
8891 readonly mcp: { readonly url: string; readonly oauth: string };
8992 readonly docs: string;
9093 }
9194
9295 const SYSTEM = new Set(SYSTEM_NAMES);
9396
97 // `tell` and `plan` file issues too, but as directing the crew, a different
98 // verb with a different check (`mayDirectCrew`).
99 const NO_ISSUE_TOOL =
100 "no MCP tool opens a plain issue; tell and plan file one as directing the crew";
101
94102 /**
95103 * The manifest for a forge path, for `viewer` holding `scope`. `origin` is
96104 * the forge's public origin.
@@ -138,7 +146,7 @@ export function manifestFor(
138146 return ok({
139147 here: { kind: "org", org: account.username },
140148 you,
141 actions: [
149 can: [
142150 {
143151 do: "list the organization's teams",
144152 http: `GET /api/v1/orgs/${account.username}/teams`,
@@ -154,7 +162,7 @@ export function manifestFor(
154162 return ok({
155163 here: { kind: "person", username: account.username },
156164 you,
157 actions: [
165 can: [
158166 {
159167 do: "read their public repositories",
160168 http: `GET /api/v1/users/${account.username}/repos`,
@@ -177,7 +185,7 @@ export function manifestFor(
177185 return ok({
178186 here: { kind: page ?? "forge" },
179187 you: youFor(viewer, scope, undefined, app),
180 actions:
188 can:
181189 page === undefined
182190 ? forgeActions(app, viewer, scope)
183191 : [
@@ -208,7 +216,7 @@ export function manifestFor(
208216 }),
209217 },
210218 you: youFor(viewer, scope, repo, app),
211 actions: repoActions(app, repo, viewer, scope),
219 can: repoActions(app, repo, viewer, scope),
212220 ...common,
213221 });
214222 }
@@ -362,7 +370,7 @@ function repoActions(
362370 // password act with the account's full authority.
363371 const acts =
364372 viewer !== undefined && (scope === undefined || scopeAllows(scope, "rw"));
365 const out: Action[] = [{ do: "read", http: `GET ${r}` }];
373 const out: Action[] = [{ do: "read", http: `GET ${r}`, mcp: "repo" }];
366374 if (canRead(db, repo, undefined))
367375 out.push({
368376 do: "report a problem",
@@ -381,13 +389,15 @@ function repoActions(
381389 const participant: Action[] = [
382390 ...(talks
383391 ? [
384 { do: "open an issue", http: `POST ${r}/issues` },
385 { do: "comment", http: `POST ${r}/issues/{number}/comments` },
392 { do: "open an issue", http: `POST ${r}/issues`, noMcp: NO_ISSUE_TOOL },
393 // `say` writes through the same `addCommentThrough` as the route.
394 { do: "comment", http: `POST ${r}/issues/{number}/comments`, mcp: "say" },
386395 ]
387396 : []),
388397 {
389398 do: "offer a change",
390399 http: `POST ${r}/forks, then POST ${r}/pulls {headRepo}`,
400 mcp: "open_pull",
391401 ...unproven,
392402 },
393403 ];
@@ -396,7 +406,11 @@ function repoActions(
396406 else if (acts) out.push(...participant);
397407 const record = getAppByRepo(db, repo.id);
398408 if (record !== undefined && canEnterApp(db, repo, record, viewer))
399 out.push({ do: "open the app", http: `GET ${r}/app/production/open` });
409 out.push({
410 do: "open the app",
411 http: `GET ${r}/app/production/open`,
412 noMcp: "a link to the running app; there is nothing to call",
413 });
400414 // The copy is for whoever cannot write the app (the web's button, `!canWrite`);
401415 // its dry run is `ro`, so a read-only token sees it too.
402416 if (
@@ -436,11 +450,20 @@ function repoActions(
436450 out.push({
437451 do: "approve a pull",
438452 http: `POST ${r}/pulls/{number}/reviews`,
453 mcp: "review",
439454 });
440455 if (canWrite(db, repo, viewer) && viewer?.kind === "user")
441 out.push({ do: "merge a pull", http: `POST ${r}/pulls/{number}/merge` });
456 out.push({
457 do: "merge a pull",
458 http: `POST ${r}/pulls/{number}/merge`,
459 mcp: "merge",
460 });
442461 if (canAdmin(db, repo, viewer))
443 out.push({ do: "change settings", http: `PATCH ${r}` });
462 out.push({
463 do: "change settings",
464 http: `PATCH ${r}`,
465 mcp: "repo_settings",
466 });
444467 // How a native fork follows its parent: a writer's moves, the same rule
445468 // `takeOrTell` and `proposeToParentForge` ask (`requireWriter`).
446469 if (
src/contract/openapi/routes/getApiV1Here.ts +1 −1
@@ -1,3 +1,3 @@
11 import type { Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: "/api/v1/here", summary: "The manifest of what you can do at `path` (default /), given who you are: { here, you, actions, mcp, docs }; never cached", auth: false, query: { path: "string" }, refusals: { not_found: "404: the path names no page you can read" } };
3 export const route: Route = { method: "get", path: "/api/v1/here", summary: "The manifest of what you can do at `path` (default /), given who you are: { here, you, can, mcp, docs }; never cached", auth: false, query: { path: "string" }, refusals: { not_found: "404: the path names no page you can read" } };
src/origins/discover.ts +5 −1
@@ -285,12 +285,16 @@ async function viaOlderForge(
285285 asString(field(health.value, "commit")) === ""
286286 )
287287 return undefined;
288 // A current forge lists what you can do as `can`; one before #10384 as `actions`.
288289 const here = await getJson(`${web}/api/v1/here`, web, probe);
289290 if (
290291 !here.ok ||
291292 typeof field(here.value, "here") !== "object" ||
292293 field(here.value, "here") === null ||
293 !Array.isArray(field(here.value, "actions"))
294 !(
295 Array.isArray(field(here.value, "can")) ||
296 Array.isArray(field(here.value, "actions"))
297 )
294298 )
295299 return undefined;
296300 }
test/forge-origin-older.test.ts +32
@@ -107,3 +107,35 @@ describe("a forge on an older release", () => {
107107 expect(page.value.next).toBeNull();
108108 });
109109 });
110
111 describe("a forge on this release", () => {
112 /** A forge with no OAuth paths, whose `here` names its list `list`; health is a release's. */
113 const forgeWhoseHereSays = (list: string) =>
114 Bun.serve({
115 port: 0,
116 hostname: "127.0.0.1",
117 fetch(req) {
118 const path = new URL(req.url).pathname;
119 if (path === "/api/v1/health")
120 return Response.json({ ok: true, version: "0.9.0", commit: "abc1234", draining: false });
121 if (path === "/api/v1/here")
122 return Response.json({ here: { kind: "forge", name: "/" }, you: null, [list]: [], mcp: {}, docs: "" });
123 return new Response('{"error":"no_route"}', { status: 404 });
124 },
125 });
126
127 test("discover recognises a here that lists what you can do as `can`", async () => {
128 const peer = forgeWhoseHereSays("can");
129 const found = await discover(`http://127.0.0.1:${peer.port}`);
130 peer.stop(true);
131 expect(found.ok).toBe(true);
132 if (found.ok) expect(found.value.software).toBe("forge 0.9.0");
133 });
134
135 test("a here with neither `can` nor `actions` is no forge", async () => {
136 const peer = forgeWhoseHereSays("verbs");
137 const found = await discover(`http://127.0.0.1:${peer.port}`);
138 peer.stop(true);
139 expect(found.ok).toBe(false);
140 });
141 });
test/here-manifest.test.ts +28 −28
@@ -122,7 +122,7 @@ describe("the manifest: what you can do here, as who you are", () => {
122122 text: "",
123123 };
124124 };
125 const does = (m: any): string[] => m.actions.map((a: any) => a.do);
125 const does = (m: any): string[] => m.can.map((a: any) => a.do);
126126
127127 // ── refusals first ────────────────────────────────────────────────────
128128
@@ -151,7 +151,7 @@ describe("the manifest: what you can do here, as who you are", () => {
151151 const { body } = await manifest("/acme/game");
152152 expect(body.you.as).toBe("anonymous");
153153 const byName = new Map<string, any>(
154 body.actions.map((a: any) => [a.do, a]),
154 body.can.map((a: any) => [a.do, a]),
155155 );
156156 expect(byName.get("report a problem").account).toBe(false);
157157 expect(byName.get("report a problem").mcp).toBe("report");
@@ -216,10 +216,10 @@ describe("the manifest: what you can do here, as who you are", () => {
216216 ];
217217 const { body } = await manifest(path, "wes");
218218 for (const d of moves) expect(does(body)).toContain(d);
219 expect(body.actions.find((a: any) => a.do === moves[1]).http).toBe(
219 expect(body.can.find((a: any) => a.do === moves[1]).http).toBe(
220220 "POST /api/v1/repos/wes/game/following/take",
221221 );
222 expect(moves.map((d) => body.actions.find((a: any) => a.do === d).mcp)).toEqual([
222 expect(moves.map((d) => body.can.find((a: any) => a.do === d).mcp)).toEqual([
223223 "following",
224224 "take_updates",
225225 "propose_to_parent_forge",
@@ -256,7 +256,7 @@ describe("the manifest: what you can do here, as who you are", () => {
256256 test("the forge's manifest names how to help, for anyone; a repository's does not", async () => {
257257 for (const who of [undefined, "carol"]) {
258258 const { body, size } = await manifest("/", who);
259 expect(body.actions).toContainEqual({
259 expect(body.can).toContainEqual({
260260 do: "see how to help build this forge",
261261 http: "GET /api/v1/contribute",
262262 mcp: "contribute",
@@ -271,7 +271,7 @@ describe("the manifest: what you can do here, as who you are", () => {
271271 test("the forge's manifest names the changelog, for anyone", async () => {
272272 for (const who of [undefined, "carol"]) {
273273 const { body, size } = await manifest("/", who);
274 expect(body.actions).toContainEqual({
274 expect(body.can).toContainEqual({
275275 do: "see what went live",
276276 http: "GET /api/v1/changelog",
277277 mcp: "releases",
@@ -281,7 +281,7 @@ describe("the manifest: what you can do here, as who you are", () => {
281281 });
282282
283283 test("an anonymous viewer is told how to get an account; a signed-in one is not", async () => {
284 const anon = (await manifest("/")).body.actions;
284 const anon = (await manifest("/")).body.can;
285285 expect(anon).toContainEqual({
286286 do: "make an account",
287287 http: "GET /signup",
@@ -306,7 +306,7 @@ describe("the manifest: what you can do here, as who you are", () => {
306306 const edgeServers = await serve(edgeApp);
307307 try {
308308 const res = await fetch(`${edgeServers.url}/api/v1/here`);
309 const names = (await res.json() as any).actions.map((a: any) => a.do);
309 const names = (await res.json() as any).can.map((a: any) => a.do);
310310 expect(names).toContain("sign in");
311311 expect(names).not.toContain("make an account");
312312 } finally {
@@ -318,7 +318,7 @@ describe("the manifest: what you can do here, as who you are", () => {
318318 test("the forge's manifest names the OpenAPI document, and it answers", async () => {
319319 for (const who of [undefined, "carol"]) {
320320 const { body, size } = await manifest("/", who);
321 expect(body.actions).toContainEqual({
321 expect(body.can).toContainEqual({
322322 do: "read the HTTP contract",
323323 http: "GET /contract/openapi.json",
324324 account: false,
@@ -334,36 +334,36 @@ describe("the manifest: what you can do here, as who you are", () => {
334334 for (const who of [undefined, "carol"]) {
335335 const e = (await manifest("/explore?q=kind:app", who)).body;
336336 expect(e.here).toEqual({ kind: "explore" });
337 expect(e.actions[0].http).toBe("GET /api/v1/explore?q=kind:app");
338 expect(e.actions[0].mcp).toBe("explore");
337 expect(e.can[0].http).toBe("GET /api/v1/explore?q=kind:app");
338 expect(e.can[0].mcp).toBe("explore");
339339 const s = (await manifest("/search", who)).body;
340340 expect(s.here).toEqual({ kind: "search" });
341 expect(s.actions[0].http).toBe("GET /api/v1/search");
342 expect(s.actions[0].mcp).toBe("search");
341 expect(s.can[0].http).toBe("GET /api/v1/search");
342 expect(s.can[0].mcp).toBe("search");
343343 const f = (await manifest("/", who)).body;
344344 expect(f.here.kind).toBe("forge");
345 expect(f.actions.map((a: any) => a.mcp)).toEqual(
345 expect(f.can.map((a: any) => a.mcp)).toEqual(
346346 expect.arrayContaining(["explore", "search"]),
347347 );
348 for (const a of f.actions)
348 for (const a of f.can)
349349 if (a.mcp === "explore" || a.mcp === "search")
350350 expect(a.account).toBeUndefined();
351351 }
352352 const q = (await manifest("/search?q=a%20b%26c")).body;
353 expect(q.actions[0].http).toBe("GET /api/v1/search?q=a%20b%26c");
353 expect(q.can[0].http).toBe("GET /api/v1/search?q=a%20b%26c");
354354 const other = (await manifest("/settings")).body;
355355 expect(other.here).toEqual({ kind: "forge" });
356 expect(other.actions[0].do).toBe("explore apps and repositories");
356 expect(other.can[0].do).toBe("explore apps and repositories");
357357 });
358358
359 test("the explore and search actions name the MCP argument q", async () => {
359 test("the explore and search verbs name the MCP argument q", async () => {
360360 const e = (await manifest("/explore?q=kind:app")).body;
361 expect(e.actions[0].mcpArgs).toEqual({ q: "kind:app" });
361 expect(e.can[0].mcpArgs).toEqual({ q: "kind:app" });
362362 const long = (await manifest(`/search?q=${"x".repeat(300)}`)).body;
363 expect(long.actions[0].mcpArgs.q).toHaveLength(200);
363 expect(long.can[0].mcpArgs.q).toHaveLength(200);
364364 const f = (await manifest("/")).body;
365365 for (const tool of ["explore", "search"])
366 expect(f.actions.find((a: any) => a.mcp === tool).mcpArgs).toHaveProperty("q");
366 expect(f.can.find((a: any) => a.mcp === tool).mcpArgs).toHaveProperty("q");
367367 });
368368
369369 test("it is small", async () => {
@@ -426,14 +426,14 @@ describe("the manifest: what you can do here, as who you are", () => {
426426
427427 test("an account whose email is unproven is told that offering a change needs a verified email", async () => {
428428 const una = (await manifest("/acme/game", "una")).body;
429 const offer = una.actions.find((a: any) => a.do === "offer a change");
429 const offer = una.can.find((a: any) => a.do === "offer a change");
430430 expect(offer.verify).toBe(true);
431431 const carol = (await manifest("/acme/game", "carol")).body;
432 expect(carol.actions.find((a: any) => a.do === "offer a change").verify).toBeUndefined();
432 expect(carol.can.find((a: any) => a.do === "offer a change").verify).toBeUndefined();
433433 });
434434
435435 test("a repository with an app offers make it yours to whoever cannot write it", async () => {
436 const find = (m: any) => m.actions.find((a: any) => a.do === "make it yours");
436 const find = (m: any) => m.can.find((a: any) => a.do === "make it yours");
437437 const carol = find((await manifest("/acme/pub", "carol")).body);
438438 expect(carol).toEqual({
439439 do: "make it yours",
@@ -485,10 +485,10 @@ describe("the manifest: what you can do here, as who you are", () => {
485485 test("one segment is a person, an org, nobody, or one of the forge's own names", async () => {
486486 const person = (await manifest("/wes")).body;
487487 expect(person.here).toEqual({ kind: "person", username: "wes" });
488 expect(person.actions[0].http).toBe("GET /api/v1/users/wes/repos");
488 expect(person.can[0].http).toBe("GET /api/v1/users/wes/repos");
489489 const org = (await manifest("/ACME", "carol")).body;
490490 expect(org.here).toEqual({ kind: "org", org: "acme" });
491 expect(org.actions[0].http).toBe("GET /api/v1/orgs/acme/teams");
491 expect(org.can[0].http).toBe("GET /api/v1/orgs/acme/teams");
492492 const nobody = await here("/nope");
493493 expect(nobody.status).toBe(404);
494494 expect(await nobody.json()).toEqual(await (await here("/acme/nothing")).json());
@@ -551,9 +551,9 @@ describe("the manifest: what you can do here, as who you are", () => {
551551
552552 test("on /explore the page read is not repeated by the generic one", async () => {
553553 const { body } = await manifest("/explore?q=kind:app");
554 const reads = body.actions.filter((a: any) => a.mcp === "explore");
554 const reads = body.can.filter((a: any) => a.mcp === "explore");
555555 expect(reads.length).toBe(1);
556556 expect(reads[0].http).toBe("GET /api/v1/explore?q=kind:app");
557 expect(body.actions.filter((a: any) => a.mcp === "search").length).toBe(1);
557 expect(body.can.filter((a: any) => a.mcp === "search").length).toBe(1);
558558 });
559559 });
test/here-roles.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * The manifest is a promise (#10384): for one repository path,
3 * /api/v1/here answers differently for a stranger, a signed-in non-member, a
4 * writer and an admin; a verb the caller cannot do is absent; every MCP tool
5 * it names exists; a private path answers exactly as a missing one.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { newApp } from "../src/actions/apps.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { TOOLS } from "../src/http/mcp/tools.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { promote } from "./helpers/members.ts";
20 import { requestTiming } from "./helpers/request-timing.ts";
21 import { statements } from "./helpers/statements.ts";
22 import { expectWork } from "./helpers/work.ts";
23 import { WALL_CLOCK_FLOOR_MS } from "../scripts/flake.ts";
24
25 let dataDir = "";
26 let app: App, servers: Servers;
27 const tokens: Record<string, string> = {};
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-here-roles-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 const mk = async (name: string, first: boolean) => {
34 const made = await createUser(app.db, {
35 origin: "seed", trusted: true, username: name, email: `${name}@forge.local`,
36 password: "a-good-passphrase", adminIfFirst: first,
37 });
38 if (!made.ok) throw new Error(String(made.error));
39 return made.value;
40 };
41 const users: Record<string, Awaited<ReturnType<typeof mk>>> = {};
42 for (const [n, f] of [["boss", true], ["wren", false], ["wes", false], ["nina", false]] as const)
43 users[n] = await mk(n, f);
44 promote(app.db, ...Object.values(users));
45 const org = createOrg(app.db, "acme", users["wren"]!.id);
46 if (!org.ok) throw new Error(String(org.error));
47 const team = createTeam(app.db, org.value.id, "Writers", "write");
48 if (!team.ok) throw new Error(String(team.error));
49 addTeamMember(app.db, team.value, users["wes"]!.id);
50 const made = await newApp(app, users["wren"]!, { name: "pub", owner: "acme", visibility: "public" });
51 if (!made.ok) throw new Error(String(made.error));
52 const priv = await createRepo(app, users["wren"]!, { name: "vault", owner: "acme", visibility: "private" });
53 if (!priv.ok) throw new Error(String(priv.error));
54 for (const n of Object.keys(users)) tokens[n] = createToken(app.db, users[n]!.id, "t", "rw").token;
55 }, 60_000);
56
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 const ask = async (path: string, who?: string) => {
64 const res = await fetch(`${servers.url}/api/v1/here?path=${encodeURIComponent(path)}`, {
65 headers: who === undefined ? {} : { authorization: `Bearer ${tokens[who]}` },
66 });
67 return { status: res.status, text: await res.text() };
68 };
69 interface Verb {
70 do: string;
71 mcp?: string;
72 noMcp?: string;
73 account?: boolean;
74 }
75 const verbs = async (who?: string): Promise<Verb[]> =>
76 JSON.parse((await ask("/acme/pub", who)).text).can;
77 const names = (a: Verb[]) => a.map((x) => x.do);
78
79 test("each role gets its own list for one repository", async () => {
80 const stranger = names(await verbs());
81 const member = names(await verbs("nina"));
82 const writer = names(await verbs("wes"));
83 const admin = names(await verbs("wren"));
84 const whole = async (who?: string) => JSON.stringify(await verbs(who));
85 const lists = await Promise.all([undefined, "nina", "wes", "wren"].map(whole));
86 expect(new Set(lists).size).toBe(4);
87 // absent, not present-and-refused
88 expect(stranger).not.toContain("merge a pull");
89 expect(member).not.toContain("merge a pull");
90 expect(member).not.toContain("tell the crew");
91 expect(member).toContain("make it yours");
92 expect(writer).toContain("merge a pull");
93 expect(writer).not.toContain("make it yours");
94 expect(writer).not.toContain("change settings");
95 expect(admin).toContain("change settings");
96 const raw = await verbs();
97 expect(raw.find((a) => a.do === "open an issue")?.account).toBe(true);
98 expect((await verbs("nina")).find((a) => a.do === "open an issue")?.account).toBeUndefined();
99 });
100
101 test("every MCP tool a verb names is a real tool, and the verbs name it", async () => {
102 const real = new Set([...TOOLS.map((t) => t.name), "report"]);
103 for (const who of [undefined, "nina", "wes", "wren"])
104 for (const a of await verbs(who)) {
105 if (a.mcp !== undefined) expect(real.has(a.mcp)).toBe(true);
106 // every verb names its tool or says why it has none
107 expect(a.mcp !== undefined || (a.noMcp ?? "") !== "").toBe(true);
108 }
109 const admin = await verbs("wren");
110 const mcp = (d: string) => admin.find((a) => a.do === d)?.mcp;
111 expect(mcp("merge a pull")).toBe("merge");
112 expect(mcp("approve a pull")).toBe("review");
113 expect(mcp("change settings")).toBe("repo_settings");
114 expect(mcp("offer a change")).toBe("open_pull");
115 expect(mcp("comment")).toBe("say");
116 });
117
118 test("a private repository answers exactly as a missing one", async () => {
119 for (const who of [undefined, "nina"]) {
120 const hidden = await ask("/acme/vault", who);
121 const missing = await ask("/acme/nonesuch", who);
122 expect(hidden).toEqual(missing);
123 expect(hidden.status).toBe(404);
124 }
125 expect((await ask("/acme/vault", "wren")).status).toBe(200);
126 });
127
128 test("it answers in a bounded number of statements, and its here phase is timed", async () => {
129 await ask("/acme/pub", "wes");
130 const n = await statements(app.db, async () => {
131 await ask("/acme/pub", "wes");
132 });
133 // the 50 ms budget is held by the work: a bounded count, not one per verb
134 expectWork(n, { max: 45 });
135 const durs: number[] = [];
136 for (let i = 0; i < 5; i++) {
137 const res = await fetch(`${servers.url}/api/v1/here?path=%2Facme%2Fpub`, {
138 headers: { authorization: `Bearer ${tokens["wes"]}` },
139 });
outdated

Box 4: assert the here phase against the 50 ms budget from Server-Timing (as api-explore-timing does) as well as the statement bound.

140 await res.text();
141 const phases = requestTiming(res).split(", ");
142 const names = phases.map((p) => p.split(";")[0]!);
143 expect(names.slice(names.indexOf("app") + 1, names.indexOf("app") + 3)).toEqual(["session", "here"]);
144 durs.push(Number(/dur=([\d.]+)/.exec(phases.find((p) => p.startsWith("here;"))!)?.[1]));
145 }
146 const best = Math.min(...durs);
147 expect(best).not.toBeNaN();
148 // a raw clock under the flake floor goes red on a loaded runner (#7824);
149 // this bound catches a hang, the statement count above catches the slide
150 expect(best).toBeLessThanOrEqual(WALL_CLOCK_FLOOR_MS);
151 });
test/muse-journey.test.ts +1 −1
@@ -131,7 +131,7 @@ describe("the Muse test: a stranger agent needs no teaching", () => {
131131
132132 // 2. The manifest names the report action, HTTP and MCP, as needing no account.
133133 const here = (await (await fetch(manifest)).json()) as any;
134 const act = here.actions.find((x: any) => x.do === "report a problem");
134 const act = here.can.find((x: any) => x.do === "report a problem");
135135 expect(act.http).toBe("POST /api/v1/repos/alice/notes/reports");
136136 expect(act.mcp).toBe("report");
137137 expect(act.account).toBe(false);
test/ssh-tell-discovery.test.ts +8 −8
@@ -42,29 +42,29 @@ const llms = () => fetch(`${servers.url}/llms.txt`).then((r) => r.text());
4242 const here = async (path = "/", tok = token) =>
4343 (await fetch(`${servers.url}/api/v1/here?path=${path}`, {
4444 headers: { authorization: `Bearer ${tok}` },
45 }).then((r) => r.json())) as { actions: { do: string; ssh?: string }[] };
45 }).then((r) => r.json())) as { can: { do: string; ssh?: string }[] };
4646
4747 test("SSH off: neither mentions it", async () => {
4848 app.config.sshPort = 0;
4949 expect(await llms()).not.toContain("tell <words>");
50 expect((await here()).actions.some((a) => a.ssh !== undefined)).toBe(false);
50 expect((await here()).can.some((a) => a.ssh !== undefined)).toBe(false);
5151 });
5252
5353 test("SSH on: both give the exact command from the forge's own address", async () => {
5454 app.config.sshPort = 2222;
5555 const cmd = `ssh -p 2222 ${app.config.host ?? "localhost"} tell <words>`;
5656 expect(await llms()).toContain(`\`${cmd}\``);
57 const a = (await here()).actions.find((x) => x.do === "tell the crew");
57 const a = (await here()).can.find((x) => x.do === "tell the crew");
5858 expect(a?.ssh).toBe(cmd);
5959 });
6060
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 () => {
61 test("a repository page puts ssh on its tell verb in `can` for who may direct the crew; a read-only token gets none anywhere", async () => {
6262 app.config.sshPort = 2222;
6363 const cmd = `ssh -p 2222 ${app.config.host ?? "localhost"} tell <words>`;
6464 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);
65 expect(repoPage.can.find((x) => x.do === "tell the crew")?.ssh).toBe(cmd);
66 expect((await here("/", roToken)).can.some((a) => a.ssh !== undefined)).toBe(false);
67 expect((await here("/alice/hello", roToken)).can.some((a) => a.ssh !== undefined)).toBe(false);
6868 app.config.sshPort = 0;
69 expect((await here("/alice/hello")).actions.some((a) => a.ssh !== undefined)).toBe(false);
69 expect((await here("/alice/hello")).can.some((a) => a.ssh !== undefined)).toBe(false);
7070 });
test/token-free-tools.test.ts +1 −1
@@ -51,7 +51,7 @@ describe("token-free MCP tools", () => {
5151 const card = (await (await fetch(`${base}/.well-known/mcp.json`)).json()) as any;
5252 expect(card.tokenFreeTools).toEqual([...TOKEN_FREE_TOOLS]);
5353 const here = (await (await fetch(`${base}/api/v1/here`)).json()) as any;
54 const open = here.actions
54 const open = here.can
5555 .filter((a: any) => a.mcp !== undefined && a.account === false)
5656 .map((a: any) => a.mcp);
5757 expect(open.sort()).toEqual([...TOKEN_FREE_TOOLS].sort());

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs