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

Crew runs on a forge admin's org repos use the forge's model account (every child made for an org fails its first run today) #11128

Merged opened this in answer to

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

Merged

mason/forge:mason/10991-crew-runs-on-a-forge-admin-s-org-repos-u → main 4 commits fork

  • Merged. mason/10991-crew-runs-on-a-forge-admin-s-org-repos-u was merged into main as d9e3080.

Review

Sign in to review this change.
7 files changed +73 −9
docs/features/org-model-grant.md added +12
@@ -0,0 +1,12 @@
1 # An org's model account
2
3 A new organization has no model account, so its crew runs refuse with
4 "the account `<org>` has no model grant; an admin can grant one at
5 /settings/accounts/<org>".
6
7 A forge admin grants it there (the "Model budget" card), or with
8 `POST /api/v1/admin/accounts/<org>/model-grant {"usdPerMonth": 20}` (0 or null
9 takes it back): dollars a month on the forge's own credential, audited as
10 `account.model_granted`. The grant is explicit on purpose: membership of an
11 org's Owners team is the org owners' to change, so it never reaches the forge's
12 credential, even with a forge admin on it.
src/actions/model_grant.ts +2 −2
@@ -32,7 +32,7 @@ export const ADD_MESSAGES = {
3232
3333 export type ModelGrantError = "not_found" | "not_allowed" | "invalid_amount";
3434
35 /** A person's account, for a forge admin who is a person: not an agent, not an org, not another admin. */
35 /** A person's or an organization's account, for a forge admin who is a person: not an agent, not another admin. An org's grant is the only way its repositories reach the forge's credential: team membership never is. */
3636 export function setAccountGrant(
3737 app: App,
3838 actor: User | undefined,
@@ -43,7 +43,7 @@ export function setAccountGrant(
4343 if (!admin.ok || !admin.value.isAdmin || admin.value.kind !== "user")
4444 return err("not_found");
4545 const user = getUserByName(app.db, username);
46 if (user === undefined || user.kind === "org") return err("not_found");
46 if (user === undefined) return err("not_found");
4747 if (user.kind === "agent" || user.isAdmin) return err("not_allowed");
4848 if (usd !== null && !validGrant(usd)) return err("invalid_amount");
4949 setModelGrant(app.db, user.id, usd, admin.value.id, new Date());
src/crew/runner.ts +14 −2
@@ -11,6 +11,7 @@
1111 * what the forge has.
1212 */
1313
14 import type { Database } from "bun:sqlite";
1415 import { isStrangerPull } from "./stranger.ts";
1516 import { noteMeasure } from "../core/measure.ts";
1617 import { capacityEnv } from "../core/capacity.ts";
@@ -265,6 +266,16 @@ export function steerRun(app: App, runId: string, text: string): boolean {
265266 return send(text);
266267 }
267268
269 /** What a refused run says: an org is named with the page where an admin grants it; a person keeps the plain words. */
270 function noGrantSaid(db: Database, ownerId: string, ownerName: string): string {
271 const org = db
272 .query<{ kind: string }, [string]>("SELECT kind FROM user WHERE id = ?1")
273 .get(ownerId)?.kind === "org";
274 return org
275 ? `the account \`${ownerName}\` has no model grant; an admin can grant one at /settings/accounts/${ownerName}`
276 : "This repository has no model account: ask an admin to grant one";
277 }
278
268279 /**
269280 * What a reviewer reads: the pull's diff (`review_range.ts` — a landed pull's
270281 * is its merge commit's), and its pictures. `landedAs` names the merge commit
@@ -1355,7 +1366,7 @@ async function work(
13551366 ) {
13561367 const said =
13571368 grantCap === undefined
1358 ? "This repository has no model account: ask an admin to grant one"
1369 ? noGrantSaid(app.db, repo.ownerId, repo.ownerName)
13591370 : "This month's model grant is spent: an admin can raise it";
13601371 write(said);
13611372 return { state: "failed", summary: said };
@@ -2344,7 +2355,8 @@ function redactResult<T extends { result: string }>(r: T): T {
23442355 export function credentialEnv(picked: Pick, dataDir?: string): Record<string, string> {
23452356 if (picked.kind === "ready") return credentialEnvOf(picked);
23462357 // 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;
2358 // "none" reaches here only under an owner's grant (an org's, #10991), which is the forge's account too.
2359 const lent = (picked.kind === "env" || picked.kind === "none") && dataDir !== undefined ? fundedEnv(dataDir) : undefined;
23482360 if (lent !== undefined) return lent;
23492361 const own: Record<string, string> = {};
23502362 for (const name of [
src/web/pages/accounts.ts +3 −3
@@ -42,7 +42,7 @@ import {
4242 userLink,
4343 } from "../ui.ts";
4444 import { sessionsCard } from "./account.ts";
45 import { ceilingCard, grantCard } from "./model.ts";
45 import { grantCard } from "./model.ts";
4646 import { accountSections } from "./origins.ts";
4747 import { markOf } from "../../core/marks.ts";
4848 import { requestsCard } from "../requests_card.ts";
@@ -269,9 +269,9 @@ export function personSignInsPage(ctx: Ctx): Response {
269269 if (!ctx.auth.user) return signInFirst(ctx);
270270 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
271271 const named = getUserByName(ctx.app.db, ctx.params.username ?? "");
272 // An organization has no sign-ins: its page is its spend ceilings.
272 // An organization has no sign-ins: its page is its model grant and spend ceilings.
273273 if (named?.kind === "org" && ctx.auth.user.kind === "user")
274 return shell(ctx, ceilingCard(ctx, named, `/settings/accounts/${encodeURIComponent(named.username)}`));
274 return shell(ctx, grantCard(ctx, named, `/settings/accounts/${encodeURIComponent(named.username)}`));
275275 const seen = accountSignIns(ctx.app, ctx.auth.user, ctx.params.username ?? "");
276276 // Yourself is your own settings; another admin, an agent, nobody: no page.
277277 if (!seen.ok || seen.value.user.id === ctx.auth.user.id)
test/child-credit.test.ts +3
@@ -195,6 +195,9 @@ describe("a parent funds a child's crew", () => {
195195 expect(env).toEqual({ ANTHROPIC_BASE_URL: parentLocal(door.port), ANTHROPIC_API_KEY: f.token });
196196 expect(JSON.stringify(env)).not.toContain(PARENT_KEY);
197197
198 // An org's grant (picked "none") reaches the same lent door (#10991).
199 expect(credentialEnv({ kind: "none" }, childDir)).toEqual(env);
200
198201 const first = await run(env);
199202 expect(first.status).toBe(200);
200203 expect(((await first.json()) as { model: string }).model).toBe("claude-sonnet-4-5");
test/grant-amounts.test.ts +3 −2
@@ -82,7 +82,7 @@ describe("a spend grant: what amount is kept, who takes one", () => {
8282 }
8383 });
8484
85 test("an agent or an admin takes no grant, and an org is not found", async () => {
85 test("an agent or an admin takes no grant; an org takes one from an admin but is no member", async () => {
8686 const bot = await createAgent(app, admin, { username: "bot" });
8787 if (!bot.ok) throw new Error(String(bot.error));
8888 const org = createOrg(app.db, "patco", pat.id);
@@ -93,9 +93,10 @@ describe("a spend grant: what amount is kept, who takes one", () => {
9393 expect(setRole(app, admin, name, "member", 5)).toEqual({ ok: false, error: "not_allowed" });
9494 expect(stored(user)).toBeUndefined();
9595 }
96 expect(setAccountGrant(app, admin, "patco", 5)).toEqual({ ok: false, error: "not_found" });
9796 expect(setRole(app, admin, "patco", "member", 5)).toEqual({ ok: false, error: "not_found" });
9897 expect(stored(getUserByName(app.db, "patco")!)).toBeUndefined();
98 expect(setAccountGrant(app, admin, "patco", 5).ok).toBe(true);
99 expect(stored(getUserByName(app.db, "patco")!)).toBe(5);
99100 expect(setAccountGrant(app, admin, "nobody", 5)).toEqual({ ok: false, error: "not_found" });
100101 });
101102
test/model-grant.test.ts +36
@@ -13,6 +13,7 @@ import { addToCrew } from "../src/actions/crew.ts";
1313 import { addOwnModel, setAccountGrant } from "../src/actions/model_grant.ts";
1414 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1515 import { assignIssue, openIssue } from "../src/actions/issues.ts";
16 import { createOrg } from "../src/core/orgs.ts";
1617 import { createUser } from "../src/core/users.ts";
1718 import { recordUsage } from "../src/core/usage.ts";
1819 import { budgetLine, grantLeft, monthSpent } from "../src/core/model_grant.ts";
@@ -222,4 +223,39 @@ describe("model budget: zero, an own subscription, a capped grant", () => {
222223 const again = addOwnModel(app, user, { token: "sk-ant-api03-bobkey2" });
223224 expect(again.ok).toBe(false);
224225 });
226
227 test("an org reaches the forge's account only by an admin's explicit grant; adding an admin to Owners grants nothing", async () => {
228 const seed = async (owner: User, org: string, name: string) => {
229 const made = await createRepo(app, owner, { name, owner: org, visibility: "private" });
230 if (!made.ok) throw new Error(String(made.error));
231 const seeded = await commitToBranch(app, owner, made.value, {
232 message: "Start",
233 files: [{ path: "README.md", content: `# ${name}\n` }],
234 });
235 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
236 expect(addToCrew(app, owner, made.value, "bot").ok).toBe(true);
237 return made.value;
238 };
239
240 // A non-admin founds an org and puts a forge admin on Owners: still refused, the fix named.
241 const { user: founder } = await person("founder");
242 const theirs = createOrg(app.db, "plainorg", founder.id);
243 if (!theirs.ok) throw new Error(String(theirs.error));
244 const team = app.db
245 .query<{ id: string }, [string]>("SELECT id FROM team WHERE org_id = ?1")
246 .get(theirs.value.id)!;
247 app.db.query("INSERT INTO team_member (team_id, user_id) VALUES (?1, ?2)").run(team.id, admin.id);
248 const repo = await seed(founder, "plainorg", "shop");
249 const refused = await runOn(founder, repo);
250 expect(refused.state).toBe("failed");
251 expect(refused.summary).toBe(
252 "the account `plainorg` has no model grant; an admin can grant one at /settings/accounts/plainorg",
253 );
254
255 // The admin grants the org explicitly: its runs use the forge's account, up to the grant.
256 expect(setAccountGrant(app, admin, "plainorg", 5).ok).toBe(true);
257 const ran = await runOn(founder, repo);
258 expect(ran.summary).not.toContain("no model grant");
259 expect(readRunLog(standIn.dataDir, ran.id).lines.join("\n")).toContain("model grant: $");
260 }, 90_000);
225261 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs