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

Perimeter: the forge's anonymous answers say only what a stranger needs #2952

Merged opened this in answer to

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

Merged

mason/forge:mason/2917-perimeter-the-forge-s-anonymous-answers → main 9 commits fork 4 threads · 4 unresolved

  • Merged. mason/2917-perimeter-the-forge-s-anonymous-answers was merged into main as c971946.

Review

Sign in to review this change.
27 files changed +635 −75
scripts/smoke.ts +8 −2
@@ -23,6 +23,7 @@
2323 import { smoke } from "../src/release/smoke.ts";
2424 import { webspec } from "../src/release/webspec.ts";
2525 import { outsider } from "../src/release/outsider.ts";
26 import { PROBE_HEADER } from "../src/kernel/probe.ts";
2627 export * from "../src/release/smoke.ts";
2728
2829 if (import.meta.main) {
@@ -41,7 +42,7 @@ if (import.meta.main) {
4142 const base = args[0];
4243 const seconds = wait === undefined ? 120 : Number(wait);
4344 if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) {
44 console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--standards]");
45 console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--standards] (--version needs FORGE_PROBE, the forge's probe)");
4546 process.exit(2);
4647 }
4748 if (withOutsider && !process.env["FORGE_TOKEN"]) {
@@ -56,7 +57,12 @@ if (import.meta.main) {
5657 console.log(`standards: ${findings.length} finding(s) over ${paths.length} path(s); report only`);
5758 process.exit(0);
5859 }
59 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 });
60 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", {
61 version,
62 waitMs: seconds * 1000,
63 // The forge tells only its probe its version (`src/kernel/probe.ts`).
64 ...(process.env["FORGE_PROBE"] ? { probe: { [PROBE_HEADER]: process.env["FORGE_PROBE"] } } : {}),
65 });
6066 for (const f of failures)
6167 console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`);
6268 const unrefused = withOutsider
src/actions/health.ts +26 −6
@@ -1,9 +1,17 @@
11 /**
2 * How the forge is, as it answers anyone who asks: the version and commit
3 * it runs, whether it is draining, and the platform's word on its rollout
4 * (`kernel/guard.ts`). A monitor has no account, so there is nothing to
5 * authorize. A site admin also gets the forge's own vitals alongside, and
6 * `ok` is then both.
2 * How the forge is, as it answers whoever asks. A monitor has no account,
3 * so anyone may ask; what it is told depends on who it is (#2917):
4 *
5 * - a stranger learns whether the forge is well and whether it is draining,
6 * and nothing of the platform's incidents or rollout. It is also told the
7 * version and commit for one release more — a running forge's swap reads
8 * its successor's commit without a probe (`release/swap.ts` before #2917);
9 * once that forge is gone the stranger's answer is `ok` alone.
10 * - anyone signed in, and the forge's own release machinery carrying the
11 * probe (`kernel/probe.ts`), get the whole answer: version, commit,
12 * rollout and the platform's incidents.
13 * - a site admin gets the whole answer and the forge's vitals alongside,
14 * `ok` then being both.
715 *
816 * One answer for the API (`GET /api/v1/health`) and the MCP `health` tool.
917 */
@@ -51,11 +59,23 @@ export function forgeVitals(app: App): Vitals {
5159 });
5260 }
5361
62 /**
63 * What a stranger is told: no incident, no rollout, no platform words.
64 * Version and commit stay for one release, while a running forge's swap
65 * reads its successor's commit without the probe; #2962 removes them.
66 */
67 export type StrangerHealth = Pick<ForgeHealth, "ok" | "version" | "commit" | "draining">;
68
5469 export function readHealth(
5570 app: App,
5671 actor: User | undefined,
57 ): ForgeHealth | (Vitals & ForgeHealth) {
72 probe = false,
73 ): StrangerHealth | ForgeHealth | (Vitals & ForgeHealth) {
outdated

Low: version and commit still reach strangers for compatibility. That is reasonable for one release, but open the follow-up issue that reduces this to { ok, draining }.

5874 const forge = forgeHealth();
75 if (actor === undefined && !probe) {
76 const { ok, version, commit, draining } = forge;
77 return { ok, version, commit, draining };
78 }
5979 if (!actor?.isAdmin) return forge;
6080 const v = forgeVitals(app);
6181 return { ...v, ...forge, ok: forge.ok && v.ok };
src/actions/runs.ts +21 −4
@@ -118,6 +118,23 @@ export function mayReadLogOfRun(
118118 );
119119 }
120120
121 /**
122 * A run as this actor may read it. Its cost and turns are the owner's own
123 * facts (`canSeeOwnerFacts`), and so is the machinery under it: the agent
124 * session a follow-up resumes and the node it ran on (#2917). A reader who
125 * cannot write here gets what ran, why and how it ended, never those — on
126 * every door, since every door reads runs through here.
127 */
128 export function runForReader(
129 app: App,
130 actor: User | undefined,
131 repo: Repo,
132 ): (run: Run) => Run {
133 return canSeeOwnerFacts(app.db, repo, actor)
134 ? (run) => run
135 : (run) => ({ ...run, costUsd: 0, turns: 0, sessionId: "", node: "" });
136 }
137
121138 export function listRepoRuns(
122139 app: App,
123140 actor: User | undefined,
@@ -129,9 +146,9 @@ export function listRepoRuns(
129146 if (!mayReadRuns(app, actor, repo)) return ok([]);
130147 // A run on a sealed report, or its fix, is the report's (`core/sealed.ts`).
131148 return ok(
132 queryRuns(app.db, repo.id, options).filter((r) =>
133 mayViewId(app.db, repo, r.issueId, actor),
134 ),
149 queryRuns(app.db, repo.id, options)
150 .filter((r) => mayViewId(app.db, repo, r.issueId, actor))
151 .map(runForReader(app, actor, repo)),
135152 );
136153 }
137154
@@ -160,7 +177,7 @@ export function getRepoRun(
160177 const run = getRun(app.db, runId);
161178 if (!run || run.repoId !== repo.id) return err("not_found");
162179 if (!mayViewId(app.db, repo, run.issueId, actor)) return err("not_found");
163 return ok(run);
180 return ok(runForReader(app, actor, repo)(run));
164181 }
165182
166183 export interface RunLogView {
src/checks/landing.ts +2 −1
@@ -28,6 +28,7 @@ import { failuresOf } from "./failures.ts";
2828 import { keepForgeGate } from "./gate_last.ts";
2929 import { checksFor } from "./service.ts";
3030 import { landVerdict, type LandVerdict } from "./tiers.ts";
31 import { ownProbeHeaders } from "../kernel/probe.ts";
3132
3233 /**
3334 * The full gate, once, on a commit that is what main would become — one
@@ -165,7 +166,7 @@ async function swapIn(
165166 );
166167 return;
167168 }
168 const swapped = await startRelease({ dir, sha, port, env: process.env,
169 const swapped = await startRelease({ dir, sha, port, env: process.env, probe: ownProbeHeaders(app.config.dataDir),
169170 ...(app.config.swapBudgetMs === undefined ? {} : { budgetMs: app.config.swapBudgetMs }),
170171 });
171172 endSwap(swapped.ok);
src/http/api.ts +15 −18
@@ -59,6 +59,7 @@ import { resendVerification } from "../actions/verify.ts";
5959 import { CAPPED_WORDS } from "../actions/mail.ts";
6060 import { emailVerified } from "../core/verify.ts";
6161 import { readHealth } from "../actions/health.ts";
62 import { isProbe } from "../kernel/probe.ts";
6263 import { listChildren } from "../actions/spawn.ts";
6364 import { forgeState, PARENT_HEADER } from "../spawn/clients.ts";
6465 import { familyView } from "../spawn/family.ts";
@@ -287,6 +288,7 @@ import {
287288 checkOfCommit,
288289 getRepoRun,
289290 listRepoRuns,
291 runForReader,
290292 runLog,
291293 } from "../actions/runs.ts";
292294 import {
@@ -4704,11 +4706,15 @@ export function apiRoutes(router: Router<Handler>): void {
47044706
47054707 // ---- apps, runs, and the crew (docs/APPS.md) ----
47064708
4707 // How the forge is, and the platform's word on its rollout: anyone may
4708 // read it (a monitor has no account). A site admin also gets the forge's
4709 // own vitals alongside, `ok` being both.
4709 // How the forge is: anyone may ask (a monitor has no account), a stranger
4710 // is told little, the release machinery's probe and a site admin the
4711 // whole of it (`actions/health.ts`).
47104712 router.add("GET", "/api/v1/health", (ctx) =>
4711 json(readHealth(ctx.app, ctx.auth.user)),
4713 json(
4714 readHealth(ctx.app, ctx.auth.user, isProbe(ctx.request, ctx.app.config.dataDir)),
4715 200,
4716 { "cache-control": "no-store", vary: "authorization, cookie, x-forge-probe" },
4717 ),
47124718 );
47134719
47144720 // Explore (#607): the rows /explore shows, for the same one line.
@@ -5347,14 +5353,6 @@ export function apiRoutes(router: Router<Handler>): void {
53475353 return json({ url: link.value });
53485354 });
53495355
5350 // A run's cost and turn count are the owner's own facts (`canSeeOwnerFacts`,
5351 // same predicate the studio page uses) — a viewer who can only read a
5352 // public repository gets every other field, never those two.
5353 const runForViewer = (ctx: Ctx, repo: Repo) =>
5354 canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user)
5355 ? (r: Run): Run => r
5356 : (r: Run): Run => ({ ...r, costUsd: 0, turns: 0 });
5357
53585356 // What the crew's runs here spent over a window (`core/usage.ts`):
53595357 // tokens, cost, wall time and outcome, by subscription, agent and issue.
53605358 // Spend is the owner's own fact, so a reader who cannot write gets 403.
@@ -5450,8 +5448,9 @@ export function apiRoutes(router: Router<Handler>): void {
54505448 limit,
54515449 });
54525450 if (!runs.ok) return notFound();
5453 const forViewer = runForViewer(ctx, repo);
5454 return json(paged(runs.value.map(forViewer), limit, (r) => r.id));
5451 // Already as this reader may see them (`runForReader`): a stranger's
5452 // runs carry no cost, turns, agent session or node.
5453 return json(paged(runs.value, limit, (r) => r.id));
54555454 });
54565455
54575456 router.add("GET", "/api/v1/repos/:owner/:name/runs/:id", (ctx) => {
@@ -5461,7 +5460,7 @@ export function apiRoutes(router: Router<Handler>): void {
54615460 if (!run.ok) return notFound();
54625461 // The merged pulls a finished production deploy carries (`core/carried.ts`).
54635462 return json({
5464 ...runForViewer(ctx, repo)(run.value),
5463 ...run.value,
54655464 carries: carriedBy(ctx.app.db, run.value.id),
54665465 });
54675466 });
@@ -5489,9 +5488,7 @@ export function apiRoutes(router: Router<Handler>): void {
54895488 // The cost and turn count are the owner's own facts, same as the studio
54905489 // page beside this stream — a viewer who cannot write here never gets
54915490 // them, whether the page renders or a fetch asks this stream directly.
5492 const ownerFacts = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
5493 const forViewer = (r: Run): Run =>
5494 ownerFacts ? r : { ...r, costUsd: 0, turns: 0 };
5491 const forViewer = runForReader(ctx.app, ctx.auth.user, repo);
54955492 // Every line carries its number, so a browser that reconnects — a
54965493 // proxy in front of the forge cuts long responses — sends the last it
54975494 // saw and hears only what followed, never the log again from the top.
src/http/mcp.ts +7 −5
@@ -183,7 +183,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
183183 import { resourceMetadataUrl } from "./oauth.ts";
184184 import { body, boundedBody, json } from "./respond.ts";
185185 import type { Router } from "./router.ts";
186 import { mayReadRuns } from "../actions/runs.ts";
186 import { mayReadRuns, runForReader } from "../actions/runs.ts";
187187 import { manifestFor } from "../actions/here.ts";
188188 import { clientBucket } from "./peer.ts";
189189 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts";
@@ -991,8 +991,11 @@ const TOOLS: readonly Tool[] = [
991991 return good([
992992 ...waiting,
993993 ...reviewWaits,
994 ...runs.map((r) => {
995 const repo = byId.get(r.repoId)!;
994 ...runs.map((run) => {
995 const repo = byId.get(run.repoId)!;
996 // As this caller may read it (`runForReader`): cost and node are
997 // the owner's facts.
998 const r = runForReader(ctx.app, user, repo)(run);
996999 const issue =
9971000 r.issueId === null
9981001 ? undefined
@@ -1005,8 +1008,7 @@ const TOOLS: readonly Tool[] = [
10051008 why: r.reason,
10061009 state: r.state,
10071010 since: r.startedAt ?? r.createdAt,
1008 // What a run costs is the owner's fact (`canSeeOwnerFacts`).
1009 costUsd: owned.has(r.repoId) ? r.costUsd : 0,
1011 costUsd: r.costUsd,
10101012 ...(placementWords(r) === "" ? {} : { ran: placementWords(r) }),
10111013 };
10121014 }),
src/http/respond.ts +24 −2
@@ -118,10 +118,28 @@ export function prefersHtml(request: Request, pathname: string): boolean {
118118 const FLOOR_POLICY =
119119 "default-src 'none'; frame-ancestors 'none'; base-uri 'none'";
120120
121 /**
122 * The powers no page of the forge's uses, refused to it and to anything
123 * framed in it: a script that ever slipped through still cannot ask for a
124 * camera, a microphone, a location, a payment or a device (#2917).
125 */
126 const PERMISSIONS_POLICY =
127 "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), bluetooth=()";
128
129 /**
130 * Once a browser has reached the forge over https, it never again asks over
131 * plain http, for a year. Sent from the forge itself rather than left to an
132 * edge that may not (#2917); a browser ignores it on an http answer, so a
133 * forge on localhost is not pinned to a scheme it cannot serve. Subdomains
134 * are not named: an app's host is not the forge's to promise.
135 */
136 const HSTS = "max-age=31536000";
137
121138 /**
122139 * The security floor every response the forge writes leaves with: never
123 * sniffed into another type, never a referrer sent off-site, and — unless
124 * the body is a document that wrote its own policy — inert.
140 * sniffed into another type, never a referrer sent off-site, no powers a
141 * page does not use, https once https, and — unless the body is a document
142 * that wrote its own policy — inert.
125143 *
126144 * A responder that already set one of these keeps its value: `page()` has a
127145 * nonce'd policy that must survive, and a fragment has a sandboxed one. The
@@ -136,6 +154,10 @@ export function securityFloor(response: Response): Response {
136154 headers.set("x-content-type-options", "nosniff");
137155 if (!headers.has("referrer-policy"))
138156 headers.set("referrer-policy", "same-origin");
157 if (!headers.has("permissions-policy"))
158 headers.set("permissions-policy", PERMISSIONS_POLICY);
159 if (!headers.has("strict-transport-security"))
160 headers.set("strict-transport-security", HSTS);
139161 // What left decides, not what was asked for: an HTML body writes the
140162 // only policy that can name its own nonce, and `default-src 'none'`
141163 // over a page would strip it of its stylesheet.
src/http/server.ts +20 −2
@@ -462,6 +462,8 @@ import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.t
462462 import { oauthRoutes } from "./oauth.ts";
463463 import { flushAccess, recordAccess } from "./access_log.ts";
464464 import { carryPeer, clientAddress, notePeer, type ProxyTrust } from "./peer.ts";
465 import { isProbe } from "../kernel/probe.ts";
466 import { takeStrangerRead } from "./stranger_reads.ts";
465467 import { parseCidrs } from "../kernel/cidr.ts";
466468 import {
467469 reportAcceptSubmit,
@@ -2328,8 +2330,10 @@ async function answerGet(
23282330 const dbStart = performance.now();
23292331 const live = liveResponse(() => app.db.query("select 1").get(), app.log);
23302332 noteDbTime(request, performance.now() - dbStart);
2331 // A smoke test learns which release answered, old pod or new.
2332 live.headers.set("X-Forge-Version", running().version);
2333 // The release proof learns which release answered, old pod or new; a
2334 // stranger is told only that one did (#2917).
2335 if (isProbe(request, app.config.dataDir))
2336 live.headers.set("X-Forge-Version", running().version);
23332337 return live;
23342338 }
23352339 if (url.pathname === "/readyz") {
@@ -2398,6 +2402,20 @@ async function answerGet(
23982402 if (verdict !== undefined && !verdict.allowed) {
23992403 return withRateHeaders(tooManyRequests(verdict), verdict);
24002404 }
2405 // A stranger's API reads have no token to count them by: their source does.
2406 const stranger = isApiPath
2407 ? takeStrangerRead(app.db, request, credential !== undefined || auth.user !== undefined)
2408 : undefined;
2409 if (stranger !== undefined && !stranger.allowed) {
2410 return withRateHeaders(
2411 apiError(
2412 "rate_limited",
2413 429,
2414 `too many requests: ${stranger.limit} per minute from one source without a token — retry in ${stranger.retryAfter}s`,
2415 ),
2416 stranger,
2417 );
2418 }
24012419
24022420 const themeCookie = cookieValue(request, "forge_theme") ?? "";
24032421 if (!isApiPath) {
src/http/stranger_reads.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * API reads without a credential, throttled per source (#2917). The token
3 * limiter counts a token's requests; a stranger has none, so a loop reading
4 * `/api/v1/...` with no token was never counted at all. Each source — the
5 * peer as `http/peer.ts` resolves it, an IPv6 client by its /64 — may read
6 * `api.anonymous_per_source` times a sliding minute (`/admin/limits`); a
7 * refused read is not counted, so the door reopens a window later.
8 *
9 * Only reads (GET, HEAD) under `/api/`, only without a session or token: a
10 * person in a browser and anything holding a token are counted elsewhere or
11 * not at all, `/healthz` and `/readyz` are never reached by this (a probe
12 * must not be throttled into a restart), and a request whose source the
13 * listener did not note (an in-process answer) is not one source's to count.
14 * One limiter per forge database, so a test's forge never throttles another's.
15 */
16
17 import type { Database } from "bun:sqlite";
18 import { limitsFromEnv, RateLimiter, type Verdict } from "../core/ratelimit.ts";
19 import { limitValue } from "../settings/limits.ts";
20 import { clientAddress, clientBucket } from "./peer.ts";
21
22 /** Each forge's limiter, or null when FORGE_RATE_LIMIT=0 turned every request limit off. */
23 const limiters = new WeakMap<Database, RateLimiter | null>();
24
25 /**
26 * Count one read without a credential from `request`'s source; the verdict,
27 * or undefined when this request is not one this limit counts.
28 */
29 export function takeStrangerRead(db: Database, request: Request, credentialed: boolean): Verdict | undefined {
30 if (credentialed) return undefined;
31 if (request.method !== "GET" && request.method !== "HEAD") return undefined;
32 if (clientAddress(request) === "unknown") return undefined;
33 let limiter = limiters.get(db);
34 if (limiter === undefined) {
35 limiter = limitsFromEnv().enabled ? new RateLimiter() : null;
36 limiters.set(db, limiter);
37 }
38 if (limiter === null) return undefined;
39 // Read on every request, so a change on /admin/limits governs the next one.
40 limiter.setLimits({ perMinute: limitValue(db, "api.anonymous_per_source"), adminPerMinute: 0, enabled: true });
41 return limiter.take(`source:${clientBucket(request)}`, "ro");

Low: this re-reads the setting and calls setLimits on every anonymous API read. That is cheap, so it's fine. Just note that the bucket is only as good as the proxy-trust config; without it, every stranger behind the ingress shares one key.

42 }
src/kernel/probe.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * The probe: how the forge's own release machinery asks a forge what it
3 * runs without being an admin or a stranger (#2917). A stranger asking
4 * `/api/v1/health` or `/healthz` learns that the forge is up; the release
5 * that swaps in its successor, the launcher that boots an image, and the
6 * proof that reads the public URL need its version and commit too.
7 *
8 * Each of those holds the data directory the forge answers from, so the
9 * credential is derived from that directory's `secret.key` (HKDF, purpose
10 * "probe") and sent as `X-Forge-Probe`. A process that can read the key
11 * already owns the forge; nothing else can produce the value. It never
12 * reuses the key that seals tokens or signs passports.
13 */
14
15 import { hkdfSync, timingSafeEqual } from "node:crypto";
16 import { readFileSync } from "node:fs";
17 import { join } from "node:path";
18 import { loadKey } from "./secret.ts";
19
20 export const PROBE_HEADER = "x-forge-probe";
21
22 /** The probe a key derives. `release/launch.ts` repeats these lines: it imports nothing of the forge's. */
23 export function probeOfKey(key: Uint8Array): string {
24 return Buffer.from(hkdfSync("sha256", key, "", "forge probe v1", 32)).toString("base64url");
25 }
26
27 const probes = new Map<string, { key: Buffer; probe: string }>();
28
29 /** The probe the forge answering from `dataDir` honours: its own key's. */
30 export function probeFor(dataDir: string): string {
31 const key = loadKey(dataDir);
32 const hit = probes.get(dataDir);
33 // Keyed on the loaded key, as passports are: a rotation picks up a new probe.
34 if (hit?.key.equals(key)) return hit.probe;
35 const probe = probeOfKey(key);
36 probes.set(dataDir, { key, probe });
37 return probe;
38 }
39
40 /** The header the forge on `dataDir` sends when it probes itself (its public URL). */
41 export function ownProbeHeaders(dataDir: string): Record<string, string> {
42 return { [PROBE_HEADER]: probeFor(dataDir) };
43 }
44
45 /**
46 * The header a caller sends to be answered as a probe of the forge on
47 * `dataDir`. Read fresh and never written: a caller polls a child that
48 * creates its key as it boots, and two processes each creating one would
49 * leave them disagreeing for good. No key yet is no header.
50 */
51 export function probeHeaders(dataDir: string): Record<string, string> {
52 let key: Buffer;
53 try {
54 key = readFileSync(join(dataDir, "secret.key"));
55 } catch {
56 return {};
57 }
58 return key.length === 32 ? { [PROBE_HEADER]: probeOfKey(key) } : {};
59 }
60
61 /** Whether `request` carries this forge's probe; a missing or wrong one is false. */
62 export function isProbe(request: Request, dataDir: string): boolean {
63 const said = request.headers.get(PROBE_HEADER);
64 if (said === null || said === "") return false;
65 const want = Buffer.from(probeFor(dataDir));
66 const got = Buffer.from(said);
67 return got.length === want.length && timingSafeEqual(got, want);
68 }
src/release/boot.ts +5 −3
@@ -27,6 +27,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync,
2727 import { tmpdir } from "node:os";
2828 import { join } from "node:path";
2929 import { running } from "../kernel/version.ts";
30 import { probeHeaders } from "../kernel/probe.ts";
3031
3132 export const BOOT_BUDGET_MS = 120_000;
3233 const POLL_MS = 250;
@@ -66,10 +67,11 @@ export function why(stderr: string): string {
6667 return line.replace(/[\u0000-\u001f\u007f]+/g, " ").slice(0, 300);
6768 }
6869
69 async function versionAt(port: number): Promise<string | undefined> {
70 async function versionAt(port: number, probe: Record<string, string>): Promise<string | undefined> {
7071 try {
72 // The probe, so a forge that tells strangers only `ok` still says what it runs.
7173 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
72 headers: { connection: "close" },
74 headers: { connection: "close", ...probe },
7375 signal: AbortSignal.timeout(2_000),
7476 });
7577 if (!res.ok) return undefined;
@@ -121,7 +123,7 @@ export async function bootProof(input: BootInput): Promise<BootProof> {
121123 const said = why(stderr);
122124 return { ok: false, line: `exited with ${child.exitCode} before it answered health${said ? `: ${said}` : ""}` };
123125 }
124 const seen = await versionAt(port);
126 const seen = await versionAt(port, probeHeaders(input.dataDir));
125127 if (seen === input.version) return { ok: true, ms: Math.round(performance.now() - started) };
126128 if (seen !== undefined) last = `health answered as ${seen}`;
127129 await Bun.sleep(POLL_MS);
src/release/launch.ts +20 −3
@@ -24,6 +24,7 @@
2424 import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2525 import { dirname, join } from "node:path";
2626 import type { Subprocess } from "bun";
27 import { hkdfSync } from "node:crypto";
2728
2829 export const READY_GRACE_MS = 150_000;
2930 const POLL_MS = 500;
@@ -122,10 +123,26 @@ export function rollbackNeed(dataDir: string): { sentence: string; since: string
122123 return { sentence: `Release ${r.from} did not become ready (${r.why}); rolled back to ${r.to}.`, since: r.at };
123124 }
124125
125 async function versionAt(port: number): Promise<string | undefined> {
126 /**
127 * `kernel/probe.ts`'s `probeHeaders`, repeated: the launcher imports nothing
128 * of the forge's, so a release can never break the thing that rolls it back.
129 * The forge on `dataDir` tells its probe what it runs, a stranger only `ok`.
130 */
131 export function probeHeaders(dataDir: string): Record<string, string> {
132 let key: Buffer;
133 try {
134 key = readFileSync(join(dataDir, "secret.key"));
135 } catch {
136 return {};
137 }
138 if (key.length !== 32) return {};
139 return { "x-forge-probe": Buffer.from(hkdfSync("sha256", key, "", "forge probe v1", 32)).toString("base64url") };
140 }
141
142 async function versionAt(port: number, probe: Record<string, string>): Promise<string | undefined> {
126143 try {
127144 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
128 headers: { connection: "close" },
145 headers: { connection: "close", ...probe },
129146 signal: AbortSignal.timeout(2_000),
130147 });
131148 if (!res.ok) return undefined;
@@ -201,7 +218,7 @@ export async function launch(input: LaunchInput): Promise<Launched> {
201218 why = `it exited with ${release.exitCode ?? release.signalCode} before it was ready`;
202219 break;
203220 }
204 if ((await versionAt(input.port)) === input.version) {
221 if ((await versionAt(input.port, probeHeaders(input.dataDir))) === input.version) {
205222 try {
206223 keepGood(input.dataDir, input.tree, input.version, input.commit, now());
207224 } catch (error) {
src/release/prove.ts +2 −1
@@ -12,6 +12,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1212 import { join } from "node:path";
1313 import { smoke, type Failure } from "./smoke.ts";
1414 import { UNSTAMPED } from "../kernel/version.ts";
15 import { ownProbeHeaders } from "../kernel/probe.ts";
1516
1617 /** How long the public URL has to answer as the new version. */
1718 export const GIVE_UP_MS = 10 * 60_000;
@@ -92,7 +93,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
9293 const deadline = now() + (o.giveUpMs ?? GIVE_UP_MS);
9394 for (;;) {
9495 try {
95 const res = await doFetch(health, { redirect: "manual", signal: AbortSignal.timeout(5_000) });
96 const res = await doFetch(health, { redirect: "manual", headers: ownProbeHeaders(o.dataDir), signal: AbortSignal.timeout(5_000) });
9697 await res.body?.cancel();
9798 if (res.headers.get("x-forge-version") === o.version) break;
9899 } catch {
src/release/rollback.ts +2 −1
@@ -14,6 +14,7 @@ import { existsSync } from "node:fs";
1414 import { join } from "node:path";
1515 import { releaseDir } from "./checkout.ts";
1616 import { startRelease, type SwapInput } from "./swap.ts";
17 import { ownProbeHeaders } from "../kernel/probe.ts";
1718
1819 export type Rollback = { ok: true; pid: number } | { ok: false; line: string };
1920
@@ -59,5 +60,5 @@ export async function rollback(
5960 const live = liveSchema(dataDir);
6061 if (known < live)
6162 return { ok: false, line: `${sha} predates schema ${live}; rolling back would need a down-migration` };
62 return startRelease({ ...swap, dir, sha });
63 return startRelease({ ...swap, dir, sha, probe: ownProbeHeaders(dataDir) });
6364 }
src/release/smoke.ts +5 −3
@@ -49,6 +49,8 @@ export function navigationBranch(source: string): string | null {
4949 export interface Options {
5050 /** Wait for `/healthz` to report this `X-Forge-Version`. */
5151 version?: string | undefined;
52 /** Sent with that wait: the forge tells only its probe its version (`kernel/probe.ts`). */
53 probe?: Readonly<Record<string, string>>;
5254 /** Longest wait for it, in milliseconds. */
5355 waitMs?: number;
5456 /** Pause between polls, in milliseconds. */
@@ -80,14 +82,14 @@ export function isSignInRedirect(url: string, res: Response): boolean {
8082 }
8183
8284 /** Polls `/healthz` until it reports `version`; null when it does, else a failure. */
83 async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number, fetch: typeof globalThis.fetch): Promise<Failure | null> {
85 async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number, fetch: typeof globalThis.fetch, probe: Readonly<Record<string, string>>): Promise<Failure | null> {
8486 const url = new URL("/healthz", root).href;
8587 const deadline = Date.now() + waitMs;
8688 let seen = "none";
8789 let status: number | string = "error";
8890 for (;;) {
8991 try {
90 const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5_000) });
92 const res = await fetch(url, { redirect: "manual", headers: probe, signal: AbortSignal.timeout(5_000) });
9193 await res.body?.cancel();
9294 status = res.status;
9395 seen = res.headers.get("x-forge-version") ?? "none";
@@ -109,7 +111,7 @@ export async function smoke(base: string, token = "", options: Options = {}): Pr
109111 const failures: Failure[] = [];
110112 const fetch = options.fetch ?? globalThis.fetch;
111113 if (options.version) {
112 const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000, fetch);
114 const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000, fetch, options.probe ?? {});
113115 if (late) return [late];
114116 }
115117
src/release/swap.ts +5 −3
@@ -32,6 +32,8 @@ export interface SwapInput {
3232 /** The environment the new forge starts with: the old one's, as it is. */
3333 env: Record<string, string | undefined>;
3434 budgetMs?: number;
35 /** Sent with each health read (`kernel/probe.ts`), so the answer names its commit. */
36 probe?: Readonly<Record<string, string>>;
3537 }
3638
3739 /** Whether `body` is a health answer from `sha` that is well and not leaving. */
@@ -41,10 +43,10 @@ export function healthyAs(body: unknown, sha: string): boolean {
4143 return h.ok === true && h.draining !== true && h.commit === sha;
4244 }
4345
44 async function readHealth(port: number): Promise<unknown> {
46 async function readHealth(port: number, probe: Readonly<Record<string, string>>): Promise<unknown> {
4547 try {
4648 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
47 headers: { connection: "close" },
49 headers: { connection: "close", ...probe },
4850 signal: AbortSignal.timeout(2_000),
4951 });
5052 return res.ok ? await res.json() : undefined;
@@ -77,7 +79,7 @@ export async function startRelease(input: SwapInput): Promise<Swapped> {
7779 while (Date.now() < deadline) {
7880 if (exited !== undefined)
7981 return { ok: false, line: `the new process exited with ${exited} before it was healthy` };
80 if (healthyAs(await readHealth(input.port), input.sha)) {
82 if (healthyAs(await readHealth(input.port, input.probe ?? {}), input.sha)) {
8183 proc.unref();
8284 return { ok: true, pid: proc.pid };
8385 }
src/settings/limits.ts +10 −1
@@ -35,7 +35,8 @@ export type LimitKey =
3535 | "signin.per_source"
3636 | "signin.per_forge"
3737 | "signup.per_source"
38 | "signup.per_forge";
38 | "signup.per_forge"
39 | "api.anonymous_per_source";
3940
4041 export interface LimitDef {
4142 readonly label: string;
@@ -159,6 +160,14 @@ export const LIMITS: Record<LimitKey, LimitDef> = {
159160 max: 100_000,
160161 reason: "Spread across many sources, sign-ups still cost hashes and accounts, so the forge as a whole has a ceiling.",
161162 },
163 "api.anonymous_per_source": {
164 label: "API reads without a token per source a minute",
165 unit: "reads",
166 default: 300,
167 min: 1,
168 max: 100_000,
169 reason: "A token's reads are counted by the token; a stranger's have no token to count, so one source reading without one has a ceiling of its own.",
170 },
162171 };
163172
164173 export const LIMIT_KEYS = Object.keys(LIMITS) as LimitKey[];
src/web/pages/og.ts +3 −1
@@ -263,7 +263,9 @@ function runInput(ctx: Ctx, ref: string): CardInput | undefined {
263263 kind: "run",
264264 title: run.summary || "agent run",
265265 lines: [repoName, ...(thread ? [thread] : [])],
266 stat: `${who ?? "agent"} · ${outcome(run)} · $${run.costUsd.toFixed(2)}`,
266 // The card is drawn for anyone a link is pasted to, so it says no
267 // cost: that is the owner's fact (`runForReader`, #2917).
268 stat: `${who ?? "agent"} · ${outcome(run)}`,
267269 };
268270 }
269271 return {
test/checks-placement.test.ts +4 −1
@@ -178,7 +178,10 @@ describe("checks placed", () => {
178178 const run = await settled(await push("exit 0 # page"));
179179 expect(run.placement).toBe("job");
180180 const page = await (await globalThis.fetch(`${servers.url}/alice/lib/runs/${run.id}`)).text();
181 expect(page).toContain("Ran as a cluster job on n7.");
181 // A stranger reads that it was a cluster job, never on which node (#2917).
182 expect(page).toContain("Ran as a cluster job.");
183 expect(page).not.toContain("job on n7");
184 expect(String(placementLine(run))).toContain("Ran as a cluster job on n7.");
182185 expect(runRows([run], new Map())[0]!.meta).toContain("ran as a cluster job on n7");
183186 expect(runPayload(run)).toMatchObject({ placement: "job", node: "n7" });
184187 const plain = { ...run, placement: "process", node: "" };
test/forge-probe.test.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * The probe (`kernel/probe.ts`, #2917): what the release machinery sends to
3 * be told what a forge runs. A caller reads the key and never writes it, and
4 * the launcher's copy of the derivation is the forge's own.
5 */
6
7 import { afterAll, expect, test } from "bun:test";
8 import { existsSync, mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { probeHeaders as launcherProbe } from "../src/release/launch.ts";
12 import { isProbe, PROBE_HEADER, probeFor, probeHeaders } from "../src/kernel/probe.ts";
13
14 const root = mkdtempSync(join(tmpdir(), "forge-probe-"));
15 afterAll(() => rmSync(root, { recursive: true, force: true }));
16
17 test("a caller with no key yet sends nothing, and leaves no key behind", () => {
18 const data = join(root, "fresh");
19 expect(probeHeaders(data)).toEqual({});
20 expect(launcherProbe(data)).toEqual({});
21 expect(existsSync(join(data, "secret.key"))).toBe(false);
22 });
23
24 test("the caller's probe, the launcher's and the forge's are one value", () => {
25 const data = join(root, "keyed");
26 const forge = probeFor(data);
27 expect(probeHeaders(data)).toEqual({ [PROBE_HEADER]: forge });
28 expect(launcherProbe(data)).toEqual({ [PROBE_HEADER]: forge });
29 const asked = (value?: string) =>
30 new Request("http://forge/healthz", value === undefined ? {} : { headers: { [PROBE_HEADER]: value } });
31 expect(isProbe(asked(forge), data)).toBe(true);
32 for (const wrong of [undefined, "", forge.slice(1), `${forge}x`, probeFor(join(root, "other"))])
33 expect(isProbe(asked(wrong), data)).toBe(false);
34 });
test/guard.test.ts +14 −2
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
77 import { join } from "node:path";
88 import { createApp, type App } from "../src/app.ts";
99 import { serve, type Servers } from "../src/http/server.ts";
10 import { ownProbeHeaders } from "../src/kernel/probe.ts";
1011 import { running } from "../src/kernel/version.ts";
1112 import {
1213 forgeHealth,
@@ -84,7 +85,9 @@ afterAll(async () => {
8485 });
8586
8687 test("/api/v1/health answers anyone, from the mounted ConfigMap", async () => {
87 let res = await fetch(servers.url + "/api/v1/health");
88 // The probe is told the platform's word; a stranger only whether all is well.
89 const probe = { headers: ownProbeHeaders(app.config.dataDir) };
90 let res = await fetch(servers.url + "/api/v1/health", probe);
8891 expect(res.status).toBe(200);
8992 let body = (await res.json()) as any;
9093 expect(body.ok).toBe(true);
@@ -99,9 +102,18 @@ test("/api/v1/health answers anyone, from the mounted ConfigMap", async () => {
99102 mkdirSync(guardDir, { recursive: true });
100103 writeFileSync(join(guardDir, "status.json"), status({ stuck: "quota refused <b>" }));
101104 writeFileSync(join(guardDir, "checkedAt"), new Date().toISOString());
102 res = await fetch(servers.url + "/api/v1/health");
105 res = await fetch(servers.url + "/api/v1/health", probe);
103106 body = await res.json();
104107 expect(body.rollout).toEqual({ state: "blocked", reason: "quota refused <b>" });
105108 expect(body.ok).toBe(false);
106109 expect(body.incidents).toEqual([{ key: "stuck", message: "quota refused <b>" }]);
110
111 // A stranger, and one who guesses at the probe, hear it is not well and never why.
112 for (const headers of [{}, { "x-forge-probe": "guess" }]) {
113 const told = (await (await fetch(servers.url + "/api/v1/health", { headers })).json()) as any;
114 expect(told.ok).toBe(false);
115 expect(told.rollout).toBeUndefined();
116 expect(told.incidents).toBeUndefined();
117 expect(JSON.stringify(told)).not.toContain("quota");
118 }
107119 });
test/health.test.ts +10 −2
@@ -13,6 +13,7 @@ import {
1313 startDraining,
1414 } from "../src/http/health.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
16 import { ownProbeHeaders } from "../src/kernel/probe.ts";
1617 import { running } from "../src/kernel/version.ts";
1718 import type { Log, LogFields } from "../src/kernel/log.ts";
1819
@@ -103,9 +104,16 @@ describe("the served paths", () => {
103104 await rm(dataDir, { recursive: true, force: true });
104105 });
105106
106 test("/healthz names the running version and keeps the security headers", async () => {
107 const res = await fetch(`${servers.url}/healthz`);
107 test("/healthz names the running version to the probe alone and keeps the security headers", async () => {
108 const res = await fetch(`${servers.url}/healthz`, { headers: ownProbeHeaders(dataDir) });
108109 expect(res.headers.get("x-forge-version")).toBe(running().version);
110 // A stranger, or a guess at the probe, hears `ok` and nothing of the release (#2917).
111 for (const headers of [{}, { "x-forge-probe": "guess" }]) {
112 const told = await fetch(`${servers.url}/healthz`, { headers });
113 expect(told.status).toBe(200);
114 expect(await told.text()).toBe("ok");
115 expect(told.headers.get("x-forge-version")).toBeNull();
116 }
109117 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
110118 expect(res.headers.get("content-security-policy")).toBeTruthy();
111119 });
test/og-route.test.ts +3 −3
@@ -273,7 +273,7 @@ describe("og cards", () => {
273273 }
274274 });
275275
276 test("an agent run names its agent, thread, cost and outcome", async () => {
276 test("an agent run names its agent, thread and outcome, never its cost", async () => {
277277 const repo = getRepo(app.db, "alice", "shown")!;
278278 const alice = getUserByName(app.db, "alice")!;
279279 const issue = getIssue(app.db, repo.id, 1)!;
@@ -288,10 +288,10 @@ describe("og cards", () => {
288288 const body = await (await get(`/og/run/alice~shown~${r.id}.svg`)).text();
289289 expect(body).toContain("alice");
290290 expect(body).toContain("#1 A public issue &lt;b&gt;");
291 expect(body).toContain("$1.23");
291 expect(body).not.toContain("$1.23");
292292 expect(body).toContain("landed a pull");
293293 app.db.query("UPDATE run SET summary = 'declined: too big', state = 'ok' WHERE id = ?1").run(r.id);
294 expect(await (await get(`/og/run/alice~shown~${r.id}.svg`)).text()).toContain("declined ·");
294 expect(await (await get(`/og/run/alice~shown~${r.id}.svg`)).text()).toContain("alice · declined");
295295 app.db.query("UPDATE run SET state = 'failed', summary = 'boom' WHERE id = ?1").run(r.id);
296296 expect(await (await get(`/og/run/alice~shown~${r.id}.svg`)).text()).toContain("failed");
297297 app.db.query("UPDATE run SET state = 'running' WHERE id = ?1").run(r.id);
test/runs-stranger.test.ts added +186
@@ -0,0 +1,186 @@
1 /**
2 * What a stranger may read of a repository's runs (#2917). A public
3 * repository's runs answer anyone with what ran, why and how it ended —
4 * never the agent session a follow-up resumes, the node a check ran on, or
5 * what it cost; its writers read all of it. A private repository's runs are
6 * a 404 to anyone it is not shared with, the same 404 as no repository.
7 * Every door says the same: the API, the run page, MCP `now` and the
8 * terminal's run rows all read through `runForReader`.
9 */
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { listRepoRuns } from "../src/actions/runs.ts";
17 import { createApp as insertApp } from "../src/core/apps.ts";
18 import { getRepo, type Repo } from "../src/core/repos.ts";
19 import { claimRun, createRun, finishRun, noteRunProgress } from "../src/core/runs.ts";
20 import { createToken } from "../src/core/tokens.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { forgeHttp, type ForgeHttp } from "../src/http/server.ts";
23 import { runRows } from "../src/tui/screens/runlist.ts";
24 import { appendRunLog } from "../src/runs/log.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 const SESSION = "S3SSION-4f2a";
30 const NODE = "node-7b-pool";
31
32 let dataDir: string;
33 let app: App;
34 let http: ForgeHttp;
35 const token: Record<string, string> = {};
36 const runs: Record<string, string> = {};
37 const users: Record<string, User> = {};
38 let open: Repo;
39
40 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
41 if (!r.ok) throw new Error(JSON.stringify(r.error));
42 return r.value;
43 };
44
45 async function get(path: string, who?: string) {
46 const res = await http.handle(
47 new Request(`http://forge.test${path}`, {
48 headers: who === undefined ? {} : { authorization: `Bearer ${token[who]}` },
49 }),
50 );
51 return { status: res.status, text: await res.text() };
52 }
53
54 const user = async (username: string, adminIfFirst = false): Promise<User> =>
55 ok(
56 await createUser(app.db, {
57 origin: "seed",
58 trusted: true,
59 username,
60 email: `${username}@forge.local`,
61 password: "a-good-passphrase",
62 adminIfFirst,
63 }),
64 );
65
66 beforeAll(async () => {
67 dataDir = await mkdtemp(join(tmpdir(), "forge-runs-stranger-"));
68 app = createApp({ dataDir, port: 0 });
69 http = forgeHttp(app);
70 const alice = await user("alice", true);
71 const mallory = await user("mallory");
72 users.alice = alice;
73 users.mallory = mallory;
74 token.alice = createToken(app.db, alice.id, "t", "ro").token;
75 token.mallory = createToken(app.db, mallory.id, "t", "ro").token;
76 for (const [name, visibility] of [["open", "public"], ["closed", "private"]] as const) {
77 const repo = ok(await createRepo(app, alice, { name, visibility }));
78 ok(
79 await commitToBranch(app, alice, repo, {
80 message: "Start",
81 files: [{ path: "README.md", content: `# ${name}\n` }],
82 }),
83 );
84 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: alice.id, reason: "assigned" });
85 claimRun(app.db, run.id);
86 noteRunProgress(app.db, run.id, { sessionId: SESSION, costUsd: 1.25, placement: "job", node: NODE });
87 appendRunLog(dataDir, run.id, "a line of the transcript");
88 finishRun(app.db, run.id, { state: "ok", summary: `${name} done`, sessionId: SESSION, costUsd: 1.25, turns: 9 });
89 runs[name] = run.id;
90 }
91 // The public repository is an app with a run under way, so MCP `now`
92 // (which lists apps' live runs) has one to answer about.
93 open = getRepo(app.db, "alice", "open")!;
94 ok(insertApp(app.db, { repoId: open.id, slug: "open", access: "public" }));
95 const live = createRun(app.db, { repoId: open.id, kind: "agent", actorId: alice.id, reason: "assigned" });
96 claimRun(app.db, live.id);
97 noteRunProgress(app.db, live.id, { sessionId: SESSION, costUsd: 0.5, placement: "job", node: NODE });
98 runs.live = live.id;
99 }, 60_000);
100
101 async function now(who: string): Promise<Record<string, unknown>[]> {
102 const res = await http.handle(
103 new Request("http://forge.test/mcp", {
104 method: "POST",
105 headers: { "content-type": "application/json", authorization: `Bearer ${token[who]}` },
106 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "now", arguments: {} } }),
107 }),
108 );
109 expect(res.status).toBe(200);
110 const answer = (await res.json()) as { result: { content: { text: string }[] } };
111 return JSON.parse(answer.result.content[0]!.text) as Record<string, unknown>[];
112 }
113
114 afterAll(async () => {
115 app?.close();
116 await rm(dataDir, { recursive: true, force: true });
117 });
118
119 describe("runs a stranger reads", () => {
120 test("a public repository's runs say what ran, never its session, node or cost", async () => {
121 for (const who of [undefined, "mallory"]) {
122 for (const path of ["/api/v1/repos/alice/open/runs", `/api/v1/repos/alice/open/runs/${runs.open}`]) {
123 const res = await get(path, who);
124 expect(res.status).toBe(200);
125 expect(res.text).toContain("open done");
126 expect(res.text).not.toContain(SESSION);
127 expect(res.text).not.toContain(NODE);
128 const body = JSON.parse(res.text) as { items?: Record<string, unknown>[] } & Record<string, unknown>;
129 const run = body.items?.[0] ?? body;
130 expect([run["costUsd"], run["turns"], run["sessionId"], run["node"]]).toEqual([0, 0, "", ""]);
131 }
132 }
133 });
134
135 test("its owner reads all of it", async () => {
136 const res = await get(`/api/v1/repos/alice/open/runs/${runs.open}`, "alice");
137 const run = JSON.parse(res.text) as Record<string, unknown>;
138 expect([run["costUsd"], run["turns"], run["sessionId"], run["node"]]).toEqual([1.25, 9, SESSION, NODE]);
139 });
140
141 test("a private repository's runs are a 404 to a stranger, and the same 404 as no repository", async () => {
142 const missing = await get("/api/v1/repos/alice/nothing-here/runs");
143 expect(missing.status).toBe(404);
144 for (const who of [undefined, "mallory"]) {
145 for (const path of [
146 "/api/v1/repos/alice/closed/runs",
147 `/api/v1/repos/alice/closed/runs/${runs.closed}`,
148 `/api/v1/repos/alice/closed/runs/${runs.closed}/log`,
149 ]) {
150 const res = await get(path, who);
151 expect(res.status).toBe(404);
152 expect(res.text).toBe(missing.text);
153 }
154 }
155 expect((await get("/api/v1/repos/alice/closed/runs", "alice")).status).toBe(200);
156 });
157
158 test("the run page shows a stranger no node", async () => {
159 const res = await get(`/alice/open/runs/${runs.open}`);
160 expect(res.status).toBe(200);
161 expect(res.text).toContain("open done");
162 expect(res.text).not.toContain(NODE);
163 });
164
165 test("MCP now tells a stranger no node or cost, and the owner both", async () => {
166 const theirs = (await now("mallory")).find((r) => r["run"] === runs.live);
167 expect(theirs).toBeDefined();
168 expect(JSON.stringify(theirs)).not.toContain(NODE);
169 expect(theirs!["costUsd"]).toBe(0);
170 const own = (await now("alice")).find((r) => r["run"] === runs.live);
171 expect(own!["ran"]).toBe(`ran as a cluster job on ${NODE}`);
172 expect(own!["costUsd"]).toBe(0.5);
173 });
174
175 test("the terminal's run rows show a stranger no node or cost, and the owner both", () => {
176 const meta = (who: User | undefined) =>
177 runRows(ok(listRepoRuns(app, who, open)), new Map()).map((row) => row.meta).join("\n");
178 for (const who of [undefined, users.mallory]) {
179 const rows = meta(who);
180 expect(rows).not.toContain(NODE);
181 expect(rows).not.toContain("$");
182 }
183 expect(meta(users.alice)).toContain(NODE);
184 expect(meta(users.alice)).toContain("$1.25");
185 });
186 });
test/stranger-reads.test.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * API reads without a credential are counted per source (#2917,
3 * `http/stranger_reads.ts`): past the limit a stranger is told 429 and when
4 * to come back; a token, the liveness probe and the pages are not counted
5 * by it.
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 { createApp, type App } from "../src/app.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { setLimit } from "../src/settings/limits.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const LIMIT = 4;
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let token = "";
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-stranger-reads-"));
28 app = createApp({ dataDir, port: 0 });
29 servers = await serve(app);
30 const made = await createUser(app.db, {
31 origin: "seed",
32 trusted: true,
33 username: "alice",
34 email: "[email protected]",
35 password: "a-good-passphrase",
36 adminIfFirst: true,
37 });
38 if (!made.ok) throw new Error(String(made.error));
39 token = createToken(app.db, made.value.id, "t", "ro").token;
40 const set = setLimit(app.db, "api.anonymous_per_source", LIMIT, made.value.id);
41 if (!set.ok) throw new Error(set.error.message);
42 });
43
44 afterAll(async () => {
45 servers.stop();
46 app.close();
47 await rm(dataDir, { recursive: true, force: true });
48 });
49
50 test("a stranger's API reads past the limit are refused with a wait; a token's and the probe's are not", async () => {
51 const read = (headers: Record<string, string> = {}) =>
52 fetch(`${servers.url}/api/v1/health`, { headers });
53 for (let i = 0; i < LIMIT; i++) expect((await read()).status).toBe(200);
54 const refused = await read();
55 expect(refused.status).toBe(429);
56 expect(((await refused.json()) as { error: string }).error).toBe("rate_limited");
57 expect(Number(refused.headers.get("retry-after"))).toBeGreaterThan(0);
58 expect(refused.headers.get("x-content-type-options")).toBe("nosniff");
59 // A wrong token is still a stranger.
60 expect((await read({ authorization: "Bearer not-a-token" })).status).toBe(429);
61 // Counted elsewhere, or never: a token, liveness, readiness and a page.
62 expect((await read({ authorization: `Bearer ${token}` })).status).toBe(200);
63 expect((await fetch(`${servers.url}/healthz`)).status).toBe(200);
64 expect((await fetch(`${servers.url}/readyz`)).status).toBe(200);
65 expect((await fetch(`${servers.url}/explore`)).status).not.toBe(429);
66 });
test/vitals.test.ts +24 −10
@@ -6,6 +6,8 @@ import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { createApp, type App } from "../src/app.ts";
88 import { createRun } from "../src/core/runs.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
911 import { vitals } from "../src/kernel/vitals.ts";
1012 import { fetchInternal } from "../src/git/hook-address.ts";
1113 import { serve, type Servers } from "../src/http/server.ts";
@@ -51,9 +53,17 @@ beforeAll(async () => {
5153 app = createApp({ dataDir, port: 0, disk: roomy });
5254 servers = await serve(app);
5355 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
54 await post("/api/v1/users", { username: "bob", email: "[email protected]", password: "a-good-passphrase" });
5556 admin = await mint("alice", "ro");
56 plain = await mint("bob", "admin");
57 // Bob signs in and is no admin: his token, made directly, is a reader's.
58 const bob = await createUser(app.db, {
59 origin: "seed",
60 trusted: true,
61 username: "bob",
62 email: "[email protected]",
63 password: "a-good-passphrase",
64 });
65 if (!bob.ok) throw new Error(bob.error);
66 plain = createToken(app.db, bob.value.id, "plain", "ro").token;
5767 repoId = (
5868 app.db.query("SELECT id FROM repo").get() as { id: string } | null
5969 )?.id ?? "";
@@ -88,14 +98,18 @@ test("a site admin with a read-only token gets the shape; others get the short o
8898 expect(v.disk.totalBytes).toBeGreaterThan(0);
8999 expect(v.memory.rssBytes).toBeGreaterThan(0);
90100 expect(v.apps).toEqual({ serving: 0, asleep: 0, previews: 0 });
91 // Everyone else gets the platform's short shape, never the numbers.
92 for (const token of [plain, undefined]) {
93 const res = await health(token);
94 expect(res.status).toBe(200);
95 const short = (await res.json()) as Record<string, unknown>;
96 expect(short["disk"]).toBeUndefined();
97 expect(short["rollout"]).toBeDefined();
98 }
101 // A signed-in reader who is not an admin gets the platform's short shape,
102 // never the numbers.
103 expect(plain).not.toBe("");
104 const res2 = await health(plain);
105 expect(res2.status).toBe(200);
106 const short = (await res2.json()) as Record<string, unknown>;
107 expect(short["disk"]).toBeUndefined();
108 expect(short["memory"]).toBeUndefined();
109 expect(short["rollout"]).toBeDefined();
110 // A stranger is told whether it is well, and no more (#2917).
111 const stranger = (await (await health()).json()) as Record<string, unknown>;
112 expect(Object.keys(stranger).sort()).toEqual(["commit", "draining", "ok", "version"]);
99113 expect((await fetch(servers.url + "/healthz")).status).toBe(200);
100114 });
101115
test/web.test.ts +9 −1
@@ -794,7 +794,8 @@ const FLOOR_POLICY =
794794 "default-src 'none'; frame-ancestors 'none'; base-uri 'none'";
795795
796796 /**
797 * The floor, on one response: never sniffed, never a referrer off-site, and
797 * The floor, on one response: never sniffed, never a referrer off-site, no
798 * powers a page does not use, https once https, and
798799 * — unless the body is a document with a policy of its own — inert.
799800 */
800801 function expectFloor(what: string, response: Response): void {
@@ -805,6 +806,13 @@ function expectFloor(what: string, response: Response): void {
805806 expect(label + response.headers.get("referrer-policy")).toBe(
806807 label + "same-origin",
807808 );
809 // No powers a page does not use, and https once https (#2917).
810 expect(label + response.headers.get("permissions-policy")).toContain(
811 label + "camera=(), microphone=(), geolocation=()",
812 );
813 expect(label + response.headers.get("strict-transport-security")).toBe(
814 label + "max-age=31536000",
815 );
808816 const policy = response.headers.get("content-security-policy") ?? "";
809817 expect(label + (policy === "" ? "(none)" : "(a policy)")).toBe(
810818 label + "(a policy)",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs