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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2304-roles-part-4-the-mcp-and-the-account-pag → main 2 commits fork 3 threads · 3 unresolved
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 | ||
| 747 | 747 | one the provider rests hands its runs to the other until the reset. |
| 748 | 748 | **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. |
| 749 | 749 | |
| 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). | |
| 751 | 751 | |
| 752 | 752 | **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. |
| 753 | 753 | **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[]>> = { | ||
| 29 | 29 | |
| 30 | 30 | export const can = (role: Role, ability: Ability): boolean => CAN[role].includes(ability); |
| 31 | 31 | |
| 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 | ||
| 32 | 39 | /** The table as `docs/ROLES.md` prints it. */ |
| 33 | 40 | export function rolesTable(): string { |
| 34 | 41 | const rows = (Object.keys(ABILITIES) as Ability[]).map( |
src/http/mcp.ts +12
| @@ -63,6 +63,7 @@ import { | ||
| 63 | 63 | } from "../web/studio/cards.ts"; |
| 64 | 64 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; |
| 65 | 65 | import { setRole } from "../actions/roles.ts"; |
| 66 | import { ABILITIES, roleOf, rowOf } from "../core/roles.ts"; | |
| 66 | 67 | import { keepThought } from "../actions/thought.ts"; |
| 67 | 68 | import { |
| 68 | 69 | FROM_WORDS, |
| @@ -1003,6 +1004,17 @@ const TOOLS: readonly Tool[] = [ | ||
| 1003 | 1004 | return good(set.value); |
| 1004 | 1005 | }, |
| 1005 | 1006 | }, |
| 1007 | { | |
| 1008 | name: "whoami_can", | |
| 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 | }, | |
| 1006 | 1018 | { |
| 1007 | 1019 | name: "account_role", |
| 1008 | 1020 | description: |
src/tui/forge.ts +9
| @@ -47,6 +47,7 @@ import { accessScreen } from "./screens/access.ts"; | ||
| 47 | 47 | import { flagsScreen } from "./screens/flags.ts"; |
| 48 | 48 | import { cloudScreen } from "./screens/cloud.ts"; |
| 49 | 49 | import { usageScreen } from "./screens/usage.ts"; |
| 50 | import { canScreen } from "./screens/can.ts"; | |
| 50 | 51 | import { peopleScreen } from "./screens/people.ts"; |
| 51 | 52 | import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts"; |
| 52 | 53 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| @@ -395,6 +396,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 395 | 396 | hint: "set an account's dollars a day and a run", |
| 396 | 397 | run: () => ctx.push(modelCeilingPrompt(session, ctx, blank("Model ceiling"))), |
| 397 | 398 | }); |
| 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 | }); | |
| 398 | 407 | // Guests, members and admins, as /settings/people has them (#2301). |
| 399 | 408 | if (session.user?.isAdmin === true && session.user.kind === "user") |
| 400 | 409 | 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 |
||
| 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 { | ||
| 26 | 26 | setNotifyUrl, |
| 27 | 27 | } from "../../crew/notify.ts"; |
| 28 | 28 | import { forgeHost, noreplyAddress } from "../../core/commit_identity.ts"; |
| 29 | import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts"; | |
| 29 | 30 | import { listKeys } from "../../core/keys.ts"; |
| 30 | 31 | import { endAccountSession } from "../../actions/signins.ts"; |
| 31 | 32 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| @@ -159,9 +160,24 @@ export async function resendVerifyLink(ctx: Ctx): Promise<Response> { | ||
| 159 | 160 | return flashTo(ctx, "/settings", { message }); |
| 160 | 161 | } |
| 161 | 162 | |
| 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 |
||
| 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 | ||
| 162 | 178 | function profileView(ctx: Ctx, values: ProfileForm): Html { |
| 163 | 179 | const user = ctx.auth.user!; |
| 164 | return html`${unverifiedNotice(ctx)}${card( | |
| 180 | return html`${unverifiedNotice(ctx)}${roleView(ctx)}${card( | |
| 165 | 181 | html`<form method="post" action="/settings"> |
| 166 | 182 | <div class="field"> |
| 167 | 183 | <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 | ||
| 20 | 20 | import { createRun, listRuns, setRunBase } from "../src/core/runs.ts"; |
| 21 | 21 | import { getAccountCeiling } from "../src/core/account_ceiling.ts"; |
| 22 | 22 | import { createToken } from "../src/core/tokens.ts"; |
| 23 | import { setMember } from "../src/core/roles.ts"; | |
| 23 | 24 | import { createUser, type User } from "../src/core/users.ts"; |
| 24 | 25 | import { savePlacement } from "../src/core/placements.ts"; |
| 25 | 26 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -181,6 +182,7 @@ describe("the forge over MCP", () => { | ||
| 181 | 182 | "health", |
| 182 | 183 | "audit", |
| 183 | 184 | "model_grant", |
| 185 | "whoami_can", | |
| 184 | 186 | "account_role", |
| 185 | 187 | "model_ceiling", |
| 186 | 188 | "outsiders", |
| @@ -930,6 +932,20 @@ describe("the forge over MCP", () => { | ||
| 930 | 932 | expect(await kinds()).toContain("release"); |
| 931 | 933 | }); |
| 932 | 934 | |
| 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 | ||
| 933 | 949 | test("model_ceiling: dry by default, an admin person with an admin token sets and clears it", async () => { |
| 934 | 950 | const admin = createToken(app.db, alice.id, "admin", "admin").token; |
| 935 | 951 | 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"; | ||
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | 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"; | |
| 10 | 10 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 11 | 11 | import { createToken } from "../src/core/tokens.ts"; |
| 12 | 12 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -88,6 +88,20 @@ describe("roles on /settings/people", () => { | ||
| 88 | 88 | expect((await fetch(`${servers.url}/settings/people`, { headers: { cookie: bob } })).status).toBe(404); |
| 89 | 89 | }); |
| 90 | 90 | |
| 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 | ||
| 91 | 105 | test("a guest is promoted with no allowance by default, and demoted", async () => { |
| 92 | 106 | const alice = await signIn("alice"); |
| 93 | 107 | 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 | }); |
new above
offline
Fine as a tool: read only, scoped to the caller. Document it in the README's Roles paragraph next to
account_role.