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

Merged opened this

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

Merged

scout/forge:scout/2095-picking-a-model-credential-returns-none → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/2095-picking-a-model-credential-returns-none was merged into main as 588b0d4.

Review

Sign in to review this change.
5 files changed +101 −3
src/core/subscriptions.ts +8 −1
@@ -543,6 +543,8 @@ function listGrantPlaceRows(
543543 export type Pick =
544544 /** No grant reaches this run: the forge's own token, `PLATFORM`, from its environment. */
545545 | { 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" }
546548 | {
547549 readonly kind: "ready";
548550 readonly subscription: Subscription;
@@ -602,7 +604,12 @@ export function pickSubscription(
602604 )
603605 .all(scope.repoId, scope.ownerId, scope.agentId, reserve?.runId ?? "");
604606 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);

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.

611 return owner?.is_admin === 1 ? { kind: "env" } : { kind: "none" };
612 }
606613 const grant = rows.filter(
607614 (r) => r.scope_id === first.scope_id && r.agent_id === first.agent_id,
608615 );
src/crew/runner.ts +8
@@ -919,6 +919,14 @@ async function work(
919919 resumesAt: new Date(picked.until),
920920 };
921921 }
922 // The forge's own token is for admin-owned repositories only; anyone
outdated

Handle picked.kind === "none" here: fail the run with a person-worded summary and return before the env is built. As written, none reaches the else branch below and spends the platform token.

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 }
922930 if (picked.kind === "ready") {
923931 write(`subscription: ${picked.subscription.name}`);
924932 noteRunProgress(app.db, run.id, {
test/credentials.test.ts +21
@@ -166,6 +166,27 @@ describe("a subscription's scope", () => {
166166 });
167167 });
168168
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
169190 describe("a crew run on the platform's token", () => {
170191 test("is counted under platform (env), moving while it runs", async () => {
171192 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", () => {
153153 const scope = { repoId: "repo-1", ownerId: "org-1", agentId: "bot-1" };
154154 const now = new Date("2026-09-28T12:00:00Z");
155155
156 // Nothing granted anywhere: the forge's own token.
156 // Nothing granted anywhere, and "org-1" is no forge admin: none.
157157 expect(pickSubscription(app.db, dataDir, scope, now)).toEqual({
158 kind: "env",
158 kind: "none",
159159 });
160160
161161 // The organization's grant reaches every repository under it.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs