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

Merged opened this

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

Merged

scout/forge:scout/8142-a-forge-admin-sets-which-claude-subscrip → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/8142-a-forge-admin-sets-which-claude-subscrip was merged into main as f3b4d1d.

Review

Sign in to review this change.
4 files changed +325 −0
README.md +11
@@ -418,6 +418,17 @@ in the very sentence Settings › Subscriptions prints (`windowWords`,
418418 `src/actions/standing_words.ts`). A sentence wider than the terminal wraps
419419 onto the next line, so the reset time is never cut off.
420420
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
421432 A repository in auto mode with a direction and no planner on its crew is lent
422433 one (#7637): at the next planning turn the forge adds, through the same
423434 `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 ? ">" : " ";

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.

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";
131131 import { forgeItCommands } from "./forge_it.ts";
132132 import { makeItYoursScreen, yoursCommands } from "./yours.ts";
133133 import { deleteRepoCommands } from "./deleterepo.ts";
134 import { grantsCommands } from "./grants.ts";
134135 import { visionCommands } from "./vision.ts";
135136 import { labelsCommands } from "./labels.ts";
136137 import { appEnvCommands } from "./appenv.ts";
@@ -1443,6 +1444,7 @@ export function workspaceScreen(
14431444 ...noteCommands(f, ctx, () => screen, repo),
14441445 ...yoursCommands(f, ctx, repo),
14451446 ...deleteRepoCommands(f, ctx, repo),
1447 ...grantsCommands(f, ctx, repo),
14461448 ...forgeItCommands(f, ctx, repo, () => screen),
14471449 ...hooksCommands(f, ctx, repo),
14481450 ...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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs