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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10384-the-page-manifest-says-what-you-can-do-h → main 6 commits fork 4 threads · 4 unresolved
10 files
changed +276
−51
docs/MCP.md +15
| @@ -260,6 +260,21 @@ answers | ||
| 260 | 260 | (decision card) sends them to `say` with the first ask's `repo`, `number` |
| 261 | 261 | and `text`. Empty words send nothing. `asks` rows carry `repo` |
| 262 | 262 | (`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` | |
| 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`). | |
| 263 | 278 | - **Contract.** `GET /contract/openapi.json` is OpenAPI 3.1 for the routes |
| 264 | 279 | the manifest (`here`) names, plus `POST /mcp`. It also describes planning: `GET …/map`, `POST …/plan`, |
| 265 | 280 | `POST …/issues/{number}/dependencies` and |
src/actions/here.ts +34 −11
| @@ -47,6 +47,8 @@ export interface Action { | ||
| 47 | 47 | readonly http?: string; |
| 48 | 48 | /** The MCP tool that does the same, when there is one. */ |
| 49 | 49 | readonly mcp?: string; |
| 50 | /** Why no MCP tool does the same, when none does: say so, never stay silent. */ | |
| 51 | readonly noMcp?: string; | |
| 50 | 52 | /** The arguments that tool takes here, plain data (`{ q }` for a filter). */ |
| 51 | 53 | readonly mcpArgs?: Readonly<Record<string, string>>; |
| 52 | 54 | /** The shell command that does the same, when the forge's SSH is on. */ |
| @@ -84,13 +86,19 @@ export interface Manifest { | ||
| 84 | 86 | /** Only when the credential limits it: a read-only token acts on nothing. */ |
| 85 | 87 | readonly scope?: "ro"; |
| 86 | 88 | }; |
| 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[]; | |
| 88 | 91 | readonly mcp: { readonly url: string; readonly oauth: string }; |
| 89 | 92 | readonly docs: string; |
| 90 | 93 | } |
| 91 | 94 | |
| 92 | 95 | const SYSTEM = new Set(SYSTEM_NAMES); |
| 93 | 96 | |
| 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 | ||
| 94 | 102 | /** |
| 95 | 103 | * The manifest for a forge path, for `viewer` holding `scope`. `origin` is |
| 96 | 104 | * the forge's public origin. |
| @@ -138,7 +146,7 @@ export function manifestFor( | ||
| 138 | 146 | return ok({ |
| 139 | 147 | here: { kind: "org", org: account.username }, |
| 140 | 148 | you, |
| 141 | actions: [ | |
| 149 | can: [ | |
| 142 | 150 | { |
| 143 | 151 | do: "list the organization's teams", |
| 144 | 152 | http: `GET /api/v1/orgs/${account.username}/teams`, |
| @@ -154,7 +162,7 @@ export function manifestFor( | ||
| 154 | 162 | return ok({ |
| 155 | 163 | here: { kind: "person", username: account.username }, |
| 156 | 164 | you, |
| 157 | actions: [ | |
| 165 | can: [ | |
| 158 | 166 | { |
| 159 | 167 | do: "read their public repositories", |
| 160 | 168 | http: `GET /api/v1/users/${account.username}/repos`, |
| @@ -177,7 +185,7 @@ export function manifestFor( | ||
| 177 | 185 | return ok({ |
| 178 | 186 | here: { kind: page ?? "forge" }, |
| 179 | 187 | you: youFor(viewer, scope, undefined, app), |
| 180 | actions: | |
| 188 | can: | |
| 181 | 189 | page === undefined |
| 182 | 190 | ? forgeActions(app, viewer, scope) |
| 183 | 191 | : [ |
| @@ -208,7 +216,7 @@ export function manifestFor( | ||
| 208 | 216 | }), |
| 209 | 217 | }, |
| 210 | 218 | you: youFor(viewer, scope, repo, app), |
| 211 | actions: repoActions(app, repo, viewer, scope), | |
| 219 | can: repoActions(app, repo, viewer, scope), | |
| 212 | 220 | ...common, |
| 213 | 221 | }); |
| 214 | 222 | } |
| @@ -362,7 +370,7 @@ function repoActions( | ||
| 362 | 370 | // password act with the account's full authority. |
| 363 | 371 | const acts = |
| 364 | 372 | 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" }]; | |
| 366 | 374 | if (canRead(db, repo, undefined)) |
| 367 | 375 | out.push({ |
| 368 | 376 | do: "report a problem", |
| @@ -381,13 +389,15 @@ function repoActions( | ||
| 381 | 389 | const participant: Action[] = [ |
| 382 | 390 | ...(talks |
| 383 | 391 | ? [ |
| 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" }, | |
| 386 | 395 | ] |
| 387 | 396 | : []), |
| 388 | 397 | { |
| 389 | 398 | do: "offer a change", |
| 390 | 399 | http: `POST ${r}/forks, then POST ${r}/pulls {headRepo}`, |
| 400 | mcp: "open_pull", | |
| 391 | 401 | ...unproven, |
| 392 | 402 | }, |
| 393 | 403 | ]; |
| @@ -396,7 +406,11 @@ function repoActions( | ||
| 396 | 406 | else if (acts) out.push(...participant); |
| 397 | 407 | const record = getAppByRepo(db, repo.id); |
| 398 | 408 | 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 | }); | |
| 400 | 414 | // The copy is for whoever cannot write the app (the web's button, `!canWrite`); |
| 401 | 415 | // its dry run is `ro`, so a read-only token sees it too. |
| 402 | 416 | if ( |
| @@ -436,11 +450,20 @@ function repoActions( | ||
| 436 | 450 | out.push({ |
| 437 | 451 | do: "approve a pull", |
| 438 | 452 | http: `POST ${r}/pulls/{number}/reviews`, |
| 453 | mcp: "review", | |
| 439 | 454 | }); |
| 440 | 455 | 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 | }); | |
| 442 | 461 | 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 | }); | |
| 444 | 467 | // How a native fork follows its parent: a writer's moves, the same rule |
| 445 | 468 | // `takeOrTell` and `proposeToParentForge` ask (`requireWriter`). |
| 446 | 469 | if ( |
src/contract/openapi/routes/getApiV1Here.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 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( | ||
| 285 | 285 | asString(field(health.value, "commit")) === "" |
| 286 | 286 | ) |
| 287 | 287 | return undefined; |
| 288 | // A current forge lists what you can do as `can`; one before #10384 as `actions`. | |
| 288 | 289 | const here = await getJson(`${web}/api/v1/here`, web, probe); |
| 289 | 290 | if ( |
| 290 | 291 | !here.ok || |
| 291 | 292 | typeof field(here.value, "here") !== "object" || |
| 292 | 293 | 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 | ) | |
| 294 | 298 | ) |
| 295 | 299 | return undefined; |
| 296 | 300 | } |
test/forge-origin-older.test.ts +32
| @@ -107,3 +107,35 @@ describe("a forge on an older release", () => { | ||
| 107 | 107 | expect(page.value.next).toBeNull(); |
| 108 | 108 | }); |
| 109 | 109 | }); |
| 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", () => { | ||
| 122 | 122 | text: "", |
| 123 | 123 | }; |
| 124 | 124 | }; |
| 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); | |
| 126 | 126 | |
| 127 | 127 | // ── refusals first ──────────────────────────────────────────────────── |
| 128 | 128 | |
| @@ -151,7 +151,7 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 151 | 151 | const { body } = await manifest("/acme/game"); |
| 152 | 152 | expect(body.you.as).toBe("anonymous"); |
| 153 | 153 | const byName = new Map<string, any>( |
| 154 | body.actions.map((a: any) => [a.do, a]), | |
| 154 | body.can.map((a: any) => [a.do, a]), | |
| 155 | 155 | ); |
| 156 | 156 | expect(byName.get("report a problem").account).toBe(false); |
| 157 | 157 | 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", () => { | ||
| 216 | 216 | ]; |
| 217 | 217 | const { body } = await manifest(path, "wes"); |
| 218 | 218 | 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( | |
| 220 | 220 | "POST /api/v1/repos/wes/game/following/take", |
| 221 | 221 | ); |
| 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([ | |
| 223 | 223 | "following", |
| 224 | 224 | "take_updates", |
| 225 | 225 | "propose_to_parent_forge", |
| @@ -256,7 +256,7 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 256 | 256 | test("the forge's manifest names how to help, for anyone; a repository's does not", async () => { |
| 257 | 257 | for (const who of [undefined, "carol"]) { |
| 258 | 258 | const { body, size } = await manifest("/", who); |
| 259 | expect(body.actions).toContainEqual({ | |
| 259 | expect(body.can).toContainEqual({ | |
| 260 | 260 | do: "see how to help build this forge", |
| 261 | 261 | http: "GET /api/v1/contribute", |
| 262 | 262 | mcp: "contribute", |
| @@ -271,7 +271,7 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 271 | 271 | test("the forge's manifest names the changelog, for anyone", async () => { |
| 272 | 272 | for (const who of [undefined, "carol"]) { |
| 273 | 273 | const { body, size } = await manifest("/", who); |
| 274 | expect(body.actions).toContainEqual({ | |
| 274 | expect(body.can).toContainEqual({ | |
| 275 | 275 | do: "see what went live", |
| 276 | 276 | http: "GET /api/v1/changelog", |
| 277 | 277 | mcp: "releases", |
| @@ -281,7 +281,7 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 281 | 281 | }); |
| 282 | 282 | |
| 283 | 283 | 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; | |
| 285 | 285 | expect(anon).toContainEqual({ |
| 286 | 286 | do: "make an account", |
| 287 | 287 | http: "GET /signup", |
| @@ -306,7 +306,7 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 306 | 306 | const edgeServers = await serve(edgeApp); |
| 307 | 307 | try { |
| 308 | 308 | 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); | |
| 310 | 310 | expect(names).toContain("sign in"); |
| 311 | 311 | expect(names).not.toContain("make an account"); |
| 312 | 312 | } finally { |
| @@ -318,7 +318,7 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 318 | 318 | test("the forge's manifest names the OpenAPI document, and it answers", async () => { |
| 319 | 319 | for (const who of [undefined, "carol"]) { |
| 320 | 320 | const { body, size } = await manifest("/", who); |
| 321 | expect(body.actions).toContainEqual({ | |
| 321 | expect(body.can).toContainEqual({ | |
| 322 | 322 | do: "read the HTTP contract", |
| 323 | 323 | http: "GET /contract/openapi.json", |
| 324 | 324 | account: false, |
| @@ -334,36 +334,36 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 334 | 334 | for (const who of [undefined, "carol"]) { |
| 335 | 335 | const e = (await manifest("/explore?q=kind:app", who)).body; |
| 336 | 336 | 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"); | |
| 339 | 339 | const s = (await manifest("/search", who)).body; |
| 340 | 340 | 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"); | |
| 343 | 343 | const f = (await manifest("/", who)).body; |
| 344 | 344 | 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( | |
| 346 | 346 | expect.arrayContaining(["explore", "search"]), |
| 347 | 347 | ); |
| 348 | for (const a of f.actions) | |
| 348 | for (const a of f.can) | |
| 349 | 349 | if (a.mcp === "explore" || a.mcp === "search") |
| 350 | 350 | expect(a.account).toBeUndefined(); |
| 351 | 351 | } |
| 352 | 352 | 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"); | |
| 354 | 354 | const other = (await manifest("/settings")).body; |
| 355 | 355 | 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"); | |
| 357 | 357 | }); |
| 358 | 358 | |
| 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 () => { | |
| 360 | 360 | 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" }); | |
| 362 | 362 | 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); | |
| 364 | 364 | const f = (await manifest("/")).body; |
| 365 | 365 | 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"); | |
| 367 | 367 | }); |
| 368 | 368 | |
| 369 | 369 | test("it is small", async () => { |
| @@ -426,14 +426,14 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 426 | 426 | |
| 427 | 427 | test("an account whose email is unproven is told that offering a change needs a verified email", async () => { |
| 428 | 428 | 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"); | |
| 430 | 430 | expect(offer.verify).toBe(true); |
| 431 | 431 | 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(); | |
| 433 | 433 | }); |
| 434 | 434 | |
| 435 | 435 | 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"); | |
| 437 | 437 | const carol = find((await manifest("/acme/pub", "carol")).body); |
| 438 | 438 | expect(carol).toEqual({ |
| 439 | 439 | do: "make it yours", |
| @@ -485,10 +485,10 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 485 | 485 | test("one segment is a person, an org, nobody, or one of the forge's own names", async () => { |
| 486 | 486 | const person = (await manifest("/wes")).body; |
| 487 | 487 | 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"); | |
| 489 | 489 | const org = (await manifest("/ACME", "carol")).body; |
| 490 | 490 | 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"); | |
| 492 | 492 | const nobody = await here("/nope"); |
| 493 | 493 | expect(nobody.status).toBe(404); |
| 494 | 494 | 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", () => { | ||
| 551 | 551 | |
| 552 | 552 | test("on /explore the page read is not repeated by the generic one", async () => { |
| 553 | 553 | 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"); | |
| 555 | 555 | expect(reads.length).toBe(1); |
| 556 | 556 | 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); | |
| 558 | 558 | }); |
| 559 | 559 | }); |
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 |
||
| 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", () => { | ||
| 131 | 131 | |
| 132 | 132 | // 2. The manifest names the report action, HTTP and MCP, as needing no account. |
| 133 | 133 | 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"); | |
| 135 | 135 | expect(act.http).toBe("POST /api/v1/repos/alice/notes/reports"); |
| 136 | 136 | expect(act.mcp).toBe("report"); |
| 137 | 137 | 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()); | ||
| 42 | 42 | const here = async (path = "/", tok = token) => |
| 43 | 43 | (await fetch(`${servers.url}/api/v1/here?path=${path}`, { |
| 44 | 44 | 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 }[] }; | |
| 46 | 46 | |
| 47 | 47 | test("SSH off: neither mentions it", async () => { |
| 48 | 48 | app.config.sshPort = 0; |
| 49 | 49 | 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); | |
| 51 | 51 | }); |
| 52 | 52 | |
| 53 | 53 | test("SSH on: both give the exact command from the forge's own address", async () => { |
| 54 | 54 | app.config.sshPort = 2222; |
| 55 | 55 | const cmd = `ssh -p 2222 ${app.config.host ?? "localhost"} tell <words>`; |
| 56 | 56 | 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"); | |
| 58 | 58 | expect(a?.ssh).toBe(cmd); |
| 59 | 59 | }); |
| 60 | 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 () => { | |
| 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 () => { | |
| 62 | 62 | app.config.sshPort = 2222; |
| 63 | 63 | const cmd = `ssh -p 2222 ${app.config.host ?? "localhost"} tell <words>`; |
| 64 | 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); | |
| 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); | |
| 68 | 68 | 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); | |
| 70 | 70 | }); |
test/token-free-tools.test.ts +1 −1
| @@ -51,7 +51,7 @@ describe("token-free MCP tools", () => { | ||
| 51 | 51 | const card = (await (await fetch(`${base}/.well-known/mcp.json`)).json()) as any; |
| 52 | 52 | expect(card.tokenFreeTools).toEqual([...TOKEN_FREE_TOOLS]); |
| 53 | 53 | const here = (await (await fetch(`${base}/api/v1/here`)).json()) as any; |
| 54 | const open = here.actions | |
| 54 | const open = here.can | |
| 55 | 55 | .filter((a: any) => a.mcp !== undefined && a.account === false) |
| 56 | 56 | .map((a: any) => a.mcp); |
| 57 | 57 | expect(open.sort()).toEqual([...TOKEN_FREE_TOOLS].sort()); |
Drop "(
actionsis gone: the list iscan)"; that is history, not shape. Wrap this paragraph's long lines.