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) #11001

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 3 commits fork 2 threads · 2 unresolved

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

Review

Sign in to review this change.
6 files changed +68 −8
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 +12 −1
@@ -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
outdated

This helper splits reviewInputs from its docblock. Move noGrantSaid above that docblock.

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 };
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/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