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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11785-a-stranger-s-first-ask-is-one-timed-test → main 6 commits fork 4 threads · 4 unresolved
src/actions/spawn.ts +4 −2
| @@ -59,7 +59,7 @@ import { | ||
| 59 | 59 | import { Database } from "bun:sqlite"; |
| 60 | 60 | import { loadKey, open } from "../kernel/secret.ts"; |
| 61 | 61 | 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"; | |
| 63 | 63 | import { childPublicUrl, parentOrigin, type Placement, type PlacementKind } from "../spawn/placement.ts"; |
| 64 | 64 | import { quotaRefusal } from "../spawn/quota.ts"; |
| 65 | 65 | import { personRefusal, planRefusal } from "../spawn/sleep.ts"; |
| @@ -209,7 +209,7 @@ async function writeLedger(app: App, ledger: Ledger): Promise<void> { | ||
| 209 | 209 | * The child's founder account: the spawner's own name, or a placeholder |
| 210 | 210 | * for a stranger's child (`copyInto`). |
| 211 | 211 | */ |
| 212 | export const founderNameOf = (owner: User | undefined): string => owner?.username ?? "founder"; | |
| 212 | export const founderNameOf = (owner: User | undefined): string => owner?.username ?? PLACEHOLDER_FOUNDER; | |
| 213 | 213 | |
| 214 | 214 | /** |
| 215 | 215 | * A child whose organisation would take its founder's name can't be made: |
| @@ -590,6 +590,8 @@ async function copyInto( | ||
| 590 | 590 | // The child's own address: <name>--<parent's host>. |
| 591 | 591 | url: liveChildUrl(app, plan.name), |
| 592 | 592 | keepAccess: plan.keepAccess, |
| 593 | // No spawner signed in: the founder account is a placeholder (#11785). | |
| 594 | anonymous: owner === undefined, | |
| 593 | 595 | size: plan.size ?? DEFAULT_SIZE, |
| 594 | 596 | ...(plan.template === undefined ? {} : { template: templateMark ?? { id: plan.template } }), |
| 595 | 597 | // 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 { | ||
| 65 | 65 | readonly org: User; |
| 66 | 66 | /** Whether the spawner keeps owner access after the claim. */ |
| 67 | 67 | 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; | |
| 68 | 74 | /** The child's own address, `<name>--<parent's host>`, when recorded. */ |
| 69 | 75 | readonly url: string | undefined; |
| 70 | 76 | /** The hash of what the parent answers to (`spawn/clients.ts`), when recorded. */ |
| 71 | 77 | readonly statusHash: string | undefined; |
| 72 | 78 | } |
| 73 | 79 | |
| 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 | ||
| 74 | 95 | /** The organisation this forge was spawned as, or undefined if it was not spawned. */ |
| 75 | 96 | export async function spawnedOrg(app: App): Promise<Spawned | undefined> { |
| 76 | 97 | const file = Bun.file(join(app.config.dataDir, "parent.json")); |
| @@ -79,6 +100,7 @@ export async function spawnedOrg(app: App): Promise<Spawned | undefined> { | ||
| 79 | 100 | const parsed = (await file.json()) as { |
| 80 | 101 | org?: unknown; |
| 81 | 102 | keepAccess?: unknown; |
| 103 | anonymous?: unknown; | |
| 82 | 104 | url?: unknown; |
| 83 | 105 | statusHash?: unknown; |
| 84 | 106 | }; |
| @@ -89,6 +111,7 @@ export async function spawnedOrg(app: App): Promise<Spawned | undefined> { | ||
| 89 | 111 | : { |
| 90 | 112 | org, |
| 91 | 113 | keepAccess: parsed.keepAccess === true, |
| 114 | anonymous: typeof parsed.anonymous === "boolean" ? parsed.anonymous : undefined, | |
| 92 | 115 | url: |
| 93 | 116 | typeof parsed.url === "string" && /^https?:\/\//.test(parsed.url) |
| 94 | 117 | ? parsed.url |
src/web/pages/claim.ts +9 −5
| @@ -46,6 +46,7 @@ import { | ||
| 46 | 46 | founderToTake, |
| 47 | 47 | linkOf, |
| 48 | 48 | handOver, |
| 49 | isPlaceholderFounder, | |
| 49 | 50 | readClaim, |
| 50 | 51 | spawnedOrg, |
| 51 | 52 | takeFounder, |
| @@ -161,16 +162,19 @@ function owners( | ||
| 161 | 162 | const team = listTeams(ctx.app.db, spawned.org.id).find( |
| 162 | 163 | (t) => t.name === OWNERS_TEAM, |
| 163 | 164 | ); |
| 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 }; | |
| 169 | 169 | } |
| 170 | 170 | |
| 171 | 171 | /** One plain sentence: who can see this forge once it is claimed. */ |
| 172 | 172 | function accessSentence(spawned: Spawned, spawner: User | undefined): string { |
| 173 | 173 | 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 | } | |
| 174 | 178 | return spawned.keepAccess |
| 175 | 179 | ? `${who} keeps owner access to this forge; you can remove it in your organisation's Owners team.` |
| 176 | 180 | : `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( | ||
| 60 | 60 | const dataDir = await mkdtemp(join(tmpdir(), "forge-crew-")); |
| 61 | 61 | const app = createApp({ dataDir, port: 0 }); |
| 62 | 62 | 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 { | |
| 63 | 173 | const argsFile = join(dataDir, "fake.args"); |
| 64 | 174 | const promptFile = join(dataDir, "fake.prompt"); |
| 65 | // The stand-in: records its argv and prompt, does the work, pushes. | |
| 66 | 175 | const fake = join(dataDir, "fake-claude"); |
| 67 | 176 | writeFileSync( |
| 68 | 177 | fake, |
| @@ -200,98 +309,5 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW | ||
| 200 | 309 | `, |
| 201 | 310 | ); |
| 202 | 311 | 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 }; | |
| 297 | 313 | } |
test/spawn-keys.test.ts +1
| @@ -363,6 +363,7 @@ describe("a spawned child's own keys and domain", () => { | ||
| 363 | 363 | await Bun.file(join(childDir, "parent.json")).text(), |
| 364 | 364 | ) as Record<string, unknown>; |
| 365 | 365 | expect(Object.keys(record).sort()).toEqual([ |
| 366 | "anonymous", | |
| 366 | 367 | "apps", |
| 367 | 368 | "beatKey", |
| 368 | 369 | "keepAccess", |
test/spawn.test.ts +2
| @@ -85,6 +85,8 @@ describe("spawnForge", () => { | ||
| 85 | 85 | org: "kids", |
| 86 | 86 | url: "https://kid--forge.test", |
| 87 | 87 | keepAccess: false, |
| 88 | // Spawned by a signed-in person: the founder is a real account (#11785). | |
| 89 | anonymous: false, | |
| 88 | 90 | size: "small", |
| 89 | 91 | // A hash only: what the parent answers to stays in its own ledger. |
| 90 | 92 | 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(/&/g, "&").replace(/'|'/g, "'").replace(/"/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 |
||
| 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 | }); |
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
anonymousfield) shows; today it still names the placeholder.