Public: anyone can read this; the crew can change it.
A forge admin sets which Claude subscriptions each crew member of a repository may use, from the terminal, as the repository's Crew settings do #8202
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8142-a-forge-admin-sets-which-claude-subscrip → main 3 commits fork 4 threads · 4 unresolved
README.md +11
| @@ -418,6 +418,17 @@ in the very sentence Settings › Subscriptions prints (`windowWords`, | ||
| 418 | 418 | `src/actions/standing_words.ts`). A sentence wider than the terminal wraps |
| 419 | 419 | onto the next line, so the reset time is never cut off. |
| 420 | 420 | |
| 421 | Inside a repository, the palette's Crew grants (`src/tui/screens/grants.ts`) | |
| 422 | sets which subscriptions each crew member may use there, as Settings › Crew | |
| 423 | does: one row for everyone on the crew and one per crew member, each | |
| 424 | subscription by name and kind (never its token) on its own line under the row. | |
| 425 | `j`/`k` choose the row, `h`/`l` the subscription, and `space` toggles it and | |
| 426 | saves the row whole through `setGrants`. Nothing ticked removes the row's | |
| 427 | grant, and the wider one applies: the owner's grant, or the forge's own token. | |
| 428 | It is offered to a person who is both a forge admin and an admin of the | |
| 429 | repository; anyone else, and every agent, is offered nothing. Settings › Crew | |
| 430 | shows a save on its next load. | |
| 431 | ||
| 421 | 432 | A repository in auto mode with a direction and no planner on its crew is lent |
| 422 | 433 | one (#7637): at the next planning turn the forge adds, through the same |
| 423 | 434 | `addToCrew` an admin uses, the agent that plans on the most of the *owner's* |
src/tui/screens/grants.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * Which Claude subscriptions each crew member of a repository may use, in the | |
| 3 | * terminal, as the repository's Crew settings have them: one row for everyone | |
| 4 | * and one per crew member. `h`/`l` choose a subscription, `space` toggles it | |
| 5 | * and the row is saved whole through `setGrants`. The gate is the page's: a | |
| 6 | * repository admin who is a forge admin and a person. Only the rows offered | |
| 7 | * can be written. A subscription shows its name and kind, never its token. | |
| 8 | */ | |
| 9 | ||
| 10 | import { listCrewRoles } from "../../core/agents.ts"; | |
| 11 | import { canAdmin, type Repo } from "../../core/repos.ts"; | |
| 12 | import { EVERYONE, listGrants, listSubscriptions, setGrants, type Subscription } from "../../core/subscriptions.ts"; | |
| 13 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import type { Command } from "../app/palette.ts"; | |
| 16 | import { style } from "../term/style.ts"; | |
| 17 | import { truncate } from "../term/width.ts"; | |
| 18 | import type { Session } from "../session.ts"; | |
| 19 | import { fill, len, split } from "../ui/layout.ts"; | |
| 20 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 21 | ||
| 22 | const WIDER = "the owner's grant, or the forge's own token"; | |
| 23 | ||
| 24 | interface Who { | |
| 25 | readonly id: string; | |
| 26 | readonly label: string; | |
| 27 | } | |
| 28 | ||
| 29 | export function grantsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 30 | const state = new ListState(); | |
| 31 | let at = 0; | |
| 32 | let note = ""; | |
| 33 | const admitted = (): boolean => | |
| 34 | f.user?.isAdmin === true && f.user.kind === "user" && canAdmin(f.db, repo, f.user); | |
| 35 | ||
| 36 | let pool: Subscription[] = []; | |
| 37 | let who: Who[] = []; | |
| 38 | let listed: ListRow[] = []; | |
| 39 | const load = (): void => { | |
| 40 | pool = admitted() ? listSubscriptions(f.db) : []; | |
| 41 | who = admitted() | |
| 42 | ? [{ id: EVERYONE, label: "Everyone on the crew" }, ...listCrewRoles(f.db, repo.id).map((c) => ({ id: c.agent.user.id, label: c.agent.user.username }))] | |
| 43 | : []; | |
| 44 | const granted = new Map<string, Set<string>>(); | |
| 45 | if (admitted()) | |
| 46 | for (const g of listGrants(f.db, repo.id)) granted.set(g.agentId, (granted.get(g.agentId) ?? new Set()).add(g.subscriptionId)); | |
| 47 | listed = who.map((w, row) => { | |
| 48 | const mine = granted.get(w.id); | |
| 49 | const cells = pool.map((s, i) => { | |
| 50 | const cursor = row === state.index && i === at ? ">" : " "; | |
| 51 | return `${cursor}[${mine?.has(s.id) ? "x" : " "}] ${s.name} (${s.kind})`; | |
| 52 | }); | |
| 53 | // One line per subscription under the row, so the cursor cell is never cut off however many there are. | |
| 54 | return { | |
| 55 | title: w.label, | |
| 56 | meta: mine === undefined || mine.size === 0 ? `none of its own: the wider grant applies: ${WIDER}` : "two ticked take turns", | |
| 57 | below: cells, | |
| 58 | }; | |
| 59 | }); | |
| 60 | }; | |
| 61 | load(); | |
| 62 | ||
| 63 | const screen: Screen = { | |
| 64 | title: "Crew grants", | |
| 65 | keys: admitted() | |
| 66 | ? [binding(["j", "k"], "move cursor"), binding(["h", "l"], "choose subscription"), binding(["space"], "toggle"), binding(["esc"], "back")] | |
| 67 | : [], | |
| 68 | render(area, s) { | |
| 69 | const c = ctx.theme.c; | |
| 70 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 71 | const text = !admitted() | |
| 72 | ? "Nothing exists here." | |
| 73 | : pool.length === 0 | |
| 74 | ? "Runs here work on the forge's own token. Add one on the Crew subscriptions screen first." | |
| 75 | : note !== "" | |
| 76 | ? note | |
| 77 | : "h and l choose a subscription, space ticks it. Nothing ticked means the wider grant applies."; | |
| 78 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 79 | if (pool.length > 0) drawList(s, body!, c, state, listed); | |
| 80 | }, | |
| 81 | key(e) { | |
| 82 | note = ""; | |
| 83 | if (!admitted() || pool.length === 0) return false; | |
| 84 | if (state.key(e, who.length)) { | |
| 85 | load(); | |
| 86 | return true; | |
| 87 | } | |
| 88 | if (e.ctrl || e.alt) return false; | |
outdated
MEDIUM: this stale-row branch and its note have no test that fails without them (see the test comment). Add the test, or drop the branch if it is not wanted. |
||
| 89 | if (e.key === "h" || e.key === "left") at = Math.max(0, at - 1); | |
| 90 | else if (e.key === "l" || e.key === "right") at = Math.min(pool.length - 1, at + 1); | |
| 91 | else if (e.key === " " || e.key === "space") { | |
| 92 | // Read at use, so a crew changed under the session is not written to. | |
| 93 | const fresh = [{ id: EVERYONE }, ...listCrewRoles(f.db, repo.id).map((c) => ({ id: c.agent.user.id }))]; | |
| 94 | const row = who[state.index]; | |
| 95 | const sub = pool[at]; | |
| 96 | if (row === undefined || sub === undefined || !fresh.some((w) => w.id === row.id)) { | |
| 97 | note = "That row is no longer on the crew."; | |
| 98 | } else { | |
| 99 | const have = new Set(listGrants(f.db, repo.id).filter((g) => g.agentId === row.id).map((g) => g.subscriptionId)); | |
| 100 | if (!have.delete(sub.id)) have.add(sub.id); | |
| 101 | setGrants(f.db, repo.id, row.id, [...have]); | |
| 102 | note = `${row.label}: ${sub.name} ${have.has(sub.id) ? "on" : "off"}.`; | |
| 103 | } | |
| 104 | } else return false; | |
| 105 | load(); | |
| 106 | ctx.invalidate(); | |
| 107 | return true; | |
| 108 | }, | |
| 109 | }; | |
| 110 | return screen; | |
| 111 | } | |
| 112 | ||
| 113 | /** Settings › Crew's subscriptions, from a repository's `:` palette: a repository admin who is a forge admin. */ | |
| 114 | export function grantsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 115 | if (repo === undefined || f.user?.isAdmin !== true || f.user.kind !== "user") return []; | |
| 116 | if (!canAdmin(f.db, repo, f.user)) return []; | |
| 117 | return [ | |
| 118 | { | |
| 119 | id: "grants", | |
| 120 | title: "Crew grants", | |
outdated
LOW: the palette says 'Crew grants', the screen title is 'Crew subscriptions', and the empty-state text sends the person to 'Crew subscriptions' for adding a subscription. Use one name for this screen and say plainly where subscriptions are added. A test should also assert the palette entry appears for the forge admin; today only the refusal is asserted. |
||
| 121 | hint: "which subscriptions each crew member may use here", | |
| 122 | run: () => ctx.push(grantsScreen(f, ctx, repo)), | |
| 123 | }, | |
| 124 | ]; | |
| 125 | } |
src/tui/screens/workspace.ts +2
| @@ -131,6 +131,7 @@ import { workingCommands } from "./working.ts"; | ||
| 131 | 131 | import { forgeItCommands } from "./forge_it.ts"; |
| 132 | 132 | import { makeItYoursScreen, yoursCommands } from "./yours.ts"; |
| 133 | 133 | import { deleteRepoCommands } from "./deleterepo.ts"; |
| 134 | import { grantsCommands } from "./grants.ts"; | |
| 134 | 135 | import { visionCommands } from "./vision.ts"; |
| 135 | 136 | import { labelsCommands } from "./labels.ts"; |
| 136 | 137 | import { appEnvCommands } from "./appenv.ts"; |
| @@ -1443,6 +1444,7 @@ export function workspaceScreen( | ||
| 1443 | 1444 | ...noteCommands(f, ctx, () => screen, repo), |
| 1444 | 1445 | ...yoursCommands(f, ctx, repo), |
| 1445 | 1446 | ...deleteRepoCommands(f, ctx, repo), |
| 1447 | ...grantsCommands(f, ctx, repo), | |
| 1446 | 1448 | ...forgeItCommands(f, ctx, repo, () => screen), |
| 1447 | 1449 | ...hooksCommands(f, ctx, repo), |
| 1448 | 1450 | ...visionCommands(f, ctx, repo), |
test/tui-grants.test.ts added +187
| @@ -0,0 +1,187 @@ | ||
| 1 | /** | |
| 2 | * Crew grants in the terminal: a repository admin who is a forge admin ticks | |
| 3 | * subscriptions per crew member; clearing a row falls back to the wider | |
| 4 | * grant; a non-admin and an agent are refused and offered no key. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addCrew, createAgentRow, removeCrew } from "../src/core/agents.ts"; | |
| 13 | import { type Repo } from "../src/core/repos.ts"; | |
| 14 | import { EVERYONE, addSubscription, listGrants, setGrants } from "../src/core/subscriptions.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { grantsCommands, grantsScreen } from "../src/tui/screens/grants.ts"; | |
| 18 | import { repoScreen } from "../src/tui/screens/repo.ts"; | |
| 19 | import { local } from "../src/tui/screens/where.ts"; | |
| 20 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dir: string; | |
| 27 | let app: App; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | let bot: User; | |
| 31 | let stranger: User; | |
| 32 | let repo: Repo; | |
| 33 | let subA: string; | |
| 34 | let subB: string; | |
| 35 | ||
| 36 | const SECRET = "sk-ant-api03-grantsecret"; | |
| 37 | ||
| 38 | function open(user: User, width = 140) { | |
| 39 | const terminal = headlessTerminal(width, 40); | |
| 40 | const { app: tui, session } = createForgeApp( | |
| 41 | app, | |
| 42 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 43 | { terminal, exitHooks: false }, | |
| 44 | ); | |
| 45 | tui.start(); | |
| 46 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo))); | |
| 47 | return { | |
| 48 | session, | |
| 49 | ctx: tui.ctx, | |
| 50 | text: () => tui.screen.toText(), | |
| 51 | feed: async (...keys: string[]) => { | |
| 52 | for (const k of keys) { | |
| 53 | terminal.feed(k); | |
| 54 | await settleApp(tui); | |
| 55 | } | |
| 56 | }, | |
| 57 | stop: () => tui.stop?.(), | |
| 58 | }; | |
| 59 | } | |
| 60 | ||
| 61 | async function grants(h: ReturnType<typeof open>): Promise<void> { | |
| 62 | await h.feed(":", ..."Crew grants"); | |
| 63 | for (let i = 0; i < 100 && !h.text().includes("which subscriptions each"); i++) | |
| 64 | await new Promise<void>((r) => setTimeout(r, 10)), await h.feed(); | |
| 65 | await h.feed("\r"); | |
| 66 | } | |
| 67 | ||
| 68 | const mk = async (username: string, admin: boolean): Promise<User> => { | |
| 69 | const made = await createUser(app.db, { | |
| 70 | origin: "seed", | |
| 71 | trusted: true, | |
| 72 | username, | |
| 73 | email: `${username}@forge.local`, | |
| 74 | password: "a-good-passphrase", | |
| 75 | adminIfFirst: admin, | |
| 76 | }); | |
| 77 | if (!made.ok) throw new Error(made.error); | |
| 78 | return made.value; | |
| 79 | }; | |
| 80 | ||
| 81 | const own = (agentId: string): string[] => | |
| 82 | listGrants(app.db, repo.id).filter((g) => g.agentId === agentId).map((g) => g.subscriptionId).sort(); | |
| 83 | ||
| 84 | beforeAll(async () => { | |
| 85 | dir = await mkdtemp(join(tmpdir(), "forge-tui-grants-")); | |
| 86 | app = createApp({ dataDir: dir, port: 0 }); | |
| 87 | alice = await mk("alice", true); | |
| 88 | bob = await mk("bob", false); | |
| 89 | stranger = await mk("stranger", false); | |
| 90 | bot = await mk("bot1", false); | |
| 91 | createAgentRow(app.db, { userId: bot.id, model: "opus" }); | |
| 92 | app.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'bot1'").run(); | |
| 93 | bot = { ...bot, isAdmin: true, kind: "agent" } as User; | |
| 94 | const made = await createRepo(app, alice, { name: "grants", visibility: "public" }); | |
| 95 | if (!made.ok) throw new Error(String(made.error)); | |
| 96 | repo = made.value; | |
| 97 | addCrew(app.db, repo.id, bot.id); | |
| 98 | const a = addSubscription(app.db, dir, { name: "Alpha", token: SECRET }); | |
| 99 | const b = addSubscription(app.db, dir, { name: "Beta", token: "sk-ant-api03-other" }); | |
| 100 | if (!a.ok || !b.ok) throw new Error("subscription"); | |
| 101 | subA = a.value.id; | |
| 102 | subB = b.value.id; | |
| 103 | }); | |
| 104 | afterAll(async () => { | |
| 105 | app.close(); | |
| 106 | await rm(dir, { recursive: true, force: true }); | |
| 107 | }); | |
| 108 | ||
| 109 | describe("crew grants in the terminal", () => { | |
| 110 | test("a toggle is saved on the selected row, whole, and never shows a token", async () => { | |
| 111 | const h = open(alice); | |
| 112 | await grants(h); | |
| 113 | expect(h.text()).toContain("Everyone on the crew"); | |
| 114 | expect(h.text()).toContain("bot1"); | |
| 115 | expect(h.text()).not.toContain(SECRET); | |
| 116 | await h.feed(" "); | |
| 117 | expect(own(EVERYONE)).toEqual([subA]); | |
| 118 | await h.feed("l", " "); | |
| 119 | expect(own(EVERYONE).sort()).toEqual([subA, subB].sort()); | |
| 120 | await h.feed("j", " "); | |
| 121 | expect(own(bot.id)).toEqual([subB]); | |
| 122 | expect(own(EVERYONE).length).toBe(2); | |
| 123 | h.stop(); | |
| 124 | }); | |
| 125 | ||
| 126 | test("clearing a row removes its grant and says the wider one applies", async () => { | |
| 127 | const h = open(alice); | |
| 128 | await grants(h); | |
| 129 | expect(h.text()).toContain("the wider grant applies"); | |
| 130 | h.stop(); | |
| 131 | setGrants(app.db, repo.id, bot.id, [subB]); | |
MEDIUM: this test cannot fail. Pressing j three times only clamps to the last row (bot), so the write lands on an offered row and the assertion holds with or without the 'fresh' guard. Fix: build a real case. Open the screen, remove bot from the crew (removeCrew or equivalent) or add a new one, then press space on the stale row. Assert that no grant is written for the removed agent and that the screen says 'That row is no longer on the crew.' |
||
| 132 | const g = open(alice); | |
| 133 | await grants(g); | |
| 134 | await g.feed("j", "l", " "); | |
| 135 | expect(own(bot.id)).toEqual([]); | |
| 136 | expect(g.text()).toContain("the wider grant applies"); | |
| 137 | g.stop(); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a row whose agent left the crew is not written", async () => { | |
| 141 | setGrants(app.db, repo.id, bot.id, []); | |
| 142 | const h = open(alice); | |
| 143 | await grants(h); | |
| 144 | await h.feed("j"); // the cursor is on bot1 | |
| 145 | removeCrew(app.db, repo.id, bot.id); | |
| 146 | await h.feed(" "); | |
| 147 | expect(own(bot.id)).toEqual([]); | |
| 148 | expect(h.text()).toContain("That row is no longer on the crew."); | |
| 149 | h.stop(); | |
| 150 | addCrew(app.db, repo.id, bot.id); | |
| 151 | }); | |
| 152 | ||
| 153 | test("the palette offers Crew grants to the forge admin", () => { | |
| 154 | const h = open(alice); | |
| 155 | const cmds = grantsCommands(h.session, h.ctx, repo); | |
| 156 | expect(cmds.map((c) => c.title)).toEqual(["Crew grants"]); | |
| 157 | expect(grantsScreen(h.session, h.ctx, repo).title).toBe("Crew grants"); | |
| 158 | h.stop(); | |
| 159 | }); | |
| 160 | ||
| 161 | test("a non-admin and an agent get an empty screen and no key", async () => { | |
| 162 | for (const who of [bob, stranger, bot]) { | |
| 163 | const before = JSON.stringify(listGrants(app.db, repo.id)); | |
| 164 | const h = open(who); | |
| 165 | const cmd = grantsCommands(h.session, h.ctx, repo); | |
| 166 | expect(cmd).toEqual([]); | |
| 167 | // Even pushed by hand, the screen is empty, offers no key and writes nothing. | |
| 168 | const screen = grantsScreen(h.session, h.ctx, repo); | |
| 169 | expect(screen.keys).toEqual([]); | |
| 170 | expect(screen.key({ type: "key", key: " ", ctrl: false, alt: false, shift: false })).toBe(false); | |
| 171 | h.stop(); | |
| 172 | expect(JSON.stringify(listGrants(app.db, repo.id))).toBe(before); | |
| 173 | } | |
| 174 | }); | |
| 175 | ||
| 176 | test("on a narrow terminal the cursor's subscription stays in view", async () => { | |
| 177 | for (const name of ["Gamma", "Delta", "Epsilon", "Zeta"]) { | |
| 178 | const made = addSubscription(app.db, dir, { name, token: `sk-ant-api03-${name}` }); | |
| 179 | if (!made.ok) throw new Error("subscription"); | |
| 180 | } | |
| 181 | const h = open(alice, 50); | |
| 182 | await grants(h); | |
| 183 | await h.feed("l", "l", "l", "l", "l"); | |
| 184 | expect(h.text()).toMatch(/>\[[ x]\] Zeta/); | |
| 185 | h.stop(); | |
| 186 | }); | |
| 187 | }); |
Cells are joined into one string; on a narrow terminal the cursor cell (>) can fall off the end and be truncated, so h/l moves a cursor the user cannot see. Window the cells around
at.