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

A parent funds a child forge's crew: a $ cap per child on the parent's own credential, metered, shown on both forges #10849

Merged opened this in answer to

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

Merged

torque/forge:torque/10484-a-parent-funds-a-child-forge-s-crew-a-ca → main 4 commits fork 4 threads · 4 unresolved

  • Merged. torque/10484-a-parent-funds-a-child-forge-s-crew-a-ca was merged into main as 5ee5b5b.

Review

Sign in to review this change.
27 files changed +1215 −17
docs/features/child-credit.md added +54
@@ -0,0 +1,54 @@
1 # A parent funds a child's crew (#10484)
2
3 A parent lends a child forge dollars of its own model credential. The child's
4 crew calls the model through the parent; the child never holds the credential.
5
6 ## Use
7
8 - Set the cap (a site admin, `rw` token or session from the forge's own origin):
9 `PUT /api/v1/forges/scottishbob/credit` with `{"usd":200}`. The first call mints the
10 child's bearer and writes it into the child's `parent.json` (`credit.token`, mode 0600, like the
11 proposal token); later calls only move the cap and keep what was spent.
12 - In the terminal (a site admin, same checks as the PUT, through `lendCredit`): the palette's
13 **Lend a child credit** (`ctrl+k`) picks a forge you sent, then asks for the dollars (`200`, `12.50`).
14 The forge list there shows "$3.20 of $200 lent" on the child's line; on the child, **Crew agents**
15 carries the "funded by its parent: …" line.
16 - The parent's `/forges` row, the terminal's forge list and `GET /api/v1/forges` show
17 `creditCapUsd` / `creditSpentUsd` ("$3.20 of $200 lent").
18 - The child's crew page says, in the readiness line, "…is ready to run the crew, funded by its parent: $196.80 of $200 left."; `GET /api/v1/credit`
19 on the child answers the same (it asks the parent afresh; the page shows what the last
20 heartbeat or ask kept).
21
22 ## How it works
23
24 - `src/spawn/model_door.ts`: on the beat door (the loopback port a cell relays in,
25 `FORGE_PARENT_LOCAL`) `POST /v1/messages` checks the bearer, refuses at the cap, forwards the call
26 on the parent's own credential (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` or
27 `CLAUDE_CODE_OAUTH_TOKEN`, to `ANTHROPIC_BASE_URL` or api.anthropic.com), and counts the usage
28 (JSON or streamed) at list price (`src/spawn/list_price.ts`).
29 - `src/spawn/credit.ts`: `<data>/credit/<child>.json`, the cap, the spend and the bearer's hash.
30 - `src/spawn/funding.ts`: the child's side. A run whose crew has no account granted gets
31 `ANTHROPIC_BASE_URL=<parent door>` and the bearer (`credentialEnv`); an account the child granted
32 itself still wins.
33 - At the cap: "scottishbob's $200 from its parent is spent; the parent's admin can raise it."
34 (HTTP 402, in Anthropic's error shape, so the run says it).
35
36 ## Assumptions, stated
37
38 - List price is Anthropic's published per-million-token rates by model family (Opus 15/75, Sonnet 3/15,
39 Haiku 1/5; cache write 1.25x, cache read 0.1x); an unknown model is priced as Opus.
40 - A call under way when the cap is reached finishes and is counted. At most 4 calls per child run at
41 once (a fifth is answered 429 at once), so the spend can pass the cap by no more than 4 calls cost.
42 - A request body is cut by the bytes that arrive, never by Content-Length; the bearer is checked
43 before a byte is read (413 past 16 MB). A heartbeat must carry its signature and is read to 1 KB
44 only. Bun holds one body limit per listener, so the listener's own limit is the model route's 16 MB.
45 - Bearers that do not check out are counted; past 30 a minute they are answered 429 (a good
46 bearer is never refused for them).
47 - The hand-off comes first: the bearer is written into the child's `parent.json` before the parent
48 saves anything, so a failed hand-off (502) leaves no credit and a retry mints again. A call for a
49 child that holds no bearer also mints again, keeping its spend.
50 - The audit event `forge.credit_set` is an admins' event; readers are not told the dollars.
51 - No new egress: `children.modelEgress` is untouched; the door only reaches the one host the parent's
52 own credential names.
53 - Not done: a spawn-time cap field (the PUT is the one door), and a funded hosted (in-process) child,
54 which has no door to call.
src/actions/child_credit.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * An admin sets the dollars a child may spend on this forge's model
3 * credential (#10484). The first time mints the child's bearer and hands it
4 * to the child's `parent.json` and only then saves it (a failed hand-off
5 * leaves nothing, so a retry mints again); later ones only move the cap. An
6 * event on the outbox, so the audit has it.
7 */
8
9 import type { App } from "../app.ts";
10 import { childDirOf, listChildren } from "./spawn.ts";
11 import { setCap, type Credit } from "../spawn/credit.ts";
12 import { fundingOf, keepFunding } from "../spawn/funding.ts";
13 import type { User } from "../core/users.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
15 import { requireUser } from "./access.ts";
16
17 export type CreditError = "unauthorized" | "forbidden" | "not_found" | "invalid_amount" | "unreachable";
18
19 export async function lendCredit(
20 app: App,
21 actor: User | undefined,
22 name: string,
23 usd: unknown,
24 ): Promise<Result<Credit, CreditError>> {
25 const user = requireUser(actor);
26 if (!user.ok) return err("unauthorized");
27 if (!user.value.isAdmin) return err("forbidden");
28 const child = (await listChildren(app)).find((c) => c.name === name && c.done);
29 if (child === undefined) return err("not_found");
30 if (typeof usd !== "number") return err("invalid_amount");
31 const dir = childDirOf(app, child.bodyId);
32 const set = setCap(app, name, usd, fundingOf(dir) !== undefined, (token, credit) =>
outdated

If this fails after setCap, the credit and token hash are already saved and the bearer is gone for good; retries return no token. Persist only after the hand-off, or re-mint.

33 keepFunding(dir, { token, capUsd: credit.capUsd, spentUsd: credit.spentUsd }),
34 );
35 if (set === undefined) return err("invalid_amount");
36 if (set === "unreachable") return err("unreachable");
37 app.bus.append({ kind: "forge.credit_set", actorId: user.value.id, payload: { name, usd: set.capUsd } });
38 return ok(set);
39 }
src/contract/openapi/routes/getApiV1Credit.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/credit",
6 summary:
7 "On a forge its parent funds: the cap and the spend of the credit it was lent, asked of the parent afresh; answers { capUsd, spentUsd, says } (\"funded by its parent: $X of $200 left\"). 404 when no parent funds this forge",
8 auth: true,
9 refusals: { not_found: "no parent funds this forge" },
10 };
src/contract/openapi/routes/index.ts +2
@@ -69,6 +69,7 @@ export { route as getApiV1Contribute } from "./getApiV1Contribute.ts";
6969 export { route as getApiV1ConversationFilesId } from "./getApiV1ConversationFilesId.ts";
7070 export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts";
7171 export { route as getApiV1Conversations } from "./getApiV1Conversations.ts";
72 export { route as getApiV1Credit } from "./getApiV1Credit.ts";
7273 export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts";
7374 export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts";
7475 export { route as getApiV1CrewWeek } from "./getApiV1CrewWeek.ts";
@@ -368,6 +369,7 @@ export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts";
368369 export { route as putApiV1BoardsSlug } from "./putApiV1BoardsSlug.ts";
369370 export { route as putApiV1FeaturesSlug } from "./putApiV1FeaturesSlug.ts";
370371 export { route as putApiV1ForgeEconomics } from "./putApiV1ForgeEconomics.ts";
372 export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts";
371373 export { route as putApiV1Front } from "./putApiV1Front.ts";
372374 export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts";
373375 export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.ts";
src/contract/openapi/routes/putApiV1ForgesNameCredit.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "put",
5 path: "/api/v1/forges/{name}/credit",
6 summary:
7 "Lend a child forge dollars of this forge's own model credential (a site admin's, rw scope). Body { usd } above 0 and up to 100000; answers { name, capUsd, spentUsd, says }. The child's crew calls the model through this forge, never holding the credential; every call is counted at list price and refused once the cap is spent. Raising the cap keeps what was spent. The list GET /api/v1/forges shows creditCapUsd and creditSpentUsd per child",
8 auth: true,
9 refusals: { not_found: "no child forge has this name", forbidden: "the caller is not a site admin", invalid: "usd is not dollars in range" },
10 };
src/core/event_audience.ts +3 −1
@@ -250,7 +250,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
250250 kind === "repo.file_held" ||
251251 kind === "repo.file_released" ||
252252 // Which repositories New app offers is the admins' choice (`actions/app_templates.ts`).
253 kind.startsWith("app_template.")
253 kind.startsWith("app_template.") ||
254 // What a parent lends a child's crew, by name and dollar, is the admins' business (`actions/child_credit.ts`, #10484).
255 kind === "forge.credit_set"
254256 )
255257 return "admins";
256258 // A broken adapter sync is for those who may write the repository (`apps/pull.ts`).
src/crew/conversation.ts +1 −1
@@ -513,7 +513,7 @@ function begin(
513513 budgetUsd: budget,
514514 resume: session,
515515 env: {
516 ...credentialEnv(picked),
516 ...credentialEnv(picked, app.config.dataDir),
517517 CLAUDE_CONFIG_DIR: configDir,
518518 FORGE_MCP_TOKEN: minted.value.token,
519519 },
src/crew/one_shot.ts +1 −1
@@ -49,7 +49,7 @@ export async function oneShot(app: App, userId: string, system: string, prompt:
4949 appendSystemPrompt: system,
5050 model: model ?? options.model,
5151 budgetUsd: budget,
52 env: { ...credentialEnv(picked), CLAUDE_CONFIG_DIR: sessionsDir(app.config.dataDir, options.configDir) },
52 env: { ...credentialEnv(picked, app.config.dataDir), CLAUDE_CONFIG_DIR: sessionsDir(app.config.dataDir, options.configDir) },
5353 sandbox: sandboxPolicy({ dataDir: app.config.dataDir, agentName: "one-shot", urls: [], enforced: false, runId }),
5454 label: "one-shot",
5555 timeoutMs: ONE_SHOT_MS,
src/crew/runner.ts +6 −2
@@ -197,6 +197,7 @@ import {
197197 skipsFeedback,
198198 } from "./guard.ts";
199199 import { passportEnv } from "./passport_env.ts";
200 import { fundedEnv } from "../spawn/funding.ts";
200201
201202 const log = createLog("crew");
202203
@@ -1408,7 +1409,7 @@ async function work(
14081409 FORGE_URL: options.forgeUrl,
14091410 FORGE_TOKEN: token.token,
14101411 ...passportEnv(app.config.dataDir),
1411 ...credentialEnv(picked),
1412 ...credentialEnv(picked, app.config.dataDir),
14121413 ...grant.env,
14131414 ...machineEnv(machine),
14141415 ...(options.configDir === undefined
@@ -2340,8 +2341,11 @@ function redactResult<T extends { result: string }>(r: T): T {
23402341 * the account has one), or — with nothing granted — whatever the forge
23412342 * itself was started with.
23422343 */
2343 export function credentialEnv(picked: Pick): Record<string, string> {
2344 export function credentialEnv(picked: Pick, dataDir?: string): Record<string, string> {
23442345 if (picked.kind === "ready") return credentialEnvOf(picked);
2346 // A forge its parent funds (#10484): with no account of its own, the crew calls the model through the parent.
2347 const lent = picked.kind === "env" && dataDir !== undefined ? fundedEnv(dataDir) : undefined;
2348 if (lent !== undefined) return lent;
23452349 const own: Record<string, string> = {};
23462350 for (const name of [
23472351 "ANTHROPIC_API_KEY",
src/http/api/forges.ts +29
@@ -4,7 +4,10 @@
44 * link. Moved out of `api.ts` (#6331); the rules live in `actions/spawn.ts` and `spawn/`.
55 */
66
7 import { lendCredit } from "../../actions/child_credit.ts";
78 import { listChildren } from "../../actions/spawn.ts";
9 import { usdWords } from "../../spawn/credit.ts";
10 import { fundingOf, fundingWords, refreshFunding } from "../../spawn/funding.ts";
811 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
912 import { clientRows } from "../../spawn/clients.ts";
1013 import { reissueDoor, spawnDoor } from "../../spawn/door.ts";
@@ -71,6 +74,32 @@ export function forgeRoutes(router: Router<Handler>): void {
7174 return json(door.body, door.status);
7275 });
7376
77 // The dollars this forge lends a child on its own model credential (#10484): a site admin sets the cap.
78 router.add("PUT", "/api/v1/forges/:name/credit", async (ctx) => {
79 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
80 const denied = requireScope(ctx, "rw");
81 if (denied) return denied;
82 const input = await body(ctx.request);
83 const name = ctx.params["name"] ?? "";
84 const set = await lendCredit(ctx.app, ctx.auth.user, name, input["usd"]);
85 if (set.ok)
86 return json({ name, capUsd: set.value.capUsd, spentUsd: set.value.spentUsd, says: `${name} may spend ${usdWords(set.value.capUsd)} of this forge's credit.` });
87 if (set.error === "unauthorized" || set.error === "forbidden") return forbidden();
88 if (set.error === "not_found") return apiError("not_found", 404, "no such forge");
89 if (set.error === "unreachable") return apiError("unreachable", 502, "the child's data could not be reached");
90 return apiError("invalid", 422, "usd is dollars above 0 and up to 100000");
91 });
92
93 // On a forge its parent funds: how much is left, as the parent last said (asked afresh first).
94 router.add("GET", "/api/v1/credit", async (ctx) => {
95 const denied = requireScope(ctx, "ro");
96 if (denied) return denied;
97 if (ctx.auth.user === undefined) return forbidden();
98 const f = fundingOf(ctx.app.config.dataDir) === undefined ? undefined : await refreshFunding(ctx.app);
99 if (f === undefined) return apiError("not_found", 404, "no parent funds this forge");
100 return json({ capUsd: f.capUsd, spentUsd: f.spentUsd, says: fundingWords(f) });
101 });
102
74103 // A new claim link for a child nobody has claimed (#729); the old one dies.
75104 router.add("POST", "/api/v1/forges/:name/claim-link", async (ctx) => {
76105 const denied = requireScope(ctx, "rw");
src/spawn/beat-door.ts +17 −7
@@ -1,13 +1,16 @@
11 /**
22 * The one door a cell child has to its parent: a loopback listener that
3 * answers `POST /api/v1/heartbeat` and nothing else. The child's cell relays
3 * answers `POST /api/v1/heartbeat`, and the lent model endpoint
4 * (`spawn/model_door.ts`: `POST /v1/messages`, `GET /api/v1/credit`), and nothing else. The child's cell relays
45 * only this port in, so untrusted child code never reaches the parent's web
56 * surface, and since the door never asks who the client is, no forwarding
67 * header it sends counts for anything.
78 */
89
910 import type { App } from "../app.ts";
11 import { readCapped } from "./bounded_read.ts";
1012 import { receiveBeat, SIGNATURE_HEADER } from "./heartbeat.ts";
13 import { creditAnswer, lendModel, MODEL_BODY_MAX, type ModelDeps } from "./model_door.ts";
1114
1215 const MAX_BODY = 1024;
1316
@@ -22,17 +25,24 @@ export interface BeatDoor {
2225 close(): void;
2326 }
2427
25 export function openBeatDoor(app: App): BeatDoor {
28 export function openBeatDoor(app: App, deps: { readonly model?: ModelDeps } = {}): BeatDoor {
2629 const server = Bun.serve({
2730 port: 0,
2831 hostname: "127.0.0.1",
outdated

16 MB for every route including the unauthenticated heartbeat, which needs 1 KB. Keep the door small and cap each route by bytes read.

29 maxRequestBodySize: MAX_BODY,
32 // Bun holds one limit per listener, and the model route needs the larger; each route counts the bytes it reads (`readCapped`).
33 maxRequestBodySize: MODEL_BODY_MAX,
3034 async fetch(req) {
3135 const refused = new Response("no such door", { status: 404 });
32 if (req.method !== "POST" || new URL(req.url).pathname !== "/api/v1/heartbeat") return refused;
33 const raw = await req.text();
34 if (raw.length > MAX_BODY) return refused;
35 const kept = await receiveBeat(app, raw, req.headers.get(SIGNATURE_HEADER));
36 const path = new URL(req.url).pathname;
37 if (req.method === "POST" && path === "/v1/messages") return lendModel(app, req, deps.model);
38 if (req.method === "GET" && path === "/api/v1/credit") return creditAnswer(app, req);
outdated

Content-Length is client-written; it is not a cap. Count the bytes actually read.

39 if (req.method !== "POST" || path !== "/api/v1/heartbeat") return refused;
40 // A beat is signed and small: no signature, no read; then at most MAX_BODY bytes, counted as they arrive.
41 const signature = req.headers.get(SIGNATURE_HEADER);
42 if (signature === null) return refused;
43 const bytes = await readCapped(req, MAX_BODY);
44 if (bytes === undefined) return refused;
45 const kept = await receiveBeat(app, new TextDecoder().decode(bytes), signature);
3646 return kept ? Response.json({ ok: true }) : refused;
3747 },
3848 });
src/spawn/bounded_read.ts added +28
@@ -0,0 +1,28 @@
1 /**
2 * A request body read by the bytes that actually arrive. A Content-Length
3 * is the client's claim and a chunked body makes none, so the stream is
4 * counted and the read stops one chunk past the limit: nothing past `max`
5 * is ever held. Undefined past the limit or when the client goes away.
6 */
7
8 export async function readCapped(req: Request, max: number): Promise<Uint8Array | undefined> {
9 if (req.body === null) return new Uint8Array();
10 const reader = req.body.getReader();
11 const chunks: Uint8Array[] = [];
12 let size = 0;
13 try {
14 for (;;) {
15 const { done, value } = await reader.read();
16 if (done) break;
17 size += value.byteLength;
18 if (size > max) {
19 await reader.cancel().catch(() => undefined);
20 return undefined;
21 }
22 chunks.push(value);
23 }
24 } catch {
25 return undefined;
26 }
27 return Buffer.concat(chunks);
28 }
src/spawn/clients.ts +10
@@ -13,6 +13,7 @@ import type { App } from "../app.ts";
1313 import { childDirOf, listChildren, statusTokenOf, type Child } from "../actions/spawn.ts";
1414 import { timingSafeEqual } from "node:crypto";
1515 import { sha256 } from "../kernel/hash.ts";
16 import { creditView, usdWords } from "./credit.ts";
1617 import type { ChildAppsSummary } from "./child-apps.ts";
1718 import { childAwake, hostedChildApps } from "./host.ts";
1819 import { hasAdmin, linkOf, spawnedOrg, type LinkInfo, type LinkState } from "./claim.ts";
@@ -54,6 +55,9 @@ export interface ClientRow {
5455 readonly spendTodayUsd: number | null;
5556 /** The child's forge cap in dollars; null when none or unsaid. */
5657 readonly ceilingUsd: number | null;
58 /** The dollars this forge lends it on its own model credential, and what it has spent; null when none is lent. */
59 readonly creditCapUsd?: number | null;
60 readonly creditSpentUsd?: number | null;
5761 /** Its size (`spawn/sizes.ts`). */
5862 readonly size: Size;
5963 /** What it uses against that size: its cell's reading, or "shared" when it is hosted here. */
@@ -231,6 +235,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
231235 reach: reachOf(app, c.name) ?? null,
232236 spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null,
233237 ceilingUsd: beats[i]?.beat.ceilingUsd ?? null,
238 ...creditView(app, c.name),
234239 size: c.size,
235240 use: forgeUse(c, childDirOf(app, c.bodyId)),
236241 lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(),
@@ -310,6 +315,11 @@ export function runningWords(r: ClientRow): string | null {
310315
311316 const money = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(2));
312317
318 /** "$3.20 of $200 lent"; null when this forge lends the child nothing. */
319 export function creditWords(r: Pick<ClientRow, "creditCapUsd" | "creditSpentUsd">): string | null {
320 return r.creditCapUsd === null || r.creditCapUsd === undefined ? null : `${usdWords(r.creditSpentUsd ?? 0)} of ${usdWords(r.creditCapUsd)} lent`;
321 }
322
313323 /**
314324 * "$3.20 of $10 today", with "ceiling reached" at the cap; `warn` from 80%.
315325 * Null when the child has not reported spend.
src/spawn/credit.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * The parent's credit to one child: a dollar cap an admin sets, the spend
3 * counted against it, and the bearer the child's crew presents to the model
4 * door (`spawn/model_door.ts`). One small file per child under
5 * `<data>/credit/<name>.json`, beside the ledger and the beats; only the
6 * bearer's hash is kept here, the bearer itself lives in the child's
7 * `parent.json`. Reads and writes are synchronous, so a check and the spend
8 * it follows cannot interleave in this one process.
9 */
10
11 import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
12 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13 import { join } from "node:path";
14 import type { App } from "../app.ts";
15 import { isChildName } from "../core/proposal_token.ts";
16
17 export interface Credit {
18 readonly name: string;
19 readonly capUsd: number;
20 readonly spentUsd: number;
21 readonly tokenHash: string;
22 }
23
24 export const CREDIT_MAX_USD = 100_000;
25
26 const sha = (s: string): string => createHash("sha256").update(s).digest("hex");
27 const path = (app: App, name: string): string => join(app.config.dataDir, "credit", `${name}.json`);
28 const cents = (n: number): number => Math.round(n * 100) / 100;
29
30 export function readCredit(app: App, name: string): Credit | undefined {
31 if (!isChildName(name)) return undefined;
32 try {
33 const d = JSON.parse(readFileSync(path(app, name), "utf8")) as Record<string, unknown>;
34 const { capUsd, spentUsd, tokenHash } = d;
35 if (typeof capUsd !== "number" || typeof spentUsd !== "number" || typeof tokenHash !== "string") return undefined;
36 return { name, capUsd, spentUsd, tokenHash };
37 } catch {
38 return undefined;
39 }
40 }
41
42 function keep(app: App, c: Credit): void {
43 mkdirSync(join(app.config.dataDir, "credit"), { recursive: true });
44 const p = path(app, c.name);
45 writeFileSync(`${p}.tmp`, JSON.stringify(c), { mode: 0o600 });
46 renameSync(`${p}.tmp`, p);
47 }
48
49 /**
50 * Set a child's cap. When the child holds no bearer (the first time, or its
51 * copy is lost) one is minted and `handOff` must put it in the child's hands
52 * before anything is saved: if it cannot, nothing is, and the next call mints
53 * again. The spend already counted is never reset. Undefined for a sum that
54 * is not dollars; "unreachable" when the hand-off failed.
55 */
56 export function setCap(
57 app: App,
58 name: string,
59 usd: number,
60 childHolds: boolean,
61 handOff: (token: string, credit: Credit) => boolean,
62 ): Credit | "unreachable" | undefined {
63 if (!isChildName(name) || !(Number.isFinite(usd) && usd > 0 && usd <= CREDIT_MAX_USD)) return undefined;
64 const before = readCredit(app, name);
65 if (before !== undefined && childHolds) {
66 const credit = { ...before, capUsd: cents(usd) };
67 keep(app, credit);
68 return credit;
69 }
70 const token = `${name}.${randomBytes(24).toString("base64url")}`;
71 const credit = { name, capUsd: cents(usd), spentUsd: before?.spentUsd ?? 0, tokenHash: sha(token) };
72 if (!handOff(token, credit)) return "unreachable";
73 keep(app, credit);
74 return credit;
75 }
76
77 /** The child a bearer was minted for, or undefined. The name rides in the bearer; the hash decides. */
78 export function creditOfToken(app: App, token: string): Credit | undefined {
79 const name = token.split(".")[0] ?? "";
80 const c = readCredit(app, name);
81 if (c === undefined) return undefined;
82 const a = Buffer.from(sha(token));
83 const b = Buffer.from(c.tokenHash);
84 return a.length === b.length && timingSafeEqual(a, b) ? c : undefined;
85 }
86
87 /** Count dollars spent against a child's cap. */
88 export function addSpend(app: App, name: string, usd: number): void {
89 const c = readCredit(app, name);
90 if (c === undefined || !(usd > 0) || !Number.isFinite(usd)) return;
91 keep(app, { ...c, spentUsd: Math.round((c.spentUsd + usd) * 1e6) / 1e6 });
92 }
93
94 export const leftUsd = (c: Pick<Credit, "capUsd" | "spentUsd">): number => Math.max(0, cents(c.capUsd - c.spentUsd));
95
96 /** Whole dollars read plainly, others to the cent. */
97 export const usdWords = (n: number): string => `$${Number.isInteger(n) ? n : n.toFixed(2)}`;
98
99 /** The one sentence a call past the cap is refused with. */
100 export const spentWords = (name: string, capUsd: number): string =>
101 `${name}'s ${usdWords(capUsd)} from its parent is spent; the parent's admin can raise it.`;
102
103 /** What the parent lists for a child: cap and spend to the cent, or nulls when no credit was set. */
104 export function creditView(app: App, name: string): { creditCapUsd: number | null; creditSpentUsd: number | null } {
105 const c = readCredit(app, name);
106 return c === undefined ? { creditCapUsd: null, creditSpentUsd: null } : { creditCapUsd: c.capUsd, creditSpentUsd: cents(c.spentUsd) };
107 }
src/spawn/door_guard.ts added +56
@@ -0,0 +1,56 @@
1 /**
2 * What keeps the lent model door from being spent past its cap or hammered
3 * (#10484). The cap is checked before a call and counted after it, so calls
4 * in flight at once would each pass the check: a child may have at most
5 * `MAX_IN_FLIGHT` calls under way, and the spend can pass its cap by no more
6 * than that many calls cost. Bad bearers are counted too: past
7 * `BAD_PER_MINUTE` a minute, a bearer that does not check out is answered
8 * "slow down" and nothing more (a good bearer is never turned away by them).
9 * Process memory only; a restart empties it, and no call is then in flight.
10 */
11
12 import type { App } from "../app.ts";
13
14 export const MAX_IN_FLIGHT = 4;
15 export const BAD_PER_MINUTE = 30;
16 const MINUTE = 60_000;
17
18 interface Guard {
19 readonly inflight: Map<string, number>;
20 bad: number[];
21 }
22
23 const guards = new WeakMap<App, Guard>();
24
25 function guardOf(app: App): Guard {
26 let g = guards.get(app);
27 if (g === undefined) {
28 g = { inflight: new Map(), bad: [] };
29 guards.set(app, g);
30 }
31 return g;
32 }
33
34 /** Take one place for `name`; the release (once only) gives it back. Undefined when all are taken. */
35 export function enter(app: App, name: string): (() => void) | undefined {
36 const g = guardOf(app);
37 const n = g.inflight.get(name) ?? 0;
38 if (n >= MAX_IN_FLIGHT) return undefined;
39 g.inflight.set(name, n + 1);
40 let released = false;
41 return () => {
42 if (released) return;
43 released = true;
44 const left = (g.inflight.get(name) ?? 1) - 1;
45 if (left <= 0) g.inflight.delete(name);
46 else g.inflight.set(name, left);
47 };
48 }
49
50 /** A bearer that did not check out. True when there have been too many of these this minute. */
51 export function noteBad(app: App, now: number = Date.now()): boolean {
52 const g = guardOf(app);
53 g.bad = g.bad.filter((t) => now - t < MINUTE);
54 if (g.bad.length < BAD_PER_MINUTE * 2) g.bad.push(now);
55 return g.bad.length > BAD_PER_MINUTE;
56 }
src/spawn/funding.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * What a child knows of the credit its parent lends it (#10484): the bearer
3 * the parent minted (`credit.token` in `parent.json`, beside the proposal
4 * token, never in the database) and the cap and spend last heard. A funded
5 * child's crew, with no account of its own granted, calls the model through
6 * the parent's door (`FORGE_PARENT_LOCAL`); the parent's credential never
7 * reaches this forge's environment or data directory, only the bearer that
8 * is good for nothing but that door.
9 */
10
11 import { readFileSync, renameSync, writeFileSync } from "node:fs";
12 import { join } from "node:path";
13 import type { App } from "../app.ts";
14 import { leftUsd, usdWords } from "./credit.ts";
15 import { isParentLocal } from "./beat-door.ts";
16
17 export interface Funding {
18 readonly token: string;
19 readonly capUsd: number | null;
20 readonly spentUsd: number | null;
21 }
22
23 const BEARER = /^[\x21-\x7e]{8,200}$/;
24 const file = (dataDir: string): string => join(dataDir, "parent.json");
25
26 function readDoc(dataDir: string): Record<string, unknown> | undefined {
27 try {
28 const d = JSON.parse(readFileSync(file(dataDir), "utf8"));
29 return typeof d === "object" && d !== null && !Array.isArray(d) ? (d as Record<string, unknown>) : undefined;
30 } catch {
31 return undefined;
32 }
33 }
34
35 const sum = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null);
36
37 /** The credit this forge was handed, or undefined. */
38 export function fundingOf(dataDir: string): Funding | undefined {
39 const c = readDoc(dataDir)?.["credit"];
40 if (typeof c !== "object" || c === null) return undefined;
41 const { token, capUsd, spentUsd } = c as Record<string, unknown>;
42 if (typeof token !== "string" || !BEARER.test(token)) return undefined;
43 return { token, capUsd: sum(capUsd), spentUsd: sum(spentUsd) };
44 }
45
46 /** Write `credit` into a forge's `parent.json`, keeping the rest. The parent calls this on a child's directory. */
47 export function keepFunding(dataDir: string, funding: Funding): boolean {
48 const doc = readDoc(dataDir);
49 if (doc === undefined) return false;
50 writeFileSync(`${file(dataDir)}.tmp`, JSON.stringify({ ...doc, credit: funding }), { mode: 0o600 });
51 renameSync(`${file(dataDir)}.tmp`, file(dataDir));
52 return true;
53 }
54
55 /** The environment that points a crew run at the parent's door; undefined unless funded and the door is reachable. */
56 export function fundedEnv(dataDir: string): Record<string, string> | undefined {
57 const local = process.env["FORGE_PARENT_LOCAL"] ?? "";
58 const f = fundingOf(dataDir);
59 if (f === undefined || !isParentLocal(local)) return undefined;
60 return { ANTHROPIC_BASE_URL: local, ANTHROPIC_API_KEY: f.token };
61 }
62
63 type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
64
65 /** Ask the parent how much is left and keep the answer; the last known stands when it does not answer. Never throws. */
66 export async function refreshFunding(app: App, fetcher: FetchLike = fetch): Promise<Funding | undefined> {
67 const f = fundingOf(app.config.dataDir);
68 const local = process.env["FORGE_PARENT_LOCAL"] ?? "";
69 if (f === undefined) return undefined;
70 if (!isParentLocal(local)) return f;
71 try {
72 const res = await fetcher(`${local}/api/v1/credit`, {
73 headers: { authorization: `Bearer ${f.token}` },
74 signal: AbortSignal.timeout(3000),
75 redirect: "error",
76 });
77 if (!res.ok) return f;
78 const d = (await res.json()) as Record<string, unknown>;
79 const next = { token: f.token, capUsd: sum(d["capUsd"]), spentUsd: sum(d["spentUsd"]) };
80 keepFunding(app.config.dataDir, next);
81 return next;
82 } catch {
83 return f;
84 }
85 }
86
87 /** The line the crew page and the API say: "funded by its parent: $199.50 of $200 left". */
88 export function fundingWords(f: Funding): string {
89 return f.capUsd === null || f.spentUsd === null
90 ? "funded by its parent"
91 : `funded by its parent: ${usdWords(leftUsd({ capUsd: f.capUsd, spentUsd: f.spentUsd }))} of ${usdWords(f.capUsd)} left`;
92 }
src/spawn/heartbeat.ts +2
@@ -22,6 +22,7 @@ import { beatKeyOf, listChildren } from "../actions/spawn.ts";
2222 import { shippedReleases } from "../release/shipped.ts";
2323 import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts";
2424 import { isParentLocal } from "./beat-door.ts";
25 import { refreshFunding } from "./funding.ts";
2526 import { upstreamState } from "./upstream.ts";
2627 import { progress } from "../core/next-steps.ts";
2728 import { running } from "../kernel/version.ts";
@@ -314,6 +315,7 @@ export async function sendBeat(
314315 signal: AbortSignal.timeout(5000),
315316 redirect: "error",
316317 });
318 if (res.ok) await refreshFunding(app, fetcher);
317319 return res.ok;
318320 } catch {
319321 return false;
src/spawn/list_price.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * What a model call costs at list price, in dollars, from the usage the
3 * provider reports. USD per million tokens, Anthropic's published list
4 * (anthropic.com/pricing): Opus 15 in / 75 out, Sonnet 3 / 15, Haiku 1 / 5;
5 * a cache write costs 1.25x the input price and a cache read 0.1x. A model
6 * whose family is not recognised is priced as Opus, the dearest: a lent
7 * credential is never undercounted, and the worst a wrong guess does is
8 * stop a child's spending early.
9 */
10
11 export interface Usage {
12 readonly inputTokens: number;
13 readonly outputTokens: number;
14 readonly cacheWriteTokens: number;
15 readonly cacheReadTokens: number;
16 }
17
18 export const NO_USAGE: Usage = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
19
20 interface Rate {
21 readonly in: number;
22 readonly out: number;
23 }
24
25 const FAMILIES: readonly (readonly [RegExp, Rate])[] = [
26 [/haiku/i, { in: 1, out: 5 }],
27 [/sonnet/i, { in: 3, out: 15 }],
28 [/opus/i, { in: 15, out: 75 }],
29 ];
30 const DEAREST: Rate = { in: 15, out: 75 };
31
32 export const rateOf = (model: string): Rate => FAMILIES.find(([re]) => re.test(model))?.[1] ?? DEAREST;
33
34 /** Dollars at list price: tokens / 1e6 * USD per million, summed. Unrounded. */
35 export function listPriceUsd(model: string, u: Usage): number {
36 const r = rateOf(model);
37 return (
38 (u.inputTokens * r.in + u.outputTokens * r.out + u.cacheWriteTokens * r.in * 1.25 + u.cacheReadTokens * r.in * 0.1) /
39 1_000_000
40 );
41 }
src/spawn/model_door.ts added +197
@@ -0,0 +1,197 @@
1 /**
2 * The model endpoint a parent lends a child (#10484): `POST /v1/messages`
3 * and `GET /api/v1/credit` on the beat door. The child's crew presents the
4 * bearer the parent minted for it; the parent forwards the call on its own
5 * credential, which the child never holds, counts the reply's usage at list
6 * price (`spawn/list_price.ts`) against the child's dollar cap
7 * (`spawn/credit.ts`), and refuses the next call once the cap is spent.
8 * This is a path through the parent: it opens no egress for the child, and
9 * the only host it reaches is the one the parent's own credential names.
10 * A call in flight when the cap runs out finishes and is counted, and at most
11 * `MAX_IN_FLIGHT` calls run at once per child (`spawn/door_guard.ts`), so the
12 * spend can pass the cap by no more than what those calls cost.
13 */
14
15 import type { App } from "../app.ts";
16 import { addSpend, creditOfToken, leftUsd, readCredit, spentWords, type Credit } from "./credit.ts";
17 import { listPriceUsd, NO_USAGE, type Usage } from "./list_price.ts";
18 import { readCapped } from "./bounded_read.ts";
19 import { enter, MAX_IN_FLIGHT, noteBad } from "./door_guard.ts";
20
21 type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
22
23 /** Where the parent's own credential points: a base URL and the headers that carry it. */
24 export interface ModelUpstream {
25 readonly base: string;
26 readonly headers: Record<string, string>;
27 }
28
29 export interface ModelDeps {
30 readonly fetch?: FetchLike;
31 readonly upstream?: () => ModelUpstream | undefined;
32 }
33
34 /** The parent's own credential from its environment, never from a request. */
35 export function ownUpstream(): ModelUpstream | undefined {
36 const base = (process.env["ANTHROPIC_BASE_URL"] ?? "https://api.anthropic.com").replace(/\/+$/, "");
37 const key = process.env["ANTHROPIC_API_KEY"];
38 const auth = process.env["ANTHROPIC_AUTH_TOKEN"];
39 const oauth = process.env["CLAUDE_CODE_OAUTH_TOKEN"];
40 if (key !== undefined && key !== "") return { base, headers: { "x-api-key": key } };
41 if (auth !== undefined && auth !== "") return { base, headers: { authorization: `Bearer ${auth}` } };
42 if (oauth !== undefined && oauth !== "")
43 return { base, headers: { authorization: `Bearer ${oauth}`, "anthropic-beta": "oauth-2025-04-20" } };
44 return undefined;
45 }
46
47 export const MODEL_BODY_MAX = 16 * 1024 * 1024;
48
49 const refuse = (status: number, type: string, message: string): Response =>
50 Response.json({ type: "error", error: { type, message } }, { status });
51
52 function bearerOf(req: Request): string {
53 const auth = req.headers.get("authorization") ?? "";
54 const m = /^Bearer\s+(\S{8,200})$/i.exec(auth);
55 return m?.[1] ?? req.headers.get("x-api-key") ?? "";
56 }
57
58 const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
59
60 function usageOf(u: unknown, into: Usage): Usage {
61 if (typeof u !== "object" || u === null) return into;
62 const r = u as Record<string, unknown>;
63 return {
64 inputTokens: "input_tokens" in r ? num(r["input_tokens"]) : into.inputTokens,
65 outputTokens: "output_tokens" in r ? num(r["output_tokens"]) : into.outputTokens,
66 cacheWriteTokens: "cache_creation_input_tokens" in r ? num(r["cache_creation_input_tokens"]) : into.cacheWriteTokens,
67 cacheReadTokens: "cache_read_input_tokens" in r ? num(r["cache_read_input_tokens"]) : into.cacheReadTokens,
68 };
69 }
70
71 /** Reads usage out of a server-sent stream as it passes: `message_start` carries the input, `message_delta` the output so far. */
72 class Tally {
73 usage: Usage = NO_USAGE;
74 model = "";
75 private buf = "";
76 private readonly dec = new TextDecoder();
77
78 feed(chunk: Uint8Array): void {
79 this.buf += this.dec.decode(chunk, { stream: true });
80 let at: number;
81 while ((at = this.buf.indexOf("\n")) >= 0) {
82 const line = this.buf.slice(0, at).trimEnd();
83 this.buf = this.buf.slice(at + 1);
84 if (!line.startsWith("data:")) continue;
85 try {
86 const ev = JSON.parse(line.slice(5)) as { type?: unknown; message?: { model?: unknown; usage?: unknown }; usage?: unknown };
87 if (ev.type === "message_start") {
88 if (typeof ev.message?.model === "string") this.model = ev.message.model;
89 this.usage = usageOf(ev.message?.usage, this.usage);
90 } else if (ev.type === "message_delta") this.usage = usageOf(ev.usage, this.usage);
91 } catch {
92 // not an event we count
93 }
94 }
95 if (this.buf.length > 1_000_000) this.buf = "";
96 }
97 }
98
99 /** Counts a reply as it passes and gives the call's place back when it ends, however it ends. */
100 function meter(app: App, credit: Credit, fetched: Response, release: () => void): Response {
101 const type = fetched.headers.get("content-type") ?? "";
102 const headers = new Headers({ "content-type": type || "application/json" });
103 // A refusal or an error from the provider is not a call that was served: nothing is charged.
104 if (!fetched.ok || fetched.body === null) {
105 release();
106 return new Response(fetched.body, { status: fetched.status, headers });
107 }
108 const streamed = type.includes("text/event-stream");
109 const reader = fetched.body.getReader();
110 const tally = new Tally();
111 const dec = new TextDecoder();
112 let text = "";
113 let done = false;
114 const finish = (): void => {
115 if (done) return;
116 done = true;
117 release();
118 if (streamed) return void addSpend(app, credit.name, listPriceUsd(tally.model, tally.usage));
119 try {
120 const doc = JSON.parse(text) as { model?: unknown; usage?: unknown };
121 addSpend(app, credit.name, listPriceUsd(typeof doc.model === "string" ? doc.model : "", usageOf(doc.usage, NO_USAGE)));
122 } catch {
123 // an unreadable answer is not counted; the provider's own ledger is the check
124 }
125 };
126 return new Response(
127 new ReadableStream<Uint8Array>({
128 async pull(c) {
129 try {
130 const { done: end, value } = await reader.read();
131 if (end) {
132 finish();
133 c.close();
134 return;
135 }
136 if (streamed) tally.feed(value);
137 else if (text.length < MODEL_BODY_MAX) text += dec.decode(value, { stream: true });
138 c.enqueue(value);
139 } catch (e) {
140 finish();
141 c.error(e);
142 }
143 },
144 // The client left: what the provider already said is still owed.
145 cancel(why) {
146 finish();
147 return reader.cancel(why);
148 },
149 }),
150 { status: fetched.status, headers },
151 );
152 }
153
154 /**
155 * `POST /v1/messages`: one lent model call. The bearer is checked before a
156 * byte of the body is read; the body is then read by bytes arrived, at most
157 * `MODEL_BODY_MAX`.
158 */
159 export async function lendModel(app: App, req: Request, deps: ModelDeps = {}): Promise<Response> {
160 const credit = creditOfToken(app, bearerOf(req));
161 if (credit === undefined)
162 return noteBad(app) ? refuse(429, "rate_limit_error", "too many keys this forge does not fund; slow down") : refuse(401, "authentication_error", "this forge does not fund that key");
163 if (readCredit(app, credit.name)!.spentUsd >= credit.capUsd)
164 return refuse(402, "billing_error", spentWords(credit.name, credit.capUsd));
165 const own = (deps.upstream ?? ownUpstream)();
166 if (own === undefined) return refuse(503, "api_error", "the parent has no model credential to lend");
167 // Checked and taken with no await between, so parallel calls cannot all pass.
168 const release = enter(app, credit.name);
169 if (release === undefined) return refuse(429, "rate_limit_error", `${credit.name} has ${MAX_IN_FLIGHT} calls under way; wait for one to finish`);
170 let handed = false;
171 try {
172 const raw = await readCapped(req, MODEL_BODY_MAX);
173 if (raw === undefined) return refuse(413, "invalid_request_error", "request too large");
174 const headers: Record<string, string> = { "content-type": "application/json", "anthropic-version": req.headers.get("anthropic-version") ?? "2023-06-01" };
175 const beta = [req.headers.get("anthropic-beta"), own.headers["anthropic-beta"]].filter((b): b is string => !!b && /^[\w.,-]{1,300}$/.test(b));
176 Object.assign(headers, own.headers);
177 if (beta.length > 0) headers["anthropic-beta"] = beta.join(",");
178 let fetched: Response;
179 try {
180 fetched = await (deps.fetch ?? fetch)(`${own.base}/v1/messages`, { method: "POST", headers, body: raw, redirect: "error" });
181 } catch {
182 return refuse(502, "api_error", "the parent could not reach its model provider");
183 }
184 handed = true;
185 return meter(app, credit, fetched, release);
186 } finally {
187 if (!handed) release();
188 }
189 }
190
191 /** `GET /api/v1/credit`: what the bearer's child may spend and has. */
192 export function creditAnswer(app: App, req: Request): Response {
193 const credit = creditOfToken(app, bearerOf(req));
194 if (credit === undefined)
195 return noteBad(app) ? refuse(429, "rate_limit_error", "too many keys this forge does not fund; slow down") : refuse(401, "authentication_error", "this forge does not fund that key");
196 return Response.json({ capUsd: credit.capUsd, spentUsd: credit.spentUsd, leftUsd: leftUsd(credit) });
197 }
src/tui/screens/agents.ts +4 −1
@@ -9,6 +9,7 @@
99 */
1010
1111 import { checkPatch, createAgent, deleteAgent, listAllAgents, updateAgent } from "../../actions/crew.ts";
12 import { fundingOf, fundingWords } from "../../spawn/funding.ts";
1213 import { CREW_ROLES, type AgentPatch } from "../../core/agents.ts";
1314 import type { Ctx, Screen } from "../app/app.ts";
1415 import { binding } from "../app/keys.ts";
@@ -52,7 +53,9 @@ export function agentsScreen(f: Session, ctx: Ctx): Screen {
5253 render(area, s) {
5354 const c = ctx.theme.c;
5455 const [head, body] = split(area, "v", [len(1), fill(1)]);
55 const text = note !== "" ? note : allowed() ? "The crew's settings; each change asks first." : "Agents are set by a forge admin.";
56 const funded = fundingOf(f.app.config.dataDir);
57 const base = allowed() ? "The crew's settings; each change asks first." : "Agents are set by a forge admin.";
58 const text = note !== "" ? note : funded === undefined ? base : `${base} ${fundingWords(funded)}`;
5659 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
5760 drawList(s, body!, c, state, rows());
5861 },
src/tui/screens/childcredit.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * A site admin sets what a child forge may spend of this forge's model
3 * credential, in the terminal (#10484): pick a forge it sent, type the
4 * dollars. The rule is the action's (`lendCredit`), the same as the API's
5 * `PUT /api/v1/forges/:name/credit`: admins only, a sum above 0 and up to
6 * 100000, the child's bearer handed over before anything is saved.
7 */
8
9 import { lendCredit, type CreditError } from "../../actions/child_credit.ts";
10 import { usdWords } from "../../spawn/credit.ts";
11 import { clientRows } from "../../spawn/clients.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import type { Session } from "../session.ts";
14 import { blank, pickerScreen, promptScreen, runAsync } from "./common.ts";
15 import type { PaletteEntry } from "./entries.ts";
16
17 const WORDS: Record<CreditError, string> = {
18 unauthorized: "sign in first",
19 forbidden: "only a site admin lends credit",
20 not_found: "no such forge",
21 invalid_amount: "dollars above 0 and up to 100000, like 200 or 12.50",
22 unreachable: "the child's data could not be reached; nothing was saved",
23 };
24
25 /** "200", "12.50": plain dollars, nothing else, else NaN (the action refuses it). */
26 export const dollarsOf = (raw: string): number => (/^\d{1,6}(\.\d{1,2})?$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN);
27
28 export function lendScreen(f: Session, ctx: Ctx, parent: Screen, forges: readonly { name: string; hint: string }[]): Screen {
29 return pickerScreen(ctx, parent, {
30 title: "Lend credit to which forge?",
31 items: forges.map((c) => ({ id: c.name, label: c.name, hint: c.hint })),
32 onPick: (ids) => {
33 const name = ids[0];
34 if (name === undefined) return;
35 ctx.push(
36 promptScreen(ctx, parent, {
37 title: `Credit for ${name}`,
38 label: "dollars it may spend on this forge's credential",
39 onSubmit: (raw) =>
40 lendCredit(f.app, f.user, name, dollarsOf(raw)).then((done) =>
41 ctx.toast(done.ok ? `${name} may spend ${usdWords(done.value.capUsd)} of this forge's credit.` : `${name}: ${WORDS[done.error]}`, done.ok ? "success" : "danger"),
42 ),
43 }),
44 );
45 },
46 });
47 }
48
49 export const entry: PaletteEntry = {
50 id: "lend-credit",
51 title: "Lend a child credit",
52 hint: "set the dollars a forge you sent may spend on this one's model credential",
53 after: ["forges"],
54 offered: (session) => session.user?.isAdmin === true,
55 run: (session, ctx) =>
56 runAsync(ctx, async () => {
57 const rows = (await clientRows(session.app)).filter((r) => r.made);
58 if (rows.length === 0) return void ctx.toast("You have not sent a forge yet");
59 ctx.push(lendScreen(session, ctx, blank("Lend credit"), rows.map((r) => ({ name: r.name, hint: r.creditCapUsd == null ? "no credit lent" : `${usdWords(r.creditCapUsd)} lent` }))));
60 }),
61 };
src/tui/screens/entries.ts +2
@@ -20,6 +20,7 @@ import { entry as bets } from "./bets.ts";
2020 import { entry as boards } from "./boards.ts";
2121 import { entry as bugs } from "./bugs.ts";
2222 import { entry as bulk } from "./bulk.ts";
23 import { entry as childCredit } from "./childcredit.ts";
2324 import { entry as cloud } from "./cloud.ts";
2425 import { entry as connections } from "./connections.ts";
2526 import { entry as crewHandover } from "./crewhandover.ts";
@@ -146,6 +147,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
146147 front,
147148 owner,
148149 forges,
150 childCredit,
149151 originAhead,
150152 profile,
151153 dotfiles,
src/tui/screens/forges.ts +2 −2
@@ -6,7 +6,7 @@
66
77 import type { Ctx, Screen } from "../app/app.ts";
88 import { binding } from "../app/keys.ts";
9 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
9 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
1010 import { useLine } from "../../spawn/usage.ts";
1111 import { timeAgo } from "../../web/ui.ts";
1212 import { card } from "../ui/widgets.ts";
@@ -23,7 +23,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
2323 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
2424 return rows.flatMap((r): [string, string][] => [[
2525 r.name,
26 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null, useLine(r.size, r.use), r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
26 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), useLine(r.size, r.use), r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
2727 .filter((m): m is string => m !== null)
2828 .join(" · "),
2929 ], ...addressLines(r)]);
src/web/pages/crew.ts +4 −1
@@ -6,6 +6,7 @@
66 * the crew is actually there, in words, before anyone assigns anything.
77 */
88
9 import { fundingOf, fundingWords } from "../../spawn/funding.ts";
910 import {
1011 createAgent,
1112 deleteAgent,
@@ -142,8 +143,10 @@ export function crewReadiness(ctx: Ctx): Html {
142143 if (!crew.ready)
143144 return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">The crew cannot run: ${crew.detail}. Agents can be
144145 created, but nothing picks up their work until this is fixed.</div></div>`;
146 const funded = fundingOf(ctx.app.config.dataDir);
147 // One sentence: a forge its parent funds says so in the readiness line, not in a second paragraph (#10484).
145148 return html`<p class="muted small mb">
146 ${icon("check")} ${crew.detail} is ready to run the crew.
149 ${icon("check")} ${crew.detail} is ready to run the crew${funded === undefined ? "." : html`, <span data-funded>${fundingWords(funded)}</span>.`}
147150 </p>
148151 ${when(
149152 !crew.sandbox.enforced,
src/web/pages/forges.ts +2 −1
@@ -7,7 +7,7 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, clientRows, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
10 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, clientRows, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
1111 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1212 import { notFoundPage, pageHead } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
@@ -52,6 +52,7 @@ function forgeRow(r: ClientRow, now: number): Html {
5252 r.made ? html`${sizeWords(r.size)}` : null,
5353 runningNote(r),
5454 spendNote(r),
55 creditWords(r) === null ? null : html`<span data-credit>${creditWords(r)}</span>`,
5556 r.sentAt === null ? null : html`sent ${time(r.sentAt)}`,
5657 r.lastActivityAt === null
5758 ? null
test/child-credit.test.ts added +433
@@ -0,0 +1,433 @@
1 /**
2 * A parent lends a child its model credential by the dollar (#10484): an
3 * admin sets a cap, the child's crew reaches the model through the parent's
4 * door with a bearer that is good for nothing else, every call is counted at
5 * list price, and a call past the cap is refused in one sentence. The
6 * parent's key never reaches the child's environment or data directory.
7 * (A real cell is not booted here: the sandbox runs no cells. The door is
8 * the one a cell relays in, `openBeatDoor`, reached on its loopback port.)
9 */
10
11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12 import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { createRepo } from "../src/actions/repos.ts";
16 import { childDirOf } from "../src/actions/spawn.ts";
17 import { createApp, type App } from "../src/app.ts";
18 import { credentialEnv } from "../src/crew/runner.ts";
19 import { eventAudience } from "../src/core/event_audience.ts";
20 import { readCapped } from "../src/spawn/bounded_read.ts";
21 import { crewReadiness } from "../src/web/pages/crew.ts";
22 import { startCrew } from "../src/crew/dispatch.ts";
23 import { createSession } from "../src/core/sessions.ts";
24 import { clientRows } from "../src/spawn/clients.ts";
25 import { forgeLines } from "../src/tui/screens/forges.ts";
26 import { createForgeApp } from "../src/tui/forge.ts";
27 import { headlessTerminal } from "../src/tui/term/terminal.ts";
28 import { getUserByName } from "../src/core/users.ts";
29 import { settleApp } from "./helpers/tui.ts";
30 import type { Ctx } from "../src/http/context.ts";
31 import { chmod, rename, writeFile } from "node:fs/promises";
32 import { createToken } from "../src/core/tokens.ts";
33 import { createUser } from "../src/core/users.ts";
34 import { commitFiles } from "../src/git/write.ts";
35 import { repoDir } from "../src/git/repos.ts";
36 import { serve, type Servers } from "../src/http/server.ts";
37 import { firstBoot } from "../src/spawn/first-boot.ts";
38 import { MAX_IN_FLIGHT } from "../src/spawn/door_guard.ts";
39 import { MODEL_BODY_MAX } from "../src/spawn/model_door.ts";
40 import { openBeatDoor, parentLocal, type BeatDoor } from "../src/spawn/beat-door.ts";
41 import { readCredit } from "../src/spawn/credit.ts";
42 import { fundedEnv, fundingOf, fundingWords, refreshFunding } from "../src/spawn/funding.ts";
43 import { listPriceUsd } from "../src/spawn/list_price.ts";
44 import { quietLogs } from "./helpers/globals.ts";
45
46 quietLogs();
47
48 const PARENT_KEY = "sk-ant-api03-PARENTS-OWN-KEY-never-lent";
49
50 describe("list price", () => {
51 test("matches a hand calculation: 200k in + 40k out on Sonnet is $0.60 + $0.60", () => {
52 // 200_000 / 1e6 * $3 = 0.6 ; 40_000 / 1e6 * $15 = 0.6
53 expect(listPriceUsd("claude-sonnet-4-5", { inputTokens: 200_000, outputTokens: 40_000, cacheWriteTokens: 0, cacheReadTokens: 0 })).toBeCloseTo(1.2, 10);
54 // Opus: 1M in $15; cache write 1M = $18.75; cache read 1M = $1.50
55 expect(listPriceUsd("claude-opus-4", { inputTokens: 1e6, outputTokens: 0, cacheWriteTokens: 1e6, cacheReadTokens: 1e6 })).toBeCloseTo(35.25, 10);
56 // A model nobody knows is priced as the dearest.
57 expect(listPriceUsd("mystery", { inputTokens: 1e6, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 })).toBe(15);
58 });
59 });
60
61 describe("a parent funds a child's crew", () => {
62 let dir = "";
63 let parent: App;
64 let child: App;
65 let childDir = "";
66 let servers: Servers;
67 let childServers: Servers;
68 let door: BeatDoor;
69 let adminRw = "";
70 let bobRw = "";
71 let childReader = "";
72 const upstreamSeen: Request[] = [];
73 const saved = process.env["FORGE_PARENT_LOCAL"];
74 let open!: () => void;
75 const gate = new Promise<void>((resolve) => (open = resolve));
76 const call = (token: string, extra: Record<string, unknown> = {}) =>
77 fetch(`${parentLocal(door.port)}/v1/messages`, {
78 method: "POST",
79 headers: { "x-api-key": token, "content-type": "application/json" },
80 body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 8, messages: [], ...extra }),
81 });
82 const spent = () => readCredit(parent, "maria")!.spentUsd;
83
84 const put = (token: string, name: string, body: unknown, headers: Record<string, string> = {}) =>
85 fetch(`${servers.url}/api/v1/forges/${name}/credit`, {
86 method: "PUT",
87 headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...headers },
88 body: JSON.stringify(body),
89 });
90 const listed = async () => {
91 const res = await fetch(`${servers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${adminRw}` } });
92 return ((await res.json()) as { items: { name: string; creditCapUsd: number | null; creditSpentUsd: number | null }[] }).items;
93 };
94 /** One crew run's model call, as Claude Code makes it from the environment the child gave it. */
95 const run = (env: Record<string, string>, stream = false) =>
96 fetch(`${env["ANTHROPIC_BASE_URL"]}/v1/messages`, {
97 method: "POST",
98 headers: { "x-api-key": env["ANTHROPIC_API_KEY"] ?? "", "content-type": "application/json", "anthropic-version": "2023-06-01" },
99 body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 8, stream, messages: [{ role: "user", content: "hi" }] }),
100 });
101
102 beforeAll(async () => {
103 dir = await mkdtemp(join(tmpdir(), "forge-child-credit-"));
104 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
105 const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true });
106 const bob = await createUser(parent.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-credit-passphrase" });
107 if (!alice.ok || !bob.ok) throw new Error("users");
108 adminRw = createToken(parent.db, alice.value.id, "rw", "rw").token;
109 bobRw = createToken(parent.db, bob.value.id, "rw", "rw").token;
110 const repo = await createRepo(parent, alice.value, { name: "bakery" });
111 if (!repo.ok) throw new Error("repo");
112 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
113 branch: "main",
114 message: "bakery",
115 files: [{ path: "README.md", content: "# bakery\n" }],
116 author: { name: "a", email: "[email protected]" },
117 });
118 if (!c.ok) throw new Error("commit");
119 servers = await serve(parent);
120 const made = await fetch(`${servers.url}/api/v1/forges`, {
121 method: "POST",
122 headers: { authorization: `Bearer ${adminRw}`, "content-type": "application/json" },
123 body: JSON.stringify({ name: "maria", apps: ["alice/bakery"], dryRun: false }),
124 });
125 if (made.status !== 201) throw new Error(`spawn ${made.status}`);
126 const { bodyId } = (await made.json()) as { bodyId: string };
127 childDir = childDirOf(parent, bodyId);
128 child = createApp({ dataDir: childDir, port: 0 });
129 await firstBoot(child);
130 childServers = await serve(child);
131 const reader = await createUser(child.db, { origin: "seed", trusted: true, username: "reader", email: "[email protected]", password: "a-long-credit-passphrase" });
132 if (!reader.ok) throw new Error("child user");
133 childReader = createToken(child.db, reader.value.id, "ro", "ro").token;
134 // The parent's own provider, faked: it answers with usage, and keeps what it was sent.
135 door = openBeatDoor(parent, {
136 model: {
137 upstream: () => ({ base: "https://provider.test", headers: { "x-api-key": PARENT_KEY } }),
138 fetch: async (url, init) => {
139 upstreamSeen.push(new Request(url, { method: init?.method ?? "POST", headers: init?.headers as Record<string, string> }));
140 const usage = { input_tokens: 200_000, output_tokens: 40_000 };
141 const sent = JSON.parse(new TextDecoder().decode(init?.body as ArrayBuffer)) as { stream?: boolean; fail?: boolean; hold?: boolean };
142 if (sent.fail) return Response.json({ type: "error", error: { type: "overloaded_error", message: "busy" }, usage }, { status: 529 });
143 if (sent.hold) await gate;
144 const stream = sent.stream === true;
145 if (!stream) return Response.json({ id: "m", type: "message", model: "claude-sonnet-4-5", content: [], usage });
146 const sse =
147 `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { model: "claude-sonnet-4-5", usage: { input_tokens: 200_000, output_tokens: 1 } } })}\n\n` +
148 `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", usage: { output_tokens: 40_000 } })}\n\n`;
149 return new Response(sse, { headers: { "content-type": "text/event-stream" } });
150 },
151 },
152 });
153 process.env["FORGE_PARENT_LOCAL"] = parentLocal(door.port);
154 }, 60_000);
155
156 afterAll(async () => {
157 if (saved === undefined) delete process.env["FORGE_PARENT_LOCAL"];
158 else process.env["FORGE_PARENT_LOCAL"] = saved;
159 door.close();
160 childServers.stop();
161 child.close();
162 servers.stop();
163 parent.close();
164 await rm(dir, { recursive: true, force: true });
165 });
166
167 test("only a site admin sets a cap, from the forge's own origin, in dollars", async () => {
168 expect((await put(bobRw, "maria", { usd: 1 })).status).toBe(403);
169 expect((await put(adminRw, "maria", { usd: 1 }, { origin: "https://evil.test" })).status).toBe(403);
170 expect((await put(adminRw, "maria", { usd: -5 })).status).toBe(422);
171 expect((await put(adminRw, "maria", { usd: "1" })).status).toBe(422);
172 expect((await put(adminRw, "nobody", { usd: 1 })).status).toBe(404);
173 expect(fundingOf(childDir)).toBeUndefined();
174 });
175
176 test("with no credit lent, the child's crew has no way to the parent's model", () => {
177 expect(fundedEnv(childDir)).toBeUndefined();
178 expect(credentialEnv({ kind: "env" }, childDir)).not.toHaveProperty("ANTHROPIC_BASE_URL", parentLocal(door.port));
179 });
180
181 test("a $1 cap: the first run reaches the model through the parent, the second is refused in one sentence", async () => {
182 const set = await put(adminRw, "maria", { usd: 1 });
183 expect(set.status).toBe(200);
184 expect(((await set.json()) as { capUsd: number }).capUsd).toBe(1);
185
186 // The child holds a bearer for the parent's door, and no provider credential anywhere.
187 const f = fundingOf(childDir)!;
188 expect(f.token.startsWith("maria.")).toBe(true);
189 for (const file of await readdir(childDir, { recursive: true })) {
190 const path = join(childDir, file);
191 const text = await readFile(path, "latin1").catch(() => "");
192 expect(text.includes(PARENT_KEY)).toBe(false);
193 }
194 const env = credentialEnv({ kind: "env" }, childDir);
195 expect(env).toEqual({ ANTHROPIC_BASE_URL: parentLocal(door.port), ANTHROPIC_API_KEY: f.token });
196 expect(JSON.stringify(env)).not.toContain(PARENT_KEY);
197
198 const first = await run(env);
199 expect(first.status).toBe(200);
200 expect(((await first.json()) as { model: string }).model).toBe("claude-sonnet-4-5");
201 // The provider saw the parent's key, never the child's bearer.
202 expect(upstreamSeen[0]!.headers.get("x-api-key")).toBe(PARENT_KEY);
203 expect(upstreamSeen[0]!.url).toBe("https://provider.test/v1/messages");
204
205 const second = await run(env);
206 expect(second.status).toBe(402);
207 const refused = (await second.json()) as { error: { message: string } };
208 expect(refused.error.message).toBe("maria's $1 from its parent is spent; the parent's admin can raise it.");
209 expect(upstreamSeen.length).toBe(1);
210 });
211
212 test("the cap and the spend read back on both forges' APIs", async () => {
213 // 200k in at $3/M + 40k out at $15/M = $1.20
214 expect((await listed()).find((i) => i.name === "maria")).toMatchObject({ creditCapUsd: 1, creditSpentUsd: 1.2 });
215 const mine = await fetch(`${childServers.url}/api/v1/credit`, { headers: { authorization: `Bearer ${childReader}` } });
216 expect(mine.status).toBe(200);
217 const said = (await mine.json()) as { capUsd: number; spentUsd: number; says: string };
218 expect(said).toMatchObject({ capUsd: 1, spentUsd: 1.2 });
219 expect(said.says).toBe("funded by its parent: $0 of $1 left");
220 expect(fundingWords({ token: "x", capUsd: 200, spentUsd: 0.5 })).toBe("funded by its parent: $199.50 of $200 left");
221 });
222
223 test("raising the cap lends more, keeps the bearer and the spend; a stream is counted as it passes", async () => {
224 const token = fundingOf(childDir)!.token;
225 expect((await put(adminRw, "maria", { usd: 5 })).status).toBe(200);
226 await refreshFunding(child);
227 expect(fundingOf(childDir)).toMatchObject({ token, capUsd: 5, spentUsd: 1.2 });
228 const env = credentialEnv({ kind: "env" }, childDir);
229 const res = await run(env, true);
230 expect(res.status).toBe(200);
231 await res.text();
232 expect((await listed()).find((i) => i.name === "maria")!.creditSpentUsd).toBe(2.4);
233 });
234
235 test("the door lends to no one without the bearer", async () => {
236 const base = parentLocal(door.port);
237 expect((await run({ ANTHROPIC_BASE_URL: base, ANTHROPIC_API_KEY: "maria.not-the-bearer-at-all" })).status).toBe(401);
238 expect((await run({ ANTHROPIC_BASE_URL: base, ANTHROPIC_API_KEY: "" })).status).toBe(401);
239 expect((await fetch(`${base}/api/v1/credit`)).status).toBe(401);
240 });
241 test("a body is capped by the bytes that arrive, not by what it says it is; the bearer is checked first", async () => {
242 const before = upstreamSeen.length;
243 const token = fundingOf(childDir)!.token;
244 const big = new Uint8Array(MODEL_BODY_MAX + 1024);
245 const refused = await fetch(`${parentLocal(door.port)}/v1/messages`, { method: "POST", headers: { "x-api-key": token }, body: big }).catch(() => undefined);
246 // Either the runtime's own 413 or ours: never an answer from the provider.
247 expect(refused === undefined || refused.status === 413).toBe(true);
248 expect(upstreamSeen.length).toBe(before);
249 // A chunked body declares no length at all; the count still stops it.
250 const chunks = new ReadableStream<Uint8Array>({
251 pull(c) {
252 c.enqueue(new Uint8Array(400));
253 },
254 });
255 const req = new Request("http://x/", { method: "POST", body: chunks, duplex: "half" } as RequestInit);
256 expect(await readCapped(req, 1000)).toBeUndefined();
257 expect((await readCapped(new Request("http://x/", { method: "POST", body: "12345" }), 5))?.length).toBe(5);
258 expect(await readCapped(new Request("http://x/", { method: "POST", body: "123456" }), 5)).toBeUndefined();
259 });
260
261 test("the heartbeat is still refused past 1 KB, and unsigned", async () => {
262 const base = parentLocal(door.port);
263 const post = (body: string, headers: Record<string, string>) =>
264 fetch(`${base}/api/v1/heartbeat`, { method: "POST", headers, body });
265 expect((await post("x".repeat(2048), { "x-forge-signature": "a".repeat(64) })).status).toBe(404);
266 expect((await post("{}", {})).status).toBe(404);
267 });
268
269 test("a forge nobody funds answers 404 on /api/v1/credit", async () => {
270 const res = await fetch(`${servers.url}/api/v1/credit`, { headers: { authorization: `Bearer ${adminRw}` } });
271 expect(res.status).toBe(404);
272 });
273
274 test("a stream the client leaves is still counted; an upstream error is not charged", async () => {
275 expect((await put(adminRw, "maria", { usd: 500 })).status).toBe(200);
276 const token = fundingOf(childDir)!.token;
277 const before = spent();
278 const res = await call(token, { stream: true });
279 const reader = res.body!.getReader();
280 await reader.read();
281 await reader.cancel();
282 await Bun.sleep(20);
283 expect(spent()).toBeCloseTo(before + 1.2, 6);
284 const failed = await call(token, { fail: true });
285 expect(failed.status).toBe(529);
286 expect(spent()).toBeCloseTo(before + 1.2, 6);
287 });
288
289 test("calls in parallel pass the cap by at most the in-flight limit, and a place is given back", async () => {
290 const token = fundingOf(childDir)!.token;
291 const before = upstreamSeen.length;
292 const held = Array.from({ length: MAX_IN_FLIGHT + 2 }, () => call(token, { hold: true }));
293 const answers = await Promise.all(held.slice(MAX_IN_FLIGHT).map((p) => p));
294 // Calls past the limit were refused at once, without reaching the provider.
295 for (const a of answers) expect(a.status).toBe(429);
296 for (let i = 0; i < 50 && upstreamSeen.length < before + MAX_IN_FLIGHT; i++) await Bun.sleep(10);
297 expect(upstreamSeen.length - before).toBe(MAX_IN_FLIGHT);
298 const was = spent();
299 open();
300 for (const p of held.slice(0, MAX_IN_FLIGHT)) {
301 const r = await p;
302 expect(r.status).toBe(200);
303 await r.text();
304 }
305 expect(spent()).toBeCloseTo(was + MAX_IN_FLIGHT * 1.2, 6);
306 expect((await call(token)).status).toBe(200);
307 });
308
309 test("the crew page, the /forges row and the terminal say it; a reader is not told the dollars", async () => {
310 const bin = join(dir, "fake-claude");
311 await writeFile(bin, "#!/bin/sh\necho 2.1.0\n");
312 await chmod(bin, 0o755);
313 const crew = await startCrew(child, { forgeUrl: childServers.url, localUrl: childServers.url, binary: bin, concurrency: 0, budgetUsd: 1, runTimeoutMs: 5_000 });
314 try {
315 const said = String(crewReadiness({ app: child } as unknown as Ctx));
316 expect(said).toContain("data-funded");
317 expect(said).toContain("funded by its parent: $");
318 expect(said.match(/<p[\s>]/g)?.length).toBe(1);
319 } finally {
320 await crew.stop();
321 }
322 const alice = getUserByName(parent.db, "alice")!;
323 const cookie = `forge_session=${createSession(parent.db, alice.id).token}`;
324 const page = await (await fetch(`${servers.url}/forges`, { headers: { cookie } })).text();
325 expect(page).toMatch(/data-credit[^>]*>\$[\d.]+ of \$500 lent/);
326 expect(forgeLines(await clientRows(parent)).map((l) => l[1]).join("\n")).toMatch(/\$[\d.]+ of \$500 lent/);
327 expect(eventAudience("forge.credit_set")).toBe("admins");
328 });
329
330 test("the terminal sets a cap as an admin, and offers it to no one else", async () => {
331 const drive = (username: string) => {
332 const terminal = headlessTerminal(140, 32);
333 const { app: tui } = createForgeApp(parent, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(parent.db, username)! }, { terminal, exitHooks: false });
334 tui.start();
335 const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
336 return {
337 text: () => tui.screen.toText(),
338 async type(t: string) {
339 for (const ch of t) key(ch);
340 await settleApp(tui);
341 },
342 async press(...ks: string[]) {
343 ks.forEach((k) => key(k));
344 await settleApp(tui);
345 },
346 async palette(q: string) {
347 key("k", true);
348 await settleApp(tui);
349 await this.type(q);
350 },
351 stop: () => tui.stop(),
352 };
353 };
354 const bobTui = drive("bob");
355 await bobTui.palette("Lend a child");
356 expect(bobTui.text()).not.toContain("Lend a child credit");
357 bobTui.stop();
358 const t = drive("alice");
359 await t.palette("Lend a child");
360 expect(t.text()).toContain("Lend a child credit");
361 await t.press("enter");
362 expect(t.text()).toContain("maria");
363 await t.press("enter");
364 await t.type("7.5");
365 await t.press("enter");
366 for (let i = 0; i < 50 && readCredit(parent, "maria")!.capUsd !== 7.5; i++) await Bun.sleep(10);
367 expect(readCredit(parent, "maria")!.capUsd).toBe(7.5);
368 // The same checks as the API: a sum that is not dollars sets nothing.
369 await t.palette("Lend a child");
370 await t.press("enter", "enter");
371 await t.type("-3");
372 await t.press("enter");
373 await Bun.sleep(50);
374 expect(readCredit(parent, "maria")!.capUsd).toBe(7.5);
375 t.stop();
376 });
377
378 test("a child's agents screen in the terminal carries the funded line", async () => {
379 const admin = await createUser(child.db, { origin: "seed", trusted: true, username: "kidadmin", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true });
380 if (!admin.ok) throw new Error("child admin");
381 const terminal = headlessTerminal(160, 32);
382 const { app: tui } = createForgeApp(child, { sshUsername: "t", remoteAddress: "127.0.0.1", user: admin.value }, { terminal, exitHooks: false });
383 tui.start();
384 const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
385 key("k", true);
386 await settleApp(tui);
387 for (const ch of "Crew agents") key(ch);
388 await settleApp(tui);
389 key("enter");
390 await settleApp(tui);
391 expect(tui.screen.toText()).toContain("funded by its parent");
392 tui.stop();
393 });
394
395 test("a failed hand-off saves nothing, so a retry still mints the bearer; a child that lost it gets a new one", async () => {
396 const credit = join(parent.config.dataDir, "credit", "maria.json");
397 const heldBack = join(childDir, "parent.json.away");
398 const doc = JSON.parse(await readFile(join(childDir, "parent.json"), "utf8")) as Record<string, unknown>;
399 const spentBefore = spent();
400 await rm(credit);
401 await rename(join(childDir, "parent.json"), heldBack);
402 // The child's directory cannot be written: 502, and no credit on the parent either.
403 expect((await put(adminRw, "maria", { usd: 9 })).status).toBe(502);
404 expect(readCredit(parent, "maria")).toBeUndefined();
405 await rename(heldBack, join(childDir, "parent.json"));
406 delete doc["credit"];
407 await writeFile(join(childDir, "parent.json"), JSON.stringify(doc));
408 expect(fundingOf(childDir)).toBeUndefined();
409 expect((await put(adminRw, "maria", { usd: 9 })).status).toBe(200);
410 expect(fundingOf(childDir)!.token.startsWith("maria.")).toBe(true);
411 expect(readCredit(parent, "maria")!.capUsd).toBe(9);
412 // The credit exists but the child holds nothing: the call mints again and keeps the spend.
413 const keptSpend = spent();
414 const old = fundingOf(childDir)!.token;
415 await writeFile(join(childDir, "parent.json"), JSON.stringify(doc));
416 expect((await put(adminRw, "maria", { usd: 9 })).status).toBe(200);
417 expect(fundingOf(childDir)!.token).not.toBe(old);
418 expect(spent()).toBe(keptSpend);
419 expect(spentBefore).toBeGreaterThan(0);
420 expect((await call(old)).status).toBe(401);
421 expect((await call(fundingOf(childDir)!.token)).status).toBe(200);
422 });
423
424 test("bad bearers are slowed past a rate, and a good one is never turned away by them", async () => {
425 const base = parentLocal(door.port);
426 const codes: number[] = [];
427 for (let i = 0; i < 40; i++) codes.push((await fetch(`${base}/api/v1/credit`, { headers: { authorization: `Bearer maria.guess-number-${i}-xxxx` } })).status);
428 expect(codes.slice(0, 5).every((c) => c === 401)).toBe(true);
429 expect(codes.at(-1)).toBe(429);
430 const good = await fetch(`${base}/api/v1/credit`, { headers: { authorization: `Bearer ${fundingOf(childDir)!.token}` } });
431 expect(good.status).toBe(200);
432 });
433 });
test/palette-roster.test.ts +2
@@ -165,6 +165,7 @@ test("a site-admin person is offered the whole roster", () => {
165165 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
166166 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
167167 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
168 ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"],
168169 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
169170 ["profile", "Your profile", "display name and email"],
170171 ["dotfiles", "Your dotfiles", "the repository every shell you open copies from"],
@@ -240,6 +241,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
240241 ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"],
241242 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
242243 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
244 ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"],
243245 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
244246 ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"],
245247 ["family", "Family", "the forge that sent this one, and the forges it sent"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs