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

The terminal lists your OAuth apps for connecting origins, adds one and removes one, never reading back a secret, as MCP `origin_apps` does #7860

Merged opened this

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

Merged

torque/forge:torque/7291-the-terminal-lists-your-oauth-apps-for-c → main 2 commits fork

  • Merged. torque/7291-the-terminal-lists-your-oauth-apps-for-c was merged into main as 30fe819.

Review

Sign in to review this change.
4 files changed +243 −0
src/tui/forge.ts +8
@@ -51,6 +51,7 @@ import { notifyScreen } from "./screens/notify.ts";
5151 import { sshKeysScreen } from "./screens/sshkeys.ts";
5252 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
5353 import { signInsScreen } from "./screens/signins.ts";
54 import { originAppsScreen } from "./screens/originapps.ts";
5455 import { addEntries } from "./screens/entries.ts";
5556 import { capacityScreen, mayCapacity } from "./screens/capacity.ts";
5657 import { forgeCeiling } from "../flow/ceiling.ts";
@@ -275,6 +276,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
275276 hint: "your browsers and tokens; d signs a browser out",
276277 run: () => ctx.push(signInsScreen(session, ctx)),
277278 },
279 // Settings → Origins' OAuth apps and MCP `origin_apps` (#7291).
280 {
281 id: "origin-apps",
282 title: "Origin apps",
283 hint: "your OAuth apps for connecting origins; a adds, d removes",
284 run: () => ctx.push(originAppsScreen(session, ctx)),
285 },
278286 );
279287 // The forge's RSS beside its JS heap, as /system gates it (#2151).
280288 if (canSeeMemory(session.user))
src/tui/screens/originapps.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * Your OAuth apps for connecting origins, in the terminal (#7291): the host
3 * (or kind), client id and whether a secret is set, as Settings and MCP
4 * `origin_apps` list them. `a` asks for a host (or github), a client id and a
5 * client secret typed masked, and registers it through `registerOriginApp`,
6 * refused in the API's words (`connectMessage`); `d` removes the one under the
7 * cursor after a confirm. A secret is write-only: it is held in a closure from
8 * the masked field to the action and is never drawn, toasted or logged.
9 */
10
11 import { deleteOriginApp, listOriginApps, registerOriginApp } from "../../actions/origins.ts";
12 import { connectMessage } from "../../http/api.ts";
13 import type { AppRow } from "../../origins/oauth.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 { wrap } from "../term/width.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import type { Session } from "../session.ts";
20 import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts";
21
22 /** A row's name: the host of its web address, as the page shows it. */
23 const hostOf = (a: AppRow): string => a.webUrl.replace(/^https?:\/\//, "");
24
25 export function originAppsScreen(f: Session, ctx: Ctx): Screen {
26 const state = new ListState();
27 let rows: AppRow[] = [];
28 let note = "";
29 const reload = (): void => {
30 rows = f.user === undefined ? [] : listOriginApps(f.app, f.user);
31 state.clamp(rows.length);
32 ctx.invalidate();
33 };
34 reload();
35 const say = (text: string): void => {
36 note = text;
37 reload();
38 };
39
40 const add = (): Screen =>
41 promptScreen(ctx, screen, {
42 title: "Register an origin app",
43 label: "host, or github",
44 placeholder: "github",
45 onSubmit: (host) => {
46 if (host === "") return "An origin app needs a host, or github.";
47 return later(ctx, () =>
48 promptScreen(ctx, screen, {
49 title: host,
50 label: "the OAuth app's client id",
51 onSubmit: (clientId) => {
52 if (clientId === "") return "`clientId` is required";
53 return later(ctx, () =>
54 promptScreen(ctx, screen, {
55 title: host,
56 label: "the client secret (sealed, never shown again)",
57 mask: true,
58 onSubmit: async (secret) => {
59 const saved = await registerOriginApp(f.app, f.user, {
60 host,
61 ...(host.toLowerCase() === "github" ? { kind: "github" as const } : {}),
62 clientId,
63 ...(secret.trim() === "" ? {} : { clientSecret: secret }),
64 });
65 say(saved.ok ? `${hostOf(saved.value)} is registered.` : connectMessage(saved.error));
66 },
67 }),
68 );
69 },
70 }),
71 );
72 },
73 });
74
75 const remove = (a: AppRow): Screen =>
76 confirmScreen(ctx, screen, {
77 title: "Remove this origin app",
78 body: [`${hostOf(a)}, client ${a.clientId}`, "Connecting that origin by OAuth stops working."],
79 actions: [{ label: "Keep", variant: "ghost" }, { label: "Remove", variant: "danger" }],
80 onChoose: (i) => {
81 if (i !== 1) return;
82 const removed = deleteOriginApp(f.app, f.user, a.id);
83 say(removed.ok ? `${hostOf(a)} is removed.` : "no such origin app");
84 },
85 });
86
87 const lines = (): ListRow[] =>
88 rows.map((a) => ({
89 title: hostOf(a),
90 secondary: a.kind,
91 meta: `client ${a.clientId} · ${a.hasSecret ? "secret set" : "no secret"}`,
92 }));
93
94 const screen: Screen = {
95 title: "Origin apps",
96 keys: [binding(["a"], "register one"), binding(["d"], "remove"), binding(["j", "k"], "move cursor")],
97 render(area, s) {
98 const c = ctx.theme.c;
99 const text =
100 note !== ""
101 ? note
102 : f.user === undefined
103 ? "Sign in to see your origin apps."
104 : rows.length === 0
105 ? "No origin app yet. Press a to register one."
106 : `${rows.length} registered; a secret is never shown.`;
107 // A refusal is the API's sentence, whole: it wraps rather than cuts.
108 const said = wrap(text, Math.max(1, area.w)).slice(0, 3);
109 const [head, list] = split(area, "v", [len(said.length), fill(1)]);
110 said.forEach((line, i) => s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w));
111 drawList(s, list!, c, state, lines());
112 },
113 key(e) {
114 if (state.key(e, rows.length)) return true;
115 if (e.ctrl || e.alt || f.user === undefined) return false;
116 if (e.key === "a") {
117 note = "";
118 ctx.push(add());
119 return true;
120 }
121 const a = rows[state.index];
122 if (e.key === "d" && a !== undefined) {
123 note = "";
124 ctx.push(remove(a));
125 return true;
126 }
127 return false;
128 },
129 };
130 return screen;
131 }
test/palette-roster.test.ts +3
@@ -68,6 +68,7 @@ test("a site-admin person is offered the whole roster", () => {
6868 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
6969 ["sentinel", "Sentinel", "the forge's open faults"],
7070 ["boards", "Boards", "the forge's boards and their panels"],
71 ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"],
7172 ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"],
7273 ["flow", "Flow", "the pipeline's bottleneck and throughput"],
7374 ["system", "System", "the machine now: CPU, memory, disks, processes"],
@@ -133,6 +134,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
133134 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
134135 ["sentinel", "Sentinel", "the forge's open faults"],
135136 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
137 ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"],
136138 ["api", "API", "the forge's endpoints; run a GET"],
137139 ["changelog", "Changelog", "the last 30 releases and their pulls"],
138140 ["releases", "Releases", "the live release and the kept ones"],
@@ -177,6 +179,7 @@ test("a member person is offered the signed-in roster without the admin entries"
177179 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
178180 ["account", "Account settings", undefined],
179181 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
182 ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"],
180183 ["api", "API", "the forge's endpoints; run a GET"],
181184 ["changelog", "Changelog", "the last 30 releases and their pulls"],
182185 ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"],
test/tui-originapps.test.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The terminal's Origin apps screen (src/tui/screens/originapps.ts, #7291):
3 * add, list without the secret, a bad host refused in the API's words, and
4 * remove after a confirm. No frame and no log line holds the secret.
5 */
6 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { listOriginApps } from "../src/actions/origins.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { connectMessage } from "../src/http/api.ts";
14 import { logLevel, setLogLevel, type LogLevel } from "../src/kernel/log.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { settleApp } from "./helpers/tui.ts";
18
19 const SECRET = "s3cr3t_Qw9zXv";
20 const RIGHT = "\x1b[C";
21 const open = [":", "Origin apps", "\r"];
22
23 let dir: string;
24 let app: App;
25 let alice: User;
26 let found: LogLevel;
27 const frames: string[] = [];
28 const logged: string[] = [];
29 const spies: { mockRestore(): void }[] = [];
30
31 async function drive(keys: string[]): Promise<string> {
32 const terminal = headlessTerminal(120, 40);
33 const { app: tui } = createForgeApp(
34 app,
35 { sshUsername: alice.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice },
36 { terminal, exitHooks: false },
37 );
38 tui.start();
39 await settleApp(tui);
40 for (const k of keys) {
41 terminal.feed(k);
42 await settleApp(tui);
43 frames.push(tui.screen.toText());
44 }
45 const text = tui.screen.toText();
46 tui.stop?.();
47 return text;
48 }
49
50 beforeAll(async () => {
51 found = logLevel();
52 setLogLevel("debug");
53 for (const m of ["log", "info", "warn", "error", "debug"] as const) {
54 spies.push(spyOn(console, m).mockImplementation((...a: unknown[]) => void logged.push(a.join(" "))));
55 }
56 dir = await mkdtemp(join(tmpdir(), "forge-tui-originapps-"));
57 app = createApp({ dataDir: dir, port: 0 });
58 const r = await createUser(app.db, {
59 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
60 password: "a-good-passphrase", adminIfFirst: true,
61 });
62 if (!r.ok) throw new Error(r.error);
63 alice = r.value;
64 });
65 afterAll(async () => {
66 for (const s of spies) s.mockRestore();
67 setLogLevel(found);
68 app.db.close();
69 await rm(dir, { recursive: true, force: true });
70 });
71
72 describe("the terminal's origin apps screen", () => {
73 test("a refused host says MCP's words and registers nothing", async () => {
74 const text = await drive([...open, "a", "not a host!", "\r", "cid", "\r", SECRET, "\r"]);
75 expect(text).toContain(connectMessage("unknown_host"));
76 expect(listOriginApps(app, alice)).toEqual([]);
77 });
78
79 test("add: listed with its client id and hasSecret, the secret nowhere", async () => {
80 const text = await drive([...open, "a", "github", "\r", "Iv1.abc123", "\r", SECRET, "\r"]);
81 const rows = listOriginApps(app, alice);
82 expect(rows.map((r) => [r.kind, r.clientId, r.hasSecret])).toEqual([["github", "Iv1.abc123", true]]);
83 expect(text).toContain("github.com is registered.");
84 expect(text).toContain("client Iv1.abc123 · secret set");
85 expect(frames.some((f) => f.includes("•".repeat(SECRET.length)))).toBe(true);
86 for (const f of frames) expect(f).not.toContain(SECRET);
87 expect(logged.join("\n")).not.toContain(SECRET);
88 });
89
90 test("d asks first; keeping leaves it, confirming removes it", async () => {
91 const asked = await drive([...open, "d"]);
92 expect(asked).toContain("Remove this origin app");
93 expect(listOriginApps(app, alice).length).toBe(1);
94 await drive([...open, "d", "\r"]);
95 expect(listOriginApps(app, alice).length).toBe(1);
96 const text = await drive([...open, "d", RIGHT, "\r"]);
97 expect(listOriginApps(app, alice)).toEqual([]);
98 expect(text).toContain("github.com is removed.");
99 for (const f of frames) expect(f).not.toContain(SECRET);
100 });
101 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs