Public: anyone can read this; the crew can change it.
The forge's scheduled /pull to an adapter carries the signed admin identity, so the template accepts it #10090
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10082-the-forge-s-scheduled-pull-to-an-adapter → main 1 commit fork
3 files
changed +84
−7
docs/ADAPTERS.md +5 −2
| @@ -32,8 +32,11 @@ The template accepts `POST /pull` only from an `admin` the forge signed for: | ||
| 32 | 32 | `x-forge-identity` and `x-forge-signature`, checked against |
| 33 | 33 | `FORGE_APP_IDENTITY_KEY` (see "Who is there" in [](APPS.md)). |
| 34 | 34 | Anyone else gets 401 or 403, and the source is not read, so a public app or |
| 35 | a preview cannot be made to pull by whoever reaches it. When the forge | |
| 36 | schedules pulls, its call has to carry that signed identity. | |
| 35 | a preview cannot be made to pull by whoever reaches it. The forge's scheduled | |
| 36 | call carries that signed identity: `src/apps/pull_schedule.ts` signs it as an | |
| 37 | `admin` named `forge`, under the app's identity key, the same way the proxy | |
| 38 | signs a person, and good only for the 10 s it waits. The key itself is never | |
| 39 | sent. | |
| 37 | 40 | |
| 38 | 41 | The template names its source hosts in `src/source.ts` and opens them with |
| 39 | 42 | `egress`. Its `adapter` block may also name them under `source`, which the |
src/apps/pull_schedule.ts +27 −4
| @@ -7,6 +7,10 @@ | ||
| 7 | 7 | * `recordPull` (`apps/pull.ts`). The request goes over the same loopback reach |
| 8 | 8 | * the scheduled tools use (`feature_tools.ts`): 64 KB, 10 s. An app that is |
| 9 | 9 | * not running is skipped, never woken; two pulls of one app never overlap. |
| 10 | * The call is signed as an `admin` the way the proxy signs a person | |
| 11 | * (`identityHeaders`, `apps/proxy.ts`), under the app's identity key, so an | |
| 12 | * adapter that admits only admins to `/pull` (examples/adapter-template) | |
| 13 | * admits the forge. Only the signature travels; the key never does. | |
| 10 | 14 | */ |
| 11 | 15 | |
| 12 | 16 | import type { Database } from "bun:sqlite"; |
| @@ -18,6 +22,7 @@ import { resolveOid } from "../git/merge.ts"; | ||
| 18 | 22 | import { repoDir } from "../git/repos.ts"; |
| 19 | 23 | import { parseAdapter, type AdapterSpec } from "./adapter.ts"; |
| 20 | 24 | import { readFileAt } from "./checkout.ts"; |
| 25 | import { identityHeaders, type GrantWho } from "./proxy.ts"; | |
| 21 | 26 | import { parsePullAnswer, PULL_BODY_MAX, recordPull, type PullAnswer } from "./pull.ts"; |
| 22 | 27 | import { appsRuntime } from "./runtime.ts"; |
| 23 | 28 | |
| @@ -73,12 +78,30 @@ export async function pullApp(db: Database, appId: string, reach: PullReach, now | ||
| 73 | 78 | } |
| 74 | 79 | } |
| 75 | 80 | |
| 76 | /** POST `/pull` on loopback: at most 64 KB read (one byte over, so the judge says so), 10 s. */ | |
| 77 | export async function postPull(port: number, waitMs: number = PULL_WAIT_MS): Promise<PullReply> { | |
| 81 | /** Who the forge says it is when it pulls: an admin of the app, named for itself. */ | |
| 82 | export const PULL_CALLER: GrantWho = { id: "forge", name: "forge", role: "admin" }; | |
| 83 | ||
| 84 | /** | |
| 85 | * The signed identity one scheduled pull carries: `PULL_CALLER`, good only | |
| 86 | * for as long as the forge waits for the answer. | |
| 87 | */ | |
| 88 | export function pullIdentity(dataDir: string, appId: string, now: number = Date.now()): Record<string, string> { | |
| 89 | return identityHeaders(dataDir, appId, { who: PULL_CALLER, expiresMs: now + PULL_WAIT_MS }); | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * POST `/pull` on loopback with `identity` (the signed headers, never a key): | |
| 94 | * at most 64 KB read (one byte over, so the judge says so), 10 s. | |
| 95 | */ | |
| 96 | export async function postPull( | |
| 97 | port: number, | |
| 98 | identity: Record<string, string> = {}, | |
| 99 | waitMs: number = PULL_WAIT_MS, | |
| 100 | ): Promise<PullReply> { | |
| 78 | 101 | try { |
| 79 | 102 | const res = await fetch(`http://127.0.0.1:${port}/pull`, { |
| 80 | 103 | method: "POST", |
| 81 | headers: { "content-type": "application/json", accept: "application/json" }, | |
| 104 | headers: { ...identity, "content-type": "application/json", accept: "application/json" }, | |
| 82 | 105 | body: "{}", |
| 83 | 106 | redirect: "manual", |
| 84 | 107 | signal: AbortSignal.timeout(waitMs), |
| @@ -139,7 +162,7 @@ export function runtimePullReach(app: App, record: AppRecord): PullReach { | ||
| 139 | 162 | return status?.state === "running" ? status.port : undefined; |
| 140 | 163 | }, |
| 141 | 164 | adapter: () => declaredAdapter(app, record), |
| 142 | post: (port) => postPull(port), | |
| 165 | post: (port) => postPull(port, pullIdentity(app.config.dataDir, record.id)), | |
| 143 | 166 | }; |
| 144 | 167 | } |
| 145 | 168 |
test/pull-schedule.test.ts +52 −1
| @@ -13,14 +13,21 @@ import { | ||
| 13 | 13 | isPullDue, |
| 14 | 14 | postPull, |
| 15 | 15 | pullApp, |
| 16 | pullIdentity, | |
| 16 | 17 | pullDue, |
| 17 | 18 | startPullSchedule, |
| 18 | 19 | type PullReach, |
| 19 | 20 | type PullReply, |
| 20 | 21 | } from "../src/apps/pull_schedule.ts"; |
| 22 | import { identityKey } from "../src/apps/proxy.ts"; | |
| 21 | 23 | import { listPullRuns } from "../src/core/runs.ts"; |
| 22 | 24 | import { migrationFiles } from "../src/core/schema.ts"; |
| 23 | 25 | import { migrate } from "../src/kernel/migrate.ts"; |
| 26 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 27 | import { tmpdir } from "node:os"; | |
| 28 | import { join } from "node:path"; | |
| 29 | import { makeHandler } from "../examples/adapter-template/src/server.ts"; | |
| 30 | import { openStore } from "../examples/adapter-template/src/store.ts"; | |
| 24 | 31 | |
| 25 | 32 | const EVERY = 900; // seconds |
| 26 | 33 | const good = JSON.stringify({ pulled: 4, skipped: 1, cursor: "c1", errors: 0 }); |
| @@ -171,7 +178,51 @@ describe("postPull", () => { | ||
| 171 | 178 | |
| 172 | 179 | test("an app that answers too slowly did not answer", async () => { |
| 173 | 180 | answer = () => new Response(new ReadableStream({ start() {} })); |
| 174 | expect(await postPull(port, 50)).toEqual({ error: "the app did not answer" }); | |
| 181 | expect(await postPull(port, {}, 50)).toEqual({ error: "the app did not answer" }); | |
| 182 | }); | |
| 183 | }); | |
| 184 | ||
| 185 | describe("a scheduled pull of the adapter template (#10082)", () => { | |
| 186 | const APP = "01TEMPLATEAPP0000000000000"; | |
| 187 | const dir = mkdtempSync(join(tmpdir(), "pull-template-")); | |
| 188 | const key = identityKey(dir, APP); | |
| 189 | const heard: Record<string, string>[] = []; | |
| 190 | const handle = makeHandler(openStore(join(dir, "app")), { | |
| 191 | key, | |
| 192 | read: async (cursor) => | |
| 193 | cursor === "c1" ? { records: [], cursor } : { records: [{ id: "a", body: "Ada" }], cursor: "c1" }, | |
| 194 | }); | |
| 195 | const server = Bun.serve({ | |
| 196 | port: 0, | |
| 197 | hostname: "127.0.0.1", | |
| 198 | fetch: (req) => { | |
| 199 | heard.push(Object.fromEntries(req.headers)); | |
| 200 | return handle(req); | |
| 201 | }, | |
| 202 | }); | |
| 203 | const port = server.port ?? 0; | |
| 204 | afterAll(() => { | |
| 205 | server.stop(true); | |
| 206 | rmSync(dir, { recursive: true, force: true }); | |
| 207 | }); | |
| 208 | const templateReach = (signed: boolean): PullReach => | |
| 209 | reach({ port: () => port, post: (p) => postPull(p, signed ? pullIdentity(dir, APP) : {}) }); | |
| 210 | ||
| 211 | test("signed as an admin, the template pulls and the run is recorded ok; the key is never sent", async () => { | |
| 212 | expect(await pullApp(db, APP, templateReach(true))).toBe(true); | |
| 213 | const [run] = listPullRuns(db, APP); | |
| 214 | expect(run).toMatchObject({ state: "ok" }); | |
| 215 | expect(run?.cursor).toBe("c1"); | |
| 216 | const sent = heard.at(-1) ?? {}; | |
| 217 | expect(sent["x-forge-identity"]).toMatch(/^forge\|forge\|admin\|\d+$/); | |
| 218 | expect(Object.values(sent).join("\n")).not.toContain(key); | |
| 219 | }); | |
| 220 | ||
| 221 | test("with the signature removed, the template refuses and the run is recorded failed", async () => { | |
| 222 | expect(await pullApp(db, APP, templateReach(false))).toBe(true); | |
| 223 | const [run] = listPullRuns(db, APP); | |
| 224 | expect(run).toMatchObject({ state: "failed", written: 0, reason: "status 401 is not 2xx" }); | |
| 225 | expect(heard.at(-1)?.["x-forge-identity"]).toBeUndefined(); | |
| 175 | 226 | }); |
| 176 | 227 | }); |
| 177 | 228 |