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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2199-cloud-accounts-have-a-terminal-screen-an → main 5 commits fork 7 threads · 7 unresolved
README.md +1 −1
| @@ -790,7 +790,7 @@ purpose, host and path, bytes out, when, allowed or refused — no token, no | ||
| 790 | 790 | query) is listed at **Settings → Cloud accounts → Every vendor call today**. |
| 791 | 791 | Rows are pruned past 90 days on every write; `at` is indexed. Left out: one |
| 792 | 792 | 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. | |
| 794 | 794 | |
| 795 | 795 | ## Share to the forge |
| 796 | 796 |
src/actions/cloud.ts +6 −2
| @@ -21,7 +21,7 @@ import { | ||
| 21 | 21 | } from "../core/cloud_account.ts"; |
| 22 | 22 | import { createIssue } from "../core/issues.ts"; |
| 23 | 23 | import { getRepo } from "../core/repos.ts"; |
| 24 | import { resolveSecrets } from "../core/secret_store.ts"; | |
| 24 | import { listSecretNames, resolveSecrets } from "../core/secret_store.ts"; | |
| 25 | 25 | import type { User } from "../core/users.ts"; |
| 26 | 26 | import { id } from "../kernel/id.ts"; |
| 27 | 27 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -111,7 +111,11 @@ export function renameAccount( | ||
| 111 | 111 | const dest = target(provider, to); |
| 112 | 112 | if (!dest.ok) return dest; |
| 113 | 113 | 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."); | |
| 115 | 119 | const value = open(app, who.value, from.value.secret, `rename:${from.value.provider}`); |
| 116 | 120 | if (value === undefined) return err("No such credential."); |
| 117 | 121 | 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"; | ||
| 22 | 22 | import { listAccess } from "../actions/access_log.ts"; |
| 23 | 23 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 24 | 24 | 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"; | |
| 25 | 29 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| 26 | 30 | import { changeSignup, readSignup } from "../actions/signup.ts"; |
| 27 | 31 | import { SIGNUP_MODES } from "../settings/signup.ts"; |
| @@ -2144,6 +2148,77 @@ const TOOLS: readonly Tool[] = [ | ||
| 2144 | 2148 | }); |
| 2145 | 2149 | }, |
| 2146 | 2150 | }, |
| 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) { | |
| 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 |
||
| 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 |
||
| 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 | }, | |
| 2147 | 2222 | { |
| 2148 | 2223 | name: "quotas", |
| 2149 | 2224 | description: |
src/tui/forge.ts +9
| @@ -42,6 +42,7 @@ import { dashboardScreen } from "./screens/dashboard.ts"; | ||
| 42 | 42 | import { mayManageFlags } from "../core/flags.ts"; |
| 43 | 43 | import { accessScreen } from "./screens/access.ts"; |
| 44 | 44 | import { flagsScreen } from "./screens/flags.ts"; |
| 45 | import { cloudScreen } from "./screens/cloud.ts"; | |
| 45 | 46 | import { usageScreen } from "./screens/usage.ts"; |
| 46 | 47 | import { quotasScreen } from "./screens/quotas.ts"; |
| 47 | 48 | import { domainsScreen } from "./screens/domains.ts"; |
| @@ -362,6 +363,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 362 | 363 | hint: "what one account may own, and who changed it", |
| 363 | 364 | run: () => ctx.push(quotasScreen(session, ctx)), |
| 364 | 365 | }); |
| 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 | }); | |
| 365 | 374 | // Each failure domain and its last drill, as Settings → Failure domains says it (#1945). |
| 366 | 375 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 367 | 376 | 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. |
||
| 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
|
||
| 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 ( |
||
| 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", () => { | ||
| 212 | 212 | "system", |
| 213 | 213 | "contribute", |
| 214 | 214 | "limits", |
| 215 | "cloud_accounts", | |
| 216 | "cloud_account", | |
| 215 | 217 | "quotas", |
| 216 | 218 | "signup", |
| 217 | 219 | "boards", |
test/tui-tell.test.ts +1 −1
| @@ -31,7 +31,7 @@ let bob: User; | ||
| 31 | 31 | let repo: Repo; |
| 32 | 32 | |
| 33 | 33 | 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. |
||
| 35 | 35 | const { app: tui, session } = createForgeApp( |
| 36 | 36 | app, |
| 37 | 37 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, |
This scans up to 100 repositories'
forge.jsonfor any rw-token caller beforecloudOverviewrefuses a non-admin. Returnbad("not_found", …)when!user.isAdminfirst, then computeuses.