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

Merged opened this

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

Merged

scout/forge:scout/10746-the-openapi-description-declares-its-bea → main 1 commit fork

  • Merged. scout/10746-the-openapi-description-declares-its-bea was merged into main as 50be709.

Review

Sign in to review this change.
11 files changed +32 −21
docs/MCP.md +1 −1
@@ -275,7 +275,7 @@ answers
275275 and an owner, and that every verb names a tool or says why not. The 50 ms
276276 budget is held by a bounded statement count; the `here` phase is read from
277277 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
279279 the manifest (`here`) names, plus `POST /mcp`. It also describes planning: `GET …/map`, `POST …/plan`,
280280 `POST …/issues/{number}/dependencies` and
281281 `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
9191 },
9292 },
9393 }),
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)] }] : [],
9596 responses: {
9697 [String(r.status ?? 200)]: {
9798 description: "Done",
@@ -129,6 +130,8 @@ export function openapi(origin: string, version: string): Record<string, unknown
129130 "The routes the forge names to an agent. `not_found` means missing or not yours to see.",
130131 },
131132 servers: [{ url: origin }],
133 // The default: a bearer token. Open routes say `security: []`.
134 security: [{ token: [] }],
132135 paths,
133136 components: {
134137 securitySchemes: {
test/openapi-flow.test.ts +10 −2
@@ -28,7 +28,7 @@ test("GET /contract/openapi.json has the flow and the scorecard", async () => {
2828 const doc = (await res.json()) as any;
2929
3030 const flow = doc.paths["/api/v1/repos/{owner}/{name}/flow"].get;
31 expect(flow.security).toBeUndefined();
31 expect(flow.security).toEqual([]);
3232 expect(flow.summary).toContain("{ open, live }");
3333 expect(flow.responses.default.description).toContain("`not_found`");
3434
@@ -50,7 +50,7 @@ test("the document describes OAuth: authorization code, refresh, and OpenID disc
5050 expect(schemes.openid.openIdConnectUrl.endsWith("/.well-known/openid-configuration")).toBe(true);
5151 const card = doc.paths["/api/v1/admin/invites"].post;
5252 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);
5454 expect(read.security).toEqual([{ token: [] }, { oauth2: ["ro"] }]);
5555 });
5656
@@ -61,3 +61,11 @@ test("the served document says the telemetry flow read can be revalidated with a
6161 expect(op.summary).toContain("cacheable by ETag");
6262 expect(op.summary).toContain("If-None-Match");
6363 });
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", () => {
2929 const doc = openapi("https://f.test", "1") as any;
3030 for (const [method, path] of want) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
3131 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([]);
3333 });
test/openapi-oa-crew.test.ts +1 −1
@@ -21,5 +21,5 @@ test("each crew-area path and method is served", () => {
2121 for (const [path, methods] of Object.entries(WANT))
2222 for (const m of methods) expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined();
2323 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([]);
2525 });
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
2929 const doc = openapi("https://f.test", "1") as any;
3030 expect(doc.paths["/api/v1/forges/{name}/claim-link"].post.summary).toContain("shown once");
3131 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([]);
3333 expect(doc.paths["/api/v1/drills"].post.summary).toContain("site admin");
3434 expect(doc.paths["/api/v1/drills"].post.security).toBeDefined();
3535 });
test/openapi-oa-pull-checks.test.ts +1 −1
@@ -8,7 +8,7 @@ test("GET and POST …/pulls/{number}/checks are described", () => {
88 const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>;
99 expect(paths[P]?.get?.summary).toContain("checks");
1010 expect(paths[P]?.post?.responses?.["202"]).toBeDefined();
11 expect(paths[P]?.get?.security).toBeUndefined();
11 expect(paths[P]?.get?.security).toEqual([]);
1212 expect(paths[P]?.post?.security).toEqual([{ token: [] }, { oauth2: ["rw"] }]);
1313 const ok = (m: string, code: string) =>
1414 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";
77 test("list, resolve and reopen are described", () => {
88 const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>;
99 expect(paths[T]?.get).toBeDefined();
10 expect(paths[T]?.get.security).toBeUndefined();
10 expect(paths[T]?.get.security).toEqual([]);
1111 expect(paths[`${T}/{anchor}/resolve`]?.post).toBeDefined();
1212 expect(paths[`${T}/{anchor}/resolve`]?.delete).toBeDefined();
1313 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", () => {
1717 for (const [method, path] of want) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
1818 expect(doc.paths[`${R}/runs/{id}/cancel`].post.security).toBeDefined();
1919 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([]);
2121 });
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", ()
1010 const one = doc.paths[`${R}/waves/{version}`]?.get;
1111 expect(list).toBeDefined();
1212 expect(one).toBeDefined();
13 expect(list.security).toBeUndefined();
14 expect(one.security).toBeUndefined();
13 expect(list.security).toEqual([]);
14 expect(one.security).toEqual([]);
1515 expect(list.responses.default.description).toContain("`not_found`");
1616 expect(one.responses.default.description).toContain("no such wave");
1717 });
test/openapi.test.ts +9 −9
@@ -59,7 +59,7 @@ test("every described route is registered in the router", () => {
5959 test("the map, plan and dependency routes are described", () => {
6060 const doc = openapi("https://f.test", "1") as any;
6161 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([]);
6363 expect(doc.paths[`${R}/plan`].post.requestBody.content["application/json"].schema.properties.issues.type).toBe("array");
6464 expect(doc.paths[`${R}/issues/{number}/dependencies`].post.security).toBeDefined();
6565 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", (
8383 const R = "/api/v1/repos/{owner}/{name}";
8484 for (const kind of ["issues", "pulls"]) {
8585 const get = doc.paths[`${R}/${kind}`].get;
86 expect(get.security).toBeUndefined();
86 expect(get.security).toEqual([]);
8787 const q = get.parameters.filter((p: any) => p.in === "query");
8888 const base = ["state", "limit", "cursor", "label", "author"];
8989 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", () => {
106106 const doc = openapi("https://f.test", "1") as any;
107107 const R = "/api/v1/repos/{owner}/{name}";
108108 const issue = doc.paths[`${R}/issues/{number}`];
109 expect(issue.get.security).toBeUndefined();
109 expect(issue.get.security).toEqual([]);
110110 expect(issue.patch.security).toBeDefined();
111111 const props = issue.patch.requestBody.content["application/json"].schema.properties;
112112 expect(Object.keys(props).sort()).toEqual(["into", "reason", "state"]);
113113 expect(props.into.type).toBe("integer");
114114 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([]);
116116 });
117117
118118 test("making and listing apps is described with every field the route reads", () => {
119119 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([]);
121121 const post = doc.paths["/api/v1/apps"].post;
122122 expect(post.security).toBeDefined();
123123 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
210210 ] as const) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
211211 expect(doc.paths[`${R}/issues/{number}/teams`].put.requestBody.content["application/json"].schema.properties.teams.type).toBe("array");
212212 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([]);
214214 expect(doc.paths[`${R}/pulls/{number}/labels`].put.security).toBeDefined();
215215 });
216216
@@ -245,7 +245,7 @@ test("signing up, signing in and the first admin are described", () => {
245245 const op = doc.paths[path]?.[method];
246246 expect(op, `${method} ${path}`).toBeDefined();
247247 expect(op.responses[String(status)], `${method} ${path} ${status}`).toBeDefined();
248 expect(op.security, `${method} ${path}`).toBeUndefined();
248 expect(op.security, `${method} ${path}`).toEqual([]);
249249 }
250250 const props = (p: string) => doc.paths[p].post.requestBody.content["application/json"].schema.properties;
251251 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
483483 expect(del.security).toBeDefined();
484484 expect(del.responses["204"]).toBeDefined();
485485 const check = doc.paths[`${R}/commits/{oid}/check`].get;
486 expect(check.security).toBeUndefined();
486 expect(check.security).toEqual([]);
487487 expect(check.parameters.find((p: any) => p.name === "oid").in).toBe("path");
488488 const read = doc.paths[`${R}/contents/{path}`].get;
489 expect(read.security).toBeUndefined();
489 expect(read.security).toEqual([]);
490490 expect(read.parameters.map((p: any) => p.name)).toEqual(["owner", "name", "path", "ref"]);
491491 });
492492

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs