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

An agent that makes a forge can own it: the claim link is the maker's, and a… #12116

Merged opened this in answer to

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

Merged

scout/forge:scout/12105-an-agent-that-makes-a-forge-can-own-it-t → main 27 commits fork

  • Merged. scout/12105-an-agent-that-makes-a-forge-can-own-it-t was merged into main as 10bfe3b.

Review

Sign in to review this change.
19 files changed +448 −15
AGENTS.md +1
@@ -28,6 +28,7 @@ State shared between modules through process memory, not the database, is named
2828
2929 `src/core/transfer.ts`: the old owner/name pairs a transfer left behind (`repo_redirect`), held per database after the first request reads them and dropped by `transferRepo`, so a request under a path nothing moved from costs no statement (#569).
3030
31 `src/spawn/funding.ts`: `askedAt`, when a run last asked the host for the lent credit (`refreshFundingOnce`, 15 s per data directory); gone on restart.
3132 `src/system/footing.ts`: `footing`, the last reading of what the forge stands on from the kube API (#11685), held 60 s so /system, its terminal screen (`src/tui/screens/footing.ts`, palette entry for admins) and the MCP `system` tool (`kind: footing`) share one read; a probe that throws is not retried for 60 s; one reading, gone on restart.
3233 `src/system/largest_dirs.ts`: `largestDirs`, the last measurement of the five largest directories under the forge's data (#11800), held 60 s so /system, its terminal screen (`src/tui/screens/system.ts`, `largestLines`) and the MCP `system` tool share one asynchronous walk; a walk that fails is not retried for 60 s; one reading, gone on restart.
3334 `src/system/db_size.ts`: `dbSize`, the last walk of the database file's pages (#12010: the ten largest tables and indexes), held an hour so /system and the MCP `system` tool (`kind: size`) share one asynchronous walk; a walk that fails is not retried for an hour; one reading, gone on restart. The file's bytes and its growth are read fresh each time; growth comes from `db_size_day`, one row a UTC day, 60 kept.
scripts/gate.ts +1
@@ -86,6 +86,7 @@ export const SLOW: readonly Exclusion[] = [
8686 { file: "test/crew-check-failed.test.ts", reason: "runs a stand-in agent through a failing check and its fix; 2.2 s a test on the gate box", issue: 1242 },
8787 { file: "test/crew-closed.test.ts", reason: "runs a stand-in agent follow-up on a pull that closes underneath it; 3.4 s a test on the gate box", issue: 1242 },
8888 { file: "test/crew-early.test.ts", reason: "runs the crew with a stand-in Claude Code process per case, each cloning and pushing; 8.9 s a test on the gate box", issue: 1242 },
89 { file: "test/crew-lent-refresh.test.ts", reason: "runs a stand-in agent end to end against a host that lends credit, to see one ask mend the copy; 6.4 s a test on the gate box", issue: 1242 },
8990 { file: "test/crew-hot-files.test.ts", reason: "builds pulls with git diffs wide enough to cross WIDE_PULL; 3.0 s a test on the gate box", issue: 1242 },
9091 { file: "test/crew-install.test.ts", reason: "runs bun install in a real clone before the stand-in agent starts; 6.4 s a test on the gate box", issue: 1242 },
9192 { file: "test/crew-mirror.test.ts", reason: "waits out a mirror clone that times out on purpose; 2.1 s a test on the gate box", issue: 1242 },
src/actions/child_credit.ts +3 −1
@@ -9,7 +9,7 @@
99 import type { App } from "../app.ts";
1010 import { childDirOf, listChildren } from "./spawn.ts";
1111 import { setCap, type Credit } from "../spawn/credit.ts";
12 import { fundingOf, keepFunding } from "../spawn/funding.ts";
12 import { fundingOf, keepFunding, syncFunding } from "../spawn/funding.ts";
1313 import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { requireUser } from "./access.ts";
@@ -34,6 +34,8 @@ export async function lendCredit(
3434 );
3535 if (set === undefined) return err("invalid_amount");
3636 if (set === "unreachable") return err("unreachable");
37 // The child reads its own copy of the cap: a raise must reach it now, not at its next refresh.
38 syncFunding(dir, set);
3739 app.bus.append({ kind: "forge.credit_set", actorId: user.value.id, payload: { name, usd: set.capUsd } });
3840 return ok(set);
3941 }
src/actions/spawn.ts +12 −3
@@ -67,7 +67,7 @@ import { quotaRefusal } from "../spawn/quota.ts";
6767 import { personRefusal, planRefusal } from "../spawn/sleep.ts";
6868 import { FREE, getPlan, PAID_PLAN_ADMIN_ONLY } from "../settings/plans.ts";
6969 import { setCap } from "../spawn/credit.ts";
70 import { fundingOf, keepFunding } from "../spawn/funding.ts";
70 import { fundingOf, keepFunding, syncFunding } from "../spawn/funding.ts";
7171 import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts";
7272 import { DEFAULT_SIZE, isSize, type Size } from "../spawn/sizes.ts";
7373 import { DEFAULT_MODE } from "../spawn/chain.ts";
@@ -411,7 +411,10 @@ export function grantPlanCredit(app: App, plan: SpawnPlan, bodyId: string): void
411411 true,
412412 );
413413 if (set === undefined || set === "unreachable") app.log.warn(`plan credit not lent to ${plan.name}`);
414 else app.bus.append({ kind: "forge.credit_set", payload: { name: plan.name, usd: set.capUsd, plan: chosen.key } });
414 else {
415 syncFunding(dir, set);
416 app.bus.append({ kind: "forge.credit_set", payload: { name: plan.name, usd: set.capUsd, plan: chosen.key } });
417 }
415418 }
416419
417420 /**
@@ -733,7 +736,13 @@ export async function reissueClaim(
733736 if (crossOrigin(deps.request, deps.url, app.log)) return err({ kind: "cross_origin" });
734737 const user = requireUser(actor);
735738 if (!user.ok) return err({ kind: "unauthorized" });
736 if (!user.value.isAdmin) return err({ kind: "forbidden" });
739 // The account that spawned the child may reveal its link again until it is
740 // claimed (#12105), agent or person; so may a site admin.
741 if (!user.value.isAdmin) {
742 const ledger = /^[a-z0-9-]{1,39}$/.test(name) ? await readLedger(app, name) : undefined;
743 if (ledger === undefined || ledger.ownerId === undefined || ledger.ownerId !== user.value.id)
744 return err({ kind: "forbidden" });
745 }
737746 const made = await reissueClaimFor(app, name);
738747 if (made.ok) app.bus.append({ kind: "forge.claim_reissued", actorId: user.value.id, payload: { name } });
739748 return made;
src/crew/runner.ts +11 −1
@@ -201,7 +201,7 @@ import {
201201 } from "./guard.ts";
202202 import { passportEnv } from "./passport_env.ts";
203203 import { hostOfApiBase, isGatewayAddress, ownEnv, sandboxTmpEnv } from "./runner_env.ts";
204 import { cappedByLent, fundedEnv, lentCreditLeft, runEnded } from "../spawn/funding.ts";
204 import { cappedByLent, fundedEnv, lentCreditLeft, refreshFundingOnce, runEnded } from "../spawn/funding.ts";
205205
206206 const log = createLog("crew");
207207
@@ -1405,8 +1405,18 @@ async function work(
14051405 picked.kind === "none"
14061406 ? grantLeft(app.db, repo.ownerId, new Date())
14071407 : undefined;
1408 // The host's included credit is a way past it too (#12105): a hosted forge
1409 // before it is claimed owns its apps through an org, which is no admin.
1410 // A copy that says there is too little may be stale (a raise the host made
1411 // while this forge was down): ask the host before refusing the run (at most once in 15 s,
1412 // whatever the queue).
1413 const heldLeft = lentCreditLeft(picked.kind === "none", app.config.dataDir);
1414 if (picked.kind === "none" && (heldLeft === undefined || heldLeft < MIN_RUN_USD)) await refreshFundingOnce(app);
1415 const lentFirst = lentCreditLeft(picked.kind === "none", app.config.dataDir);
1416 const lentOpen = lentFirst !== undefined && lentFirst >= MIN_RUN_USD;
14081417 if (
14091418 picked.kind === "none" &&
1419 !lentOpen &&
14101420 (grantCap === undefined || grantCap < MIN_RUN_USD)
14111421 ) {
14121422 const said =
src/http/route_marks.ts +1 −1
@@ -210,7 +210,7 @@ export const ROUTES: Readonly<Record<string, Mark>> = {
210210 "/forges/refused": "admin",
211211 "/forges/name/:name": "open",
212212 "/forges/:name/bill": "open",
213 "/forges/:name/claim-link": "admin",
213 "/forges/:name/claim-link": "open",
214214 "/forges/:name/revoke-link": "admin",
215215 "/forges/:name/undo-link": "admin",
216216 "/forges/:name/stop": "admin",
src/http/server.ts +2 −1
@@ -298,7 +298,7 @@ import { forgeName } from "../web/pages/forge-name.ts";
298298 import { claimFragmentPage, claimOptions, claimSubmit, welcomePage, welcomeSubmit } from "../web/pages/claim.ts";
299299 import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts";
300300 import { familyPage } from "../web/pages/family.ts";
301 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeArchiveConfirm, forgeArchiveSubmit, forgeRestoreSubmit, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit,
301 import { forgeLinkForm, forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeArchiveConfirm, forgeArchiveSubmit, forgeRestoreSubmit, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit,
302302 forgeOwnCodeSubmit, forgeSandboxSubmit, forgesPage } from "../web/pages/forges.ts";
303303 import { billDownload } from "./api/forge_bill.ts";
304304 import { forgesRefusedPage } from "../web/pages/forges-refused.ts";
@@ -909,6 +909,7 @@ export function buildRouter(): Router<Handler> {
909909 router.add("POST", "/forges/:name/restore", forgeRestoreSubmit);
910910 router.add("GET", "/family", familyPage);
911911 router.add("GET", "/forges/name/:name", forgeName);
912 router.add("GET", "/forges/:name/claim-link", forgeLinkForm);
912913 router.add("GET", "/forges/:name/bill", billDownload);
913914 router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit);
914915 router.add("POST", "/forges/:name/revoke-link", forgeLinkRevoke);
src/spawn/funding.ts +28
@@ -62,6 +62,15 @@ export function keepFunding(dataDir: string, funding: Funding): boolean {
6262 }
6363 }
6464
65 type Lent = { readonly capUsd: number; readonly spentUsd: number };
66
67 /** The parent's cap and spend go into the child's copy, its bearer kept.
68 * False when the child holds none. */
69 export function syncFunding(dataDir: string, credit: Lent): boolean {
70 const f = fundingOf(dataDir);
71 return f !== undefined && keepFunding(dataDir, { token: f.token, capUsd: credit.capUsd, spentUsd: credit.spentUsd });
72 }
73
6574 /** The environment that points a crew run at the parent's door; undefined unless funded and the door is reachable. */
6675 export function fundedEnv(dataDir: string): Record<string, string> | undefined {
6776 const local = process.env["FORGE_PARENT_LOCAL"] ?? "";
@@ -94,6 +103,25 @@ export async function refreshFunding(app: App, fetcher: FetchLike = fetch): Prom
94103 }
95104 }
96105
106 /** When each data directory's host was last asked by a run (`refreshFundingOnce`); gone on
107 * restart. */
108 const askedAt = new Map<string, number>();
109 /** The host is asked for the lent credit at most this often by the runs that start. */
110 export const ASK_HOST_EVERY_MS = 15_000;
111
112 /**
113 * `refreshFunding` for the run path: the first run to find the copy short asks, and the
114 * runs that start in the next 15 seconds (a queue of issues, a host that is down) go on
115 * with the copy they have. One ask is bounded by its own 3 s timeout.
116 */
117 export async function refreshFundingOnce(app: App, fetcher: FetchLike = fetch, now: number = Date.now()): Promise<Funding | undefined> {
118 const dir = app.config.dataDir;
119 const at = askedAt.get(dir);
120 if (at !== undefined && now - at < ASK_HOST_EVERY_MS) return fundingOf(dir);
121 askedAt.set(dir, now);
122 return refreshFunding(app, fetcher);
123 }
124
97125 /** The line the crew page and the API say: "funded by its parent: $199.50 of $200 left". */
98126 export function fundingWords(f: Funding): string {
99127 return f.capUsd === null || f.spentUsd === null
src/tui/screens/entries.ts +2
@@ -53,6 +53,7 @@ import { entry as forgeReady } from "./forgeready.ts";
5353 import { entry as hostReach } from "./hostreach.ts";
5454 import { entry as homePins } from "./home_pins.ts";
5555 import { entry as claimStart } from "./claim_start.ts";
56 import { entry as claimLink } from "./claim_link.ts";
5657 import { entry as invites } from "./invites.ts";
5758 import { entry as billingLinks } from "./billinglinks.ts";
5859 import { entry as planRow } from "./planrow.ts";
@@ -184,6 +185,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
184185 planRow,
185186 cores,
186187 claimStart,
188 claimLink,
187189 requests,
188190 bulk,
189191 subscriptions,
src/web/pages/forges.ts +25 −3
@@ -264,6 +264,27 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
264264 return response;
265265 }
266266
267 // GET /forges/:name/claim-link — the button for the account that sent the forge, who
268 // has no /forges list (#12105). Nothing is minted until it is pressed; whether this
269 // account may is the door's answer, not this page's.
270 export function forgeLinkForm(ctx: Ctx): Response {
271 if (ctx.auth.user === undefined) return notFoundPage(ctx);
272 const name = ctx.params["name"] ?? "";
273 const response = page({
274 ctx,
275 title: "A fresh link",
276 body: html`<div class="narrow-wide" data-forges>
277 ${pageHead(`A fresh link for ${name}`)}
278 <section class="settings-block">
279 <p>The link you have stops working; the new one is shown once.</p>
280 <form method="post" action="/forges/${name}/claim-link">${btn("Send a fresh link", { title: "A new claim link; the old one stops working" })}</form>
281 </section>
282 </div>`,
283 });
284 response.headers.set("cache-control", "no-store");
285 return response;
286 }
287
267288 /** One line each for what was archived: where the copy is, and the way back. */
268289 function archivedSection(ctx: Ctx, archived: readonly ArchiveRecord[]): Html | null {
269290 const said = ctx.url.searchParams.get("archive");
@@ -350,11 +371,12 @@ export async function forgeRestoreSubmit(ctx: Ctx): Promise<Response> {
350371 }
351372
352373 // POST /forges/:name/claim-link — a fresh link for an unclaimed child; the
353 // old one stops working. Shown once, in this answer only.
374 // old one stops working. Shown once, in this answer only. The account that sent the
375 // forge may ask, as an admin may (#12105): `reissueClaim` says which.
354376 export async function forgeLinkSubmit(ctx: Ctx): Promise<Response> {
355377 const user = ctx.auth.user;
356 if (!user?.isAdmin) return notFoundPage(ctx);
357 const form = await readForm(ctx, "admin");
378 if (user === undefined) return notFoundPage(ctx);
379 const form = await readForm(ctx, "rw");
358380 if (form instanceof Response) return form;
359381 const name = ctx.params["name"] ?? "";
360382 const door = await reissueDoor(ctx, user, name);
test/agent-owns-forge.test.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * An agent that makes a forge can own it (#12105): the account that spawned a forge
3 * reveals its claim link again until it is claimed, over the API, MCP, the page and the
4 * terminal, agent or person, with no admin. Anyone else may not; ending or restoring a
5 * link stays an admin's. The first crew run on the included credit is
6 * `crew-lent-refresh.test.ts`.
7 */
8
9 import { afterAll, beforeAll, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { childDirOf } from "../src/actions/spawn.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { Session } from "../src/tui/session.ts";
19 import { entry } from "../src/tui/screens/claim_link.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import "./helpers/roomy-disk.ts";
22
23 quietLogs();
24
25 let dir = "";
26 let parent: App;
27 let servers: Servers;
28 let spawner = "";
29 let other = "";
30 let admin = "";
31 let appRef = "";
32 let bodyId = "";
33 let first = "";
34 let id = 1;
35
36 const json = { "content-type": "application/json" };
37 const start = async (name: string) => {
38 const res = await fetch(`${servers.url}/api/v1/start`, { method: "POST", headers: json, body: JSON.stringify({ name, agent: "scout" }) });
39 expect(res.status).toBe(201);
40 return (await res.json()) as { token: string; repo: string };
41 };
42 const api = (token: string, path = "/api/v1/forges/mine/claim-link") =>
43 fetch(`${servers.url}${path}`, { method: "POST", headers: { authorization: `Bearer ${token}`, ...json } });
44 const mcp = async (token: string, args: Record<string, unknown>) => {
45 const res = await fetch(`${servers.url}/mcp`, {
46 method: "POST",
47 headers: { authorization: `Bearer ${token}`, ...json, accept: "application/json, text/event-stream" },
48 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "claim_link", arguments: { name: "mine", ...args } } }),
49 });
50 const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
51 return { isError: body.result.isError === true, text: body.result.content[0]!.text };
52 };
53 const page = (token: string, path: string, method: "GET" | "POST") =>
54 fetch(`${servers.url}${path}`, { method, redirect: "manual", headers: { authorization: `Bearer ${token}`, origin: "https://forge.test" } });
55
56 beforeAll(async () => {
57 dir = await mkdtemp(join(tmpdir(), "forge-agent-owns-"));
58 parent = createApp({ dataDir: join(dir, "parent"), url: "https://forge.test", port: 0 });
59 servers = await serve(parent);
60 const s = await start("stranger");
61 spawner = s.token;
62 appRef = s.repo;
63 other = (await start("other")).token;
64 const a = await createUser(parent.db, { origin: "seed", trusted: true, username: "boss", email: "[email protected]", password: "a-long-claim-passphrase", isAdmin: true });
65 if (!a.ok) throw new Error("admin");
66 admin = createToken(parent.db, a.value.id, "rw", "rw").token;
67 const made = await fetch(`${servers.url}/api/v1/forges`, { method: "POST", headers: { authorization: `Bearer ${spawner}`, ...json }, body: JSON.stringify({ name: "mine", apps: [appRef], dryRun: false }) });
68 expect(made.status).toBe(201);
69 const body = (await made.json()) as { claimUrl: string; bodyId: string };
70 first = body.claimUrl;
71 bodyId = body.bodyId;
72 }, 60_000);
73
74 afterAll(async () => {
75 servers?.stop();
76 parent?.close();
77 await rm(dir, { recursive: true, force: true });
78 });
79
80 test("the spawner reveals the link again over the API, and the old one stops", async () => {
81 const again = await api(spawner);
82 expect(again.status).toBe(201);
83 const second = ((await again.json()) as { claimUrl: string }).claimUrl;
84 expect(second).not.toBe(first);
85 });
86
87 test("MCP: the spawner reveals it, another account is refused", async () => {
88 const mine = await mcp(spawner, { reveal: true });
89 expect(mine.isError).toBe(false);
90 expect(mine.text).toContain("claimUrl");
91 const theirs = await mcp(other, { reveal: true });
92 expect(theirs.isError).toBe(true);
93 expect(theirs.text).not.toContain("claimUrl");
94 expect((await api(other)).status).toBe(403);
95 });
96
97 test("the page and the terminal are open to the spawner, and to nobody else", async () => {
98 const form = await page(spawner, "/forges/mine/claim-link", "GET");
99 expect(form.status).toBe(200);
100 const done = await page(spawner, "/forges/mine/claim-link", "POST");
101 expect(done.status).toBe(201);
102 expect(await done.text()).toContain("#");
103 expect((await page(other, "/forges/mine/claim-link", "POST")).status).toBe(403);
104 expect((await page("", "/forges/mine/claim-link", "POST")).status).not.toBe(201);
105 const asUser = (username: string) => {
106 const user = getUserByName(parent.db, username);
107 return new Session(parent, { sshUsername: username, remoteAddress: "127.0.0.1", user });
108 };
109 const person = asUser("boss");
110 const bot = asUser("stranger");
111 expect(entry.offered?.(person)).toBe(true);
112 expect(entry.offered?.(bot)).toBe(false);
113 });
114
115 test("ending or restoring the link stays an admin's: the spawner may not, on any door", async () => {
116 for (const action of ["revoke", "undo"]) {
117 const said = await mcp(spawner, { action });
118 expect(said.isError).toBe(true);
119 expect((await page(spawner, `/forges/mine/${action}-link`, "POST")).status).toBe(403);
120 }
121 const ended = await mcp(admin, { action: "revoke" });
122 expect(ended.isError).toBe(false);
123 expect((await mcp(admin, { action: "undo" })).isError).toBe(false);
124 });
125
126 test("once the forge is claimed the spawner cannot reveal a link", async () => {
127 const child = createApp({ dataDir: childDirOf(parent, bodyId) });
128 try {
129 const owner = await createUser(child.db, { origin: "seed", trusted: true, username: "mina", email: "[email protected]", password: "a-long-claim-passphrase", isAdmin: true });
130 expect(owner.ok).toBe(true);
131 } finally {
132 child.close();
133 }
134 const refused = await api(spawner);
135 expect(refused.status).toBeGreaterThanOrEqual(400);
136 expect(await refused.text()).not.toContain("claimUrl");
137 expect((await mcp(spawner, { reveal: true })).isError).toBe(true);
138 });
test/crew-lent-refresh.test.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * A hosted forge whose copy of the lent credit is stale asks its host before the crew
3 * refuses a run for want of a way to pay (#12105), and asks at most once in 15 s.
4 */
5
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { writeFileSync } from "node:fs";
8 import { join } from "node:path";
9 import type { App } from "../src/app.ts";
10 import type { Repo } from "../src/core/repos.ts";
11 import type { User } from "../src/core/users.ts";
12 import { assignIssue, openIssue } from "../src/actions/issues.ts";
13 import { fundingOf, keepFunding } from "../src/spawn/funding.ts";
14 import { promote } from "./helpers/members.ts";
15 import { agentRunSettled, startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
16
17 let standIn: StandInCrew;
18 let app: App;
19 let alice: User;
20 let bot: User;
21 let repo: Repo;
22 let host: ReturnType<typeof Bun.serve>;
23 let asked = 0;
24 const savedLocal = process.env["FORGE_PARENT_LOCAL"];
25 const savedKey = process.env["ANTHROPIC_API_KEY"];
26
27 beforeAll(async () => {
28 host = Bun.serve({
29 port: 0,
30 hostname: "127.0.0.1",
31 fetch: (req) => {
32 if (new URL(req.url).pathname === "/api/v1/credit") asked++;
33 return Response.json({ capUsd: 2, spentUsd: 0 });
34 },
35 });
36 standIn = await startStandInCrew();
37 ({ app, alice, bot, repo } = standIn);
38 writeFileSync(join(standIn.dataDir, "parent.json"), "{}");
39 delete process.env["ANTHROPIC_API_KEY"];
40 process.env["FORGE_PARENT_LOCAL"] = `http://127.0.0.1:${host.port}`;
41 // A hosted forge before it is claimed: its apps' owner is no admin.
42 app.db.query("UPDATE user SET is_admin = 0 WHERE id = ?1").run(alice.id);
43 promote(app.db, alice);
44 keepFunding(standIn.dataDir, { token: "child.abcdefghijkl", capUsd: 0, spentUsd: 0 });
45 }, 30_000);
46
47 afterAll(async () => {
48 if (savedLocal === undefined) delete process.env["FORGE_PARENT_LOCAL"];
49 else process.env["FORGE_PARENT_LOCAL"] = savedLocal;
50 if (savedKey !== undefined) process.env["ANTHROPIC_API_KEY"] = savedKey;
51 host.stop(true);
52 await stopStandInCrew(standIn);
53 });
54
55 async function run(title: string) {
56 const issue = openIssue(app, alice, repo, { title, body: "Once." });
57 if (!issue.ok) throw new Error(String(issue.error));
58 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
59 return agentRunSettled(app, repo, (r) => r.issueId === issue.value.id, 60_000);
60 }
61
62 test("a copy that says no credit is mended by one ask, and the run goes ahead on it", async () => {
63 const first = await run("Prune the roses");
64 expect(asked).toBeGreaterThanOrEqual(1);
65 expect(fundingOf(standIn.dataDir)?.capUsd).toBe(2);
66 expect(first.summary ?? "").not.toContain("no model key");
67 expect(first.state).toBe("ok");
68 }, 90_000);
69
70 test("the next run within 15 s does not ask again (the host would lend; the copy stands): the stale copy refuses it in the add-a-key words", async () => {
71 keepFunding(standIn.dataDir, { token: "child.abcdefghijkl", capUsd: 0, spentUsd: 0 });
72 // The host would say 2 again; it is not asked, so the copy stands and refuses.
73 const second = await run("Water the ferns");
74 expect(second.state).toBe("failed");
75 expect(second.summary).toContain("no model key");
76 }, 90_000);
test/funding-sync.test.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * A lent credit reaches the child's own copy at once, and a run asks the host about it at most
3 * every 15 s (#12105).
4 */
5
6 import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
7 import { mkdtempSync, writeFileSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import type { App } from "../src/app.ts";
11
12 import { ASK_HOST_EVERY_MS, fundingOf, keepFunding, refreshFunding, refreshFundingOnce, syncFunding } from "../src/spawn/funding.ts";
13 import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
14
15 let standIn: StandInCrew;
16 let app: App;
17
18 beforeAll(async () => {
19 standIn = await startStandInCrew();
20 ({ app } = standIn);
21 }, 60_000);
22
23 afterAll(() => stopStandInCrew(standIn));
24
25 // A failed expect must not leave the parent's address in the next file's environment.
26 const savedLocal = process.env["FORGE_PARENT_LOCAL"];
27 afterEach(() => {
28 if (savedLocal === undefined) delete process.env["FORGE_PARENT_LOCAL"];
29 else process.env["FORGE_PARENT_LOCAL"] = savedLocal;
30 });
31
32 test("syncFunding moves the child's own cap and keeps its bearer", () => {
33 const dir = mkdtempSync(join(tmpdir(), "fund-"));
34 writeFileSync(join(dir, "parent.json"), JSON.stringify({ x: 1 }));
35 keepFunding(dir, { token: "child.abcdefghijkl", capUsd: 0, spentUsd: 0 });
36 expect(syncFunding(dir, { capUsd: 2, spentUsd: 0 })).toBe(true);
37 expect(fundingOf(dir)).toEqual({ token: "child.abcdefghijkl", capUsd: 2, spentUsd: 0 });
38 expect(syncFunding(mkdtempSync(join(tmpdir(), "fund-")), { capUsd: 2, spentUsd: 0 })).toBe(false);
39 });
40
41 test("a child's copy with no cap is repaired by asking the host, and a run then has credit", async () => {
42 const dir = app.config.dataDir;
43 writeFileSync(join(dir, "parent.json"), JSON.stringify({}));
44 keepFunding(dir, { token: "child.abcdefghijkl", capUsd: null, spentUsd: null });
45 expect(fundingOf(dir)?.capUsd).toBeNull();
46 process.env["FORGE_PARENT_LOCAL"] = "http://127.0.0.1:9";
47 const f = await refreshFunding(app, async () => new Response(JSON.stringify({ capUsd: 2, spentUsd: 0 })));
48 expect(f).toEqual({ token: "child.abcdefghijkl", capUsd: 2, spentUsd: 0 });
49 expect(fundingOf(dir)?.capUsd).toBe(2);
50 });
51
52 test("a run asks the host once, then not again within 15 s, even with a queue behind it", async () => {
53 const dir = app.config.dataDir;
54 writeFileSync(join(dir, "parent.json"), JSON.stringify({}));
55 keepFunding(dir, { token: "child.abcdefghijkl", capUsd: 0, spentUsd: 0 });
56 process.env["FORGE_PARENT_LOCAL"] = "http://127.0.0.1:9";
57 let asked = 0;
58 const host = async () => {
59 asked++;
60 return new Response(JSON.stringify({ capUsd: 2, spentUsd: 0 }));
61 };
62 const t = 1_000_000_000_000;
63 const [a, b, c] = await Promise.all([refreshFundingOnce(app, host, t), refreshFundingOnce(app, host, t + 1), refreshFundingOnce(app, host, t + 2)]);
64 expect(asked).toBe(1);
65 expect(a?.capUsd).toBe(2);
66 expect(b?.capUsd).toBeDefined();
67 expect(c).toBeDefined();
68 await refreshFundingOnce(app, host, t + ASK_HOST_EVERY_MS - 1);
69 expect(asked).toBe(1);
70 await refreshFundingOnce(app, host, t + ASK_HOST_EVERY_MS + 1);
71 expect(asked).toBe(2);
72 });
test/openapi-drift.test.ts +1 −1
@@ -52,7 +52,7 @@ const PINNED: Readonly<Record<string, "anonymous" | "member" | "admin">> = {
5252 "GET /api/v1/crew/scorecard": "admin",
5353 "GET /api/v1/forges": "admin",
5454 "PUT /api/v1/forges/{name}/credit": "admin",
55 "POST /api/v1/forges/{name}/claim-link": "admin",
55 "POST /api/v1/forges/{name}/claim-link": "member",
5656 "POST /api/v1/drills": "admin",
5757 "PUT /api/v1/forge/economics": "admin",
5858 "GET /metrics": "admin",
test/palette-roster.test.ts +2
@@ -173,6 +173,7 @@ test("a site-admin person is offered the whole roster", () => {
173173 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
174174 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
175175 ["crewkey", "Crew key", "what the crew runs on; add your own model key"],
176 ["claim-link", "A fresh claim link for a forge I sent", "the old link stops; shown once"],
176177 ["money", "Money", "this month's cost, revenue and gap, and the last 30 days"],
177178 ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"],
178179 ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"],
@@ -370,6 +371,7 @@ test("a member person is offered the signed-in roster without the admin entries"
370371 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
371372 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
372373 ["claim-start", "Take an agent's work", "claim the repositories an agent started, from its one-time link"],
374 ["claim-link", "A fresh claim link for a forge I sent", "the old link stops; shown once"],
373375 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
374376 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
375377 ["origins", "Connected accounts", "the hosts your mirrors come from"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs