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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10991-crew-runs-on-a-forge-admin-s-org-repos-u → main 4 commits fork
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 = { | ||
| 32 | 32 | |
| 33 | 33 | export type ModelGrantError = "not_found" | "not_allowed" | "invalid_amount"; |
| 34 | 34 | |
| 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. */ | |
| 36 | 36 | export function setAccountGrant( |
| 37 | 37 | app: App, |
| 38 | 38 | actor: User | undefined, |
| @@ -43,7 +43,7 @@ export function setAccountGrant( | ||
| 43 | 43 | if (!admin.ok || !admin.value.isAdmin || admin.value.kind !== "user") |
| 44 | 44 | return err("not_found"); |
| 45 | 45 | 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"); | |
| 47 | 47 | if (user.kind === "agent" || user.isAdmin) return err("not_allowed"); |
| 48 | 48 | if (usd !== null && !validGrant(usd)) return err("invalid_amount"); |
| 49 | 49 | setModelGrant(app.db, user.id, usd, admin.value.id, new Date()); |
src/crew/runner.ts +14 −2
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * what the forge has. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import type { Database } from "bun:sqlite"; | |
| 14 | 15 | import { isStrangerPull } from "./stranger.ts"; |
| 15 | 16 | import { noteMeasure } from "../core/measure.ts"; |
| 16 | 17 | import { capacityEnv } from "../core/capacity.ts"; |
| @@ -265,6 +266,16 @@ export function steerRun(app: App, runId: string, text: string): boolean { | ||
| 265 | 266 | return send(text); |
| 266 | 267 | } |
| 267 | 268 | |
| 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 | ||
| 268 | 279 | /** |
| 269 | 280 | * What a reviewer reads: the pull's diff (`review_range.ts` — a landed pull's |
| 270 | 281 | * is its merge commit's), and its pictures. `landedAs` names the merge commit |
| @@ -1355,7 +1366,7 @@ async function work( | ||
| 1355 | 1366 | ) { |
| 1356 | 1367 | const said = |
| 1357 | 1368 | grantCap === undefined |
| 1358 | ? "This repository has no model account: ask an admin to grant one" | |
| 1369 | ? noGrantSaid(app.db, repo.ownerId, repo.ownerName) | |
| 1359 | 1370 | : "This month's model grant is spent: an admin can raise it"; |
| 1360 | 1371 | write(said); |
| 1361 | 1372 | return { state: "failed", summary: said }; |
| @@ -2344,7 +2355,8 @@ function redactResult<T extends { result: string }>(r: T): T { | ||
| 2344 | 2355 | export function credentialEnv(picked: Pick, dataDir?: string): Record<string, string> { |
| 2345 | 2356 | if (picked.kind === "ready") return credentialEnvOf(picked); |
| 2346 | 2357 | // 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; | |
| 2348 | 2360 | if (lent !== undefined) return lent; |
| 2349 | 2361 | const own: Record<string, string> = {}; |
| 2350 | 2362 | for (const name of [ |
src/web/pages/accounts.ts +3 −3
| @@ -42,7 +42,7 @@ import { | ||
| 42 | 42 | userLink, |
| 43 | 43 | } from "../ui.ts"; |
| 44 | 44 | import { sessionsCard } from "./account.ts"; |
| 45 | import { ceilingCard, grantCard } from "./model.ts"; | |
| 45 | import { grantCard } from "./model.ts"; | |
| 46 | 46 | import { accountSections } from "./origins.ts"; |
| 47 | 47 | import { markOf } from "../../core/marks.ts"; |
| 48 | 48 | import { requestsCard } from "../requests_card.ts"; |
| @@ -269,9 +269,9 @@ export function personSignInsPage(ctx: Ctx): Response { | ||
| 269 | 269 | if (!ctx.auth.user) return signInFirst(ctx); |
| 270 | 270 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); |
| 271 | 271 | 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. | |
| 273 | 273 | 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)}`)); | |
| 275 | 275 | const seen = accountSignIns(ctx.app, ctx.auth.user, ctx.params.username ?? ""); |
| 276 | 276 | // Yourself is your own settings; another admin, an agent, nobody: no page. |
| 277 | 277 | 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", () => { | ||
| 195 | 195 | expect(env).toEqual({ ANTHROPIC_BASE_URL: parentLocal(door.port), ANTHROPIC_API_KEY: f.token }); |
| 196 | 196 | expect(JSON.stringify(env)).not.toContain(PARENT_KEY); |
| 197 | 197 | |
| 198 | // An org's grant (picked "none") reaches the same lent door (#10991). | |
| 199 | expect(credentialEnv({ kind: "none" }, childDir)).toEqual(env); | |
| 200 | ||
| 198 | 201 | const first = await run(env); |
| 199 | 202 | expect(first.status).toBe(200); |
| 200 | 203 | 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", () => { | ||
| 82 | 82 | } |
| 83 | 83 | }); |
| 84 | 84 | |
| 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 () => { | |
| 86 | 86 | const bot = await createAgent(app, admin, { username: "bot" }); |
| 87 | 87 | if (!bot.ok) throw new Error(String(bot.error)); |
| 88 | 88 | const org = createOrg(app.db, "patco", pat.id); |
| @@ -93,9 +93,10 @@ describe("a spend grant: what amount is kept, who takes one", () => { | ||
| 93 | 93 | expect(setRole(app, admin, name, "member", 5)).toEqual({ ok: false, error: "not_allowed" }); |
| 94 | 94 | expect(stored(user)).toBeUndefined(); |
| 95 | 95 | } |
| 96 | expect(setAccountGrant(app, admin, "patco", 5)).toEqual({ ok: false, error: "not_found" }); | |
| 97 | 96 | expect(setRole(app, admin, "patco", "member", 5)).toEqual({ ok: false, error: "not_found" }); |
| 98 | 97 | 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); | |
| 99 | 100 | expect(setAccountGrant(app, admin, "nobody", 5)).toEqual({ ok: false, error: "not_found" }); |
| 100 | 101 | }); |
| 101 | 102 |
test/model-grant.test.ts +36
| @@ -13,6 +13,7 @@ import { addToCrew } from "../src/actions/crew.ts"; | ||
| 13 | 13 | import { addOwnModel, setAccountGrant } from "../src/actions/model_grant.ts"; |
| 14 | 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | 15 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 16 | import { createOrg } from "../src/core/orgs.ts"; | |
| 16 | 17 | import { createUser } from "../src/core/users.ts"; |
| 17 | 18 | import { recordUsage } from "../src/core/usage.ts"; |
| 18 | 19 | import { budgetLine, grantLeft, monthSpent } from "../src/core/model_grant.ts"; |
| @@ -222,4 +223,39 @@ describe("model budget: zero, an own subscription, a capped grant", () => { | ||
| 222 | 223 | const again = addOwnModel(app, user, { token: "sk-ant-api03-bobkey2" }); |
| 223 | 224 | expect(again.ok).toBe(false); |
| 224 | 225 | }); |
| 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); | |
| 225 | 261 | }); |