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

Merged opened this

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

Merged

scout/forge:scout/10082-the-forge-s-scheduled-pull-to-an-adapter → main 1 commit fork

  • Merged. scout/10082-the-forge-s-scheduled-pull-to-an-adapter was merged into main as 68aebb6.

Review

Sign in to review this change.
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:
3232 `x-forge-identity` and `x-forge-signature`, checked against
3333 `FORGE_APP_IDENTITY_KEY` (see "Who is there" in [`docs/APPS.md`](APPS.md)).
3434 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.
3740
3841 The template names its source hosts in `src/source.ts` and opens them with
3942 `egress`. Its `adapter` block may also name them under `source`, which the
src/apps/pull_schedule.ts +27 −4
@@ -7,6 +7,10 @@
77 * `recordPull` (`apps/pull.ts`). The request goes over the same loopback reach
88 * the scheduled tools use (`feature_tools.ts`): 64 KB, 10 s. An app that is
99 * 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.
1014 */
1115
1216 import type { Database } from "bun:sqlite";
@@ -18,6 +22,7 @@ import { resolveOid } from "../git/merge.ts";
1822 import { repoDir } from "../git/repos.ts";
1923 import { parseAdapter, type AdapterSpec } from "./adapter.ts";
2024 import { readFileAt } from "./checkout.ts";
25 import { identityHeaders, type GrantWho } from "./proxy.ts";
2126 import { parsePullAnswer, PULL_BODY_MAX, recordPull, type PullAnswer } from "./pull.ts";
2227 import { appsRuntime } from "./runtime.ts";
2328
@@ -73,12 +78,30 @@ export async function pullApp(db: Database, appId: string, reach: PullReach, now
7378 }
7479 }
7580
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> {
78101 try {
79102 const res = await fetch(`http://127.0.0.1:${port}/pull`, {
80103 method: "POST",
81 headers: { "content-type": "application/json", accept: "application/json" },
104 headers: { ...identity, "content-type": "application/json", accept: "application/json" },
82105 body: "{}",
83106 redirect: "manual",
84107 signal: AbortSignal.timeout(waitMs),
@@ -139,7 +162,7 @@ export function runtimePullReach(app: App, record: AppRecord): PullReach {
139162 return status?.state === "running" ? status.port : undefined;
140163 },
141164 adapter: () => declaredAdapter(app, record),
142 post: (port) => postPull(port),
165 post: (port) => postPull(port, pullIdentity(app.config.dataDir, record.id)),
143166 };
144167 }
145168
test/pull-schedule.test.ts +52 −1
@@ -13,14 +13,21 @@ import {
1313 isPullDue,
1414 postPull,
1515 pullApp,
16 pullIdentity,
1617 pullDue,
1718 startPullSchedule,
1819 type PullReach,
1920 type PullReply,
2021 } from "../src/apps/pull_schedule.ts";
22 import { identityKey } from "../src/apps/proxy.ts";
2123 import { listPullRuns } from "../src/core/runs.ts";
2224 import { migrationFiles } from "../src/core/schema.ts";
2325 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";
2431
2532 const EVERY = 900; // seconds
2633 const good = JSON.stringify({ pulled: 4, skipped: 1, cursor: "c1", errors: 0 });
@@ -171,7 +178,51 @@ describe("postPull", () => {
171178
172179 test("an app that answers too slowly did not answer", async () => {
173180 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();
175226 });
176227 });
177228

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs