Public: anyone can read this; the crew can change it.

Roles, part 4: the MCP and the account page answer 'what can I do here as me' #2331

Merged opened this

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

Merged

scout/forge:scout/2304-roles-part-4-the-mcp-and-the-account-pag → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/2304-roles-part-4-the-mcp-and-the-account-pag was merged into main as 6a42c3b.

Review

Sign in to review this change.
9 files changed +178 −3
README.md +1 −1
@@ -747,7 +747,7 @@ the work repositories and nothing else. Two on one grant take turns, and
747747 one the provider rests hands its runs to the other until the reset.
748748 **Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks.
749749
750 **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. For now roles are shown and recorded; nothing is refused because of one. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token).
750 **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. For now roles are shown and recorded; nothing is refused because of one. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included).
751751
752752 **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent.
753753 **Sandbox.** An agent's shell is held to a policy — the network is the forge
src/core/roles.ts +7
@@ -29,6 +29,13 @@ export const CAN: Readonly<Record<Role, readonly Ability[]>> = {
2929
3030 export const can = (role: Role, ability: Ability): boolean => CAN[role].includes(ability);
3131
32 /** One role's row of the table: every ability, yes or no. */
33 export function rowOf(role: Role): Record<Ability, boolean> {
34 const row = {} as Record<Ability, boolean>;
35 for (const a of Object.keys(ABILITIES) as Ability[]) row[a] = can(role, a);
36 return row;
37 }
38
3239 /** The table as `docs/ROLES.md` prints it. */
3340 export function rolesTable(): string {
3441 const rows = (Object.keys(ABILITIES) as Ability[]).map(
src/http/mcp.ts +12
@@ -63,6 +63,7 @@ import {
6363 } from "../web/studio/cards.ts";
6464 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
6565 import { setRole } from "../actions/roles.ts";
66 import { ABILITIES, roleOf, rowOf } from "../core/roles.ts";
6667 import { keepThought } from "../actions/thought.ts";
6768 import {
6869 FROM_WORDS,
@@ -1003,6 +1004,17 @@ const TOOLS: readonly Tool[] = [
10031004 return good(set.value);
10041005 },
10051006 },
1007 {
1008 name: "whoami_can",

Fine as a tool: read only, scoped to the caller. Document it in the README's Roles paragraph next to account_role.

1009 description:
1010 "What you can do here as you: your role (guest, member or admin) and that role's row of the roles table, each ability yes or no. Read only.",
1011 inputSchema: { type: "object", properties: {} },
1012 writes: false,
1013 run: (ctx, user) => {
1014 const role = roleOf(ctx.app.db, user);
1015 return good({ username: user.username, role, can: rowOf(role), abilities: ABILITIES });
1016 },
1017 },
10061018 {
10071019 name: "account_role",
10081020 description:
src/tui/forge.ts +9
@@ -47,6 +47,7 @@ import { accessScreen } from "./screens/access.ts";
4747 import { flagsScreen } from "./screens/flags.ts";
4848 import { cloudScreen } from "./screens/cloud.ts";
4949 import { usageScreen } from "./screens/usage.ts";
50 import { canScreen } from "./screens/can.ts";
5051 import { peopleScreen } from "./screens/people.ts";
5152 import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts";
5253 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
@@ -395,6 +396,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
395396 hint: "set an account's dollars a day and a run",
396397 run: () => ctx.push(modelCeilingPrompt(session, ctx, blank("Model ceiling"))),
397398 });
399 // The caller's own role and row of the roles table, as /settings and `whoami_can` have it (#2304).
400 if (session.user !== undefined)
401 commands.push({
402 id: "can",
403 title: "What I can do",
404 hint: "your role and what it allows here",
405 run: () => ctx.push(canScreen(session, ctx)),
406 });
398407 // Guests, members and admins, as /settings/people has them (#2301).
399408 if (session.user?.isAdmin === true && session.user.kind === "user")
400409 commands.push({
src/tui/screens/can.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * "What I can do" in the terminal, as the account page and the `whoami_can`
3 * tool have it: the signed-in account's role and its row of the roles table.
4 * Read only.
5 */
6
7 import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts";
8 import type { Ctx, Screen } from "../app/app.ts";
9 import { binding } from "../app/keys.ts";
10 import type { Session } from "../session.ts";
11 import { style } from "../term/style.ts";
12 import { truncate } from "../term/width.ts";
13
14 export function canScreen(f: Session, ctx: Ctx): Screen {
15 return {
16 title: "What I can do",
17 keys: [binding(["esc"], "back")],
18 render(area, s) {
19 const c = ctx.theme.c;
20 if (f.user === undefined) return;
21 const role = roleOf(f.app.db, f.user);

Minor: this runs the member query on every frame. Other screens (usage.ts, for example) read once when the screen opens. Computing role and row in canScreen before the return keeps render pure and costs nothing, since the screen is closed and reopened to see a change anyway.

22 const row = rowOf(role);
23 [`You are ${role} here.`, ...(Object.keys(ABILITIES) as Ability[]).map((a) => `${ABILITIES[a]}: ${row[a] ? "yes" : "no"}`)]
24 .slice(0, Math.max(0, area.h))
25 .forEach((l, i) => s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w));
26 },
27 key: () => false,
28 };
29 }
src/web/pages/account.ts +17 −1
@@ -26,6 +26,7 @@ import {
2626 setNotifyUrl,
2727 } from "../../crew/notify.ts";
2828 import { forgeHost, noreplyAddress } from "../../core/commit_identity.ts";
29 import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts";
2930 import { listKeys } from "../../core/keys.ts";
3031 import { endAccountSession } from "../../actions/signins.ts";
3132 import { listSessions, type SessionInfo } from "../../core/sessions.ts";
@@ -159,9 +160,24 @@ export async function resendVerifyLink(ctx: Ctx): Promise<Response> {
159160 return flashTo(ctx, "/settings", { message });
160161 }
161162
163 /** The signed-in account's role and its row of the roles table. */
164 function roleView(ctx: Ctx): Html {

This card has no terminal counterpart. Add the same role and row to a src/tui command available to every signed-in session, so both doors answer "what can I do here as me".

165 const role = roleOf(ctx.app.db, ctx.auth.user!);
166 const row = rowOf(role);
167 return card(
168 html`<p>You are ${badge(role)} here.</p>
169 <ul class="role-can">
170 ${(Object.keys(ABILITIES) as Ability[]).map(
171 (a) => html`<li>${ABILITIES[a]}: <strong>${row[a] ? "yes" : "no"}</strong></li>`,
172 )}
173 </ul>`,
174 { head: html`<h3>What you can do here</h3>` },
175 );
176 }
177
162178 function profileView(ctx: Ctx, values: ProfileForm): Html {
163179 const user = ctx.auth.user!;
164 return html`${unverifiedNotice(ctx)}${card(
180 return html`${unverifiedNotice(ctx)}${roleView(ctx)}${card(
165181 html`<form method="post" action="/settings">
166182 <div class="field">
167183 <label for="f-username">Username</label>
test/mcp.test.ts +16
@@ -20,6 +20,7 @@ import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts
2020 import { createRun, listRuns, setRunBase } from "../src/core/runs.ts";
2121 import { getAccountCeiling } from "../src/core/account_ceiling.ts";
2222 import { createToken } from "../src/core/tokens.ts";
23 import { setMember } from "../src/core/roles.ts";
2324 import { createUser, type User } from "../src/core/users.ts";
2425 import { savePlacement } from "../src/core/placements.ts";
2526 import { serve, type Servers } from "../src/http/server.ts";
@@ -181,6 +182,7 @@ describe("the forge over MCP", () => {
181182 "health",
182183 "audit",
183184 "model_grant",
185 "whoami_can",
184186 "account_role",
185187 "model_ceiling",
186188 "outsiders",
@@ -930,6 +932,20 @@ describe("the forge over MCP", () => {
930932 expect(await kinds()).toContain("release");
931933 });
932934
935 test("whoami_can: the caller's role and its row, for a guest, a member and an admin", async () => {
936 const guest = (await tool(bobRw, "whoami_can")).value;
937 expect(guest.role).toBe("guest");
938 expect(guest.can).toEqual({ read: true, comment: true, make_it_yours: false, run_crew: false, administer: false });
939 setMember(app.db, bob.id, true, alice.id, new Date());
940 const member = (await tool(bobRw, "whoami_can")).value;
941 expect(member.role).toBe("member");
942 expect(member.can).toEqual({ read: true, comment: true, make_it_yours: true, run_crew: true, administer: false });
943 setMember(app.db, bob.id, false, alice.id, new Date());
944 const admin = (await tool(ro, "whoami_can")).value;
945 expect(admin.role).toBe("admin");
946 expect(admin.can.administer).toBe(true);
947 });
948
933949 test("model_ceiling: dry by default, an admin person with an admin token sets and clears it", async () => {
934950 const admin = createToken(app.db, alice.id, "admin", "admin").token;
935951 const readOnly = createToken(app.db, alice.id, "ro", "ro").token;
test/roles.test.ts +15 −1
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { createApp, type App } from "../src/app.ts";
88 import { getModelGrant } from "../src/core/model_grant.ts";
9 import { ABILITIES, CAN, ROLES, can, roleOf, rolesTable } from "../src/core/roles.ts";
9 import { ABILITIES, CAN, ROLES, can, roleOf, rolesTable, setMember } from "../src/core/roles.ts";
1010 import { createUser, getUserByName } from "../src/core/users.ts";
1111 import { createToken } from "../src/core/tokens.ts";
1212 import { createForgeApp } from "../src/tui/forge.ts";
@@ -88,6 +88,20 @@ describe("roles on /settings/people", () => {
8888 expect((await fetch(`${servers.url}/settings/people`, { headers: { cookie: bob } })).status).toBe(404);
8989 });
9090
91 test("the account page shows the caller's own row of the table", async () => {
92 const page = async (c: string) => (await fetch(`${servers.url}/settings`, { headers: { cookie: c } })).text();
93 const bob = await page(await signIn("bob"));
94 expect(bob).toContain("What you can do here");
95 expect(bob).toMatch(/Administer the forge: <strong>no<\/strong>/);
96 setMember(app.db, (getUserByName(app.db, "bob"))!.id, true, "x", new Date());
97 const member = await page(await signIn("bob"));
98 expect(member).toMatch(/Make an app their own: <strong>yes<\/strong>/);
99 expect(member).toMatch(/Administer the forge: <strong>no<\/strong>/);
100 setMember(app.db, getUserByName(app.db, "bob")!.id, false, "x", new Date());
101 const alice = await page(await signIn("alice"));
102 expect(alice).toMatch(/Administer the forge: <strong>yes<\/strong>/);
103 });
104
91105 test("a guest is promoted with no allowance by default, and demoted", async () => {
92106 const alice = await signIn("alice");
93107 expect((await post(alice, "bob", { role: "member" })).status).toBe(303);
test/tui-can.test.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * "What I can do" in the terminal (#2304): every signed-in session sees its
3 * role and its row of the roles table.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App as Forge } from "../src/app.ts";
10 import { setMember } from "../src/core/roles.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import type { TuiIdentity } from "../src/tui/session.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 describe("what I can do in the terminal", () => {
21 let dataDir: string;
22 let forge: Forge;
23
24 async function shown(username: string): Promise<string> {
25 const terminal = headlessTerminal(140, 32);
26 const user = getUserByName(forge.db, username)!;
27 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
28 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
29 app.start();
30 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
31 key("k", true);
32 for (const ch of "What I can do") key(ch);
33 key("enter");
34 await settleApp(app);
35 const text = app.screen.toText();
36 app.stop();
37 return text;
38 }
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-can-"));
42 forge = createApp({ dataDir, port: 0 });
43 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
44 const made = await createUser(forge.db, {
45 trusted: true,
46 username,
47 email: `${username}@forge.local`,
48 password: "a-good-passphrase",
49 adminIfFirst,
50 });
51 if (!made.ok) throw new Error(made.error);
52 }
53 });
54 afterAll(async () => {
55 forge.close();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 test("a guest, a member and an admin each see their own row", async () => {
60 const guest = await shown("bob");
61 expect(guest).toContain("You are guest here.");
62 expect(guest).toContain("Make an app their own: no");
63 setMember(forge.db, getUserByName(forge.db, "bob")!.id, true, "x", new Date());
64 const member = await shown("bob");
65 expect(member).toContain("You are member here.");
66 expect(member).toContain("Make an app their own: yes");
67 expect(member).toContain("Administer the forge: no");
68 const admin = await shown("alice");
69 expect(admin).toContain("You are admin here.");
70 expect(admin).toContain("Administer the forge: yes");
71 });
72 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs