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

A crew run can open its own preview on a private repository #7832

Merged opened this in answer to

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

Merged

torque/forge:torque/7829-a-crew-run-can-open-its-own-preview-on-a → main 2 commits fork

  • Merged. torque/7829-a-crew-run-can-open-its-own-preview-on-a was merged into main as 1a18bf3.

Review

Sign in to review this change.
4 files changed +395 −4
docs/APPS.md +4
@@ -197,6 +197,10 @@ Under the box, four starter sentences are plain links (no script):
197197 to choose.
198198 - A **private** app answers only to someone the forge has vouched for
199199 (`canRead` on the repository, then a signed grant); a public one to anyone.
200 A scripted caller needs no browser: `Authorization: Bearer <token>` on the app's host
201 admits a token whose person may enter the app (a run's token only on its own
202 repository), and the header is stripped before the app sees the request. A
203 reader who may not enter is told so in the 404; anyone else gets the plain one.
200204 - **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's
201205 `tty` command from `forge.json` on a real pty over the environment's own
202206 data; `<name>--<pull>@<forge>` opens a preview's. The scaffold ships one.
src/apps/proxy.ts +118 −2
@@ -33,6 +33,8 @@ import {
3333 type Repo,
3434 ownerInStanding,
3535 } from "../core/repos.ts";
36 import { RateLimiter } from "../core/ratelimit.ts";
37 import { verifyToken } from "../core/tokens.ts";
3638 import { getUserById, isActive, type User } from "../core/users.ts";
3739 import { err, ok, type Result } from "../kernel/result.ts";
3840 import { loadKey } from "../kernel/secret.ts";
@@ -548,6 +550,79 @@ function stillEntitled(
548550 );
549551 }
550552
553 /** What the app's host tells a signed-in reader it may not enter. */
554 export const NOT_ENTITLED_WORDS =
555 "not found: this token can read the repository but may not enter its app. Ask a writer of the repository to add you to the app, or use a token of a writer or of the crew.";
556
557 const BEARER_GRANT_MS = 60_000;
558 /** Failed tokens one client may present per minute, across every app host. */
559 const BEARER_FAILURES_PER_MINUTE = 30;
560 const failedTokens = new WeakMap<App, RateLimiter>();
561
562 /**
563 * A bearer token on a request to a guarded app, read the way the forge's
564 * own API reads it. It admits when its person may enter the app; a run's
565 * token only on the repository it was minted for. A person who can read
566 * the repository but not enter is told so (`hint`); anyone else — a token
567 * that cannot read it, an expired one — gets nothing to learn from.
568 */
569 function bearerAdmission(
570 runtime: AppsRuntime,
571 request: Request,
572 target: AppTarget,
573 ): {
574 grant?: Grant;
575 hint?: string;
576 readOnly?: boolean;
577 retryAfter?: number;
578 } | undefined {
579 const header = request.headers.get("authorization");
580 if (header === null || !header.startsWith("Bearer ")) return undefined;
581 if (
582 target.app.access !== "private" &&
583 sealedPull(runtime, target) === undefined
584 )
585 return undefined;
586 // Counted before the lookup, given back when the token is good: only
587 // failures use up the allowance.
588 let limiter = failedTokens.get(runtime.app);
589 if (limiter === undefined) {
590 limiter = new RateLimiter({
591 perMinute: BEARER_FAILURES_PER_MINUTE,
592 adminPerMinute: BEARER_FAILURES_PER_MINUTE,
593 enabled: true,
594 });
595 failedTokens.set(runtime.app, limiter);
596 }
597 const key = clientBucket(request);
598 const allowed = limiter.take(key, "ro");
599 if (!allowed.allowed) return { retryAfter: allowed.retryAfter };
600 const verified = verifyToken(runtime.app.db, header.slice(7));
601 if (!verified.ok) return undefined;
602 limiter.refund(key);
603 const { user, run, scope } = verified.value;
604 const repo = getRepoById(runtime.app.db, target.app.repoId);
605 if (repo === undefined) return undefined;
606 if (run !== undefined && run.repoId !== repo.id && run.forkId !== repo.id)
607 return undefined;
608 if (!canRead(runtime.app.db, repo, user)) return undefined;
609 const grant: Grant = {
610 who: {
611 id: user.id,
612 name: user.username,
613 // A read-only token is never an admin inside the app.
614 role:
615 scope !== "ro" && canWrite(runtime.app.db, repo, user)
616 ? "admin"
617 : "user",
618 },
619 expiresMs: Date.now() + BEARER_GRANT_MS,
620 };
621 return stillEntitled(runtime, target, grant)
622 ? { grant, readOnly: scope === "ro" }
623 : { hint: NOT_ENTITLED_WORDS };
624 }
625
551626 /**
552627 * What a request addressed to an app must pass before anything is served:
553628 * a private app's grant, then a live port. One gate for both the request
@@ -560,10 +635,40 @@ function admit(
560635 target: AppTarget,
561636 ): Result<{ status: EnvStatus; port: number; grant?: Grant }, Response> {
562637 const cookie = cookieValue(request, grantCookieName(target));
563 const signed =
638 const cookieGrant =
564639 cookie === undefined
565640 ? undefined
566641 : readGrant(runtime.app.config.dataDir, cookie, target);
642 // No browser, no cookie: a bearer token of someone who may enter is as
643 // good as a grant for this one request (#7829).
644 const bearer =
645 cookieGrant === undefined
646 ? bearerAdmission(runtime, request, target)
647 : undefined;
648 if (bearer?.retryAfter !== undefined)
649 return err(
650 new Response("too many failed tokens", {
651 status: 429,
652 headers: {
653 "retry-after": String(bearer.retryAfter),
654 "cache-control": "no-store",
655 },
656 }),
657 );
658 // A read-only token reads: the forge's API refuses it writes, and the
659 // app's door is no wider.
660 if (
661 bearer?.readOnly === true &&
662 request.method !== "GET" &&
663 request.method !== "HEAD"
664 )
665 return err(
666 new Response("this token is read-only", {
667 status: 403,
668 headers: { "cache-control": "no-store" },
669 }),
670 );
671 const signed = cookieGrant ?? bearer?.grant;
567672 // A private app's grant is asked again on every request whether the
568673 // person it names may still enter: a grant outlives a change of heart —
569674 // a writer removed, the app made private — by up to twelve hours, and
@@ -594,6 +699,10 @@ function admit(
594699 // gets that answer here.
595700 const url = new URL(request.url);
596701 const navigating =
702 bearer?.hint === undefined &&
703 // A caller that presented a token is a script, not a person at a
704 // link: it is answered, not sent to sign in.
705 !(request.headers.get("authorization") ?? "").startsWith("Bearer ") &&
597706 (request.method === "GET" || request.method === "HEAD") &&
598707 (request.headers.get("accept") ?? "").includes("text/html") &&
599708 !url.pathname.startsWith("/__forge/");
@@ -609,7 +718,14 @@ function admit(
609718 }),
610719 );
611720 }
612 return err(new Response("not found", { status: 404 }));
721 return err(
722 new Response(bearer?.hint ?? "not found", {
723 status: 404,
724 ...(bearer?.hint === undefined
725 ? {}
726 : { headers: { "content-type": "text/plain; charset=utf-8" } }),
727 }),
728 );
613729 }
614730 }
615731 // An archived repository's app is put away: stopped, its data kept, and
src/crew/brief.ts +2 −2
@@ -179,7 +179,7 @@ export function systemPrompt(input: BriefInput): string {
179179 ...(input.previewUrl === undefined
180180 ? []
181181 : [
182 `- The preview of your pull is at ${input.previewUrl} once it is up.`,
182 `- The preview of your pull is at ${input.previewUrl} once it is up; fetch it, private repository or not, with \`curl -H "authorization: Bearer $FORGE_TOKEN" ${input.previewUrl}\`.`,
183183 ]),
184184 ...ownAppLines(input.ownApp),
185185 );
@@ -427,7 +427,7 @@ function taskBody(input: TaskInput): string {
427427 ...(input.previewUrl === undefined
428428 ? []
429429 : [
430 `- This pull's preview, at this head, is at ${input.previewUrl}. Use it as a person would: curl its pages, try the doors it offers. Judge this preview only — another pull's preview is not this change.`,
430 `- This pull's preview, at this head, is at ${input.previewUrl}. Use it as a person would: curl its pages with \`-H "authorization: Bearer $FORGE_TOKEN"\` (it works on a private repository), try the doors it offers. Judge this preview only — another pull's preview is not this change.`,
431431 ]),
432432 ...(input.looks !== undefined && input.looks.length > 0
433433 ? [
test/apps-preview-token.test.ts added +271
@@ -0,0 +1,271 @@
1 /**
2 * A bearer token opens the preview of a repository its person may enter
3 * (#7829): a crew run fetches its own pull's preview with the FORGE_TOKEN
4 * it holds, no browser. A token that cannot read the repository learns
5 * nothing; the token itself never reaches the app.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { openIssue } from "../src/actions/issues.ts";
10 import { newApp, updateAppSettings } from "../src/actions/apps.ts";
11 import { commitToBranch } from "../src/actions/repos.ts";
12 import type { App } from "../src/app.ts";
13 import { NOT_ENTITLED_WORDS } from "../src/apps/proxy.ts";
14 import type { AppRecord } from "../src/core/apps.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { mintRunToken } from "../src/core/run_token.ts";
17 import { createIssue } from "../src/core/issues.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { systemPrompt, type BriefInput } from "../src/crew/brief.ts";
21 import {
22 deploySettled,
23 fetchAsHost,
24 startAppsForge,
25 stopAppsForge,
26 type AppsForge,
27 } from "./helpers/apps-forge.ts";
28
29 const SERVER_SUITES = [
30 "src/audit.test.ts",
31 "src/openapi.test.ts",
32 "src/security.test.ts",
33 "src/site.test.ts",
34 ];
35
36 describe("a bearer token on an app's host", () => {
37 let forge: AppsForge;
38 let app: App;
39 let alice: User;
40 let bob: User;
41 let repo: Repo;
42 let record: AppRecord;
43 let open: { repo: Repo; record: AppRecord };
44 const host = () => `vault.${forge.runtime.options.domain}`;
45 const get = (h: string, token?: string, accept = "application/json") =>
46 fetchAsHost(forge.servers, h, "/", {
47 headers: {
48 accept,
49 ...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
50 },
51 });
52
53 beforeAll(async () => {
54 forge = await startAppsForge();
55 ({ app, alice } = forge);
56 const made = await createUser(app.db, {
57 origin: "seed",
58 trusted: true,
59 username: "bob",
60 email: "[email protected]",
61 password: "a-good-passphrase",
62 });
63 if (!made.ok) throw new Error(made.error);
64 bob = made.value;
65 const vault = await newApp(app, alice, {
66 name: "vault",
67 description: "Private",
68 visibility: "private",
69 });
70 if (!vault.ok) throw new Error(String(vault.error));
71 ({ repo, app: record } = vault.value);
72 expect(record.access).toBe("private");
73 await deploySettled(app, repo.id, (r) => r.env === "production");
74 const committed = await commitToBranch(app, alice, repo, {
75 message: "Echo the request",
76 files: [
77 ...SERVER_SUITES.map((path) => ({ path, content: null })),
78 {
79 path: "src/server.test.ts",
80 content:
81 'import { expect, test } from "bun:test";\ntest("passes", () => { expect(1).toBe(1); });\n',
82 },
83 {
84 path: "src/server.ts",
85 content: `Bun.serve({
86 port: Number(process.env.PORT),
87 hostname: "127.0.0.1",
88 fetch(request) {
89 return Response.json({
90 page: "secret page",
91 authorization: request.headers.get("authorization"),
92 identity: request.headers.get("x-forge-identity"),
93 });
94 },
95 });
96 `,
97 },
98 ],
99 });
100 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
101 const run = await deploySettled(
102 app,
103 repo.id,
104 (r) => r.env === "production" && r.commitOid === committed.value.commitOid,
105 );
106 if (run.state !== "ok") throw new Error(run.summary);
107 // A public repository whose app is private: its readers may read the code, not enter.
108 const second = await newApp(app, alice, {
109 name: "lobby",
110 description: "Public code",
111 visibility: "public",
112 });
113 if (!second.ok) throw new Error(String(second.error));
114 open = { repo: second.value.repo, record: second.value.app };
115 updateAppSettings(app, alice, open.repo, { access: "private" });
116 }, 120_000);
117
118 afterAll(async () => {
119 await forge.runtime.stop();
120 await stopAppsForge(forge);
121 });
122
123 test("a reader's token sees the page; the token never reaches the app", async () => {
124 const token = createToken(app.db, alice.id, "t", "ro").token;
125 const res = await get(host(), token);
126 expect(res.status).toBe(200);
127 const heard = (await res.json()) as { page: string; authorization: string | null };
128 expect(heard.page).toBe("secret page");
129 expect(heard.authorization).toBeNull();
130 });
131
132 test("no token, a stranger's token and a made-up token get the plain 404", async () => {
133 const stranger = createToken(app.db, bob.id, "t", "ro").token;
134 for (const token of [undefined, stranger, "forge_nope"]) {
135 const res = await get(host(), token);
136 expect(res.status).toBe(404);
137 expect(await res.text()).toBe("not found");
138 }
139 });
140
141 test("a run token works on its own repository; an expired one does not", async () => {
142 const live = mintRunToken(
143 app.db,
144 alice.id,
145 "run1",
146 { repoId: repo.id, forkId: repo.id },
147 new Date(Date.now() + 3_600_000).toISOString(),
148 ).token;
149 expect((await get(host(), live)).status).toBe(200);
150 const expired = mintRunToken(
151 app.db,
152 alice.id,
153 "run2",
154 { repoId: repo.id, forkId: repo.id },
155 new Date(Date.now() - 1000).toISOString(),
156 ).token;
157 const res = await get(host(), expired);
158 expect(res.status).toBe(404);
159 expect(await res.text()).toBe("not found");
160 // Bound to another repository, it opens nothing here.
161 const elsewhere = mintRunToken(
162 app.db,
163 alice.id,
164 "run3",
165 { repoId: open.repo.id, forkId: open.repo.id },
166 new Date(Date.now() + 3_600_000).toISOString(),
167 ).token;
168 expect((await get(host(), elsewhere)).status).toBe(404);
169 });
170
171 test("a reader who may not enter is told what to do; a stranger is not", async () => {
172 const lobby = `lobby.${forge.runtime.options.domain}`;
173 const reader = createToken(app.db, bob.id, "t", "ro").token;
174 for (const accept of ["application/json", "text/html"]) {
175 const told = await get(lobby, reader, accept);
176 expect(told.status).toBe(404);
177 expect(await told.text()).toBe(NOT_ENTITLED_WORDS);
178 }
179 const none = await get(lobby);
180 expect(await none.text()).toBe("not found");
181 });
182
183 test("a read-only token of a writer is a user in the app, and reads only", async () => {
184 const ro = createToken(app.db, alice.id, "t", "ro").token;
185 const rw = createToken(app.db, alice.id, "t", "rw").token;
186 const roleOf = async (token: string) => {
187 const res = await get(host(), token);
188 const heard = (await res.json()) as { identity: string };
189 return heard.identity.split("|")[2];
190 };
191 expect(await roleOf(ro)).toBe("user");
192 expect(await roleOf(rw)).toBe("admin");
193 const post = (token: string) =>
194 fetchAsHost(forge.servers, host(), "/", {
195 method: "POST",
196 headers: { authorization: `Bearer ${token}`, accept: "application/json" },
197 body: "x",
198 });
199 expect((await post(ro)).status).toBe(403);
200 expect((await post(rw)).status).toBe(200);
201 });
202
203 test("a token that cannot read gets the plain 404 on a page navigation, not a redirect or a hint", async () => {
204 const stranger = createToken(app.db, bob.id, "t", "ro").token;
205 const res = await get(host(), stranger, "text/html");
206 expect(res.status).toBe(404);
207 expect(res.headers.get("location")).toBeNull();
208 expect(await res.text()).toBe("not found");
209 });
210
211 test("a sealed pull's preview on a public app admits a writer's token and tells a reader's why not", async () => {
212 const plaza = await newApp(app, alice, {
213 name: "plaza",
214 description: "Public",
215 visibility: "public",
216 });
217 if (!plaza.ok) throw new Error(String(plaza.error));
218 const report = openIssue(app, alice, plaza.value.repo, {
219 title: "Hole",
220 sealed: true,
221 });
222 if (!report.ok) throw new Error(String(report.error));
223 const sealed = createIssue(app.db, {
224 repoId: plaza.value.repo.id,
225 authorId: alice.id,
226 title: "Fix",
227 body: `Closes #${report.value.number}`,
228 kind: "pull",
229 sealed: true,
230 });
231 if (!sealed.ok) throw new Error("pull");
232 const sealedHost = forge.runtime.hostFor(
233 plaza.value.app.slug,
234 `pull-${sealed.value.number}` as never,
235 );
236 const reader = createToken(app.db, bob.id, "t", "ro").token;
237 const told = await get(sealedHost, reader);
238 expect(told.status).toBe(404);
239 expect(await told.text()).toBe(NOT_ENTITLED_WORDS);
240 const writer = createToken(app.db, alice.id, "t", "ro").token;
241 const past = await get(sealedHost, writer);
242 expect(await past.text()).not.toBe(NOT_ENTITLED_WORDS);
243 });
244
245 test("the brief says how a run opens its preview", () => {
246 const prompt = systemPrompt({
247 agent: { user: { username: "bot", displayName: "" }, brief: "" },
248 repo: { ownerName: "alice", name: "square", defaultBranch: "main" },
249 issue: { number: 1 },
250 branch: "bot/1",
251 forgeUrl: "http://forge.test",
252 isApp: true,
253 previewUrl: "http://square--2.apps.test/",
254 } as unknown as BriefInput);
255 expect(prompt).toContain(
256 'curl -H "authorization: Bearer $FORGE_TOKEN" http://square--2.apps.test/',
257 );
258 });
259
260 test("a client that keeps presenting bad tokens is slowed down; a good token is not counted", async () => {
261 let last = 0;
262 for (let i = 0; i < 40; i++) {
263 last = (await get(host(), `forge_bad${i}`)).status;
264 if (last === 429) break;
265 }
266 expect(last).toBe(429);
267 const res = await get(host(), "forge_more");
268 expect(res.status).toBe(429);
269 expect(Number(res.headers.get("retry-after"))).toBeGreaterThan(0);
270 });
271 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs