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

A new forge comes with a crew that builds at once: agents made, autonomy on, the plan's included credit to start, and one obvious place to add your own model key #11165

Merged opened this

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

Merged

mason/forge:mason/11154-a-new-forge-comes-with-a-crew-that-build → main 6 commits fork

  • Merged. mason/11154-a-new-forge-comes-with-a-crew-that-build was merged into main as 6b2678b.

Review

Sign in to review this change.
26 files changed +758 −34
docs/MCP.md +1 −1
@@ -133,7 +133,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
133133 | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 |
134134 | `lift` | — | Lift an issue out from behind the Later gate | 214 |
135135 | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 |
136 | `limits` | — | Every limit the owner can hit, value and bounds | 949 |
136 | `limits` | — | Every limit the owner can hit, value and bounds | 977 |
137137 | `maintenance` | `status`, `prepare`, `resume` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 256 |
138138 | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 |
139139 | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 |
docs/features/starter-crew.md added +11
@@ -0,0 +1,11 @@
1 # A new forge builds at once (#11154)
2
3 A hosted forge is made with a crew: a `builder` and a `reviewer`, seated on every app its owner makes, with autonomy on (`src/spawn/starter_crew.ts`). A forge handed a crew by its host keeps that one.
4
5 **Bring your own key** is the main road. The forge's home shows the one field when the crew has nothing to run on: paste a model API key or a Claude setup token. Every other page (a new app, an app, Subscriptions) says it in a line and links to the home. The key is checked at the boundary (one line of printable text, at most 512 bytes), sealed in that forge (`POST /settings/crew/key`, `src/web/studio/crew_key.ts`, saved by `src/actions/crew_key.ts`), offered to the owner's crew and the spawned organisation's, and never leaves it. Admin only: anyone else gets the not-found page.
6
7 **Both doors.** The terminal's Crew key screen (`src/tui/screens/crewkey.ts`, palette entry "Crew key") shows what the crew runs on and takes the key with `a`, typed masked, through the same `saveCrewKey`. It is offered to a forge admin who is a person, as the page is.
8
9 **Included credit** is the on-ramp. When no key was handed over, the host lends the new forge `spawn.included_credit_usd` dollars (default 2, once; 0 turns it off) through the child credit (`src/spawn/included_credit.ts`). The crew runs on the host's model door up to that amount; past it the door answers 402 and the forge says: add your own key to keep building, or wait for next month. The forge never holds the host's key, only a capped bearer. Settings → Limits shows the worst case a day: new accounts an hour × 24 × forges a person may own × the credit.
10
11 Try: open `/` on a new forge, paste a key, write one ask.
src/actions/crew_key.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * The owner's own model key, saved from either door (#11154): the web form
3 * (`POST /settings/crew/key`) and the terminal screen. A forge admin only;
4 * the key is checked at the boundary, sealed by `addSubscription`, and
5 * offered to the crew of the admin's apps and of the spawned organisation.
6 * Never logged, never echoed.
7 */
8
9 import type { App } from "../app.ts";
10 import { addSubscription, getSubscriptionByName, setGrants } from "../core/subscriptions.ts";
11 import type { User } from "../core/users.ts";
12 import { spawnedOrg } from "../spawn/claim.ts";
13
14 const KEY_NAME = "my-key";
15 /** The most bytes a key may be; the longest real one is well under it. */
16 export const KEY_MAX_BYTES = 512;
17
18 export type CrewKeyError = "not_found" | "invalid_token";
19
20 /** A key is one line of printable text, within {@link KEY_MAX_BYTES}. */
21 export function keyShapeOk(raw: string): boolean {
22 const token = raw.trim();
23 return token !== "" && Buffer.byteLength(token) <= KEY_MAX_BYTES && !/[\u0000-\u001f\u007f-\u009f]/.test(token);
24 }
25
26 export async function saveCrewKey(
27 app: App,
28 user: User | undefined,
29 raw: string,
30 ): Promise<{ ok: true } | { ok: false; error: CrewKeyError }> {
31 if (user === undefined || !user.isAdmin || user.kind === "agent") return { ok: false, error: "not_found" };
32 if (!keyShapeOk(raw)) return { ok: false, error: "invalid_token" };
33 const name = getSubscriptionByName(app.db, KEY_NAME) === undefined ? KEY_NAME : `${KEY_NAME}-${Date.now().toString(36)}`;
34 const added = addSubscription(app.db, app.config.dataDir, { name, token: raw.trim(), createdBy: user.id });
35 if (!added.ok) return { ok: false, error: "invalid_token" };
36 const spawned = await spawnedOrg(app);
37 for (const scope of [user.id, ...(spawned === undefined ? [] : [spawned.org.id])]) {
38 const held = app.db
39 .query<{ subscription_id: string }, [string]>("SELECT subscription_id FROM subscription_grant WHERE scope_id = ?1 AND agent_id = ''")
40 .all(scope)
41 .map((r) => r.subscription_id);
42 setGrants(app.db, scope, "", [...held, added.value.id]);
43 }
44 return { ok: true };
45 }
src/actions/repos.ts +2
@@ -78,6 +78,7 @@ import {
7878 import { validPolishShare } from "../crew/polish_share.ts";
7979 import { removeInstallCache } from "../crew/workdir.ts";
8080 import type { SettingsChanged } from "../core/settings_changed.ts";
81 import { seatStarterCrew } from "../spawn/starter_crew.ts";
8182
8283 export interface CreateRepoInput {
8384 /** Owner name: the actor themselves (default) or an org they can write to. */
@@ -180,6 +181,7 @@ export async function createRepo(
180181 });
181182 if (!created.ok) return created;
182183 storageChanged(app, ownerId);
184 seatStarterCrew(app.db, created.value, user.value.id);
183185 app.bus.append({
184186 kind: "repo.created",
185187 repoId: created.value.id,
src/actions/spawn.ts +8
@@ -39,6 +39,8 @@ import { runGit } from "../git/env.ts";
3939 import { repoDir } from "../git/repos.ts";
4040 import { commitFiles } from "../git/write.ts";
4141 import { adoptForgeCode, forkForgeCode } from "../spawn/forge_code.ts";
42 import { lendIncludedCredit } from "../spawn/included_credit.ts";
43 import { seedStarterCrew } from "../spawn/starter_crew.ts";
4244 import { birthFromTemplate, type TemplateMark } from "../spawn/template_birth.ts";
4345 import { sha256 } from "../kernel/hash.ts";
4446 import { secret } from "../kernel/id.ts";
@@ -324,6 +326,8 @@ async function make(
324326 const copied = await copyInto(app, owner, plan, ledger, deps.modelKey, deps.modelEndpoint);
325327 if (!copied.ok) return failed(app, deps, "copy", copied.error);
326328 const claimUrl = claimLink(liveChildUrl(app, plan.name), copied.value);
329 // A plan carries its own credit (grantPlanCredit); the included credit is for a spawn on no plan.
330 if (plan.plan === undefined) lendIncludedCredit(app, plan.name, childDirOf(app, ledger.bodyId), deps.modelKey !== undefined);
327331
328332 grantPlanCredit(app, plan, ledger.bodyId);
329333 ledger.done = true;
@@ -503,6 +507,10 @@ async function copyInto(
503507 setCrewRole(child.db, repo.id, theirs.user.id, role);
504508 }
505509
510 // A forge with no crew of its own comes with a builder and a reviewer,
511 // seated on its apps with autonomy on (#11154).
512 await seedStarterCrew(child.db, org.id, founder.id);
513
506514 // The fork is the child's own source: its `forgeRepo`, and the crew seated on it.
507515 adoptForgeCode(child, org.username, founder.id);
508516
src/http/settings_routes.ts +2
@@ -7,6 +7,7 @@
77 * in the pattern list the usage page reads, so this module never imports it.
88 */
99
10 import { crewKeyAdd } from "../web/studio/crew_key.ts";
1011 import type { Handler } from "./context.ts";
1112 import type { Router } from "./router.ts";
1213 import { parentLinkStart } from "./rp.ts";
@@ -174,6 +175,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
174175 // The crew: a forge admin's section, hidden from everyone else.
175176 router.add("GET", "/settings/subscriptions", subscriptionsPage);
176177 router.add("POST", "/settings/subscriptions", subscriptionAdd);
178 router.add("POST", "/settings/crew/key", crewKeyAdd);
177179 router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete);
178180 router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake);
179181 router.add(
src/settings/limits.ts +9
@@ -26,6 +26,7 @@ export type LimitKey =
2626 | "crew.concurrency"
2727 | "checks.concurrency"
2828 | "agent.budget_max_usd"
29 | "spawn.included_credit_usd"
2930 | "subscription.budget_max_usd"
3031 | "app.members_max"
3132 | "front.pinned_max"
@@ -95,6 +96,14 @@ export const LIMITS: Record<LimitKey, LimitDef> = {
9596 max: 1_000_000,
9697 reason: "A crew member's per-run budget is spent unattended, so what one run may cost has a top.",
9798 },
99 "spawn.included_credit_usd": {
100 label: "Crew credit a new forge comes with",
101 unit: "dollars",
102 default: 2,
103 min: 0,
104 max: 1_000,
105 reason: "A new forge's crew runs on this forge's model access until its owner adds a key; 0 turns it off. Worst case a day is the visitor limit times this.",
106 },
98107 "subscription.budget_max_usd": {
99108 label: "Pool account budget ceiling",
100109 unit: "dollars",
src/spawn/included_credit.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * The crew credit a new forge comes with (#11154): the `spawn.included_credit_usd`
3 * limit, lent through the existing child credit (`spawn/credit.ts`) when the
4 * forge was not handed a model key of its own. Lent once: a retry or a
5 * restart finds the credit and leaves it, spent or not. The child never sees
6 * the host's key, only a bearer good for the host's model door, capped.
7 */
8
9 import type { App } from "../app.ts";
10 import { limitValue, type LimitKey } from "../settings/limits.ts";
11 import { readCredit, setCap } from "./credit.ts";
12 import { keepFunding } from "./funding.ts";
13
14 /** The dollars lent, or 0 when none (off, already lent, or the forge brought its own key). */
15 export function lendIncludedCredit(app: App, name: string, childDir: string, hasOwnKey: boolean): number {
16 if (hasOwnKey || readCredit(app, name) !== undefined) return 0;
17 const usd = limitValue(app.db, "spawn.included_credit_usd");
18 if (!(usd > 0)) return 0;
19 const set = setCap(app, name, usd, false, (token, credit) =>
20 keepFunding(childDir, { token, capUsd: credit.capUsd, spentUsd: credit.spentUsd }),
21 );
22 return typeof set === "object" ? set.capUsd : 0;
23 }
24
25 /**
26 * The most the included credit can cost in a day: new accounts an hour for
27 * the whole forge, over 24 hours, each with the forges one person may own,
28 * each lent the credit. Every term is a limit an admin sets here.
29 */
30 export function worstCaseDailyUsd(app: App): number {
31 return worstCaseOf((key) => limitValue(app.db, key));
32 }
33
34 /** The same figure from limit values already read (the limits page has them all). */
35 export function worstCaseOf(value: (key: LimitKey) => number): number {
36 return value("signup.per_forge_hour") * 24 * value("forges.per_person") * value("spawn.included_credit_usd");
37 }
src/spawn/starter_crew.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * The crew a new forge comes with (#11154): a builder and a reviewer, seated
3 * on every app the owner makes, with autonomy on, so the first ask is taken
4 * with no setup. Written into a child's own database by the parent at spawn
5 * (`seedStarterCrew`); the owner's organisation carries a mark, and a
6 * repository made later under it is seated the same way (`seatStarterCrew`,
7 * called by `createRepo`). A forge that already has crew is left as it is.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { addCrew, createAgentRow, getAgentByName, listAgents, setCrewRole, type CrewRole } from "../core/agents.ts";
12 import { getRepoById, listReposOwnedBy, updateRepoSettings, type Repo } from "../core/repos.ts";
13 import { getUserSetting, setUserSetting } from "../core/settings.ts";
14 import { createUser } from "../core/users.ts";
15 import { FORGE_CODE_NAME } from "./forge_code.ts";
16
17 const MARK = "starter_crew";
18
19 interface Seat {
20 readonly name: string;
21 readonly role: CrewRole;
22 readonly brief: string;
23 }
24
25 export const STARTER_SEATS: readonly Seat[] = [
26 {
27 name: "builder",
28 role: "builder",
29 brief:
30 "You build what the issue asks, in the smallest change that works. Read the repository first, keep its shape, add a test for every behaviour you add, and run the tests before you push.",
31 },
32 {
33 name: "reviewer",
34 role: "reviewer",
35 brief:
36 "You review pulls against the issue they answer. Check that behaviour is tested, that inputs are validated and outputs escaped, and that nothing beyond the ask slipped in. Say plainly what to change.",
37 },
38 ];
39
40 /** Whether this organisation was given the starter crew, so what it makes is seated on creation. */
41 export const hasStarterCrew = (db: Database, orgId: string): boolean => getUserSetting(db, orgId, MARK) === "on";
42
43 /**
44 * Make the builder and reviewer when the forge has no crew at all, and mark
45 * the organisation. Returns whether the crew exists afterwards.
46 */
47 export async function seedStarterCrew(db: Database, orgId: string, founderId: string): Promise<boolean> {
48 if (listAgents(db).length === 0) {
49 for (const seat of STARTER_SEATS) {
50 const made = await createUser(db, {
51 username: seat.name,
52 email: `${seat.name}@agents.spawn.forge.invalid`,
53 agent: true,
54 origin: "spawn",
55 });
56 if (!made.ok) return false;
57 createAgentRow(db, { userId: made.value.id, brief: seat.brief, roles: [seat.role], createdBy: founderId });
58 }
59 }
60 if (STARTER_SEATS.some((s) => getAgentByName(db, s.name) === undefined)) return false;
61 setUserSetting(db, orgId, MARK, "on");
62 for (const repo of listReposOwnedBy(db, orgId)) seatStarterCrew(db, repo, founderId);
63 return true;
64 }
65
66 /** Seat the starter crew on one repository and turn autonomy on, unless a person already chose either. */
67 export function seatStarterCrew(db: Database, repo: Repo, founderId: string): void {
68 // The forge's own code is not an app the owner made: it is seated by `adoptForgeCode`, autonomy as the owner sets it.
69 if (!hasStarterCrew(db, repo.ownerId) || repo.name === FORGE_CODE_NAME) return;
70 for (const seat of STARTER_SEATS) {
71 const agent = getAgentByName(db, seat.name);
72 if (agent === undefined) continue;
73 addCrew(db, repo.id, agent.user.id, founderId, seat.role);
74 setCrewRole(db, repo.id, agent.user.id, seat.role);
75 }
76 const fresh = getRepoById(db, repo.id) ?? repo;
77 if (fresh.autonomy === "off" && fresh.autonomyBy === null)
78 updateRepoSettings(db, fresh, { autonomy: "autonomous", autonomyBy: founderId });
79 }
src/tui/screens/crewkey.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * The crew's model key in the terminal (#11154), the second door of
3 * `POST /settings/crew/key`: what the crew runs on, and `a` to paste a key
4 * (typed masked, never shown again), through the same `saveCrewKey` the page
5 * uses. A forge admin who is a person only, as the page is.
6 */
7
8 import { saveCrewKey } from "../../actions/crew_key.ts";
9 import { crewHasCredential } from "../../core/subscriptions.ts";
10 import { usdWords } from "../../spawn/credit.ts";
11 import { includedCredit } from "../../web/studio/crew_key.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
16 import type { Session } from "../session.ts";
17 import { promptScreen } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
19
20 const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind === "user";
21
22 /** What the crew runs on, in one line. */
23 export function crewKeyLine(f: Session): string {
24 if (crewHasCredential(f.app.db)) return "The crew runs on your key. a adds another.";
25 const credit = includedCredit(f.app);
26 if (credit !== undefined && credit.leftUsd > 0)
27 return `The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit. a adds your own key.`;
28 if (credit !== undefined) return "The included credit is spent. a adds your own key to keep building.";
29 return "The crew needs a model key to start. a adds yours.";
30 }
31
32 export function crewKeyScreen(f: Session, ctx: Ctx): Screen {
33 let note = "";
34 const screen: Screen = {
35 title: "Crew key",
36 keys: [binding(["a"], "add your key"), binding(["esc"], "back")],
37 render(area, s) {
38 const c = ctx.theme.c;
39 s.text(area.x, area.y, truncate(note !== "" ? note : crewKeyLine(f), area.w), style(c.mutedFg, c.bg), area.x + area.w);
40 },
41 key(e) {
42 if (e.ctrl || e.alt || e.key !== "a" || !isAdmin(f)) return false;
43 ctx.push(
44 promptScreen(ctx, screen, {
45 title: "Your model key",
46 label: "API key or Claude token (never shown again)",
47 mask: true,
48 onSubmit: (token) =>
49 saveCrewKey(f.app, f.user, token).then((saved) => {
50 note = saved.ok ? "Key saved. The crew can work now." : saved.error === "not_found" ? "Only a forge admin adds a key." : "That key could not be saved. Check that it is the whole key.";
51 ctx.invalidate();
52 }),
53 }),
54 );
55 return true;
56 },
57 };
58 return screen;
59 }
60
61 /** The crew's key: what it runs on, and add yours. */
62 export const entry: PaletteEntry = {
63 id: "crewkey",
64 title: "Crew key",
65 hint: "what the crew runs on; add your own model key",
66 after: ["subscriptions"],
67 offered: isAdmin,
68 run: (session, ctx) => ctx.push(crewKeyScreen(session, ctx)),
69 };
src/tui/screens/entries.ts +2
@@ -77,6 +77,7 @@ import { entry as securityControls } from "./controls.ts";
7777 import { entry as secrets } from "./secrets.ts";
7878 import { entry as sentinel } from "./sentinel.ts";
7979 import { entry as subscriptions } from "./subscriptions.ts";
80 import { entry as crewKey } from "./crewkey.ts";
8081 import { entry as system } from "./system.ts";
8182 import { entry as teams } from "./teams.ts";
8283 import { entry as telemetry } from "./telemetry.ts";
@@ -150,6 +151,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
150151 requests,
151152 bulk,
152153 subscriptions,
154 crewKey,
153155 cloud,
154156 pinned,
155157 bets,
src/web/pages/admin-limits.ts +3
@@ -5,6 +5,8 @@
55 * who, when, from what to what. Anyone else gets the not-found page.
66 */
77
8 import { usdWords } from "../../spawn/credit.ts";
9 import { worstCaseOf } from "../../spawn/included_credit.ts";
810 import { QUOTA_INVALID } from "../../actions/admin_limits_words.ts";
911 import { changeLimit, listLimits } from "../../actions/limits.ts";
1012 import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
@@ -201,6 +203,7 @@ export function limitsPage(ctx: Ctx): Response {
201203 ${historyBlock("Invite links made", inviteRows)}
202204 <section class="settings-block mt">
203205 <h2>Limits</h2>
206 <p class="small muted mb" id="included-credit-worst">Worst case a day for the crew credit new forges come with: ${usdWords(worstCaseOf((k) => limits.find((l) => l.key === k)?.value ?? 0))} (new accounts an hour × 24 × forges a person may own × the credit).</p>
204207 <div class="table-scroll">
205208 <table class="table setting-table" data-dense>
206209 <caption class="sr-only">Limits, their values and how to change them</caption>
src/web/pages/apps.ts +2
@@ -12,6 +12,7 @@
1212 * 404 for everyone.
1313 */
1414
15 import { crewKeyCard } from "../studio/crew_key.ts";
1516 import { storageLimitMessage } from "../../core/storage.ts";
1617 import { repositoryLimitMessage } from "../../actions/repos.ts";
1718 import { appLimitWords } from "../../actions/app_limit.ts";
@@ -328,6 +329,7 @@ function newAppForm(
328329 ${pageHead("New app", {
329330 sub: "Describe what it should do. You get a repository, a first issue, a crew member on it, a live app, and a preview for every change.",
330331 })}
332 ${crewKeyCard(ctx, true)}
331333 ${html`<form method="post" action="/apps/new">
332334 ${values.template
333335 ? formField({ label: "Copy of", name: "template", value: values.template, readonly: true, mono: true })
src/web/pages/claim.ts +5 −2
@@ -18,6 +18,7 @@
1818 * so before anyone submits, and where to remove it.
1919 */
2020
21 import { crewCanRun, hasCrew } from "../studio/crew_key.ts";
2122 import { PASSWORD_TOO_LONG, withinPasswordCap } from "../../core/hash-gate.ts";
2223 import { givenEmail, MIN_SIGNUP_PASSWORD, noEmail } from "../../actions/users.ts";
2324 import type { Credential } from "../../auth/webauthn.ts";
@@ -152,8 +153,10 @@ function claimForm(
152153 ${pageHead("This forge is yours")}
153154 <section class="settings-block">
154155 <p class="mb">
155 ${spawned.org.username} was made for you, with its apps and a crew
156 ready to build them. Make your account to take it: this first account
156 ${spawned.org.username} was made for you, with its apps and
157 ${crewCanRun(ctx.app) && hasCrew(ctx.app)
158 ? "a crew ready to build them"
159 : "a crew that builds them once you add a model key"}. Make your account to take it: this first account
157160 is the forge's admin, so it can change everything here.
158161 </p>
159162 <p class="mb" id="claim-access">${accessSentence(spawned, spawner)}</p>
src/web/pages/subscriptions.ts +12 −2
@@ -60,6 +60,8 @@ import { whenText } from "../../crew/limits.ts";
6060 import { accountSections } from "./origins.ts";
6161 import { liveRegionsJs } from "../client.ts";
6262 import { markOf } from "../../core/marks.ts";
63 import { crewKeyCard, includedCredit, isHostedForge } from "../studio/crew_key.ts";
64 import { usdWords } from "../../spawn/credit.ts";
6365 import { limitValue } from "../../settings/limits.ts";
6466
6567 const POOL = "/settings/subscriptions";
@@ -108,8 +110,11 @@ export function subscriptionsPage(ctx: Ctx): Response {
108110 const denied = requireAdmin(ctx);
109111 if (denied) return denied;
110112 const now = new Date();
113 const included = includedCredit(ctx.app);
114 // On a hosted forge the platform row is the host's included credit, said
115 // as what is left of it, or it is not shown (#11154).
111116 const pool = timePhase(ctx.request, "credentials", () =>
112 listCredentials(ctx.app.db),
117 listCredentials(ctx.app.db).filter((s) => s.id !== PLATFORM || !isHostedForge(ctx.app)),
113118 );
114119 const places = timePhase(ctx.request, "places", () =>
115120 listGrantPlaces(ctx.app.db),
@@ -270,6 +275,11 @@ export function subscriptionsPage(ctx: Ctx): Response {
270275 { extra: "setting-list" },
271276 );
272277
278 const own = pool.some((s) => s.id !== PLATFORM) ? null : crewKeyCard(ctx, true);
279 const lent =
280 included === undefined
281 ? null
282 : html`<p class="small muted" id="included-credit">${icon("key")} Included crew credit: ${usdWords(included.leftUsd)} of ${usdWords(included.capUsd)} left. When it is spent the crew stops until you add your own key.</p>`;
273283 const add = html`<section class="settings-block"><h2>Add one</h2>
274284 <form class="stack" method="post" action="${POOL}">
275285 <div class="field">
@@ -336,7 +346,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
336346
337347 const response = shell(
338348 ctx,
339 html`${list} ${none === null ? null : html`<div class="mt">${none}</div>`}
349 html`${lent} ${own} ${isHostedForge(ctx.app) && sealed.length === 0 ? null : list} ${none === null || isHostedForge(ctx.app) ? null : html`<div class="mt">${none}</div>`}
340350 <p class="mt muted small">What they spent: <a href="/settings/spend">Usage → Spend</a>.</p>
341351 <div class="mt">${add}</div>
342352 ${used === null ? null : html`<div class="mt">${used}</div>`}`,
src/web/studio/app.ts +3 −3
@@ -61,7 +61,7 @@ import { notesCard } from "./notes.ts";
6161 import { adapterCard } from "./adapter.ts";
6262 import { appPanels } from "./previews.ts";
6363 import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts";
64 import { crewKeyNote } from "./crew.ts";
64 import { crewKeyCard } from "./crew_key.ts";
6565 import {
6666 notePhase,
6767 takeSessionTime,
@@ -500,7 +500,7 @@ export async function studioApp(
500500 ? archivedParentLine(ctx, repo)
501501 : followingCard(ctx, repo, following)}
502502 <div id="app-head">${head}</div>
503 ${ownerFacts ? crewKeyNote(ctx) : null}
503 ${ownerFacts ? crewKeyCard(ctx, true) : null}
504504 <div class="stack">
505505 ${adapter}
506506 ${tellRegion}
@@ -520,7 +520,7 @@ export async function studioApp(
520520 <div id="app-head">${head}</div>
521521 ${askingLine(ctx, repo)}
522522 ${policy}
523 ${ownerFacts ? crewKeyNote(ctx) : null}
523 ${ownerFacts ? crewKeyCard(ctx, true) : null}
524524 ${ownerFacts
525525 ? egressNote(
526526 ctx.app.config.dataDir,
src/web/studio/crew.ts −16
@@ -30,7 +30,6 @@ import { listCrewRoles, type Agent, type CrewRole } from "../../core/agents.ts";
3030 import { getAppByRepo } from "../../core/apps.ts";
3131 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3232 import { seatsOf, type Seat } from "../../crew/seats.ts";
33 import { crewHasCredential } from "../../core/subscriptions.ts";
3433 import { autonomyStatus } from "../../crew/autonomy.ts";
3534 import { governorSeen } from "../../crew/governed.ts";
3635 import { crewOffInPreview } from "../../crew/preview.ts";
@@ -415,18 +414,3 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html {
415414 ${admin ? fileHoldsCard(ctx, repo, base) : null}
416415 </div>`;
417416 }
418
419 /**
420 * One sentence when the crew has no model credential, so it cannot start:
421 * what is missing and where it is added — for the admin, a link to it; for
422 * anyone else, who can. Nothing when the crew can run.
423 */
424 export function crewKeyNote(ctx: Ctx): Html | null {
425 if (crewHasCredential(ctx.app.db)) return null;
426 return html`<p class="small muted" id="crew-key-note" role="status">
427 ${icon("alert")} The crew cannot start work yet: it needs a subscription.
428 ${ctx.auth.user?.isAdmin
429 ? html`<a href="/settings/subscriptions">Add one in Subscriptions</a>.`
430 : "Ask this forge's admin to add one."}
431 </p>`;
432 }
src/web/studio/crew_key.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * Bring your own key (#11154): the one field that makes a forge's crew work,
3 * the words for each state of the crew's means (own key, included credit
4 * with money left, credit spent, nothing), and the POST that seals the key.
5 * The key is sealed in this forge's database by `addSubscription`, offered to
6 * the crew of the owner's apps, and goes nowhere else: never logged, never
7 * echoed, never sent to the host.
8 */
9
10 import { existsSync } from "node:fs";
11 import { join } from "node:path";
12 import type { App } from "../../app.ts";
13 import { listAgents } from "../../core/agents.ts";
14 import { saveCrewKey } from "../../actions/crew_key.ts";
15 import { crewHasCredential } from "../../core/subscriptions.ts";
16 import { leftUsd, usdWords } from "../../spawn/credit.ts";
17 import { fundingOf } from "../../spawn/funding.ts";
18 import type { Ctx } from "../../http/context.ts";
19 import { notFoundPage, signInFirst } from "../chrome.ts";
20 import { field, readForm } from "../form.ts";
21 import { html, type Html } from "../html.ts";
22 import { icon } from "../icons.ts";
23 import { flashTo } from "../layout.ts";
24 import { btn } from "../ui.ts";
25
26 export const KEY_ACTION = "/settings/crew/key";
27 /** The form is the key and little else: far under the general body limit. */
28 const KEY_FORM_MAX = 2048;
29
30 export interface Included {
31 readonly capUsd: number;
32 readonly leftUsd: number;
33 }
34
35 /** The credit this forge came with and what is left of it (last heard from its host), or undefined when it has none. */
36 export function includedCredit(app: App): Included | undefined {
37 const f = fundingOf(app.config.dataDir);
38 if (f === undefined || f.capUsd === null) return undefined;
39 return { capUsd: f.capUsd, leftUsd: leftUsd({ capUsd: f.capUsd, spentUsd: f.spentUsd ?? 0 }) };
40 }
41
42 /** Whether a forge is a hosted child: its host's file is in its data directory. */
43 export const isHostedForge = (app: App): boolean => existsSync(join(app.config.dataDir, "parent.json"));
44
45 /** Whether the crew has anything to run on: a key of the owner's, or included credit with money left. */
46 export function crewCanRun(app: App): boolean {
47 if (crewHasCredential(app.db)) return true;
48 const c = includedCredit(app);
49 return c !== undefined && c.leftUsd > 0;
50 }
51
52 /** Whether the forge has a crew at all, so a line about it is true. */
53 export const hasCrew = (app: App): boolean => listAgents(app.db).length > 0;
54
55 const keyField = (): Html => html`<form class="stack" method="post" action="${KEY_ACTION}" id="crew-key-form">
56 <div class="field">
57 <label for="crew-key">Your Anthropic API key or Claude login</label>
58 <input class="input" id="crew-key" name="token" type="password" required maxlength="512" autocomplete="off" spellcheck="false" placeholder="sk-ant-…" />
59 </div>
60 <div>${btn("Save key", { variant: "primary", size: "sm", icon: "key" })}</div>
61 </form>`;
62
63 /** What the crew runs on, said once, with the field when a key would change anything (`compact`: a line and a link, no field). */
64 export function crewKeyCard(ctx: Ctx, compact = false): Html | null {
65 if (crewHasCredential(ctx.app.db)) return null;
66 const admin = ctx.auth.user?.isAdmin === true;
67 // On a repository page: one line and a link, the field lives on the home page.
68 if (compact)
69 return html`<p class="small muted" id="crew-key-note" role="status">${icon("alert")} The crew needs a model key to start.
70 ${admin ? html`<a href="/#crew-key-note">Add one on the home page</a>.` : "Ask this forge's admin to add one."}</p>`;
71 const credit = includedCredit(ctx.app);
72 if (credit !== undefined && credit.leftUsd > 0)
73 return html`<section class="settings-block" id="crew-key-note" role="status">
74 <p class="small muted">${icon("key")} The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit.
75 ${admin ? "Add your own key any time to keep building after it:" : "Its admin can add a key to keep building after it."}</p>
76 ${admin ? keyField() : null}
77 </section>`;
78 const said =
79 credit !== undefined
80 ? "The included credit is spent. Add your own key to keep building, or wait for next month."
81 : "The crew needs a model key to start. Paste yours.";
82 return html`<section class="settings-block" id="crew-key-note" role="status">
83 <p class="small muted">${icon("alert")} ${said}${admin ? "" : " Ask this forge's admin."}</p>
84 ${admin ? keyField() : null}
85 </section>`;
86 }
87
88 // POST /settings/crew/key
89 export async function crewKeyAdd(ctx: Ctx): Promise<Response> {
90 const user = ctx.auth.user;
91 if (!user) return signInFirst(ctx);
92 if (!user.isAdmin) return notFoundPage(ctx);
93 const form = await readForm(ctx, "admin", KEY_FORM_MAX);
94 if (form instanceof Response) return form;
95 const saved = await saveCrewKey(ctx.app, user, field(form, "token"));
96 if (!saved.ok)
97 return saved.error === "not_found"
98 ? notFoundPage(ctx)
99 : flashTo(ctx, "/", { tone: "danger", message: "That key could not be saved. Check that it is the whole key and try again." });
100 return flashTo(ctx, "/", { message: "Key saved. The crew can work now." });
101 }
src/web/studio/home.ts +3 −3
@@ -80,7 +80,7 @@ import { pinnedPanels, pinnedSlot } from "./pinned.ts";
8080 import { isNavigation, lastVisit, noteVisit } from "../../core/home_visit.ts";
8181 import { sinceYouWere } from "./since.ts";
8282 import { homeTabs, unshippedAsks } from "./home_tabs.ts";
83 import { crewKeyNote } from "./crew.ts";
83 import { crewKeyCard } from "./crew_key.ts";
8484 import { everydayCard } from "../pages/everyday.ts";
8585 import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts";
8686 import { spawnedOrg } from "../../spawn/claim.ts";
@@ -317,7 +317,7 @@ export async function studioHome(
317317 ) {
318318 const apps = await clientApps(ctx, user);
319319 const lean = html`<h1 class="sr-only">Home</h1>
320 ${firstSteps} ${address} ${crewKeyNote(ctx)} ${heldLine(ctx, user)} ${apps}
320 ${firstSteps} ${address} ${crewKeyCard(ctx)} ${heldLine(ctx, user)} ${apps}
321321 <div class="stack">
322322 ${composer(ctx, user)}
323323 <p class="small muted">
@@ -420,7 +420,7 @@ export async function feedPage(ctx: Ctx): Promise<Response> {
420420 ${await regionOf("feed")}
421421 </div>
422422 <aside class="stack">
423 ${user.isAdmin || appRepos.length > 0 ? crewKeyNote(ctx) : null}
423 ${user.isAdmin || appRepos.length > 0 ? crewKeyCard(ctx) : null}
424424 ${appsRegion(ctx, user)}
425425 ${terminalCard(ctx)} ${downloadsCard(ctx, user)}
426426 ${orgsCard(ctx, user)}
test/child-credit.test.ts +3
@@ -41,6 +41,7 @@ import { openBeatDoor, parentLocal, type BeatDoor } from "../src/spawn/beat-door
4141 import { readCredit } from "../src/spawn/credit.ts";
4242 import { fundedEnv, fundingOf, fundingWords, refreshFunding } from "../src/spawn/funding.ts";
4343 import { listPriceUsd } from "../src/spawn/list_price.ts";
44 import { setLimit } from "../src/settings/limits.ts";
4445 import { quietLogs } from "./helpers/globals.ts";
4546
4647 quietLogs();
@@ -105,6 +106,8 @@ describe("a parent funds a child's crew", () => {
105106 const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true });
106107 const bob = await createUser(parent.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-credit-passphrase" });
107108 if (!alice.ok || !bob.ok) throw new Error("users");
109 // These tests lend credit by hand: the credit a new forge comes with (#11154) is off here.
110 setLimit(parent.db, "spawn.included_credit_usd", 0, alice.value.id);
108111 adminRw = createToken(parent.db, alice.value.id, "rw", "rw").token;
109112 bobRw = createToken(parent.db, bob.value.id, "rw", "rw").token;
110113 const repo = await createRepo(parent, alice.value, { name: "bakery" });
test/crew-key.test.ts added +164
@@ -0,0 +1,164 @@
1 /**
2 * The owner's model key (#11154), at both doors: the page's POST
3 * /settings/crew/key (admin only, same-origin, bounded) and the terminal's
4 * Crew key screen, both through `saveCrewKey`.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { keyShapeOk, saveCrewKey, KEY_MAX_BYTES } from "../src/actions/crew_key.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { listGrants, listSubscriptions } from "../src/core/subscriptions.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import type { TuiIdentity } from "../src/tui/session.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp, settleUntil } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 const GOOD = "sk-ant-api03-abcdefghijklmnop";
25
26 describe("adding the crew's key", () => {
27 let dir = "";
28 let app: App;
29 let servers: Servers;
30 let admin = "";
31 let plain = "";
32 const post = (cookie: string | undefined, token: string, headers: Record<string, string> = {}) =>
33 fetch(`${servers.url}/settings/crew/key`, {
34 method: "POST",
35 redirect: "manual",
36 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, ...(cookie ? { cookie } : {}), ...headers },
37 body: new URLSearchParams({ token }),
38 });
39
40 beforeAll(async () => {
41 dir = await mkdtemp(join(tmpdir(), "forge-crew-key-"));
42 app = createApp({ dataDir: dir, port: 0 });
43 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
44 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-starter-passphrase", adminIfFirst });
45 if (!made.ok) throw new Error(made.error);
46 }
47 admin = `forge_session=${createSession(app.db, getUserByName(app.db, "alice")!.id).token}`;
48 plain = `forge_session=${createSession(app.db, getUserByName(app.db, "bob")!.id).token}`;
49 servers = await serve(app);
50 });
51 afterAll(async () => {
52 servers?.stop();
53 app?.close();
54 await rm(dir, { recursive: true, force: true });
55 });
56
57 test("a signed-out caller is sent to sign in, and nothing is saved", async () => {
58 const res = await post(undefined, GOOD);
59 expect([302, 303]).toContain(res.status);
60 expect(res.headers.get("location") ?? "").toContain("/signin");
61 expect(listSubscriptions(app.db)).toEqual([]);
62 });
63
64 test("a non-admin gets the not-found page", async () => {
65 expect((await post(plain, GOOD)).status).toBe(404);
66 expect(listSubscriptions(app.db)).toEqual([]);
67 });
68
69 test("a cross-origin POST is refused", async () => {
70 const res = await post(admin, GOOD, { origin: "https://evil.example" });
71 expect(res.status).toBe(403);
72 expect(listSubscriptions(app.db)).toEqual([]);
73 });
74
75 test("a key with a control character or over the byte cap is refused before it is sealed", async () => {
76 expect(keyShapeOk("sk-ant-api03-ab\ncd")).toBe(false);
77 expect(keyShapeOk("sk-ant-api03-ab\u0000cd")).toBe(false);
78 expect(keyShapeOk("é".repeat(KEY_MAX_BYTES / 2 + 1))).toBe(false);
79 expect(keyShapeOk(GOOD)).toBe(true);
80 const res = await post(admin, `sk-ant-api03-ab\u0007cd`);
81 expect(res.status).toBe(303);
82 expect(listSubscriptions(app.db)).toEqual([]);
83 const flashed = await (await fetch(`${servers.url}/`, { headers: { cookie: `${admin}; ${(res.headers.getSetCookie()[0] ?? "").split(";")[0]}` } })).text();
84 expect(flashed).toContain("That key could not be saved");
85 });
86
87 test("a bad key flashes the error and saves nothing", async () => {
88 const res = await post(admin, " ");
89 expect(res.status).toBe(303);
90 expect(listSubscriptions(app.db)).toEqual([]);
91 });
92
93 test("a good key is sealed and granted to the admin; the page never echoes it", async () => {
94 const res = await post(admin, GOOD);
95 expect(res.status).toBe(303);
96 const subs = listSubscriptions(app.db);
97 expect(subs.map((s) => s.name)).toEqual(["my-key"]);
98 expect(listGrants(app.db, getUserByName(app.db, "alice")!.id).map((g) => g.subscriptionId)).toEqual([subs[0]!.id]);
99 const home = await (await fetch(`${servers.url}/`, { headers: { cookie: admin } })).text();
100 expect(home).not.toContain(GOOD);
101 });
102
103 test("a person who is not an admin is refused by the action itself", async () => {
104 expect(await saveCrewKey(app, getUserByName(app.db, "bob")!, GOOD)).toEqual({ ok: false, error: "not_found" });
105 });
106 });
107
108 describe("the terminal door", () => {
109 test("Crew key is offered to an admin, takes a masked key, and is not offered to anyone else", async () => {
110 const dataDir = await mkdtemp(join(tmpdir(), "forge-tui-crewkey-"));
111 const forge = createApp({ dataDir, port: 0 });
112 try {
113 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
114 const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-starter-passphrase", adminIfFirst });
115 if (!made.ok) throw new Error(made.error);
116 }
117 const boot = (username: string) => {
118 const terminal = headlessTerminal(140, 32);
119 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, username)! };
120 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
121 app.start();
122 const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
123 return {
124 app,
125 open: async () => {
126 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
127 await settleApp(app);
128 for (const ch of "Crew key") key(ch);
129 await settleApp(app);
130 },
131 type: async (t: string) => {
132 for (const ch of t) key(ch);
133 await settleApp(app);
134 },
135 press: async (k: string) => {
136 key(k);
137 await settleApp(app);
138 },
139 };
140 };
141 const bob = boot("bob");
142 await bob.open();
143 expect(bob.app.screen.toText()).not.toContain("what the crew runs on");
144 bob.app.stop();
145
146 const h = boot("alice");
147 await h.open();
148 await settleUntil(h.app, "what the crew runs on", { timeoutMs: 4_000 });
149 await h.press("enter");
150 await settleUntil(h.app, "The crew needs a model key to start", { timeoutMs: 4_000 });
151 await h.press("a");
152 await settleUntil(h.app, "Your model key", { timeoutMs: 4_000 });
153 await h.type(GOOD);
154 expect(h.app.screen.toText()).not.toContain("abcdefghijklmnop");
155 await h.press("enter");
156 await settleUntil(h.app, "Key saved", { timeoutMs: 4_000 });
157 expect(listSubscriptions(forge.db).map((s) => s.name)).toEqual(["my-key"]);
158 h.app.stop();
159 } finally {
160 forge.close();
161 await rm(dataDir, { recursive: true, force: true });
162 }
163 });
164 });
test/palette-roster.test.ts +1
@@ -155,6 +155,7 @@ test("a site-admin person is offered the whole roster", () => {
155155 ["requests", "Waiting", "strangers who asked for an account: allow or decline"],
156156 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
157157 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
158 ["crewkey", "Crew key", "what the crew runs on; add your own model key"],
158159 ["money", "Money", "this month's cost, revenue and gap, and the last 30 days"],
159160 ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"],
160161 ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"],
test/signup-gate-parent.test.ts +1
@@ -119,6 +119,7 @@ test("every caller of createUser says where its account comes from", async () =>
119119 "src/packs/mirror.ts": ["seed"],
120120 "src/packs/up.ts": ["seed"],
121121 "src/spawn/preview-seed.ts": ["seed", "seed"],
122 "src/spawn/starter_crew.ts": ["spawn"],
122123 "src/web/pages/claim.ts": ["claim"],
123124 "src/web/pages/everyday.ts": ["everyday"],
124125 "src/web/pages/start_claim.ts": ["claimer"],
test/spawn-child-apps.test.ts +10 −7
@@ -5,6 +5,7 @@
55 * credential, the home and the app page say so and where to add one.
66 */
77
8 import { setLimit } from "../src/settings/limits.ts";
89 import { seatAs } from "./helpers/seat.ts";
910 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1011 import { mkdtemp, rm } from "node:fs/promises";
@@ -55,6 +56,8 @@ describe("a spawned child's apps", () => {
5556 });
5657 if (!made.ok) throw new Error("user");
5758 const alice = made.value;
59 // This forge is made with no included credit (#11154), so it has neither credit nor key.
60 setLimit(parent.db, "spawn.included_credit_usd", 0, alice.id);
5861 // One app (its forge.json says so) and one plain repository.
5962 for (const [name, files] of [
6063 [
@@ -175,18 +178,18 @@ describe("a spawned child's apps", () => {
175178 ).replace(/\s+/g, " ");
176179 expect(home).toContain("bakery");
177180 expect(home).not.toContain("No apps yet");
178 expect(home).toContain("The crew cannot start work yet");
179 expect(home).toContain('href="/settings/subscriptions"');
181 expect(home).toContain("The crew needs a model key to start");
182 expect(home).toContain('action="/settings/crew/key"');
180183 const appPage = await (
181184 await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } })
182185 ).text();
183 expect(appPage).toContain("The crew cannot start work yet");
186 expect(appPage).toContain("The crew needs a model key to start");
184187 });
185188
186189 test("on the app page the key note is an owner fact: not for a stranger or a plain reader", async () => {
187190 const anonymous = await (await fetch(`${servers.url}/marias/bakery`)).text();
188191 expect(anonymous).toContain("bakery");
189 expect(anonymous).not.toContain("The crew cannot start work yet");
192 expect(anonymous).not.toContain("The crew needs a model key to start");
190193 const reader = await createUser(child.db, { origin: "seed", trusted: true,
191194 username: "reader",
192195 email: "[email protected]",
@@ -201,14 +204,14 @@ describe("a spawned child's apps", () => {
201204 })
202205 ).text();
203206 expect(asReader).toContain("bakery");
204 expect(asReader).not.toContain("The crew cannot start work yet");
207 expect(asReader).not.toContain("The crew needs a model key to start");
205208 // Nor on the home: there the note is for an admin or a writer of an app.
206209 const readerHome = await (
207210 await fetch(`${servers.url}/`, {
208211 headers: { cookie: `forge_session=${session.token}` },
209212 })
210213 ).text();
211 expect(readerHome).not.toContain("The crew cannot start work yet");
214 expect(readerHome).not.toContain("The crew needs a model key to start");
212215 });
213216
214217 test("first boot registers apps as the founder, an account with no verified email", () => {
@@ -262,6 +265,6 @@ describe("a spawned child's apps", () => {
262265 const home = await (
263266 await fetch(`${servers.url}/`, { headers: { cookie } })
264267 ).text();
265 expect(home).not.toContain("The crew cannot start work yet");
268 expect(home).not.toContain("The crew needs a model key to start");
266269 });
267270 });
test/spawn.test.ts +2
@@ -90,6 +90,8 @@ describe("spawnForge", () => {
9090 statusHash: expect.stringMatching(/^[0-9a-f]{64}$/),
9191 // The child signs heartbeats to its parent with it (#425).
9292 beatKey: expect.stringMatching(/^[A-Za-z0-9_-]{20,}$/),
93 // The plan's included credit, lent once (#11154).
94 credit: { capUsd: 2, spentUsd: 0, token: expect.stringMatching(/^kid\./) },
9395 });
9496 });
9597
test/starter-crew.test.ts added +183
@@ -0,0 +1,183 @@
1 /**
2 * A new forge comes with a crew that builds at once (#11154): a builder and
3 * a reviewer seated on its apps with autonomy on, the included credit lent
4 * through the child credit, one field for the owner's own key, and no
5 * promise of a crew when there is neither credit nor key.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { childDirOf } from "../src/actions/spawn.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { createAgentRow, listAgents, listCrewRoles } from "../src/core/agents.ts";
16 import { getRepo } from "../src/core/repos.ts";
17 import { listTeamMembers, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts";
18 import { getUserByName } from "../src/core/users.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { createUser } from "../src/core/users.ts";
21 import { listGrants, listSubscriptions } from "../src/core/subscriptions.ts";
22 import { repoDir } from "../src/git/repos.ts";
23 import { commitFiles } from "../src/git/write.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { setLimit } from "../src/settings/limits.ts";
26 import { readCredit } from "../src/spawn/credit.ts";
27 import { firstBoot } from "../src/spawn/first-boot.ts";
28 import { fundingOf, keepFunding } from "../src/spawn/funding.ts";
29 import type { Ctx } from "../src/http/context.ts";
30 import { crewKeyCard, crewCanRun, includedCredit } from "../src/web/studio/crew_key.ts";
31 import { saveCrewKey } from "../src/actions/crew_key.ts";
32 import { FORGE_CODE_NAME } from "../src/spawn/forge_code.ts";
33 import { hasStarterCrew, seatStarterCrew, seedStarterCrew } from "../src/spawn/starter_crew.ts";
34 import { worstCaseDailyUsd } from "../src/spawn/included_credit.ts";
35 import { quietLogs } from "./helpers/globals.ts";
36
37 quietLogs();
38
39 describe("a new forge's starter crew", () => {
40 let dir = "";
41 let parent: App;
42 let child: App;
43 let servers: Servers;
44 let token = "";
45 let childDir = "";
46
47 const spawn = async (name: string): Promise<string> => {
48 const made = await fetch(`${servers.url}/api/v1/forges`, {
49 method: "POST",
50 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
51 body: JSON.stringify({ name, apps: ["alice/bakery"], dryRun: false }),
52 });
53 if (made.status !== 201) throw new Error(`spawn ${made.status}`);
54 return ((await made.json()) as { bodyId: string }).bodyId;
55 };
56
57 beforeAll(async () => {
58 dir = await mkdtemp(join(tmpdir(), "forge-starter-crew-"));
59 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
60 const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-starter-passphrase", isAdmin: true });
61 if (!alice.ok) throw new Error("user");
62 token = createToken(parent.db, alice.value.id, "rw", "rw").token;
63 const repo = await createRepo(parent, alice.value, { name: "bakery" });
64 if (!repo.ok) throw new Error("repo");
65 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
66 branch: "main",
67 message: "bakery",
68 files: [
69 { path: "README.md", content: "# bakery\n" },
70 { path: "forge.json", content: JSON.stringify({ app: { run: "bun run index.ts" } }) },
71 ],
72 author: { name: "a", email: "[email protected]" },
73 });
74 if (!c.ok) throw new Error("commit");
75 servers = await serve(parent);
76 childDir = childDirOf(parent, await spawn("maria"));
77 child = createApp({ dataDir: childDir, port: 0 });
78 await firstBoot(child);
79 });
80
81 afterAll(async () => {
82 servers?.stop();
83 child?.close();
84 parent?.close();
85 await rm(dir, { recursive: true, force: true });
86 });
87
88 test("it has a builder and a reviewer, seated on its app, autonomy on", () => {
89 expect(listAgents(child.db).map((a) => a.user.username).sort()).toEqual(["builder", "reviewer"]);
90 const repo = getRepo(child.db, "maria", "bakery")!;
91 expect(listCrewRoles(child.db, repo.id).map((c) => c.role).sort()).toEqual(["builder", "reviewer"]);
92 expect(repo.autonomy).toBe("autonomous");
93 expect(repo.autonomyBy).not.toBeNull();
94 });
95
96 test("it is lent the plan's included credit once, with no key of its own", () => {
97 expect(readCredit(parent, "maria")?.capUsd).toBe(2);
98 expect(fundingOf(childDir)?.capUsd).toBe(2);
99 expect(listSubscriptions(child.db)).toEqual([]);
100 expect(includedCredit(child)).toEqual({ capUsd: 2, leftUsd: 2 });
101 expect(crewCanRun(child)).toBe(true);
102 });
103
104 test("spent credit and no key: the crew cannot run, and the page says what is needed", () => {
105 keepFunding(childDir, { token: fundingOf(childDir)!.token, capUsd: 2, spentUsd: 2 });
106 expect(crewCanRun(child)).toBe(false);
107 expect(includedCredit(child)?.leftUsd).toBe(0);
108 });
109
110 test("the key card says what is needed, once, and never calls it a subscription", () => {
111 const ctx = { app: child, auth: { user: { isAdmin: true } } } as unknown as Ctx;
112 const spent = String(crewKeyCard(ctx));
113 expect(spent).toContain("included credit is spent");
114 expect(spent).toContain('name="token"');
115 expect(spent.toLowerCase()).not.toContain("subscription");
116 keepFunding(childDir, { token: fundingOf(childDir)!.token, capUsd: 2, spentUsd: 0.5 });
117 expect(String(crewKeyCard(ctx))).toContain("$1.50 of $2 included credit");
118 });
119
120 test("a repository made later in the forge is seated and autonomous too", async () => {
121 const owners = listTeams(child.db, getUserByName(child.db, "maria")!.id).find((t) => t.name === OWNERS_TEAM)!;
122 const founder = listTeamMembers(child.db, owners.id)[0]!;
123 const made = await createRepo(child, founder, { name: "second", owner: "maria" });
124 if (!made.ok) throw new Error(`repo ${made.error}`);
125 expect(listCrewRoles(child.db, made.value.id).map((c) => c.role).sort()).toEqual(["builder", "reviewer"]);
126 expect(getRepo(child.db, "maria", "second")!.autonomy).toBe("autonomous");
127 });
128
129 test("a forge brought its own key is lent nothing; 0 turns the credit off", async () => {
130 setLimit(parent.db, "spawn.included_credit_usd", 0, parent.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id);
131 await spawn("nolend");
132 expect(readCredit(parent, "nolend")).toBeUndefined();
133 });
134
135 test("the worst case a day is the visitor limit times the credit", () => {
136 expect(worstCaseDailyUsd(parent)).toBe(0);
137 });
138
139 test("seatStarterCrew leaves the forge's own code to adoptForgeCode, and a person's autonomy choice alone", async () => {
140 const owners = listTeams(child.db, getUserByName(child.db, "maria")!.id).find((t) => t.name === OWNERS_TEAM)!;
141 const founder = listTeamMembers(child.db, owners.id)[0]!;
142 // The forge's own code: named FORGE_CODE_NAME, so not seated by the starter crew.
143 const code = await createRepo(child, founder, { name: "third", owner: "maria" });
144 if (!code.ok) throw new Error(`repo ${code.error}`);
145 child.db.query("DELETE FROM repo_crew WHERE repo_id = ?1").run(code.value.id);
146 seatStarterCrew(child.db, { ...code.value, name: FORGE_CODE_NAME }, founder.id);
147 expect(listCrewRoles(child.db, code.value.id)).toEqual([]);
148 // A person chose autonomy off: it stays off though the crew is seated.
149 const chosen = await createRepo(child, founder, { name: "fourth", owner: "maria" });
150 if (!chosen.ok) throw new Error(`repo ${chosen.error}`);
151 child.db.query("UPDATE repo SET autonomy = 'off', autonomy_by = ?2 WHERE id = ?1").run(chosen.value.id, founder.id);
152 seatStarterCrew(child.db, getRepo(child.db, "maria", "fourth")!, founder.id);
153 expect(getRepo(child.db, "maria", "fourth")!.autonomy).toBe("off");
154 });
155
156 test("a forge that already has crew keeps it, and the starter pair is not added", async () => {
157 const dataDir = await mkdtemp(join(tmpdir(), "forge-has-crew-"));
158 const own = createApp({ dataDir, port: 0 });
159 try {
160 const made = await createUser(own.db, { origin: "seed", trusted: true, username: "zed", email: "[email protected]", password: "a-long-starter-passphrase", isAdmin: true });
161 const scribe = await createUser(own.db, { username: "scribe", email: "[email protected]", agent: true, origin: "seed" });
162 if (!made.ok || !scribe.ok) throw new Error("user");
163 createAgentRow(own.db, { userId: scribe.value.id, roles: ["builder"], createdBy: made.value.id });
164 expect(await seedStarterCrew(own.db, made.value.id, made.value.id)).toBe(false);
165 expect(listAgents(own.db).map((a) => a.user.username)).toEqual(["scribe"]);
166 expect(hasStarterCrew(own.db, made.value.id)).toBe(false);
167 } finally {
168 own.close();
169 await rm(dataDir, { recursive: true, force: true });
170 }
171 });
172
173 test("a key saved by the admin is sealed and granted to the owner and the spawned organisation", async () => {
174 const owners = listTeams(child.db, getUserByName(child.db, "maria")!.id).find((t) => t.name === OWNERS_TEAM)!;
175 const founder = listTeamMembers(child.db, owners.id)[0]!;
176 const saved = await saveCrewKey(child, { ...founder, isAdmin: true, kind: "user" }, " sk-ant-api03-abcdefghij ");
177 expect(saved).toEqual({ ok: true });
178 const subs = listSubscriptions(child.db);
179 expect(subs.map((x) => x.name)).toEqual(["my-key"]);
180 const org = getUserByName(child.db, "maria")!;
181 for (const scope of [founder.id, org.id]) expect(listGrants(child.db, scope).map((g) => g.subscriptionId)).toEqual([subs[0]!.id]);
182 });
183 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs