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

The new-forge page reads in plain words: a test fails it on the jargon list, for a visitor and for the owner #10816

Merged opened this

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

Merged

scout/forge:scout/10787-the-new-forge-page-reads-in-plain-words → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/10787-the-new-forge-page-reads-in-plain-words was merged into main as 478a75f.

Review

Sign in to review this change.
5 files changed +136 −11
docs/features/forges-side-by-side.md +1 −1
@@ -8,4 +8,4 @@ The table is for comparing; the list below it stays for what needs doing (claim
88
99 ## The pages' shape (#9647)
1010
11 `/forges` is plain rows, no boxed cards: each forge's line starts with who it is for and the apps it was given, then its state (awake, size, spend, last activity, claim state). `/forges/new` asks the name, who it is for, what it is for, the apps and an optional fresh app up front; follow mode, crew, owner access, model key, API base URL, budget, template and size sit behind "More options", inside the same form so they submit as before. The disclosure opens by itself when the door refuses a value in one of them. No field or action was removed. The terminal's forge form is unchanged.
11 `/forges` is plain rows, no boxed cards: each forge's line starts with who it is for and the apps it was given, then its state (awake, size, spend, last activity, claim state). `/forges/new` asks the name, who it is for, what it is for, the apps and an optional fresh app up front; follow mode, crew, owner access, AI account key, API base URL, budget, template and size sit behind "More options", inside the same form so they submit as before. The disclosure opens by itself when the door refuses a value in one of them. No field or action was removed. The terminal's forge form keeps its fields; its key field is labelled "AI account key", as on the page (#10787).
src/tui/screens/newforge.ts +1 −1
@@ -43,7 +43,7 @@ const SPECS: readonly Spec[] = [
4343 { key: "direction", label: "What it is for", hint: "The crew's first direction. Optional." },
4444 { key: "apps", label: "Apps to fork", hint: "owner/name, comma-separated. Optional." },
4545 { key: "fresh", label: "Or a fresh app", hint: "One line on what it is. Optional." },
46 { key: "modelKey", label: "Model key", hint: "Sealed in the child, never shown again. Optional.", secret: true },
46 { key: "modelKey", label: "AI account key", hint: "Sealed in the new forge, never shown again. Optional.", secret: true },
4747 { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." },
4848 { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` },
4949 { key: "followMode", label: "Follows this forge's code", hint: FOLLOW_LINE },
src/web/pages/new-forge.ts +32 −8
@@ -80,8 +80,8 @@ const all = (form: Form, name: string): string[] =>
8080 function errorsOf(door: Door): Errors {
8181 const message = String(door.body["message"] ?? "");
8282 const m = /^([A-Za-z]+): (.*)$/.exec(message);
83 if (door.body["error"] === "invalid" && m) return { [m[1]!]: m[2]! };
84 return { form: message };
83 if (door.body["error"] === "invalid" && m) return { [m[1]!]: plainError(m[2]!) };
84 return { form: plainError(message) };
8585 }
8686
8787 /** What the form reads before it draws: room, then (when there is room) the repositories and the crew. */
@@ -132,6 +132,30 @@ function templateOptions(chosen: string): { value: string; label: string; select
132132 }));
133133 }
134134
135 /** The page's words for what the data names in the API's terms (#10787); the data keeps its own. */
136 const PLAIN: Readonly<Record<string, string>> = {
137 tokens: "access passes",
138 "SSH keys": "remote-login keys",
139 "agent credentials": "the crew's sign-ins",
140 };
141 /**
142 * The door's refusals speak to the API and the terminal too; on this page they
outdated

Fragile string match on plan.ts text; add a test that renders the plan with a key (box 2).

143 * name the key the way the form does. A test posts each one and reads the page.
144 */
145 const PLAIN_ERRORS: readonly (readonly [RegExp, string])[] = [
146 [/^is empty or over 4096 characters[^]*$/, "is empty or too long: paste the whole key from your AI account"],
147 [/a subscription token \(sk-ant-oat…\)/g, "a Claude sign-in key (sk-ant-oat…)"],
148 [/; send modelKey with it/g, ": add the key too"],
149 [/\bmodel key\b/g, "AI account key"],
150 ];
151 export function plainError(message: string): string {
152 return PLAIN_ERRORS.reduce((m, [from, to]) => m.replace(from, to), message);
153 }
154
155 export function plainWords(item: string): string {
156 return PLAIN[item] ?? item.replace("its own model key", "its own AI account key");
157 }
158
135159 function formView(
136160 ctx: Ctx,
137161 user: User | undefined,
@@ -160,7 +184,7 @@ function formView(
160184 : "Pick a name and the apps to start from; you get one link to send.",
161185 actions: btn("Forges you sent", { href: "/forges", size: "sm" }),
162186 })}
163 <p class="muted small" id="hosting-terms">Free within limits: it sleeps when idle, and its crew stays off until you bring a model key.</p>
187 <p class="muted small" id="hosting-terms">Free within limits: it sleeps when idle, and its crew starts once you add your AI account.</p>
164188 ${quota ? html`<p role="status" id="quota-full">This forge cannot hold another child: ${quota}.</p>` : null}
165189 ${errors["form"]
166190 ? html`<p class="error mb" role="alert">${errors["form"]}</p>`
@@ -270,14 +294,14 @@ function formView(
270294 ${anon
271295 ? null
272296 : formField({
273 label: values.hadKey ? "Model key" : "Model key (optional)",
297 label: values.hadKey ? "AI account key" : "AI account key (optional)",
274298 name: "modelKey",
275299 type: "password",
276300 autocomplete: "off",
277301 required: values.hadKey,
278302 hint: values.hadKey
279 ? "Type the key again to spawn: it is never sent back to your browser."
280 : "An Anthropic key (sk-ant-api…, sk-ant-usr…), a `claude setup-token` token (sk-ant-oat…), or any gateway's key with its base URL below; with its own spend limit, sealed in the child, never shown again.",
303 ? "Type the key again to make the forge: it is never sent back to your browser."
304 : "Paste a key from your AI account. It is sealed in the new forge and never shown again.",
281305 ...(errors["modelKey"] ? { error: errors["modelKey"] } : {}),
282306 })}
283307 ${anon
@@ -315,7 +339,7 @@ function formView(
315339 ...(errors["size"] ? { error: errors["size"] } : {}),
316340 })}
317341 <p class="muted small">
318 Never copied: ${LEFT_BEHIND.join(", ")}. The child mints its own.
342 Never copied: ${LEFT_BEHIND.map(plainWords).join(", ")}. The child mints its own.
319343 </p>
320344 </details>
321345 <div class="form-actions">
@@ -362,7 +386,7 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record
362386 </ul>
363387 <p class="mb">Copied into it:</p>
364388 <ul class="mb">
365 ${copied.map((c) => html`<li>${c}</li>`)}
389 ${copied.map((c) => html`<li>${plainWords(c)}</li>`)}
366390 </ul>
367391 <p class="muted small">
368392 About ${Math.max(1, Math.round(bytes / 1024))} KB. Nothing is made until
test/new-forge-words.test.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The page where a forge is made, in plain words (#10787): what sits in
3 * `<main>` of /forges/new, for a stranger and for the owner, holds none of
4 * the words in `helpers/jargon.ts`; and the not-copied promise keeps every item.
5 */
6 import { afterAll, beforeAll, describe, expect, 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 { createApp, type App } from "../src/app.ts";
11 import { createSession } from "../src/core/sessions.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { LEFT_BEHIND } from "../src/spawn/plan.ts";
15 import { plainWords } from "../src/web/pages/new-forge.ts";
16 import { jargonIn } from "./helpers/jargon.ts";
17
18 const mainOf = (html: string) => /<main\b[\s\S]*?<\/main>/.exec(html)?.[0] ?? "";
19
20 describe("new forge words", () => {
21 let dir: string;
22 let app: App;
23 let servers: Servers;
24 let cookie = "";
25
26 beforeAll(async () => {
27 dir = await mkdtemp(join(tmpdir(), "forge-new-forge-words-"));
28 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0, forgeRepo: "alice/forge-code" });
29 servers = await serve(app);
30 const u = await createUser(app.db, {
31 origin: "seed",
32 trusted: true,
33 username: "alice",
34 email: "[email protected]",
35 password: "a-good-passphrase",
36 adminIfFirst: true,
37 });
38 if (!u.ok) throw new Error("user");
39 cookie = `forge_session=${createSession(app.db, u.value.id).token}`;
40 }, 30_000);
41
42 afterAll(async () => {
43 servers.stop();
44 app.close();
45 await rm(dir, { recursive: true, force: true });
46 });
47
48 test("a visitor's page has no jargon", async () => {
49 const main = mainOf(await (await fetch(`${servers.url}/forges/new`)).text());
50 expect(main).toContain("Never copied");
51 expect(jargonIn(main)).toEqual([]);
52 });
53
54 test("the owner's page has no jargon", async () => {
55 const main = mainOf(await (await fetch(`${servers.url}/forges/new`, { headers: { cookie } })).text());
56 expect(main).toContain("For whom");
57 expect(jargonIn(main)).toEqual([]);
58 });
59
outdated

A length check passes if an item is swapped for another. Assert the items themselves.

60 const post = async (form: [string, string][]) =>
61 mainOf(
62 await (
63 await fetch(`${servers.url}/forges/new`, {
64 method: "POST",
65 headers: { cookie, origin: "https://forge.test" },
66 body: new URLSearchParams(form),
67 redirect: "manual",
68 })
69 ).text(),
70 );
71
72 test("the owner's refusals about the key, budget and address have no jargon", async () => {
73 const cases: [string, [string, string][]][] = [
74 ["modelKey", [["modelKey", "x".repeat(5000)], ["budgetUsd", "5"]]],
75 ["budgetUsd", [["modelKey", "sk-ant-api03-abcdefghijklmnop"]]],
76 ["budgetUsd", [["budgetUsd", "5"]]],
77 ["modelEndpoint", [["modelEndpoint", "https://openrouter.ai/api"]]],
78 ["modelEndpoint", [["modelKey", "sk-ant-oat01-abcdefghijklmnop"], ["budgetUsd", "5"], ["modelEndpoint", "https://openrouter.ai/api"]]],
79 ];
80 for (const [field, extra] of cases) {
81 const main = await post([["name", "wordy"], ...extra, ["do", "plan"]]);
82 expect(main).toContain(`id="f-${field}-err"`);
83 expect(jargonIn(main)).toEqual([]);
84 }
85 });
86
87 test("a plan made with a key names it as the page does", async () => {
88 const main = await post([["name", "wordy"], ["modelKey", "sk-ant-api03-abcdefghijklmnop"], ["budgetUsd", "5"], ["do", "plan"]]);
89 expect(main).toContain("its own AI account key");
90 expect(jargonIn(main)).toEqual([]);
91 });
92
93 test("the not-copied list keeps its seven items, and the page words them plainly", () => {
94 expect([...LEFT_BEHIND]).toEqual([
95 "secrets", "tokens", "SSH keys", "agent credentials", "run ledger", "issues", "user accounts",
96 ]);
97 expect(LEFT_BEHIND.map(plainWords)).toEqual([
98 "secrets", "access passes", "remote-login keys", "the crew's sign-ins", "run ledger", "issues", "user accounts",
99 ]);
100 });
101 });
test/new-forge.test.ts +1 −1
@@ -228,7 +228,7 @@ describe("+ New forge", () => {
228228 const html = await planned.text();
229229 expect(html).not.toContain(KEY);
230230 expect(html).toMatch(/name="modelKey"[^>]*required|required[^>]*name="modelKey"/);
231 expect(html).toContain("Type the key again to spawn");
231 expect(html).toContain("Type the key again to make the forge");
232232 expect(html).not.toMatch(/Model key \(optional\)/);
233233 // The plan's own Spawn button submits the same form.
234234 expect(html).toMatch(/<button[^>]*form="new-forge"[^>]*>\s*Spawn/);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs