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

A stranger's first ask is one timed test: link, first screen, one sentence, crew activity, against the first-five-minutes budgets #11790

Merged opened this

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

Merged

scout/forge:scout/11785-a-stranger-s-first-ask-is-one-timed-test → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/11785-a-stranger-s-first-ask-is-one-timed-test was merged into main as 7391f7a.

Review

Sign in to review this change.
8 files changed +595 −102
src/actions/spawn.ts +4 −2
@@ -59,7 +59,7 @@ import {
5959 import { Database } from "bun:sqlite";
6060 import { loadKey, open } from "../kernel/secret.ts";
6161 import { loadOrCreateHostKey } from "../ssh/keys.ts";
62 import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts";
62 import { claimLink, hasAdmin, mintClaim, PLACEHOLDER_FOUNDER, spawnedOrg } from "../spawn/claim.ts";
6363 import { childPublicUrl, parentOrigin, type Placement, type PlacementKind } from "../spawn/placement.ts";
6464 import { quotaRefusal } from "../spawn/quota.ts";
6565 import { personRefusal, planRefusal } from "../spawn/sleep.ts";
@@ -209,7 +209,7 @@ async function writeLedger(app: App, ledger: Ledger): Promise<void> {
209209 * The child's founder account: the spawner's own name, or a placeholder
210210 * for a stranger's child (`copyInto`).
211211 */
212 export const founderNameOf = (owner: User | undefined): string => owner?.username ?? "founder";
212 export const founderNameOf = (owner: User | undefined): string => owner?.username ?? PLACEHOLDER_FOUNDER;
213213
214214 /**
215215 * A child whose organisation would take its founder's name can't be made:
@@ -590,6 +590,8 @@ async function copyInto(
590590 // The child's own address: <name>--<parent's host>.
591591 url: liveChildUrl(app, plan.name),
592592 keepAccess: plan.keepAccess,
593 // No spawner signed in: the founder account is a placeholder (#11785).
594 anonymous: owner === undefined,
593595 size: plan.size ?? DEFAULT_SIZE,
594596 ...(plan.template === undefined ? {} : { template: templateMark ?? { id: plan.template } }),
595597 // Each app is a fork: where it came from, so the child can follow it.
src/spawn/claim.ts +23
@@ -65,12 +65,33 @@ export interface Spawned {
6565 readonly org: User;
6666 /** Whether the spawner keeps owner access after the claim. */
6767 readonly keepAccess: boolean;
68 /**
69 * Whether nobody signed in to start it: its founder is a placeholder, not
70 * a person. Undefined for a `parent.json` from before it was recorded
71 * (#11785): `isPlaceholderFounder` tells those by the founder's name.
72 */
73 readonly anonymous: boolean | undefined;
6874 /** The child's own address, `<name>--<parent's host>`, when recorded. */
6975 readonly url: string | undefined;
7076 /** The hash of what the parent answers to (`spawn/clients.ts`), when recorded. */
7177 readonly statusHash: string | undefined;
7278 }
7379
80 /** The founder account a start nobody signed in for is made under (`founderNameOf`). */
81 export const PLACEHOLDER_FOUNDER = "founder";
82
83 /**
84 * Whether the founder (the Owners team's first member) is the placeholder an
85 * anonymous start made: by the start's record, or, for a `parent.json` from
86 * before it was recorded (#11785), by the founder still holding the
87 * placeholder name. A real spawner of that name on such an old forge reads as
88 * anonymous too, and is then not named: harmless, never wrong about access.
89 * Pure: the caller has the founder already, so no query is spent here.
90 */
91 export function isPlaceholderFounder(spawned: Spawned, founder: User | undefined): boolean {
92 return spawned.anonymous ?? founder?.username === PLACEHOLDER_FOUNDER;
93 }
94
7495 /** The organisation this forge was spawned as, or undefined if it was not spawned. */
7596 export async function spawnedOrg(app: App): Promise<Spawned | undefined> {
7697 const file = Bun.file(join(app.config.dataDir, "parent.json"));
@@ -79,6 +100,7 @@ export async function spawnedOrg(app: App): Promise<Spawned | undefined> {
79100 const parsed = (await file.json()) as {
80101 org?: unknown;
81102 keepAccess?: unknown;
103 anonymous?: unknown;
82104 url?: unknown;
83105 statusHash?: unknown;
84106 };
@@ -89,6 +111,7 @@ export async function spawnedOrg(app: App): Promise<Spawned | undefined> {
89111 : {
90112 org,
91113 keepAccess: parsed.keepAccess === true,
114 anonymous: typeof parsed.anonymous === "boolean" ? parsed.anonymous : undefined,
92115 url:
93116 typeof parsed.url === "string" && /^https?:\/\//.test(parsed.url)
94117 ? parsed.url
src/web/pages/claim.ts +9 −5
@@ -46,6 +46,7 @@ import {
4646 founderToTake,
4747 linkOf,
4848 handOver,
49 isPlaceholderFounder,
4950 readClaim,
5051 spawnedOrg,
5152 takeFounder,
@@ -161,16 +162,19 @@ function owners(
161162 const team = listTeams(ctx.app.db, spawned.org.id).find(
162163 (t) => t.name === OWNERS_TEAM,
163164 );
outdated

Items 3/4: pin the anonymous start's takeover and 'send you a new one' lines in a test, and decide what a forge from before this change (no anonymous field) shows; today it still names the placeholder.

164 return {
165 team,
166 spawner:
167 team === undefined ? undefined : listTeamMembers(ctx.app.db, team.id)[0],
168 };
165 const first = team === undefined ? undefined : listTeamMembers(ctx.app.db, team.id)[0];
166 // The placeholder an anonymous start made is no person to name (#11785):
outdated

Matching on the username 'founder' also hides a real person with that name. Identify the placeholder account by id or flag, not by its name. This line is also over the formatter width.

167 // told by the start's record, never by the account's name.
168 return { team, spawner: isPlaceholderFounder(spawned, first) ? undefined : first };
169169 }
170170
171171 /** One plain sentence: who can see this forge once it is claimed. */
172172 function accessSentence(spawned: Spawned, spawner: User | undefined): string {
173173 const who = spawner?.username ?? "Whoever made it";
174 // An anonymous start has no one to name: never the placeholder account (#11785).
outdated

New branch, no direct test. Add a claim-page test for anonymous, named-spawner keepAccess, and named-spawner not-keepAccess.

175 if (spawner === undefined && !spawned.keepAccess) {
176 return "Once you open it as yours, only you can see its private work.";
177 }
174178 return spawned.keepAccess
175179 ? `${who} keeps owner access to this forge; you can remove it in your organisation's Owners team.`
176180 : `Once you open it as yours, only you can see its private work: ${who}, who made it, no longer has access unless you invite them back.`;
test/claim-access-sentence.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * The claim page's sentence on who can see the forge afterwards (#11785): a
3 * start nobody signed in for never names the placeholder account that holds
4 * the forge until it is claimed; a real spawner, whatever they are called, is
5 * named whether or not they keep access.
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 { childDirOf, childNamed } from "../src/actions/spawn.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { useProbe } from "../src/spawn/door.ts";
17 import { firstBoot } from "../src/spawn/first-boot.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const ONLY_YOU = "Once you open it as yours, only you can see its private work.";
23
24 describe("the claim page's access sentence", () => {
25 let dir = "";
26 let parent: App;
27 let parentServers: Servers;
28 let rw = "";
29 const kids: { app: App; servers: Servers }[] = [];
30
31 /** The text of the claim page of a child, once it has booted. */
32 async function claimPage(bodyId: string, before?: (dir: string) => Promise<void>): Promise<string> {
33 await before?.(childDirOf(parent, bodyId));
34 const app = createApp({ dataDir: childDirOf(parent, bodyId), port: 0 });
35 await firstBoot(app);
36 const servers = await serve(app);
37 kids.push({ app, servers });
38 const res = await fetch(`${servers.url}/claim`);
39 expect(res.status).toBe(200);
40 return (await res.text()).replace(/\s+/g, " ");
41 }
42
43 /** A forge a signed-in person starts, and its claim page. */
44 async function spawnedBy(name: string, keepAccess: boolean): Promise<string> {
45 const res = await fetch(`${parentServers.url}/api/v1/forges`, {
46 method: "POST",
47 headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" },
48 body: JSON.stringify({ name, apps: [], dryRun: false, keepAccess }),
49 });
50 if (res.status !== 201) throw new Error(`spawn ${res.status} ${await res.text()}`);
51 return claimPage(((await res.json()) as { bodyId: string }).bodyId);
52 }
53
54 beforeAll(async () => {
55 dir = await mkdtemp(join(tmpdir(), "forge-claim-access-"));
56 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
57 useProbe(parent, () => async () => true);
58 // A real person whose username is the placeholder's.
59 const made = await createUser(parent.db, {
60 origin: "seed", trusted: true, username: "founder", email: "[email protected]",
61 password: "a-long-claim-passphrase", isAdmin: true,
62 });
63 if (!made.ok) throw new Error("user");
64 rw = createToken(parent.db, made.value.id, "rw", "rw").token;
65 parentServers = await serve(parent);
66 }, 60_000);
67
68 afterAll(async () => {
69 for (const k of kids) {
70 k.servers.stop();
71 k.app.close();
72 }
73 parentServers.stop();
74 parent.close();
75 await rm(dir, { recursive: true, force: true });
76 });
77
78 /** A forge a stranger starts from the home page, with no account, and its claim page. */
79 async function startedAnonymously(name: string, before?: (dir: string) => Promise<void>): Promise<string> {
80 const started = await fetch(`${parentServers.url}/forges/start`, {
81 method: "POST",
82 headers: { "content-type": "application/x-www-form-urlencoded" },
83 body: new URLSearchParams({ name, js: "1" }),
84 redirect: "manual",
85 });
86 expect(started.status).toBe(303);
87 const made = await childNamed(parent, name);
88 if (made === undefined) throw new Error("no child");
89 return claimPage(made.bodyId, before);
90 }
91
92 /** What an anonymous start's claim page says, whatever its parent.json's age. */
93 function namesNoOne(text: string): void {
94 expect(text).toContain(ONLY_YOU);
95 expect(text).not.toMatch(/founder/i);
96 expect(text).not.toContain("Whoever made it");
97 // The placeholder has no way in, so `founderToTake` would offer it ("Open it as
98 // founder and you will take over…"): never, here. A new link is asked of whoever sent it.
99 expect(text).not.toContain('id="claim-takeover"');
100 expect(text).not.toContain("take over");
101 expect(text).toContain('id="claim-expiry"');
102 expect(text).toContain("ask whoever sent it to send you a new one");
103 }
104
105 test("an anonymous start never shows the placeholder name", async () => {
106 namesNoOne(await startedAnonymously("stranger"));
107 });
108
109 test("a forge started anonymously before the start was recorded names no one either", async () => {
110 namesNoOne(
111 await startedAnonymously("older", async (dir) => {
112 const file = join(dir, "parent.json");
113 const { anonymous, ...rest } = (await Bun.file(file).json()) as Record<string, unknown>;
114 expect(anonymous).toBe(true);
115 await Bun.write(file, JSON.stringify(rest));
116 }),
117 );
118 });
119
120 test("a named spawner is named when they keep access", async () => {
121 const text = await spawnedBy("keeps", true);
122 expect(text).toContain("founder keeps owner access to this forge");
123 // A real spawner is asked for a new link by name; keeping access, their account is not offered.
124 expect(text).toContain("ask founder to send you a new one");
125 expect(text).not.toContain('id="claim-takeover"');
126 });
127
128 test("a named spawner is named when they do not keep access", async () => {
129 const text = await spawnedBy("leaves", false);
130 expect(text).toContain(
131 "founder, who made it, no longer has access unless you invite them back",
132 );
133 expect(text).not.toContain(ONLY_YOU);
134 // Asked for a new link by name, and offered their account here, by name, to take over.
135 expect(text).toContain("ask founder to send you a new one");
136 expect(text).toContain(
137 "Open it as founder and you will take over founder's account on this forge, with everything already made under it.",
138 );
139 });
140 });
test/helpers/crew-standin.ts +111 −95
@@ -60,9 +60,118 @@ export async function startStandInCrew(
6060 const dataDir = await mkdtemp(join(tmpdir(), "forge-crew-"));
6161 const app = createApp({ dataDir, port: 0 });
6262 const servers = await serve(app);
63 const { fake, argsFile, promptFile } = writeStandInClaude(dataDir);
64 const checks = startChecks(app, {
65 forgeUrl: servers.url,
66 concurrency: 1,
67 ...(options.checkStep === undefined ? {} : { checkStep: options.checkStep }),
68 });
69 const crew = await startCrew(app, {
70 forgeUrl: servers.url,
71 localUrl: servers.url,
72 binary: fake,
73 concurrency: 1,
74 budgetUsd: 1,
75 runTimeoutMs: 60_000,
76 networkRetryScale: 0,
77 reviewBudgetMs: options.reviewBudgetMs ?? 4_000,
78 ...(options.tickMs === undefined ? {} : { tickMs: options.tickMs }),
79 ...(options.now === undefined ? {} : { now: options.now }),
80 ...(options.installRunner === undefined
81 ? {}
82 : { installRunner: options.installRunner }),
83 });
84 const a = await createUser(app.db, {
85 origin: "seed",
86 trusted: true,
87 username: "alice",
88 email: "[email protected]",
89 password: "a-good-passphrase",
90 adminIfFirst: true,
91 });
92 if (!a.ok) throw new Error(a.error);
93 const alice = a.value;
94 const made = await createAgent(app, alice, {
95 username: "bot",
96 brief: "Be brief.",
97 });
98 if (!made.ok) throw new Error(String(made.error));
99 const bot = made.value.user;
100 const created = await createRepo(app, alice, {
101 name: "garden",
102 visibility: "private",
103 });
104 if (!created.ok) throw new Error(String(created.error));
105 const repo = created.value;
106 const seeded = await commitToBranch(app, alice, repo, {
107 message: "Start",
108 files: [
109 { path: "README.md", content: "# garden\n" },
110 ...(options.files ?? []),
111 ],
112 });
113 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
114 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
115 return {
116 dataDir,
117 app,
118 servers,
119 crew,
120 checks,
121 alice,
122 bot,
123 repo,
124 argsFile,
125 promptFile,
126 };
127 }
128
129 export async function stopStandInCrew(standIn: StandInCrew): Promise<void> {
130 await standIn.crew.stop();
131 await standIn.checks.stop();
132 standIn.servers.stop();
133 standIn.app.close();
134 await rm(standIn.dataDir, { recursive: true, force: true });
135 }
136
137 /** Wait for the first agent run on `repo` matching `where` to finish, and return it. */
138 export async function agentRunSettled(
139 app: App,
140 repo: Repo,
141 where: (run: Run) => boolean,
142 timeoutMs = 30_000,
143 pollMs = 100,
144 ): Promise<Run> {
145 const deadline = Date.now() + timeoutMs;
146 while (Date.now() < deadline) {
147 const run = listRuns(app.db, repo.id, { kind: "agent" }).find(where);
148 if (
149 run &&
150 (run.state === "ok" ||
151 run.state === "failed" ||
152 run.state === "cancelled")
153 )
154 return run;
155 await Bun.sleep(pollMs);
156 }
157 throw new Error("agent run did not settle");
158 }
159
160 /** The stand-in's paths: the binary, and where it writes its last argv and prompt. */
161 export interface StandInClaude {
162 readonly fake: string;
163 readonly argsFile: string;
164 readonly promptFile: string;
165 }
166
167 /**
168 * Write the stand-in for Claude Code into `dataDir`: it records its argv and
169 * prompt, does the work, pushes, and answers in stream-json. Any forge's crew
170 * can run it (`startCrew(app, { binary: fake, … })`).
171 */
172 export function writeStandInClaude(dataDir: string): StandInClaude {
63173 const argsFile = join(dataDir, "fake.args");
64174 const promptFile = join(dataDir, "fake.prompt");
65 // The stand-in: records its argv and prompt, does the work, pushes.
66175 const fake = join(dataDir, "fake-claude");
67176 writeFileSync(
68177 fake,
@@ -200,98 +309,5 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
200309 `,
201310 );
202311 chmodSync(fake, 0o755);
203 const checks = startChecks(app, {
204 forgeUrl: servers.url,
205 concurrency: 1,
206 ...(options.checkStep === undefined ? {} : { checkStep: options.checkStep }),
207 });
208 const crew = await startCrew(app, {
209 forgeUrl: servers.url,
210 localUrl: servers.url,
211 binary: fake,
212 concurrency: 1,
213 budgetUsd: 1,
214 runTimeoutMs: 60_000,
215 networkRetryScale: 0,
216 reviewBudgetMs: options.reviewBudgetMs ?? 4_000,
217 ...(options.tickMs === undefined ? {} : { tickMs: options.tickMs }),
218 ...(options.now === undefined ? {} : { now: options.now }),
219 ...(options.installRunner === undefined
220 ? {}
221 : { installRunner: options.installRunner }),
222 });
223 const a = await createUser(app.db, {
224 origin: "seed",
225 trusted: true,
226 username: "alice",
227 email: "[email protected]",
228 password: "a-good-passphrase",
229 adminIfFirst: true,
230 });
231 if (!a.ok) throw new Error(a.error);
232 const alice = a.value;
233 const made = await createAgent(app, alice, {
234 username: "bot",
235 brief: "Be brief.",
236 });
237 if (!made.ok) throw new Error(String(made.error));
238 const bot = made.value.user;
239 const created = await createRepo(app, alice, {
240 name: "garden",
241 visibility: "private",
242 });
243 if (!created.ok) throw new Error(String(created.error));
244 const repo = created.value;
245 const seeded = await commitToBranch(app, alice, repo, {
246 message: "Start",
247 files: [
248 { path: "README.md", content: "# garden\n" },
249 ...(options.files ?? []),
250 ],
251 });
252 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
253 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
254 return {
255 dataDir,
256 app,
257 servers,
258 crew,
259 checks,
260 alice,
261 bot,
262 repo,
263 argsFile,
264 promptFile,
265 };
266 }
267
268 export async function stopStandInCrew(standIn: StandInCrew): Promise<void> {
269 await standIn.crew.stop();
270 await standIn.checks.stop();
271 standIn.servers.stop();
272 standIn.app.close();
273 await rm(standIn.dataDir, { recursive: true, force: true });
274 }
275
276 /** Wait for the first agent run on `repo` matching `where` to finish, and return it. */
277 export async function agentRunSettled(
278 app: App,
279 repo: Repo,
280 where: (run: Run) => boolean,
281 timeoutMs = 30_000,
282 pollMs = 100,
283 ): Promise<Run> {
284 const deadline = Date.now() + timeoutMs;
285 while (Date.now() < deadline) {
286 const run = listRuns(app.db, repo.id, { kind: "agent" }).find(where);
287 if (
288 run &&
289 (run.state === "ok" ||
290 run.state === "failed" ||
291 run.state === "cancelled")
292 )
293 return run;
294 await Bun.sleep(pollMs);
295 }
296 throw new Error("agent run did not settle");
312 return { fake, argsFile, promptFile };
297313 }
test/spawn-keys.test.ts +1
@@ -363,6 +363,7 @@ describe("a spawned child's own keys and domain", () => {
363363 await Bun.file(join(childDir, "parent.json")).text(),
364364 ) as Record<string, unknown>;
365365 expect(Object.keys(record).sort()).toEqual([
366 "anonymous",
366367 "apps",
367368 "beatKey",
368369 "keepAccess",
test/spawn.test.ts +2
@@ -85,6 +85,8 @@ describe("spawnForge", () => {
8585 org: "kids",
8686 url: "https://kid--forge.test",
8787 keepAccess: false,
88 // Spawned by a signed-in person: the founder is a real account (#11785).
89 anonymous: false,
8890 size: "small",
8991 // A hash only: what the parent answers to stays in its own ledger.
9092 statusHash: expect.stringMatching(/^[0-9a-f]{64}$/),
test/stranger-first-ask-journey.test.ts added +305
@@ -0,0 +1,305 @@
1 /**
2 * A stranger's first ask, timed (#11785, steps 1–4 of #11158): with no cookies
3 * from the home page to a claimed forge, then one sentence posted through the
4 * form a person uses, and the crew's first activity on the forge's home. The
5 * budgets are `FIRST_FIVE_MINUTES` and the words `STRANGER_NEVER_SEES`, imported.
6 * The waiting page is the hand-over between screens 1 and 2 (it moves on by
7 * itself), so it is checked like any screen but not counted as one, as in
8 * `stranger-three-screens.test.ts`. Parent and child both run for real, and
9 * the child's crew runs `crew-standin.ts`'s stand-in for Claude Code, so the
10 * crew's first activity is its own reply on the home, not the forge's echo.
11 */
12 import { afterAll, beforeAll, expect, test } from "bun:test";
13 import { mkdtemp, rm } from "node:fs/promises";
14 import { tmpdir } from "node:os";
15 import { join } from "node:path";
16 import { childDirOf, childNamed } from "../src/actions/spawn.ts";
17 import { createApp, type App } from "../src/app.ts";
18 import {
19 FIRST_FIVE_MINUTES,
20 STRANGER_NEVER_SEES,
21 } from "../src/design/first_five_minutes.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { listAgents } from "../src/core/agents.ts";
24 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
25 import { useProbe } from "../src/spawn/door.ts";
26 import { firstBoot } from "../src/spawn/first-boot.ts";
27 import { writeStandInClaude } from "./helpers/crew-standin.ts";
28 import { quietLogs } from "./helpers/globals.ts";
29
30 quietLogs();
31
32 /** One response on the path, as a person met it. */
33 interface Screen {
34 readonly name: string;
35 readonly status: number;
36 readonly html: string;
37 /** Whether a person counts it as a screen (the waiting page is the hand-over). */
38 readonly counted: boolean;
39 /** False only where a known follow-up owns the words (#11838); checked otherwise. */
40 readonly words?: boolean;
41 }
42
43 const BANNED = new RegExp(
44 `(?<![A-Za-z0-9_])(?:${STRANGER_NEVER_SEES.join("|")})(?:s|ed|ing)?(?![A-Za-z0-9_])`,
45 "i",
46 );
47
48 /** The text a person reads: no tags, scripts, styles or attributes. */
49 function visibleText(html: string): string {
50 return html
51 .replace(/<(script|style|template|svg|head)\b[\s\S]*?<\/\1>/gi, " ")
52 .replace(/<[^>]*>/g, " ")
53 .replace(/&amp;/g, "&").replace(/&#39;|&apos;/g, "'").replace(/&quot;/g, '"')
54 .replace(/\s+/g, " ");
55 }
56
57 const CLAIM_FORM = /<form[^>]*action="\/claim"/;
58
59 /** Every way the journey can regress, named; empty when it holds. */
60 function journeyProblems(
61 screens: readonly Screen[],
62 seconds: { ready: number; crew: number },
63 ): string[] {
64 const out: string[] = [];
65 const b = FIRST_FIVE_MINUTES;
66 const counted = screens.filter((s) => s.counted);
67 if (counted.length > b.screensBeforeForge) {
68 out.push(`${counted.length} screens before the forge, budget ${b.screensBeforeForge}`);
69 }
70 if (seconds.ready >= b.forgeReadySeconds) {
71 out.push(`forge ready in ${seconds.ready} s, budget ${b.forgeReadySeconds}`);
72 }
73 if (seconds.crew >= b.firstCrewActivitySeconds) {
74 out.push(`first crew activity in ${seconds.crew} s, budget ${b.firstCrewActivitySeconds}`);
75 }
76 // The way in is a password on the claim form: a walk that never met that form has none.
77 if (!screens.some((s) => CLAIM_FORM.test(s.html) && /type="password"/.test(s.html))) {
78 out.push("no screen with a password way in");
79 }
80 for (const s of screens) {
81 if (s.status >= 500) out.push(`${s.name}: status ${s.status}`);
82 const text = visibleText(s.html);
83 if (/(must|need to|have to|required to)[^.]{0,30}passkey/i.test(text)) {
84 out.push(`${s.name}: requires a passkey`);
85 }
86 if (/type="password"/.test(s.html) === false && CLAIM_FORM.test(s.html)) {
87 out.push(`${s.name}: no password way in`);
88 }
89 if (/cop(y|ied) (the |this |your )?(link|address|url)|send (the|this) link/i.test(text)) {
90 out.push(`${s.name}: a copy-the-link step`);
91 }
92 const word = s.words === false ? undefined : BANNED.exec(text)?.[0];
93 if (word !== undefined) out.push(`${s.name}: shows "${word}"`);
94 }
95 return out;
96 }
97
98 let dir = "";
99 let parent: App;
100 let parentServers: Servers;
101 let child: App | undefined;
102 let childServers: Servers | undefined;
103 let crew: CrewHandle | undefined;
104 let up = false;
105
106 beforeAll(async () => {
107 dir = await mkdtemp(join(tmpdir(), "forge-first-ask-"));
108 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
109 useProbe(parent, () => async () => up);
110 parentServers = await serve(parent);
111 });
112 afterAll(async () => {
113 await crew?.stop();
114 childServers?.stop();
115 child?.close();
116 parentServers.stop();
117 parent.close();
118 await rm(dir, { recursive: true, force: true });
119 });
120
121 const screens: Screen[] = [];
122 const seconds = { ready: 0, crew: 0 };
123 const since = (t0: number) => Math.ceil((performance.now() - t0) / 1000);
124 let ask = "";
125 let homeHtml = "";
126
127 test("the walk: link, first screen, one sentence, the crew at work", async () => {
128 up = true;
129 const record = async (name: string, res: Response, counted: boolean) => {
130 const html = res.headers.get("content-type")?.includes("html") ? await res.text() : "";
131 screens.push({ name, status: res.status, html, counted });
132 return html;
133 };
134
135 // Screen 1: the home page, no cookies; the name goes in the form it carries.
136 const home = await fetch(`${parentServers.url}/`);
137 expect(await record("home", home, true)).toContain('id="start-forge"');
138 const t0 = performance.now();
139 const started = await fetch(`${parentServers.url}/forges/start`, {
140 method: "POST",
141 headers: { "content-type": "application/x-www-form-urlencoded" },
142 body: new URLSearchParams({ name: "asker", js: "1" }),
143 redirect: "manual",
144 });
145 expect(started.status).toBe(303);
146 const to = started.headers.get("location") ?? "";
147 expect(to).toMatch(/^\/forges\/asker\/waiting#[A-Za-z0-9_-]{43}$/);
148 const token = to.split("#")[1] ?? "";
149 await record("waiting", await fetch(`${parentServers.url}${to.split("#")[0]}`), false);
150
151 // The child boots (the probe says it is up); screen 2 is its form.
outdated

Box 2: this only proves the sentence echoes back. Assert the crew's activity on the home, and let seconds.crew time that, not a single GET. As written the 60 s budget cannot fail.

152 const made = await childNamed(parent, "asker");
153 if (made === undefined) throw new Error("no child");
154 child = createApp({ dataDir: childDirOf(parent, made.bodyId), port: 0 });
155 await firstBoot(child);
156 childServers = await serve(child);
157 const form = await fetch(`${childServers.url}/claim`);
158 expect(form.status).toBe(200);
159 await record("claim form", form, true);
160 seconds.ready = since(t0);
161
162 const claimed = await fetch(`${childServers.url}/claim`, {
163 method: "POST",
164 headers: { origin: childServers.url },
165 body: new URLSearchParams({
166 token,
167 username: "asker-owner",
168 password: "a-long-asker-passphrase",
169 }),
170 redirect: "manual",
171 });
172 expect(claimed.status).toBe(303);
173 const cookie = (claimed.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
174 expect(cookie).not.toBe("");
175
176 // Screen 3: the forge's first screen. One sentence through its own form.
177 const landed = `${childServers.url}${claimed.headers.get("location") ?? "/"}`;
178 const landing = await fetch(landed, { headers: { cookie } });
179 expect(landing.status).toBe(200);
180 const first = await record("forge", landing, true);
181 const t1 = performance.now();
182 const formTag = /<form[^>]*action="\/tell"[^>]*>([\s\S]*?)<\/form>/.exec(first);
183 expect(formTag).not.toBeNull();
184 const fields = new URLSearchParams();
185 for (const m of formTag![1]!.matchAll(/<input[^>]*type="hidden"[^>]*>/g)) {
186 const name = /name="([^"]+)"/.exec(m[0])?.[1];
187 if (name) fields.set(name, /value="([^"]*)"/.exec(m[0])?.[1] ?? "");
188 }
189 const textName = /<textarea[^>]*name="([^"]+)"/.exec(formTag![1]!)?.[1] ?? "";
190 expect(textName).not.toBe("");
191 ask = "Make me a page that lists my reading";
192 fields.set(textName, ask);
193 const posted = await fetch(`${childServers.url}/tell`, {
194 method: "POST",
195 headers: { origin: childServers.url, cookie },
196 body: fields,
197 redirect: "manual",
198 });
199 expect(posted.status).toBeLessThan(400);
200 screens.push({ name: "tell", status: posted.status, html: "", counted: false });
201
202 // The crew's first activity: the stand-in's own reply in the ask's answer
203 // row on the forge's home, polled until it shows and timed from the post.
204 // The row's "Waiting for the crew." is the forge's echo of the ask, drawn by
205 // the same request that stores it, so it is not counted.
206 const homeUrl = `${childServers.url}${posted.headers.get("location") ?? "/"}`;
207 // The page the post lands on, before any crew run: the forge's echo of the
208 // ask, checked like every screen.
209 const echoed = await fetch(homeUrl, { headers: { cookie } });
210 const echoHtml = await record("forge after the ask", echoed, false);
211 expect(echoHtml).toContain("Waiting for the crew.");
212 expect(crewRow(echoHtml)).toBe(false);
213 // Then the child's crew, as `main.ts` starts it, with the stand-in for
214 // Claude Code: started after the post, so its timing only grows stricter.
215 crew = await startCrew(child, {
216 forgeUrl: childServers.url,
217 localUrl: childServers.url,
218 binary: writeStandInClaude(dir).fake,
219 concurrency: 1,
220 budgetUsd: 1,
221 runTimeoutMs: 60_000,
222 networkRetryScale: 0,
223 tickMs: 200,
224 });
225 const deadline = performance.now() + FIRST_FIVE_MINUTES.firstCrewActivitySeconds * 1000;
226 let back = await fetch(homeUrl, { headers: { cookie } });
227 homeHtml = await back.text();
228 while (!crewRow(homeHtml) && performance.now() < deadline) {
229 await Bun.sleep(100);
230 back = await fetch(homeUrl, { headers: { cookie } });
231 homeHtml = await back.text();
232 }
233 seconds.crew = since(t1);
234 // Once the crew has run, the app page leaves its fresh layout and shows the
235 // owner's "On its own" card (Mode · Autonomous): its words are #11838's.
236 screens.push({
237 name: "forge with the crew's answer",
238 status: back.status,
239 html: homeHtml,
240 counted: false,
241 words: false,
242 });
243 expect(back.status).toBe(200);
244 expect(crewRow(homeHtml)).toBe(true);
245 }, 90_000);
246
247 /**
248 * Whether the home carries the crew's own answer to the ask: the row's
249 * `answer-said`, which only an agent's comment or a finished agent run fills,
250 * spoken by one of the forge's agents. The row the tell itself draws is
251 * "Waiting for the crew." and does not count.
252 */
253 function crewRow(page: string): boolean {
254 const row = /<li class="answer"[^>]*>([\s\S]*?)<\/li>/.exec(page)?.[1] ?? "";
255 const who = /<p class="answer-said"><b>([^<]+)<\/b>/.exec(row)?.[1];
256 const agents = child === undefined ? [] : listAgents(child.db).map((a) => a.user.username);
257 return row.includes(ask) && who !== undefined && agents.includes(who);
258 }
259
260 test("the home of the forge shows the sentence and the crew's own answer", () => {
261 expect(crewRow(homeHtml)).toBe(true);
262 // The forge's echo alone is not the crew: the row the tell itself draws does not pass.
263 const echo = `<li class="answer"><a>${ask}</a><p class="muted small answer-wait">Waiting for the crew.</p></li>`;
264 expect(crewRow(echo)).toBe(false);
265 });
266
267 test("the whole walk keeps the first-five-minutes budgets and words", () => {
268 expect(journeyProblems(screens, seconds)).toEqual([]);
269 });
270
271 test("a regression turns it red: a copy-the-link screen, a 5xx, a passkey, a banned word, a slow forge", () => {
272 const ok = { ready: 1, crew: 1 };
273 const page = (name: string, html: string, counted = true): Screen => ({
274 name,
275 status: 200,
276 html,
277 counted,
278 });
279 const claim = `<form method="post" action="/claim"><input type="password" name="p"></form>`;
280 const form = page("form", `<main>Pick ${claim}</main>`, false);
281 const only = (html: string) => journeyProblems([form, page("a", html)], ok);
282 const base = [
283 page("home", "<main>Name it</main>"),
284 page("form", `<main>Pick ${claim}</main>`),
285 page("forge", "<main>Hi</main>"),
286 ];
287 expect(journeyProblems(base, ok)).toEqual([]);
288 const link = page("link", "<main><p>Copy the link and open it.</p></main>");
289 const found = journeyProblems([base[0]!, link, base[1]!, base[2]!], ok);
290 expect(found).toEqual(["4 screens before the forge, budget 3", "link: a copy-the-link step"]);
291 expect(journeyProblems([{ ...base[0]!, status: 502 }, form], ok)).toEqual(["home: status 502"]);
292 expect(only("<main>You must add a passkey</main>")).toEqual(["a: requires a passkey"]);
293 expect(only(`<form action="/claim"><input name="x"></form>`)).toEqual(["a: no password way in"]);
294 // A walk whose screens carry no claim form has no password way in: the check is not met by absence.
295 const noClaim = [page("home", "<main>Name it</main>"), page("b", `<form action="/elsewhere"><input type="password" name="p"></form>`)];
296 expect(journeyProblems(noClaim, ok)).toEqual(["no screen with a password way in"]);
297 expect(only(`<form action="/claim"><input type="password" name="p"></form>`)).toEqual([]);
298 expect(journeyProblems([form, { ...page("a", "<main>Your child forge</main>"), words: false }], ok)).toEqual([]);
299 expect(only("<main>Your child forge</main>")).toEqual(['a: shows "child"']);
300 expect(only(`<main a="claim"><script>claim</script>fine</main>`)).toEqual([]);
301 const slow = { ready: FIRST_FIVE_MINUTES.forgeReadySeconds, crew: 1 };
302 expect(journeyProblems(base, slow)).toHaveLength(1);
303 const idle = { ready: 1, crew: FIRST_FIVE_MINUTES.firstCrewActivitySeconds };
304 expect(journeyProblems(base, idle)).toHaveLength(1);
305 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs