Public: anyone can read this; the crew can change it.
Picking a model credential returns "none" for a repository whose owner is not a forge admin and has no grant; admin-owned repositories still fall back #2097
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2095-picking-a-model-credential-returns-none → main 2 commits fork 3 threads · 3 unresolved
5 files
changed +101
−3
src/core/subscriptions.ts +8 −1
| @@ -543,6 +543,8 @@ function listGrantPlaceRows( | ||
| 543 | 543 | export type Pick = |
| 544 | 544 | /** No grant reaches this run: the forge's own token, `PLATFORM`, from its environment. */ |
| 545 | 545 | | { readonly kind: "env" } |
| 546 | /** No grant reaches this run and its owner is not a forge admin: the forge's token is not theirs to spend. */ | |
| 547 | | { readonly kind: "none" } | |
| 546 | 548 | | { |
| 547 | 549 | readonly kind: "ready"; |
| 548 | 550 | readonly subscription: Subscription; |
| @@ -602,7 +604,12 @@ export function pickSubscription( | ||
| 602 | 604 | ) |
| 603 | 605 | .all(scope.repoId, scope.ownerId, scope.agentId, reserve?.runId ?? ""); |
| 604 | 606 | const first = rows[0]; |
| 605 | if (first === undefined) return { kind: "env" }; | |
| 607 | if (first === undefined) { | |
| 608 | const owner = db | |
| 609 | .query<{ is_admin: number }, [string]>("SELECT is_admin FROM user WHERE id = ?1") | |
| 610 | .get(scope.ownerId); | |
| 611 | return owner?.is_admin === 1 ? { kind: "env" } : { kind: "none" }; | |
| 612 | } | |
| 606 | 613 | const grant = rows.filter( |
| 607 | 614 | (r) => r.scope_id === first.scope_id && r.agent_id === first.agent_id, |
| 608 | 615 | ); |
src/crew/runner.ts +8
| @@ -919,6 +919,14 @@ async function work( | ||
| 919 | 919 | resumesAt: new Date(picked.until), |
| 920 | 920 | }; |
| 921 | 921 | } |
| 922 | // The forge's own token is for admin-owned repositories only; anyone | |
outdated
Handle |
||
| 923 | // else's run waits for a grant rather than spend what is not theirs. | |
| 924 | if (picked.kind === "none") { | |
| 925 | const said = | |
Good: returns before the env is built. The wording is plain; consider naming the repository so the owner knows which grant to ask for. |
||
| 926 | "no model credential reaches this repository; ask an admin to grant one"; | |
| 927 | write(said); | |
| 928 | return { state: "failed", summary: said }; | |
| 929 | } | |
| 922 | 930 | if (picked.kind === "ready") { |
| 923 | 931 | write(`subscription: ${picked.subscription.name}`); |
| 924 | 932 | noteRunProgress(app.db, run.id, { |
test/credentials.test.ts +21
| @@ -166,6 +166,27 @@ describe("a subscription's scope", () => { | ||
| 166 | 166 | }); |
| 167 | 167 | }); |
| 168 | 168 | |
| 169 | describe("a repository whose owner is not a forge admin", () => { | |
| 170 | test("gets none without a grant and the subscription once granted; an admin's still gets env", async () => { | |
| 171 | const b = await createUser(app.db, { | |
| 172 | trusted: true, | |
| 173 | username: "bob", | |
| 174 | email: "[email protected]", | |
| 175 | password: "a-good-passphrase", | |
| 176 | }); | |
| 177 | if (!b.ok) throw new Error("user"); | |
| 178 | const espo = listSubscriptions(app.db)[0]!; | |
| 179 | const outsider = { repoId: "bobs-repo", ownerId: b.value.id, agentId: bot.id }; | |
| 180 | expect(pickSubscription(app.db, dataDir, outsider, new Date()).kind).toBe("none"); | |
| 181 | setGrants(app.db, "bobs-repo", EVERYONE, [espo.id]); | |
| 182 | const picked = pickSubscription(app.db, dataDir, outsider, new Date()); | |
| 183 | expect(picked.kind === "ready" && picked.subscription.id).toBe(espo.id); | |
| 184 | setGrants(app.db, "bobs-repo", EVERYONE, []); | |
| 185 | const admin = { repoId: "admins-repo", ownerId: alice.id, agentId: bot.id }; | |
| 186 | expect(pickSubscription(app.db, dataDir, admin, new Date()).kind).toBe("env"); | |
| 187 | }); | |
| 188 | }); | |
| 189 | ||
| 169 | 190 | describe("a crew run on the platform's token", () => { |
| 170 | 191 | test("is counted under platform (env), moving while it runs", async () => { |
| 171 | 192 | writeFileSync(join(gateDir, "hold"), ""); |
test/crew-nocredential.test.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * The crew with a stand-in Claude Code: a repository whose owner is not a | |
| 3 | * forge admin and has no grant never spends the forge's own credential. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { existsSync } from "node:fs"; | |
| 8 | import type { App } from "../src/app.ts"; | |
| 9 | import type { Repo } from "../src/core/repos.ts"; | |
| 10 | import type { User } from "../src/core/users.ts"; | |
| 11 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 12 | import { readRunLog } from "../src/runs/log.ts"; | |
| 13 | import { | |
| 14 | agentRunSettled, | |
| 15 | startStandInCrew, | |
| 16 | stopStandInCrew, | |
| 17 | type StandInCrew, | |
| 18 | } from "./helpers/crew-standin.ts"; | |
| 19 | ||
| 20 | describe("the crew, with a stand-in Claude Code: no credential reaches the repository", () => { | |
| 21 | let standIn: StandInCrew; | |
| 22 | let app: App; | |
| 23 | let alice: User; | |
| 24 | let bot: User; | |
| 25 | let repo: Repo; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | standIn = await startStandInCrew(); | |
| 29 | ({ app, alice, bot, repo } = standIn); | |
| 30 | }, 30_000); | |
| 31 | ||
| 32 | afterAll(() => stopStandInCrew(standIn)); | |
| 33 | ||
| 34 | test("a non-admin owner's run fails with a person-worded line, before any agent starts", async () => { | |
| 35 | app.db.query("UPDATE user SET is_admin = 0 WHERE id = ?1").run(alice.id); | |
| 36 | process.env.ANTHROPIC_API_KEY = "sk-forge-own-token"; | |
| 37 | try { | |
| 38 | const issue = openIssue(app, alice, repo, { | |
| 39 | title: "Prune the roses", | |
| 40 | body: "Before spring.", | |
| 41 | }); | |
| 42 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 43 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 44 | const run = await agentRunSettled( | |
| 45 | app, | |
| 46 | repo, | |
| 47 | (r) => r.issueId === issue.value.id, | |
| 48 | ); | |
| 49 | const said = | |
| 50 | "no model credential reaches this repository; ask an admin to grant one"; | |
| 51 | expect(run.state).toBe("failed"); | |
| 52 | expect(run.summary).toContain(said); | |
| 53 | expect(readRunLog(standIn.dataDir, run.id).lines.join("\n")).toContain( | |
| 54 | said, | |
| 55 | ); | |
| 56 | // The agent never started, so no credential was handed to it. | |
| 57 | expect(existsSync(standIn.argsFile)).toBe(false); | |
| 58 | } finally { | |
| 59 | delete process.env.ANTHROPIC_API_KEY; | |
| 60 | } | |
| 61 | }, 60_000); | |
| 62 | }); |
test/subscriptions.test.ts +2 −2
| @@ -153,9 +153,9 @@ describe("the pool", () => { | ||
| 153 | 153 | const scope = { repoId: "repo-1", ownerId: "org-1", agentId: "bot-1" }; |
| 154 | 154 | const now = new Date("2026-09-28T12:00:00Z"); |
| 155 | 155 | |
| 156 | // Nothing granted anywhere: the forge's own token. | |
| 156 | // Nothing granted anywhere, and "org-1" is no forge admin: none. | |
| 157 | 157 | expect(pickSubscription(app.db, dataDir, scope, now)).toEqual({ |
| 158 | kind: "env", | |
| 158 | kind: "none", | |
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | 161 | // The organization's grant reaches every repository under it. |
new above
offline
owner?.is_admin === 1 treats a missing user row (an organization owner) as none. Make that an explicit choice, with a test for an org-owned repo.