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

Cloud accounts have a terminal screen and MCP tools as well as the page: list, attach, verify, rotate, rename, remove, and today's vendor calls #2206

Merged opened this

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

Merged

scout/forge:scout/2199-cloud-accounts-have-a-terminal-screen-an → main 5 commits fork 7 threads · 7 unresolved

  • Merged. scout/2199-cloud-accounts-have-a-terminal-screen-an was merged into main as a65e159.

Review

Sign in to review this change.
9 files changed +632 −4
README.md +1 −1
@@ -790,7 +790,7 @@ purpose, host and path, bytes out, when, allowed or refused — no token, no
790790 query) is listed at **Settings → Cloud accounts → Every vendor call today**.
791791 Rows are pruned past 90 days on every write; `at` is indexed. Left out: one
792792 token per vendor is not enforced (several names per vendor stay allowed), and
793 an agent's own vendor calls beyond the hand-out are not metered. Cloud accounts have no terminal or MCP door yet.
793 an agent's own vendor calls beyond the hand-out are not metered. A forge admin reaches the same accounts in the terminal (`:` → Cloud accounts: each account with what it is used for, verified / last used and today's vendor calls; `a` attach, `v` verify, `r` rotate, `n` rename, `x` remove, `w` flip the write switch, `A` audit into an `owner/repo`, `c` every vendor call today (purpose, host and path, bytes out, allowed or refused), the token typed masked) and over MCP (`cloud_accounts` lists them with today's audit rows; `cloud_account` with `verb` attach, verify, rotate, rename, remove, `allow_writes` (with `allowed`) or `audit` (with `repo`) needs an admin-scope token). Both call `actions/cloud.ts`, neither ever shows a token, and anyone else is refused. A rename is refused when the account or the secret `CLOUD_<PROVIDER>_<NAME>` already exists.
794794
795795 ## Share to the forge
796796
src/actions/cloud.ts +6 −2
@@ -21,7 +21,7 @@ import {
2121 } from "../core/cloud_account.ts";
2222 import { createIssue } from "../core/issues.ts";
2323 import { getRepo } from "../core/repos.ts";
24 import { resolveSecrets } from "../core/secret_store.ts";
24 import { listSecretNames, resolveSecrets } from "../core/secret_store.ts";
2525 import type { User } from "../core/users.ts";
2626 import { id } from "../kernel/id.ts";
2727 import { err, ok, type Result } from "../kernel/result.ts";
@@ -111,7 +111,11 @@ export function renameAccount(
111111 const dest = target(provider, to);
112112 if (!dest.ok) return dest;
113113 if (!getCloudAccount(app.db, from.value.provider, from.value.name)) return err("No such credential.");
114 if (getCloudAccount(app.db, dest.value.provider, dest.value.name)) return err("An account has that name already.");
114 if (
115 getCloudAccount(app.db, dest.value.provider, dest.value.name) ||
116 listSecretNames(app.db, "forge").some((s) => s.name === dest.value.secret)
117 )
118 return err("An account has that name already.");
115119 const value = open(app, who.value, from.value.secret, `rename:${from.value.provider}`);
116120 if (value === undefined) return err("No such credential.");
117121 const set = putSecret(app, who.value, "forge", "", dest.value.secret, value);
src/cloud/overview.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * Cloud accounts as plain rows for the doors that are not the page (the
3 * terminal, MCP): provider, name, what it is used for, when it was verified and
4 * last used, and how many vendor calls it made today (UTC day). Never a token
5 * value: nothing here opens the sealed secret. A forge admin's; anyone else is
6 * told `not_found`.
7 */
8
9 import type { App } from "../app.ts";
10 import { auditToday, type AuditRow } from "../core/cloud_audit.ts";
11 import { listCloudAccounts, recentCloudReads } from "../core/cloud_account.ts";
12 import type { User } from "../core/users.ts";
13 import { err, ok, type Result } from "../kernel/result.ts";
14 import { usedFor, type VendorUse } from "./declared.ts";
15 import { VENDOR_OF, type Provider } from "./providers.ts";
16
17 export interface AccountRow {
18 readonly provider: Provider;
19 readonly name: string;
20 readonly summary: string;
21 readonly usedFor: string;
22 readonly verifiedAt: string | null;
23 readonly lastUsedAt: string | null;
24 readonly writeAllowed: boolean;
25 readonly callsToday: number;
26 }
27
28 export interface CloudOverview {
29 readonly accounts: AccountRow[];
30 /** Today's vendor calls, newest first. */
31 readonly today: AuditRow[];
32 }
33
34 export function cloudOverview(
35 app: App,
36 actor: User | undefined,
37 uses: ReadonlyMap<string, readonly VendorUse[]> = new Map(),
38 ): Result<CloudOverview, string> {
39 if (actor?.isAdmin !== true) return err("not_found");
40 const today = auditToday(app.db);
41 const reads = recentCloudReads(app.db);
42 const accounts = listCloudAccounts(app.db).map((a) => ({
43 provider: a.provider,
44 name: a.name,
45 summary: a.summary,
46 usedFor: usedFor(uses.get(VENDOR_OF[a.provider])),
47 verifiedAt: a.verifiedAt,
48 lastUsedAt: reads.find((r) => r.provider === a.provider && r.name === a.name)?.at ?? null,
49 writeAllowed: a.writeAllowed,
50 callsToday: today.filter((r) => r.provider === a.provider && r.account === a.name).length,
51 }));
52 return ok({ accounts, today });
53 }
src/http/mcp.ts +75
@@ -22,6 +22,10 @@ import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2222 import { listAccess } from "../actions/access_log.ts";
2323 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2424 import { changeLimit, listLimits } from "../actions/limits.ts";
25 import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts";
26 import { usesByVendor, type VendorUse } from "../cloud/declared.ts";
27 import { cloudOverview } from "../cloud/overview.ts";
28 import { PROVIDERS } from "../cloud/providers.ts";
2529 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
2630 import { changeSignup, readSignup } from "../actions/signup.ts";
2731 import { SIGNUP_MODES } from "../settings/signup.ts";
@@ -2144,6 +2148,77 @@ const TOOLS: readonly Tool[] = [
21442148 });
21452149 },
21462150 },
2151 {
2152 name: "cloud_accounts",
2153 description:
2154 "Cloud accounts, for a forge admin: each attached provider token (github, cloudflare, vercel) with what it is used for, when it was verified and last used, whether runs may write with it, and today's vendor calls (UTC day) — then every audit row of today, newest first. Never a token value. Needs a write-scope token. The same as Settings → Cloud accounts.",
2155 inputSchema: { type: "object", properties: {} },
2156 // A read, but marked a write so a `ro` token never lists the accounts; it
2157 // is a forge admin's and the audit rows name every vendor call.
2158 writes: true,
2159 async run(ctx, user) {

This scans up to 100 repositories' forge.json for any rw-token caller before cloudOverview refuses a non-admin. Return bad("not_found", …) when !user.isAdmin first, then compute uses.

2160 if (!user.isAdmin) return bad("not_found", "no such tool here");
2161 const uses = await usesByVendor(ctx.app.db, ctx.app.reposRoot, user).catch(() => new Map<string, VendorUse[]>());
2162 const seen = cloudOverview(ctx.app, user, uses);
2163 if (!seen.ok) return bad("not_found", "no such tool here");
2164 return good(seen.value);
2165 },
2166 },
2167 {
2168 name: "cloud_account",
2169 description:
2170 "Change a cloud account, as a forge admin with an admin-scope token. `verb` is attach (checks `value` with the provider's read-only calls, then seals it), verify (re-checks the sealed token), rotate (checks a new `value`, then replaces it), rename (to `to`; refused when the account or the secret CLOUD_<PROVIDER>_<NAME> already exists), remove, allow_writes (`allowed` true or false: whether a run that declares writes may hold the token) or audit (a read-only vendor audit, one issue per finding in `repo`). `provider` is github, cloudflare or vercel; `name` is lowercase letters, digits and underscores. The answer never contains the token. The same as Settings → Cloud accounts.",
outdated

Add allow_writes (with a boolean allowed) and audit (with repo) to this enum, and route them to allowWrite and auditAccount, so MCP can do everything Settings → Cloud accounts does.

2171 inputSchema: {
2172 type: "object",
2173 properties: {
2174 verb: { type: "string", enum: ["attach", "verify", "rotate", "rename", "remove", "allow_writes", "audit"] },
2175 provider: { type: "string", enum: [...PROVIDERS] },
2176 name: { type: "string", maxLength: 32 },
2177 value: { type: "string", maxLength: 4096, description: "The token, for attach and rotate." },
2178 to: { type: "string", maxLength: 32, description: "The new name, for rename." },
2179 allowed: { type: "boolean", description: "For allow_writes: whether runs that declare writes may hold the token." },
2180 repo: { type: "string", maxLength: 200, description: "For audit: owner/name, where the findings are filed as issues." },
2181 },
2182 required: ["verb", "provider", "name"],
2183 },
2184 writes: true,
2185 async run(ctx, user, args) {
2186 if (!user.isAdmin) return bad("not_found", "no such tool here");
2187 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
2188 return bad("forbidden", "changing cloud accounts needs an admin-scope token");
2189 const verb = str(args["verb"]);
2190 const [provider, name] = [str(args["provider"]), str(args["name"])];
2191 const value = str(args["value"]);
2192 if (str(args["verb"]) === "allow_writes" && typeof args["allowed"] !== "boolean")
2193 return bad("bad_args", "allow_writes needs allowed: true or false");
2194 const done =
2195 verb === "attach"

Nit: a seven-level nested ternary is hard to scan. A Record<verb, () => Promise<Result>> table would read better, and its key set could also feed the enum in the schema. Line 2192 can use verb instead of calling str(args["verb"]) again.

2196 ? await addAccount(ctx.app, user, provider, name, value)
2197 : verb === "rotate"
2198 ? await rotateAccount(ctx.app, user, provider, name, value)
2199 : verb === "verify"
2200 ? await reverify(ctx.app, user, provider, name)
2201 : verb === "rename"
2202 ? renameAccount(ctx.app, user, provider, name, str(args["to"]))
2203 : verb === "remove"
2204 ? removeAccount(ctx.app, user, provider, name)
2205 : verb === "allow_writes"
2206 ? allowWrite(ctx.app, user, provider, name, args["allowed"] === true)
2207 : verb === "audit"
2208 ? await auditAccount(ctx.app, user, provider, name, str(args["repo"]))
2209 : undefined;
2210 if (done === undefined) return bad("bad_verb", "verb is attach, verify, rotate, rename, remove, allow_writes or audit");
2211 if (!done.ok) return bad("refused", done.error);
2212 const v = done.value as unknown;
2213 const said =
2214 typeof v === "string"
2215 ? v
2216 : verb === "audit"
2217 ? `${(v as { opened: number }).opened} issue(s) opened, ${(v as { found: number }).found} found`
2218 : "done";
2219 return good({ says: `${verb} ${provider}/${name}: ${said}.` });
2220 },
2221 },
21472222 {
21482223 name: "quotas",
21492224 description:
src/tui/forge.ts +9
@@ -42,6 +42,7 @@ import { dashboardScreen } from "./screens/dashboard.ts";
4242 import { mayManageFlags } from "../core/flags.ts";
4343 import { accessScreen } from "./screens/access.ts";
4444 import { flagsScreen } from "./screens/flags.ts";
45 import { cloudScreen } from "./screens/cloud.ts";
4546 import { usageScreen } from "./screens/usage.ts";
4647 import { quotasScreen } from "./screens/quotas.ts";
4748 import { domainsScreen } from "./screens/domains.ts";
@@ -362,6 +363,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
362363 hint: "what one account may own, and who changed it",
363364 run: () => ctx.push(quotasScreen(session, ctx)),
364365 });
366 // Cloud accounts, as Settings → Cloud accounts has them: a forge admin (#2199).
367 if (session.user?.isAdmin === true)
368 commands.push({
369 id: "cloud",
370 title: "Cloud accounts",
371 hint: "provider tokens, what they are used for, today's calls",
372 run: () => ctx.push(cloudScreen(session, ctx)),
373 });
365374 // Each failure domain and its last drill, as Settings → Failure domains says it (#1945).
366375 if (session.user?.isAdmin === true && session.user.kind !== "agent")
367376 commands.push({
src/tui/screens/cloud.ts added +212
@@ -0,0 +1,212 @@
1 /**
2 * Cloud accounts in the terminal, a forge admin's (#2199): each account with
3 * what it is used for, when it was verified and last used, and today's vendor
4 * calls, as Settings → Cloud accounts shows them. `a` attaches (verified, then
5 * sealed), `v` re-verifies, `r` rotates, `n` renames, `x` removes — all through
6 * `actions/cloud.ts`, so the checks are the page's. A token is typed masked and
7 * is never drawn, kept or put in a note. Anyone else sees nothing and does nothing.
8 */
9
10 import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../../actions/cloud.ts";
11 import { usesByVendor, type VendorUse } from "../../cloud/declared.ts";
12 import { cloudOverview } from "../../cloud/overview.ts";
13 import { LABEL, PROVIDERS } from "../../cloud/providers.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.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, later, promptScreen, type ListRow } from "./common.ts";
21
22 const day = (at: string | null): string => (at === null ? "never" : at.slice(0, 16).replace("T", " "));
23
24 /** Every vendor call of today, newest first: when, whose, why, where, how much, let through or refused. */
outdated

The page and MCP both list today's vendor calls row by row (purpose, target, bytes, allowed/refused). Here only a per-account count is drawn. read().value.today is already available, so render it, or open it with a key, and cover it in test/cloud-doors.test.ts.

25 function callsScreen(f: Session, ctx: Ctx): Screen {
26 const state = new ListState();
27 const calls = () => {
28 const r = cloudOverview(f.app, f.user);
29 return r.ok ? r.value.today : [];
30 };
31 return {
32 title: "Vendor calls today",
33 keys: [binding(["j", "k"], "move cursor"), binding(["esc"], "back")],
34 render(area, s) {
35 const c = ctx.theme.c;
36 const [head, list] = split(area, "v", [len(1), fill(1)]);
37 const all = calls();
38 s.text(head!.x, head!.y, truncate(`${all.length} vendor call${all.length === 1 ? "" : "s"} today (UTC), newest first.`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
39 drawList(
40 s,
41 list!,
42 c,
43 state,
44 all.map((r) => ({
45 title: `${r.provider}/${r.account} ${r.purpose}`,
46 ...(r.allowed ? {} : { tone: "danger" as const }),
47 secondary: `${r.allowed ? "allowed" : "REFUSED"} · ${r.bytesOut} B out · ${r.at.slice(11, 19)}`,
48 meta: r.target,
outdated

settle and settleWith overlap. settleWith with a constant message covers every settle call, so drop one.

49 })),
50 );
51 },
52 key: (e) => state.key(e, calls().length),
53 };
54 }
55
56 export function cloudScreen(f: Session, ctx: Ctx): Screen {
57 const state = new ListState();
58 let note = "";
59 let uses: ReadonlyMap<string, readonly VendorUse[]> = new Map();
60 const read = () => cloudOverview(f.app, f.user, uses);
61 const allowed = (): boolean => read().ok;
62 const accounts = () => {
63 const r = read();
64 return r.ok ? r.value.accounts : [];
65 };
66
67 if (f.user?.isAdmin === true)
68 void usesByVendor(f.app.db, f.app.reposRoot, f.user)
69 .then((m) => {
70 uses = m;
71 ctx.invalidate();
72 })
73 .catch(() => undefined);
74
75 const say = (text: string): void => {
76 note = text;
77 ctx.invalidate();
78 };
79 const settle = async <T>(r: Promise<{ ok: true; value: T } | { ok: false; error: string }>, good: (v: T) => string): Promise<void> => {
80 const made = await r;
81 say(made.ok ? good(made.value) : made.error);
82 };
83
84 const rows = (): ListRow[] =>
85 accounts().map((a) => ({
86 title: `${a.provider}/${a.name}`,
87 secondary: `verified ${day(a.verifiedAt)} · used ${day(a.lastUsedAt)} · ${a.callsToday} call${a.callsToday === 1 ? "" : "s"} today`,
outdated

The page also has the write switch (allowWrite) and the audit (auditAccount). Add w to toggle writes and a key for audit (prompt for owner/repo), so the terminal can do everything the page does. The screen already shows writes allowed / read-only but gives no way to change it.

88 meta: a.writeAllowed ? "writes allowed" : "read-only",
89 }));
90
91 const attach = (parent: Screen): Screen =>
92 promptScreen(ctx, parent, {
93 title: "Attach a cloud account",
94 label: `provider/name, provider is ${PROVIDERS.join(", ")}`,
95 placeholder: "github/main",
96 onSubmit: (raw) => {
97 const [provider = "", name = "", ...rest] = raw.split("/");
98 if (rest.length > 0 || name === "") return "Write it as provider/name.";
99 if (!(PROVIDERS as readonly string[]).includes(provider)) return `A provider is ${PROVIDERS.join(", ")}.`;
100 return later(ctx, () =>
101 promptScreen(ctx, parent, {
102 title: `${LABEL[provider as (typeof PROVIDERS)[number]]} / ${name}`,
103 label: "the token (checked, then sealed, never shown)",
104 mask: true,
105 onSubmit: (value) => settle(addAccount(f.app, f.user, provider, name, value), () => `Sealed ${provider}/${name}.`),
106 }),
107 );
108 },
109 });
110
111 const screen: Screen = {
112 title: "Cloud accounts",
113 keys: [
114 binding(["j", "k"], "move cursor"),
115 binding(["a"], "attach"),
116 binding(["v"], "verify"),
117 binding(["r"], "rotate"),
118 binding(["n"], "rename"),
119 binding(["x"], "remove"),
120 binding(["w"], "writes on/off"),
121 binding(["A"], "audit"),
122 binding(["c"], "calls today"),
123 ],
124 render(area, s) {
125 const c = ctx.theme.c;
126 const r = read();
127 const [head, list] = split(area, "v", [len(1), fill(1)]);
128 const selected = r.ok ? r.value.accounts[state.index] : undefined;
129 const text =
130 note !== ""
131 ? note
132 : !r.ok
133 ? "Cloud accounts are a forge admin's."
134 : selected !== undefined
135 ? `${selected.summary} ${selected.usedFor}`
136 : "No account attached. Press a to attach one.";
137 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
138 drawList(s, list!, c, state, rows());
139 },
140 key(e) {
141 if (state.key(e, accounts().length)) return true;
142 if (e.ctrl || e.alt || !allowed()) return false;
143 if (e.key === "a") {
144 ctx.push(attach(screen));
145 return true;
146 }
147 if (e.key === "c") {
148 ctx.push(callsScreen(f, ctx));
149 return true;
150 }
151 const a = accounts()[state.index];
152 if (a === undefined) return false;
153 const { provider, name } = a;
154 if (e.key === "v") {
155 say(`Verifying ${provider}/${name}…`);
156 void settle(reverify(f.app, f.user, provider, name), () => `Verified ${provider}/${name}.`);
157 } else if (e.key === "r")
158 ctx.push(
159 promptScreen(ctx, screen, {
160 title: `Rotate ${provider}/${name}`,
161 label: "the new token (checked, then sealed)",
162 mask: true,
163 onSubmit: (value) => settle(rotateAccount(f.app, f.user, provider, name, value), () => `Rotated ${provider}/${name}.`),
164 }),
165 );
166 else if (e.key === "n")
167 ctx.push(
168 promptScreen(ctx, screen, {
169 title: `Rename ${provider}/${name}`,
170 label: "new name",
171 onSubmit: (to) => {
172 const r = renameAccount(f.app, f.user, provider, name, to);
173 if (!r.ok) return r.error;
174 say(`Renamed to ${provider}/${to}.`);
175 return undefined;
176 },
177 }),
178 );
179 else if (e.key === "w") {
180 const r = allowWrite(f.app, f.user, provider, name, !a.writeAllowed);
181 say(r.ok ? `${provider}/${name}: ${a.writeAllowed ? "read-only" : "writes allowed"}.` : r.error);
182 } else if (e.key === "A")
183 ctx.push(
184 promptScreen(ctx, screen, {
185 title: `Audit ${provider}/${name}`,
186 label: "owner/repo to file the findings in",
187 onSubmit: (repo) => {
188 say(`Auditing ${provider}/${name}…`);
189 return settle(auditAccount(f.app, f.user, provider, name, repo), (v) => `Audit: ${v.opened} issue${v.opened === 1 ? "" : "s"} opened, ${v.found} found.`);
190 },
191 }),
192 );
193 else if (e.key === "x")
194 ctx.push(
195 promptScreen(ctx, screen, {
196 title: `Remove ${provider}/${name}`,
197 label: `type ${name} to remove it and its sealed token`,
198 onSubmit: (typed) => {
199 if (typed !== name) return `Type ${name} to confirm.`;
200 const r = removeAccount(f.app, f.user, provider, name);
201 if (!r.ok) return r.error;
202 say(`Removed ${provider}/${name}.`);
203 return undefined;
204 },
205 }),
206 );
207 else return false;
208 return true;
209 },
210 };
211 return screen;
212 }
test/cloud-doors.test.ts added +273
@@ -0,0 +1,273 @@
1 /**
2 * Cloud accounts at the terminal and MCP doors (#2199): a forge admin lists,
3 * attaches, verifies, rotates, renames and removes through the functions the
4 * page uses; anyone else is refused at both; no frame and no result carries a
5 * token; a rename is refused when the secret name is taken even with no row.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { addAccount, net, renameAccount } from "../src/actions/cloud.ts";
14 import { recordAudit } from "../src/core/cloud_audit.ts";
15 import { getCloudAccount, listCloudAccounts } from "../src/core/cloud_account.ts";
16 import { listSecretNames, setSecret } from "../src/core/secret_store.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { createForgeApp } from "../src/tui/forge.ts";
21 import { cloudScreen } from "../src/tui/screens/cloud.ts";
22 import { Session } from "../src/tui/session.ts";
23 import { headlessTerminal } from "../src/tui/term/terminal.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25 import { settleApp } from "./helpers/tui.ts";
26
27 quietLogs();
28
29 const T1 = "ghp_first9Zq4TxLm7Kp2Vb8nWcR5";
30 const T2 = "ghp_second7Hs3DfGj6Lm1Qw0Zx8";
31 net.fetch = async (_url, init) => {
32 const auth = String((init?.headers as Record<string, string> | undefined)?.authorization ?? "");
33 return new Response(JSON.stringify({ login: "octo" }), {
34 status: auth.includes(T1) || auth.includes(T2) ? 200 : 401,
35 });
36 };
37
38 describe("cloud accounts at the terminal and MCP doors", () => {
39 let dataDir = "";
40 let app: App;
41 let servers: Servers;
42 let alice: User;
43 let adminToken = "";
44 let rwToken = "";
45 let bobToken = "";
46
47 beforeAll(async () => {
48 dataDir = await mkdtemp(join(tmpdir(), "forge-cloud-doors-"));
49 app = createApp({ dataDir, port: 0 });
50 servers = await serve(app);
51 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
52 const made = await createUser(app.db, { trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst });
53 if (!made.ok) throw new Error(made.error);
54 }
55 alice = getUserByName(app.db, "alice")!;
56 const bob = getUserByName(app.db, "bob")!;
57 adminToken = createToken(app.db, alice.id, "admin", "admin").token;
58 rwToken = createToken(app.db, alice.id, "rw", "rw").token;
59 bobToken = createToken(app.db, bob.id, "admin", "admin").token;
60 });
61 afterAll(async () => {
62 servers.stop();
63 app.close();
64 await rm(dataDir, { recursive: true, force: true });
65 });
66
67 const mcp = async (token: string, name: string, args: unknown = {}) => {
68 const res = await fetch(`${servers.url}/mcp`, {
69 method: "POST",
70 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
71 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
72 });
73 const body = (await res.json()) as any;
74 const text: string = body.result.content[0].text;
75 return { error: body.result.isError === true, text };
76 };
77
78 const boot = (username: string) => {
79 const user = getUserByName(app.db, username)!;
80 const { app: tui } = createForgeApp(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal: headlessTerminal(160, 32), exitHooks: false });
81 tui.start();
82 const key = (k: string) => tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
83 return {
84 text: () => tui.screen.toText(),
85 press: async (...keys: string[]) => {
86 keys.forEach(key);
87 await settleApp(tui);
88 },
89 type: async (t: string) => {
90 for (const ch of t) key(ch);
91 await settleApp(tui);
92 },
93 palette: async (q: string) => {
94 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
95 await settleApp(tui);
96 for (const ch of q) key(ch);
97 await settleApp(tui);
98 },
99 stop: () => tui.stop(),
100 };
101 };
102
103 test("terminal: an admin attaches, verifies, rotates, renames and removes, and no frame holds a token", async () => {
104 const h = boot("alice");
105 await h.palette("Cloud accounts");
106 await h.press("enter");
107 expect(h.text()).toContain("No account attached");
108
109 await h.press("a");
110 await h.type("github/main");
111 await h.press("enter");
112 await h.type(T1);
113 expect(h.text()).not.toContain(T1);
114 await h.press("enter");
115 expect(listCloudAccounts(app.db).map((a) => a.name)).toEqual(["main"]);
116 expect(listSecretNames(app.db, "forge").map((s) => s.name)).toEqual(["CLOUD_GITHUB_MAIN"]);
117 expect(h.text()).toContain("github/main");
118 expect(h.text()).toContain("1 call today");
119 expect(h.text()).not.toContain(T1);
120
121 await h.press("v");
122 expect(h.text()).toContain("Verified github/main");
123 // The attach made one gated vendor call, the verify one more, and this is a third.
124 recordAudit(app.db, { provider: "github", account: "main", purpose: "verify", target: "https://api.github.com/user", bytesOut: 0, allowed: true });
125 await h.press("j", "k");
126 expect(h.text()).toContain("3 calls today");
127
128 recordAudit(app.db, { provider: "github", account: "main", purpose: "audit", target: "https://api.github.com/orgs/blocked-here", bytesOut: 42, allowed: false });
129 await h.press("c");
130 expect(h.text()).toContain("https://api.github.com/user");
131 expect(h.text()).toContain("https://api.github.com/orgs/blocked-here");
132 expect(h.text()).toContain("REFUSED");
133 expect(h.text()).toContain("42 B out");
134 await h.press("esc");
135
136 await h.press("r");
137 await h.type(T2);
138 expect(h.text()).not.toContain(T2);
139 await h.press("enter");
140 expect(h.text()).toContain("Rotated github/main");
141 expect(h.text()).not.toContain(T2);
142
143 await h.press("n");
144 await h.type("work");
145 await h.press("enter");
146 expect(getCloudAccount(app.db, "github", "work")).toBeDefined();
147 expect(getCloudAccount(app.db, "github", "main")).toBeUndefined();
148
149 await h.press("x");
150 await h.type("work");
151 await h.press("enter");
152 expect(listCloudAccounts(app.db)).toEqual([]);
153 expect(listSecretNames(app.db, "forge")).toEqual([]);
154 h.stop();
155 });
156
157 test("terminal: a non-admin has no command and, driving the screen, does nothing", async () => {
158 const h = boot("bob");
159 await h.palette("Cloud accounts");
160 expect(h.text()).not.toContain("provider tokens");
161 h.stop();
162
163 const bob = getUserByName(app.db, "bob")!;
164 const session = new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: bob });
165 const pushed: unknown[] = [];
166 const screen = cloudScreen(session, { push: (s: unknown) => pushed.push(s), invalidate() {} } as never);
167 for (const k of ["a", "v", "r", "n", "x", "w", "A", "c"])
168 screen.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never);
169 expect(pushed).toEqual([]);
170 });
171
172 test("MCP: an admin lists, attaches, verifies, rotates, renames and removes; no result holds a token", async () => {
173 const attached = await mcp(adminToken, "cloud_account", { verb: "attach", provider: "github", name: "main", value: T1 });
174 expect(attached.error).toBe(false);
175 expect(attached.text).not.toContain(T1);
176 const listed = await mcp(adminToken, "cloud_accounts");
177 const value = JSON.parse(listed.text);
178 expect(value.accounts).toHaveLength(1);
179 expect(value.accounts[0]).toMatchObject({ provider: "github", name: "main", callsToday: value.today.length });
180 expect(value.today.length).toBeGreaterThan(0);
181 expect(value.today[0]).toMatchObject({ provider: "github", account: "main" });
182 expect(listed.text).not.toContain(T1);
183
184 for (const args of [
185 { verb: "verify", provider: "github", name: "main" },
186 { verb: "rotate", provider: "github", name: "main", value: T2 },
187 { verb: "rename", provider: "github", name: "main", to: "work" },
188 ]) {
189 const r = await mcp(adminToken, "cloud_account", args);
190 expect(r.error).toBe(false);
191 expect(r.text).not.toContain(T1);
192 expect(r.text).not.toContain(T2);
193 }
194 expect(getCloudAccount(app.db, "github", "work")).toBeDefined();
195 expect((await mcp(adminToken, "cloud_account", { verb: "remove", provider: "github", name: "work" })).error).toBe(false);
196 expect(listCloudAccounts(app.db)).toEqual([]);
197 });
198
199 test("MCP: a non-admin is refused, and a write-scope token cannot change accounts", async () => {
200 for (const r of [
201 await mcp(bobToken, "cloud_accounts"),
202 await mcp(bobToken, "cloud_account", { verb: "attach", provider: "github", name: "main", value: T1 }),
203 ]) {
204 expect(r.error).toBe(true);
205 expect(r.text).not.toContain(T1);
206 }
207 expect(listCloudAccounts(app.db)).toEqual([]);
208 const weak = await mcp(rwToken, "cloud_account", { verb: "attach", provider: "github", name: "main", value: T1 });
209 expect(weak.error).toBe(true);
210 expect(listCloudAccounts(app.db)).toEqual([]);
211 });
212
213 test("terminal: an admin flips the write switch and audits into a repository", async () => {
214 const made = await createRepo(app, alice, { name: "ops", visibility: "private" });
215 if (!made.ok) throw new Error("repo");
216 expect((await addAccount(app, alice, "github", "ledger", T1)).ok).toBe(true);
217 const h = boot("alice");
218 await h.palette("Cloud accounts");
219 await h.press("enter");
220 expect(h.text()).toContain("read-only");
221 await h.press("w");
222 expect(getCloudAccount(app.db, "github", "ledger")!.writeAllowed).toBe(true);
223 expect(h.text()).toContain("github/ledger: writes allowed.");
224 await h.press("w");
225 expect(getCloudAccount(app.db, "github", "ledger")!.writeAllowed).toBe(false);
226 expect(h.text()).toContain("github/ledger: read-only.");
227
228 await h.press("A");
229 await h.type("alice/nowhere");
230 await h.press("enter");
231 expect(h.text()).toContain("Name a repository");
232 await h.press("A");
233 await h.type("alice/ops");
234 await h.press("enter");
235 expect(h.text()).toContain("Audit: 0 issues opened");
236 h.stop();
237 await mcp(adminToken, "cloud_account", { verb: "remove", provider: "github", name: "ledger" });
238 });
239
240 test("MCP: an admin flips the write switch and audits; a non-admin cannot", async () => {
241 expect((await addAccount(app, alice, "github", "ledger", T1)).ok).toBe(true);
242 const on = await mcp(adminToken, "cloud_account", { verb: "allow_writes", provider: "github", name: "ledger", allowed: true });
243 expect(on.error).toBe(false);
244 expect(getCloudAccount(app.db, "github", "ledger")!.writeAllowed).toBe(true);
245 expect((await mcp(adminToken, "cloud_account", { verb: "allow_writes", provider: "github", name: "ledger" })).error).toBe(true);
246 expect(getCloudAccount(app.db, "github", "ledger")!.writeAllowed).toBe(true);
247 expect((await mcp(adminToken, "cloud_account", { verb: "allow_writes", provider: "github", name: "ledger", allowed: false })).error).toBe(false);
248 expect(getCloudAccount(app.db, "github", "ledger")!.writeAllowed).toBe(false);
249
250 const audited = await mcp(adminToken, "cloud_account", { verb: "audit", provider: "github", name: "ledger", repo: "alice/ops" });
251 expect(audited.error).toBe(false);
252 expect(audited.text).toContain("opened");
253 expect((await mcp(adminToken, "cloud_account", { verb: "audit", provider: "github", name: "ledger", repo: "alice/nowhere" })).error).toBe(true);
254
255 for (const args of [
256 { verb: "allow_writes", provider: "github", name: "ledger", allowed: true },
257 { verb: "audit", provider: "github", name: "ledger", repo: "alice/ops" },
258 ])
259 expect((await mcp(bobToken, "cloud_account", args)).error).toBe(true);
260 expect(getCloudAccount(app.db, "github", "ledger")!.writeAllowed).toBe(false);
261 await mcp(adminToken, "cloud_account", { verb: "remove", provider: "github", name: "ledger" });
262 });
263
264 test("a rename is refused when the secret exists, with no account row", async () => {
265 expect((await addAccount(app, alice, "github", "main", T1)).ok).toBe(true);
266 expect(setSecret(app.db, dataDir, "forge", "", "CLOUD_GITHUB_TAKEN", "an-unrelated-sealed-value").ok).toBe(true);
267 expect(getCloudAccount(app.db, "github", "taken")).toBeUndefined();
268 const r = renameAccount(app, alice, "github", "main", "taken");
269 expect(r.ok).toBe(false);
270 expect(getCloudAccount(app.db, "github", "main")).toBeDefined();
271 expect(listSecretNames(app.db, "forge").map((s) => s.name)).toEqual(["CLOUD_GITHUB_MAIN", "CLOUD_GITHUB_TAKEN"]);
272 });
273 });
test/mcp.test.ts +2
@@ -212,6 +212,8 @@ describe("the forge over MCP", () => {
212212 "system",
213213 "contribute",
214214 "limits",
215 "cloud_accounts",
216 "cloud_account",
215217 "quotas",
216218 "signup",
217219 "boards",
test/tui-tell.test.ts +1 −1
@@ -31,7 +31,7 @@ let bob: User;
3131 let repo: Repo;
3232
3333 async function palette(user: User, keys: string[]): Promise<string> {
34 const terminal = headlessTerminal(120, 40);
34 const terminal = headlessTerminal(120, 48);

Minor: at this head the suite passes with the original 120×40 terminal. I put 40 back in a worktree and all 7 tests pass. Consider reverting to 40, so that an admin command crowding the owner's Tell row on a normal terminal fails here and isn't hidden by a bigger screen.

3535 const { app: tui, session } = createForgeApp(
3636 app,
3737 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs