Public: anyone can read this; the crew can change it.
Perimeter: the forge's anonymous answers say only what a stranger needs #2952
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2917-perimeter-the-forge-s-anonymous-answers → main 9 commits fork 4 threads · 4 unresolved
27 files
changed +635
−75
scripts/smoke.ts +8 −2
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | import { smoke } from "../src/release/smoke.ts"; |
| 24 | 24 | import { webspec } from "../src/release/webspec.ts"; |
| 25 | 25 | import { outsider } from "../src/release/outsider.ts"; |
| 26 | import { PROBE_HEADER } from "../src/kernel/probe.ts"; | |
| 26 | 27 | export * from "../src/release/smoke.ts"; |
| 27 | 28 | |
| 28 | 29 | if (import.meta.main) { |
| @@ -41,7 +42,7 @@ if (import.meta.main) { | ||
| 41 | 42 | const base = args[0]; |
| 42 | 43 | const seconds = wait === undefined ? 120 : Number(wait); |
| 43 | 44 | 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)"); | |
| 45 | 46 | process.exit(2); |
| 46 | 47 | } |
| 47 | 48 | if (withOutsider && !process.env["FORGE_TOKEN"]) { |
| @@ -56,7 +57,12 @@ if (import.meta.main) { | ||
| 56 | 57 | console.log(`standards: ${findings.length} finding(s) over ${paths.length} path(s); report only`); |
| 57 | 58 | process.exit(0); |
| 58 | 59 | } |
| 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 | }); | |
| 60 | 66 | for (const f of failures) |
| 61 | 67 | console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`); |
| 62 | 68 | const unrefused = withOutsider |
src/actions/health.ts +26 −6
| @@ -1,9 +1,17 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 7 | 15 | * |
| 8 | 16 | * One answer for the API (`GET /api/v1/health`) and the MCP `health` tool. |
| 9 | 17 | */ |
| @@ -51,11 +59,23 @@ export function forgeVitals(app: App): Vitals { | ||
| 51 | 59 | }); |
| 52 | 60 | } |
| 53 | 61 | |
| 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 | ||
| 54 | 69 | export function readHealth( |
| 55 | 70 | app: App, |
| 56 | 71 | actor: User | undefined, |
| 57 | ): ForgeHealth | (Vitals & ForgeHealth) { | |
| 72 | probe = false, | |
| 73 | ): StrangerHealth | ForgeHealth | (Vitals & ForgeHealth) { | |
| 58 | 74 | const forge = forgeHealth(); |
| 75 | if (actor === undefined && !probe) { | |
| 76 | const { ok, version, commit, draining } = forge; | |
| 77 | return { ok, version, commit, draining }; | |
| 78 | } | |
| 59 | 79 | if (!actor?.isAdmin) return forge; |
| 60 | 80 | const v = forgeVitals(app); |
| 61 | 81 | return { ...v, ...forge, ok: forge.ok && v.ok }; |
src/actions/runs.ts +21 −4
| @@ -118,6 +118,23 @@ export function mayReadLogOfRun( | ||
| 118 | 118 | ); |
| 119 | 119 | } |
| 120 | 120 | |
| 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 | ||
| 121 | 138 | export function listRepoRuns( |
| 122 | 139 | app: App, |
| 123 | 140 | actor: User | undefined, |
| @@ -129,9 +146,9 @@ export function listRepoRuns( | ||
| 129 | 146 | if (!mayReadRuns(app, actor, repo)) return ok([]); |
| 130 | 147 | // A run on a sealed report, or its fix, is the report's (`core/sealed.ts`). |
| 131 | 148 | 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)), | |
| 135 | 152 | ); |
| 136 | 153 | } |
| 137 | 154 | |
| @@ -160,7 +177,7 @@ export function getRepoRun( | ||
| 160 | 177 | const run = getRun(app.db, runId); |
| 161 | 178 | if (!run || run.repoId !== repo.id) return err("not_found"); |
| 162 | 179 | if (!mayViewId(app.db, repo, run.issueId, actor)) return err("not_found"); |
| 163 | return ok(run); | |
| 180 | return ok(runForReader(app, actor, repo)(run)); | |
| 164 | 181 | } |
| 165 | 182 | |
| 166 | 183 | export interface RunLogView { |
src/checks/landing.ts +2 −1
| @@ -28,6 +28,7 @@ import { failuresOf } from "./failures.ts"; | ||
| 28 | 28 | import { keepForgeGate } from "./gate_last.ts"; |
| 29 | 29 | import { checksFor } from "./service.ts"; |
| 30 | 30 | import { landVerdict, type LandVerdict } from "./tiers.ts"; |
| 31 | import { ownProbeHeaders } from "../kernel/probe.ts"; | |
| 31 | 32 | |
| 32 | 33 | /** |
| 33 | 34 | * The full gate, once, on a commit that is what main would become — one |
| @@ -165,7 +166,7 @@ async function swapIn( | ||
| 165 | 166 | ); |
| 166 | 167 | return; |
| 167 | 168 | } |
| 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), | |
| 169 | 170 | ...(app.config.swapBudgetMs === undefined ? {} : { budgetMs: app.config.swapBudgetMs }), |
| 170 | 171 | }); |
| 171 | 172 | endSwap(swapped.ok); |
src/http/api.ts +15 −18
| @@ -59,6 +59,7 @@ import { resendVerification } from "../actions/verify.ts"; | ||
| 59 | 59 | import { CAPPED_WORDS } from "../actions/mail.ts"; |
| 60 | 60 | import { emailVerified } from "../core/verify.ts"; |
| 61 | 61 | import { readHealth } from "../actions/health.ts"; |
| 62 | import { isProbe } from "../kernel/probe.ts"; | |
| 62 | 63 | import { listChildren } from "../actions/spawn.ts"; |
| 63 | 64 | import { forgeState, PARENT_HEADER } from "../spawn/clients.ts"; |
| 64 | 65 | import { familyView } from "../spawn/family.ts"; |
| @@ -287,6 +288,7 @@ import { | ||
| 287 | 288 | checkOfCommit, |
| 288 | 289 | getRepoRun, |
| 289 | 290 | listRepoRuns, |
| 291 | runForReader, | |
| 290 | 292 | runLog, |
| 291 | 293 | } from "../actions/runs.ts"; |
| 292 | 294 | import { |
| @@ -4704,11 +4706,15 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4704 | 4706 | |
| 4705 | 4707 | // ---- apps, runs, and the crew (docs/APPS.md) ---- |
| 4706 | 4708 | |
| 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`). | |
| 4710 | 4712 | 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 | ), | |
| 4712 | 4718 | ); |
| 4713 | 4719 | |
| 4714 | 4720 | // Explore (#607): the rows /explore shows, for the same one line. |
| @@ -5347,14 +5353,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5347 | 5353 | return json({ url: link.value }); |
| 5348 | 5354 | }); |
| 5349 | 5355 | |
| 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 | ||
| 5358 | 5356 | // What the crew's runs here spent over a window (`core/usage.ts`): |
| 5359 | 5357 | // tokens, cost, wall time and outcome, by subscription, agent and issue. |
| 5360 | 5358 | // 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 { | ||
| 5450 | 5448 | limit, |
| 5451 | 5449 | }); |
| 5452 | 5450 | 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)); | |
| 5455 | 5454 | }); |
| 5456 | 5455 | |
| 5457 | 5456 | router.add("GET", "/api/v1/repos/:owner/:name/runs/:id", (ctx) => { |
| @@ -5461,7 +5460,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5461 | 5460 | if (!run.ok) return notFound(); |
| 5462 | 5461 | // The merged pulls a finished production deploy carries (`core/carried.ts`). |
| 5463 | 5462 | return json({ |
| 5464 | ...runForViewer(ctx, repo)(run.value), | |
| 5463 | ...run.value, | |
| 5465 | 5464 | carries: carriedBy(ctx.app.db, run.value.id), |
| 5466 | 5465 | }); |
| 5467 | 5466 | }); |
| @@ -5489,9 +5488,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5489 | 5488 | // The cost and turn count are the owner's own facts, same as the studio |
| 5490 | 5489 | // page beside this stream — a viewer who cannot write here never gets |
| 5491 | 5490 | // 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); | |
| 5495 | 5492 | // Every line carries its number, so a browser that reconnects — a |
| 5496 | 5493 | // proxy in front of the forge cuts long responses — sends the last it |
| 5497 | 5494 | // 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"; | ||
| 183 | 183 | import { resourceMetadataUrl } from "./oauth.ts"; |
| 184 | 184 | import { body, boundedBody, json } from "./respond.ts"; |
| 185 | 185 | import type { Router } from "./router.ts"; |
| 186 | import { mayReadRuns } from "../actions/runs.ts"; | |
| 186 | import { mayReadRuns, runForReader } from "../actions/runs.ts"; | |
| 187 | 187 | import { manifestFor } from "../actions/here.ts"; |
| 188 | 188 | import { clientBucket } from "./peer.ts"; |
| 189 | 189 | import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts"; |
| @@ -991,8 +991,11 @@ const TOOLS: readonly Tool[] = [ | ||
| 991 | 991 | return good([ |
| 992 | 992 | ...waiting, |
| 993 | 993 | ...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); | |
| 996 | 999 | const issue = |
| 997 | 1000 | r.issueId === null |
| 998 | 1001 | ? undefined |
| @@ -1005,8 +1008,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1005 | 1008 | why: r.reason, |
| 1006 | 1009 | state: r.state, |
| 1007 | 1010 | 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, | |
| 1010 | 1012 | ...(placementWords(r) === "" ? {} : { ran: placementWords(r) }), |
| 1011 | 1013 | }; |
| 1012 | 1014 | }), |
src/http/respond.ts +24 −2
| @@ -118,10 +118,28 @@ export function prefersHtml(request: Request, pathname: string): boolean { | ||
| 118 | 118 | const FLOOR_POLICY = |
| 119 | 119 | "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"; |
| 120 | 120 | |
| 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 | ||
| 121 | 138 | /** |
| 122 | 139 | * 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. | |
| 125 | 143 | * |
| 126 | 144 | * A responder that already set one of these keeps its value: `page()` has a |
| 127 | 145 | * nonce'd policy that must survive, and a fragment has a sandboxed one. The |
| @@ -136,6 +154,10 @@ export function securityFloor(response: Response): Response { | ||
| 136 | 154 | headers.set("x-content-type-options", "nosniff"); |
| 137 | 155 | if (!headers.has("referrer-policy")) |
| 138 | 156 | 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); | |
| 139 | 161 | // What left decides, not what was asked for: an HTML body writes the |
| 140 | 162 | // only policy that can name its own nonce, and `default-src 'none'` |
| 141 | 163 | // 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 | ||
| 462 | 462 | import { oauthRoutes } from "./oauth.ts"; |
| 463 | 463 | import { flushAccess, recordAccess } from "./access_log.ts"; |
| 464 | 464 | import { carryPeer, clientAddress, notePeer, type ProxyTrust } from "./peer.ts"; |
| 465 | import { isProbe } from "../kernel/probe.ts"; | |
| 466 | import { takeStrangerRead } from "./stranger_reads.ts"; | |
| 465 | 467 | import { parseCidrs } from "../kernel/cidr.ts"; |
| 466 | 468 | import { |
| 467 | 469 | reportAcceptSubmit, |
| @@ -2328,8 +2330,10 @@ async function answerGet( | ||
| 2328 | 2330 | const dbStart = performance.now(); |
| 2329 | 2331 | const live = liveResponse(() => app.db.query("select 1").get(), app.log); |
| 2330 | 2332 | 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); | |
| 2333 | 2337 | return live; |
| 2334 | 2338 | } |
| 2335 | 2339 | if (url.pathname === "/readyz") { |
| @@ -2398,6 +2402,20 @@ async function answerGet( | ||
| 2398 | 2402 | if (verdict !== undefined && !verdict.allowed) { |
| 2399 | 2403 | return withRateHeaders(tooManyRequests(verdict), verdict); |
| 2400 | 2404 | } |
| 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 | } | |
| 2401 | 2419 | |
| 2402 | 2420 | const themeCookie = cookieValue(request, "forge_theme") ?? ""; |
| 2403 | 2421 | 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, | ||
| 27 | 27 | import { tmpdir } from "node:os"; |
| 28 | 28 | import { join } from "node:path"; |
| 29 | 29 | import { running } from "../kernel/version.ts"; |
| 30 | import { probeHeaders } from "../kernel/probe.ts"; | |
| 30 | 31 | |
| 31 | 32 | export const BOOT_BUDGET_MS = 120_000; |
| 32 | 33 | const POLL_MS = 250; |
| @@ -66,10 +67,11 @@ export function why(stderr: string): string { | ||
| 66 | 67 | return line.replace(/[\u0000-\u001f\u007f]+/g, " ").slice(0, 300); |
| 67 | 68 | } |
| 68 | 69 | |
| 69 | async function versionAt(port: number): Promise<string | undefined> { | |
| 70 | async function versionAt(port: number, probe: Record<string, string>): Promise<string | undefined> { | |
| 70 | 71 | try { |
| 72 | // The probe, so a forge that tells strangers only `ok` still says what it runs. | |
| 71 | 73 | const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { |
| 72 | headers: { connection: "close" }, | |
| 74 | headers: { connection: "close", ...probe }, | |
| 73 | 75 | signal: AbortSignal.timeout(2_000), |
| 74 | 76 | }); |
| 75 | 77 | if (!res.ok) return undefined; |
| @@ -121,7 +123,7 @@ export async function bootProof(input: BootInput): Promise<BootProof> { | ||
| 121 | 123 | const said = why(stderr); |
| 122 | 124 | return { ok: false, line: `exited with ${child.exitCode} before it answered health${said ? `: ${said}` : ""}` }; |
| 123 | 125 | } |
| 124 | const seen = await versionAt(port); | |
| 126 | const seen = await versionAt(port, probeHeaders(input.dataDir)); | |
| 125 | 127 | if (seen === input.version) return { ok: true, ms: Math.round(performance.now() - started) }; |
| 126 | 128 | if (seen !== undefined) last = `health answered as ${seen}`; |
| 127 | 129 | await Bun.sleep(POLL_MS); |
src/release/launch.ts +20 −3
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; |
| 25 | 25 | import { dirname, join } from "node:path"; |
| 26 | 26 | import type { Subprocess } from "bun"; |
| 27 | import { hkdfSync } from "node:crypto"; | |
| 27 | 28 | |
| 28 | 29 | export const READY_GRACE_MS = 150_000; |
| 29 | 30 | const POLL_MS = 500; |
| @@ -122,10 +123,26 @@ export function rollbackNeed(dataDir: string): { sentence: string; since: string | ||
| 122 | 123 | return { sentence: `Release ${r.from} did not become ready (${r.why}); rolled back to ${r.to}.`, since: r.at }; |
| 123 | 124 | } |
| 124 | 125 | |
| 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> { | |
| 126 | 143 | try { |
| 127 | 144 | const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { |
| 128 | headers: { connection: "close" }, | |
| 145 | headers: { connection: "close", ...probe }, | |
| 129 | 146 | signal: AbortSignal.timeout(2_000), |
| 130 | 147 | }); |
| 131 | 148 | if (!res.ok) return undefined; |
| @@ -201,7 +218,7 @@ export async function launch(input: LaunchInput): Promise<Launched> { | ||
| 201 | 218 | why = `it exited with ${release.exitCode ?? release.signalCode} before it was ready`; |
| 202 | 219 | break; |
| 203 | 220 | } |
| 204 | if ((await versionAt(input.port)) === input.version) { | |
| 221 | if ((await versionAt(input.port, probeHeaders(input.dataDir))) === input.version) { | |
| 205 | 222 | try { |
| 206 | 223 | keepGood(input.dataDir, input.tree, input.version, input.commit, now()); |
| 207 | 224 | } catch (error) { |
src/release/prove.ts +2 −1
| @@ -12,6 +12,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | ||
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | import { smoke, type Failure } from "./smoke.ts"; |
| 14 | 14 | import { UNSTAMPED } from "../kernel/version.ts"; |
| 15 | import { ownProbeHeaders } from "../kernel/probe.ts"; | |
| 15 | 16 | |
| 16 | 17 | /** How long the public URL has to answer as the new version. */ |
| 17 | 18 | export const GIVE_UP_MS = 10 * 60_000; |
| @@ -92,7 +93,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> | ||
| 92 | 93 | const deadline = now() + (o.giveUpMs ?? GIVE_UP_MS); |
| 93 | 94 | for (;;) { |
| 94 | 95 | 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) }); | |
| 96 | 97 | await res.body?.cancel(); |
| 97 | 98 | if (res.headers.get("x-forge-version") === o.version) break; |
| 98 | 99 | } catch { |
src/release/rollback.ts +2 −1
| @@ -14,6 +14,7 @@ import { existsSync } from "node:fs"; | ||
| 14 | 14 | import { join } from "node:path"; |
| 15 | 15 | import { releaseDir } from "./checkout.ts"; |
| 16 | 16 | import { startRelease, type SwapInput } from "./swap.ts"; |
| 17 | import { ownProbeHeaders } from "../kernel/probe.ts"; | |
| 17 | 18 | |
| 18 | 19 | export type Rollback = { ok: true; pid: number } | { ok: false; line: string }; |
| 19 | 20 | |
| @@ -59,5 +60,5 @@ export async function rollback( | ||
| 59 | 60 | const live = liveSchema(dataDir); |
| 60 | 61 | if (known < live) |
| 61 | 62 | 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) }); | |
| 63 | 64 | } |
src/release/smoke.ts +5 −3
| @@ -49,6 +49,8 @@ export function navigationBranch(source: string): string | null { | ||
| 49 | 49 | export interface Options { |
| 50 | 50 | /** Wait for `/healthz` to report this `X-Forge-Version`. */ |
| 51 | 51 | 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>>; | |
| 52 | 54 | /** Longest wait for it, in milliseconds. */ |
| 53 | 55 | waitMs?: number; |
| 54 | 56 | /** Pause between polls, in milliseconds. */ |
| @@ -80,14 +82,14 @@ export function isSignInRedirect(url: string, res: Response): boolean { | ||
| 80 | 82 | } |
| 81 | 83 | |
| 82 | 84 | /** 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> { | |
| 84 | 86 | const url = new URL("/healthz", root).href; |
| 85 | 87 | const deadline = Date.now() + waitMs; |
| 86 | 88 | let seen = "none"; |
| 87 | 89 | let status: number | string = "error"; |
| 88 | 90 | for (;;) { |
| 89 | 91 | 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) }); | |
| 91 | 93 | await res.body?.cancel(); |
| 92 | 94 | status = res.status; |
| 93 | 95 | seen = res.headers.get("x-forge-version") ?? "none"; |
| @@ -109,7 +111,7 @@ export async function smoke(base: string, token = "", options: Options = {}): Pr | ||
| 109 | 111 | const failures: Failure[] = []; |
| 110 | 112 | const fetch = options.fetch ?? globalThis.fetch; |
| 111 | 113 | 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 ?? {}); | |
| 113 | 115 | if (late) return [late]; |
| 114 | 116 | } |
| 115 | 117 |
src/release/swap.ts +5 −3
| @@ -32,6 +32,8 @@ export interface SwapInput { | ||
| 32 | 32 | /** The environment the new forge starts with: the old one's, as it is. */ |
| 33 | 33 | env: Record<string, string | undefined>; |
| 34 | 34 | budgetMs?: number; |
| 35 | /** Sent with each health read (`kernel/probe.ts`), so the answer names its commit. */ | |
| 36 | probe?: Readonly<Record<string, string>>; | |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | /** 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 { | ||
| 41 | 43 | return h.ok === true && h.draining !== true && h.commit === sha; |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | async function readHealth(port: number): Promise<unknown> { | |
| 46 | async function readHealth(port: number, probe: Readonly<Record<string, string>>): Promise<unknown> { | |
| 45 | 47 | try { |
| 46 | 48 | const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { |
| 47 | headers: { connection: "close" }, | |
| 49 | headers: { connection: "close", ...probe }, | |
| 48 | 50 | signal: AbortSignal.timeout(2_000), |
| 49 | 51 | }); |
| 50 | 52 | return res.ok ? await res.json() : undefined; |
| @@ -77,7 +79,7 @@ export async function startRelease(input: SwapInput): Promise<Swapped> { | ||
| 77 | 79 | while (Date.now() < deadline) { |
| 78 | 80 | if (exited !== undefined) |
| 79 | 81 | 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)) { | |
| 81 | 83 | proc.unref(); |
| 82 | 84 | return { ok: true, pid: proc.pid }; |
| 83 | 85 | } |
src/settings/limits.ts +10 −1
| @@ -35,7 +35,8 @@ export type LimitKey = | ||
| 35 | 35 | | "signin.per_source" |
| 36 | 36 | | "signin.per_forge" |
| 37 | 37 | | "signup.per_source" |
| 38 | | "signup.per_forge"; | |
| 38 | | "signup.per_forge" | |
| 39 | | "api.anonymous_per_source"; | |
| 39 | 40 | |
| 40 | 41 | export interface LimitDef { |
| 41 | 42 | readonly label: string; |
| @@ -159,6 +160,14 @@ export const LIMITS: Record<LimitKey, LimitDef> = { | ||
| 159 | 160 | max: 100_000, |
| 160 | 161 | reason: "Spread across many sources, sign-ups still cost hashes and accounts, so the forge as a whole has a ceiling.", |
| 161 | 162 | }, |
| 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 | }, | |
| 162 | 171 | }; |
| 163 | 172 | |
| 164 | 173 | 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 { | ||
| 263 | 263 | kind: "run", |
| 264 | 264 | title: run.summary || "agent run", |
| 265 | 265 | 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)}`, | |
| 267 | 269 | }; |
| 268 | 270 | } |
| 269 | 271 | return { |
test/checks-placement.test.ts +4 −1
| @@ -178,7 +178,10 @@ describe("checks placed", () => { | ||
| 178 | 178 | const run = await settled(await push("exit 0 # page")); |
| 179 | 179 | expect(run.placement).toBe("job"); |
| 180 | 180 | 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."); | |
| 182 | 185 | expect(runRows([run], new Map())[0]!.meta).toContain("ran as a cluster job on n7"); |
| 183 | 186 | expect(runPayload(run)).toMatchObject({ placement: "job", node: "n7" }); |
| 184 | 187 | 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"; | ||
| 7 | 7 | import { join } from "node:path"; |
| 8 | 8 | import { createApp, type App } from "../src/app.ts"; |
| 9 | 9 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | import { ownProbeHeaders } from "../src/kernel/probe.ts"; | |
| 10 | 11 | import { running } from "../src/kernel/version.ts"; |
| 11 | 12 | import { |
| 12 | 13 | forgeHealth, |
| @@ -84,7 +85,9 @@ afterAll(async () => { | ||
| 84 | 85 | }); |
| 85 | 86 | |
| 86 | 87 | 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); | |
| 88 | 91 | expect(res.status).toBe(200); |
| 89 | 92 | let body = (await res.json()) as any; |
| 90 | 93 | expect(body.ok).toBe(true); |
| @@ -99,9 +102,18 @@ test("/api/v1/health answers anyone, from the mounted ConfigMap", async () => { | ||
| 99 | 102 | mkdirSync(guardDir, { recursive: true }); |
| 100 | 103 | writeFileSync(join(guardDir, "status.json"), status({ stuck: "quota refused <b>" })); |
| 101 | 104 | 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); | |
| 103 | 106 | body = await res.json(); |
| 104 | 107 | expect(body.rollout).toEqual({ state: "blocked", reason: "quota refused <b>" }); |
| 105 | 108 | expect(body.ok).toBe(false); |
| 106 | 109 | 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 | } | |
| 107 | 119 | }); |
test/health.test.ts +10 −2
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | startDraining, |
| 14 | 14 | } from "../src/http/health.ts"; |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | import { ownProbeHeaders } from "../src/kernel/probe.ts"; | |
| 16 | 17 | import { running } from "../src/kernel/version.ts"; |
| 17 | 18 | import type { Log, LogFields } from "../src/kernel/log.ts"; |
| 18 | 19 | |
| @@ -103,9 +104,16 @@ describe("the served paths", () => { | ||
| 103 | 104 | await rm(dataDir, { recursive: true, force: true }); |
| 104 | 105 | }); |
| 105 | 106 | |
| 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) }); | |
| 108 | 109 | 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 | } | |
| 109 | 117 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); |
| 110 | 118 | expect(res.headers.get("content-security-policy")).toBeTruthy(); |
| 111 | 119 | }); |
test/og-route.test.ts +3 −3
| @@ -273,7 +273,7 @@ describe("og cards", () => { | ||
| 273 | 273 | } |
| 274 | 274 | }); |
| 275 | 275 | |
| 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 () => { | |
| 277 | 277 | const repo = getRepo(app.db, "alice", "shown")!; |
| 278 | 278 | const alice = getUserByName(app.db, "alice")!; |
| 279 | 279 | const issue = getIssue(app.db, repo.id, 1)!; |
| @@ -288,10 +288,10 @@ describe("og cards", () => { | ||
| 288 | 288 | const body = await (await get(`/og/run/alice~shown~${r.id}.svg`)).text(); |
| 289 | 289 | expect(body).toContain("alice"); |
| 290 | 290 | expect(body).toContain("#1 A public issue <b>"); |
| 291 | expect(body).toContain("$1.23"); | |
| 291 | expect(body).not.toContain("$1.23"); | |
| 292 | 292 | expect(body).toContain("landed a pull"); |
| 293 | 293 | 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"); | |
| 295 | 295 | app.db.query("UPDATE run SET state = 'failed', summary = 'boom' WHERE id = ?1").run(r.id); |
| 296 | 296 | expect(await (await get(`/og/run/alice~shown~${r.id}.svg`)).text()).toContain("failed"); |
| 297 | 297 | 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"; | ||
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | import { createRun } from "../src/core/runs.ts"; |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 9 | 11 | import { vitals } from "../src/kernel/vitals.ts"; |
| 10 | 12 | import { fetchInternal } from "../src/git/hook-address.ts"; |
| 11 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -51,9 +53,17 @@ beforeAll(async () => { | ||
| 51 | 53 | app = createApp({ dataDir, port: 0, disk: roomy }); |
| 52 | 54 | servers = await serve(app); |
| 53 | 55 | 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" }); | |
| 55 | 56 | 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; | |
| 57 | 67 | repoId = ( |
| 58 | 68 | app.db.query("SELECT id FROM repo").get() as { id: string } | null |
| 59 | 69 | )?.id ?? ""; |
| @@ -88,14 +98,18 @@ test("a site admin with a read-only token gets the shape; others get the short o | ||
| 88 | 98 | expect(v.disk.totalBytes).toBeGreaterThan(0); |
| 89 | 99 | expect(v.memory.rssBytes).toBeGreaterThan(0); |
| 90 | 100 | 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"]); | |
| 99 | 113 | expect((await fetch(servers.url + "/healthz")).status).toBe(200); |
| 100 | 114 | }); |
| 101 | 115 |
test/web.test.ts +9 −1
| @@ -794,7 +794,8 @@ const FLOOR_POLICY = | ||
| 794 | 794 | "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"; |
| 795 | 795 | |
| 796 | 796 | /** |
| 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 | |
| 798 | 799 | * — unless the body is a document with a policy of its own — inert. |
| 799 | 800 | */ |
| 800 | 801 | function expectFloor(what: string, response: Response): void { |
| @@ -805,6 +806,13 @@ function expectFloor(what: string, response: Response): void { | ||
| 805 | 806 | expect(label + response.headers.get("referrer-policy")).toBe( |
| 806 | 807 | label + "same-origin", |
| 807 | 808 | ); |
| 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 | ); | |
| 808 | 816 | const policy = response.headers.get("content-security-policy") ?? ""; |
| 809 | 817 | expect(label + (policy === "" ? "(none)" : "(a policy)")).toBe( |
| 810 | 818 | label + "(a policy)", |
new above
offline
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 }.