Public: anyone can read this; the crew can change it.
The OpenAPI description declares its bearer scheme and servers, so a harness's Code Mode can call it untaught #10751
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10746-the-openapi-description-declares-its-bea → main 1 commit fork
11 files
changed +32
−21
docs/MCP.md +1 −1
| @@ -275,7 +275,7 @@ answers | ||
| 275 | 275 | and an owner, and that every verb names a tool or says why not. The 50 ms |
| 276 | 276 | budget is held by a bounded statement count; the `here` phase is read from |
| 277 | 277 | Server-Timing and bounded at the flake floor (`WALL_CLOCK_FLOOR_MS`). |
| 278 | - **Contract.** `GET /contract/openapi.json` is OpenAPI 3.1 for the routes | |
| 278 | - **Contract.** `GET /contract/openapi.json` (also `/openapi.json`, `/api/v1/openapi.json`; `servers` is the request's origin, bearer `token` is the default `security`, open routes say `security: []`) is OpenAPI 3.1 for the routes | |
| 279 | 279 | the manifest (`here`) names, plus `POST /mcp`. It also describes planning: `GET …/map`, `POST …/plan`, |
| 280 | 280 | `POST …/issues/{number}/dependencies` and |
| 281 | 281 | `DELETE …/issues/{number}/dependencies/{blocker}`. `test/openapi.test.ts` fails |
src/contract/openapi.ts +4 −1
| @@ -91,7 +91,8 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 91 | 91 | }, |
| 92 | 92 | }, |
| 93 | 93 | }), |
| 94 | ...(r.auth ? { security: [{ token: [] }, { oauth2: [oauthScopeFor(r.method)] }] } : {}), | |
| 94 | // Routes that need no credential opt out of the document's default with `security: []`. | |
| 95 | security: r.auth ? [{ token: [] }, { oauth2: [oauthScopeFor(r.method)] }] : [], | |
| 95 | 96 | responses: { |
| 96 | 97 | [String(r.status ?? 200)]: { |
| 97 | 98 | description: "Done", |
| @@ -129,6 +130,8 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 129 | 130 | "The routes the forge names to an agent. `not_found` means missing or not yours to see.", |
| 130 | 131 | }, |
| 131 | 132 | servers: [{ url: origin }], |
| 133 | // The default: a bearer token. Open routes say `security: []`. | |
| 134 | security: [{ token: [] }], | |
| 132 | 135 | paths, |
| 133 | 136 | components: { |
| 134 | 137 | securitySchemes: { |
test/openapi-flow.test.ts +10 −2
| @@ -28,7 +28,7 @@ test("GET /contract/openapi.json has the flow and the scorecard", async () => { | ||
| 28 | 28 | const doc = (await res.json()) as any; |
| 29 | 29 | |
| 30 | 30 | const flow = doc.paths["/api/v1/repos/{owner}/{name}/flow"].get; |
| 31 | expect(flow.security).toBeUndefined(); | |
| 31 | expect(flow.security).toEqual([]); | |
| 32 | 32 | expect(flow.summary).toContain("{ open, live }"); |
| 33 | 33 | expect(flow.responses.default.description).toContain("`not_found`"); |
| 34 | 34 | |
| @@ -50,7 +50,7 @@ test("the document describes OAuth: authorization code, refresh, and OpenID disc | ||
| 50 | 50 | expect(schemes.openid.openIdConnectUrl.endsWith("/.well-known/openid-configuration")).toBe(true); |
| 51 | 51 | const card = doc.paths["/api/v1/admin/invites"].post; |
| 52 | 52 | expect(card.security).toEqual([{ token: [] }, { oauth2: ["rw"] }]); |
| 53 | const read = Object.values(doc.paths).map((p: any) => p.get).find((op: any) => op?.security); | |
| 53 | const read = Object.values(doc.paths).map((p: any) => p.get).find((op: any) => op?.security?.length); | |
| 54 | 54 | expect(read.security).toEqual([{ token: [] }, { oauth2: ["ro"] }]); |
| 55 | 55 | }); |
| 56 | 56 | |
| @@ -61,3 +61,11 @@ test("the served document says the telemetry flow read can be revalidated with a | ||
| 61 | 61 | expect(op.summary).toContain("cacheable by ETag"); |
| 62 | 62 | expect(op.summary).toContain("If-None-Match"); |
| 63 | 63 | }); |
| 64 | ||
| 65 | test("the description declares its origin, a bearer default, and marks open routes", async () => { | |
| 66 | const doc = (await (await fetch(`${servers.url}/contract/openapi.json`)).json()) as any; | |
| 67 | expect(doc.servers).toEqual([{ url: servers.url }]); | |
| 68 | expect(doc.components.securitySchemes.token).toEqual({ type: "http", scheme: "bearer" }); | |
| 69 | expect(doc.security).toEqual([{ token: [] }]); | |
| 70 | expect(doc.paths["/api/v1/repos/{owner}/{name}/flow"].get.security).toEqual([]); | |
| 71 | }); |
test/openapi-oa-agents.test.ts +1 −1
| @@ -29,5 +29,5 @@ test("each agent and crew door is in the served document", () => { | ||
| 29 | 29 | const doc = openapi("https://f.test", "1") as any; |
| 30 | 30 | for (const [method, path] of want) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); |
| 31 | 31 | expect(doc.paths["/api/v1/thoughts"].post.security).toBeDefined(); |
| 32 | expect(doc.paths["/api/v1/here"].get.security).toBeUndefined(); | |
| 32 | expect(doc.paths["/api/v1/here"].get.security).toEqual([]); | |
| 33 | 33 | }); |
test/openapi-oa-crew.test.ts +1 −1
| @@ -21,5 +21,5 @@ test("each crew-area path and method is served", () => { | ||
| 21 | 21 | for (const [path, methods] of Object.entries(WANT)) |
| 22 | 22 | for (const m of methods) expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined(); |
| 23 | 23 | expect(doc.paths[`${R}/hooks`].post.security).toBeDefined(); |
| 24 | expect(doc.paths[`${R}/decisions`].get.security).toBeUndefined(); | |
| 24 | expect(doc.paths[`${R}/decisions`].get.security).toEqual([]); | |
| 25 | 25 | }); |
test/openapi-oa-forges.test.ts +1 −1
| @@ -29,7 +29,7 @@ test("the claim link is shown once and the heartbeat and drills say who calls th | ||
| 29 | 29 | const doc = openapi("https://f.test", "1") as any; |
| 30 | 30 | expect(doc.paths["/api/v1/forges/{name}/claim-link"].post.summary).toContain("shown once"); |
| 31 | 31 | expect(doc.paths["/api/v1/heartbeat"].post.summary).toContain("child"); |
| 32 | expect(doc.paths["/api/v1/heartbeat"].post.security).toBeUndefined(); | |
| 32 | expect(doc.paths["/api/v1/heartbeat"].post.security).toEqual([]); | |
| 33 | 33 | expect(doc.paths["/api/v1/drills"].post.summary).toContain("site admin"); |
| 34 | 34 | expect(doc.paths["/api/v1/drills"].post.security).toBeDefined(); |
| 35 | 35 | }); |
test/openapi-oa-pull-checks.test.ts +1 −1
| @@ -8,7 +8,7 @@ test("GET and POST …/pulls/{number}/checks are described", () => { | ||
| 8 | 8 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>; |
| 9 | 9 | expect(paths[P]?.get?.summary).toContain("checks"); |
| 10 | 10 | expect(paths[P]?.post?.responses?.["202"]).toBeDefined(); |
| 11 | expect(paths[P]?.get?.security).toBeUndefined(); | |
| 11 | expect(paths[P]?.get?.security).toEqual([]); | |
| 12 | 12 | expect(paths[P]?.post?.security).toEqual([{ token: [] }, { oauth2: ["rw"] }]); |
| 13 | 13 | const ok = (m: string, code: string) => |
| 14 | 14 | paths[P]?.[m]?.responses?.[code]?.content?.["application/json"]?.schema?.properties; |
test/openapi-oa-pull-threads.test.ts +1 −1
| @@ -7,7 +7,7 @@ const T = "/api/v1/repos/{owner}/{name}/pulls/{number}/threads"; | ||
| 7 | 7 | test("list, resolve and reopen are described", () => { |
| 8 | 8 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>; |
| 9 | 9 | expect(paths[T]?.get).toBeDefined(); |
| 10 | expect(paths[T]?.get.security).toBeUndefined(); | |
| 10 | expect(paths[T]?.get.security).toEqual([]); | |
| 11 | 11 | expect(paths[`${T}/{anchor}/resolve`]?.post).toBeDefined(); |
| 12 | 12 | expect(paths[`${T}/{anchor}/resolve`]?.delete).toBeDefined(); |
| 13 | 13 | expect(paths[`${T}/{anchor}/resolve`]?.post.security).toBeDefined(); |
test/openapi-oa-runs.test.ts +1 −1
| @@ -17,5 +17,5 @@ test("runs, log, cancel, usage and spend are in the served document", () => { | ||
| 17 | 17 | for (const [method, path] of want) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); |
| 18 | 18 | expect(doc.paths[`${R}/runs/{id}/cancel`].post.security).toBeDefined(); |
| 19 | 19 | expect(doc.paths[`${R}/spend`].post.security).toBeDefined(); |
| 20 | expect(doc.paths[`${R}/runs`].get.security).toBeUndefined(); | |
| 20 | expect(doc.paths[`${R}/runs`].get.security).toEqual([]); | |
| 21 | 21 | }); |
test/openapi-oa-waves.test.ts +2 −2
| @@ -10,8 +10,8 @@ test("the waves list and one wave are in the served document with their 404", () | ||
| 10 | 10 | const one = doc.paths[`${R}/waves/{version}`]?.get; |
| 11 | 11 | expect(list).toBeDefined(); |
| 12 | 12 | expect(one).toBeDefined(); |
| 13 | expect(list.security).toBeUndefined(); | |
| 14 | expect(one.security).toBeUndefined(); | |
| 13 | expect(list.security).toEqual([]); | |
| 14 | expect(one.security).toEqual([]); | |
| 15 | 15 | expect(list.responses.default.description).toContain("`not_found`"); |
| 16 | 16 | expect(one.responses.default.description).toContain("no such wave"); |
| 17 | 17 | }); |
test/openapi.test.ts +9 −9
| @@ -59,7 +59,7 @@ test("every described route is registered in the router", () => { | ||
| 59 | 59 | test("the map, plan and dependency routes are described", () => { |
| 60 | 60 | const doc = openapi("https://f.test", "1") as any; |
| 61 | 61 | const R = "/api/v1/repos/{owner}/{name}"; |
| 62 | expect(doc.paths[`${R}/map`].get.security).toBeUndefined(); | |
| 62 | expect(doc.paths[`${R}/map`].get.security).toEqual([]); | |
| 63 | 63 | expect(doc.paths[`${R}/plan`].post.requestBody.content["application/json"].schema.properties.issues.type).toBe("array"); |
| 64 | 64 | expect(doc.paths[`${R}/issues/{number}/dependencies`].post.security).toBeDefined(); |
| 65 | 65 | const del = doc.paths[`${R}/issues/{number}/dependencies/{blocker}`].delete; |
| @@ -83,7 +83,7 @@ test("listing issues and pulls is described with the query the handlers read", ( | ||
| 83 | 83 | const R = "/api/v1/repos/{owner}/{name}"; |
| 84 | 84 | for (const kind of ["issues", "pulls"]) { |
| 85 | 85 | const get = doc.paths[`${R}/${kind}`].get; |
| 86 | expect(get.security).toBeUndefined(); | |
| 86 | expect(get.security).toEqual([]); | |
| 87 | 87 | const q = get.parameters.filter((p: any) => p.in === "query"); |
| 88 | 88 | const base = ["state", "limit", "cursor", "label", "author"]; |
| 89 | 89 | expect(q.map((p: any) => p.name)).toEqual(kind === "issues" ? [...base, "fields"] : base); |
| @@ -106,18 +106,18 @@ test("reading and closing one issue and reading one pull are described", () => { | ||
| 106 | 106 | const doc = openapi("https://f.test", "1") as any; |
| 107 | 107 | const R = "/api/v1/repos/{owner}/{name}"; |
| 108 | 108 | const issue = doc.paths[`${R}/issues/{number}`]; |
| 109 | expect(issue.get.security).toBeUndefined(); | |
| 109 | expect(issue.get.security).toEqual([]); | |
| 110 | 110 | expect(issue.patch.security).toBeDefined(); |
| 111 | 111 | const props = issue.patch.requestBody.content["application/json"].schema.properties; |
| 112 | 112 | expect(Object.keys(props).sort()).toEqual(["into", "reason", "state"]); |
| 113 | 113 | expect(props.into.type).toBe("integer"); |
| 114 | 114 | expect(issue.patch.parameters.find((p: any) => p.name === "number").in).toBe("path"); |
| 115 | expect(doc.paths[`${R}/pulls/{number}`].get.security).toBeUndefined(); | |
| 115 | expect(doc.paths[`${R}/pulls/{number}`].get.security).toEqual([]); | |
| 116 | 116 | }); |
| 117 | 117 | |
| 118 | 118 | test("making and listing apps is described with every field the route reads", () => { |
| 119 | 119 | const doc = openapi("https://f.test", "1") as any; |
| 120 | expect(doc.paths["/api/v1/apps"].get.security).toBeUndefined(); | |
| 120 | expect(doc.paths["/api/v1/apps"].get.security).toEqual([]); | |
| 121 | 121 | const post = doc.paths["/api/v1/apps"].post; |
| 122 | 122 | expect(post.security).toBeDefined(); |
| 123 | 123 | expect(post.responses["201"]).toBeDefined(); |
| @@ -210,7 +210,7 @@ test("an issue's teams and labels, a pull's labels and an issue's private reposi | ||
| 210 | 210 | ] as const) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); |
| 211 | 211 | expect(doc.paths[`${R}/issues/{number}/teams`].put.requestBody.content["application/json"].schema.properties.teams.type).toBe("array"); |
| 212 | 212 | expect(doc.paths[`${R}/issues/{number}/private`].post.responses["201"]).toBeDefined(); |
| 213 | expect(doc.paths[`${R}/issues/{number}/teams`].get.security).toBeUndefined(); | |
| 213 | expect(doc.paths[`${R}/issues/{number}/teams`].get.security).toEqual([]); | |
| 214 | 214 | expect(doc.paths[`${R}/pulls/{number}/labels`].put.security).toBeDefined(); |
| 215 | 215 | }); |
| 216 | 216 | |
| @@ -245,7 +245,7 @@ test("signing up, signing in and the first admin are described", () => { | ||
| 245 | 245 | const op = doc.paths[path]?.[method]; |
| 246 | 246 | expect(op, `${method} ${path}`).toBeDefined(); |
| 247 | 247 | expect(op.responses[String(status)], `${method} ${path} ${status}`).toBeDefined(); |
| 248 | expect(op.security, `${method} ${path}`).toBeUndefined(); | |
| 248 | expect(op.security, `${method} ${path}`).toEqual([]); | |
| 249 | 249 | } |
| 250 | 250 | const props = (p: string) => doc.paths[p].post.requestBody.content["application/json"].schema.properties; |
| 251 | 251 | expect(props("/api/v1/users").password.type).toBe("string"); |
| @@ -483,10 +483,10 @@ test("labels made and removed, a commit's check and a file read by path are desc | ||
| 483 | 483 | expect(del.security).toBeDefined(); |
| 484 | 484 | expect(del.responses["204"]).toBeDefined(); |
| 485 | 485 | const check = doc.paths[`${R}/commits/{oid}/check`].get; |
| 486 | expect(check.security).toBeUndefined(); | |
| 486 | expect(check.security).toEqual([]); | |
| 487 | 487 | expect(check.parameters.find((p: any) => p.name === "oid").in).toBe("path"); |
| 488 | 488 | const read = doc.paths[`${R}/contents/{path}`].get; |
| 489 | expect(read.security).toBeUndefined(); | |
| 489 | expect(read.security).toEqual([]); | |
| 490 | 490 | expect(read.parameters.map((p: any) => p.name)).toEqual(["owner", "name", "path", "ref"]); |
| 491 | 491 | }); |
| 492 | 492 |