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

Any agent with only the URL starts building in one call: account, repo and live app, no person or crew needed #10916

Merged opened this in answer to

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

Merged

mason/forge:mason/10912-any-agent-with-only-the-url-starts-build → main 13 commits fork 4 threads · 4 unresolved

  • Merged. mason/10912-any-agent-with-only-the-url-starts-build was merged into main as ba03110.

Review

Sign in to review this change.
47 files changed +1459 −45
docs/MCP.md +1
@@ -233,6 +233,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
233233 | `whoami_can` | `role`, `passkeys` | What you can do: your role and its roles-table row, yes or no each | 231 |
234234 | `write_file` | — | Commit one file to a branch | 302 |
235235 | `report` | — | Report a problem in a public repository, no account needed | 468 |
236 | `start` | — | Start building with no account: an account, a repo, a live app and a token in one call | 358 |
236237 <!-- mcp-tools:end -->
237238
238239 The complete list, with schemas, is MCP `tools/list` called with a token.
docs/features/start.md added +25
@@ -0,0 +1,25 @@
1 # Start: an agent builds with only the forge's URL

Both doors: no src/tui/ screen for claiming or for the agent_start setting. The terminal must offer what the page does, gated the same way, with its entry where AGENTS.md says. This doc then covers the terminal path too.

2
3 This page is for agents and the people who run them. No account, token or crew is needed to begin.
4
5 ## Start
6
7 ```
8 curl -sX POST https://forge.open-platform.sh/api/v1/start -d '{"name":"my-app"}'
9 ```
10
11 `name` is required; `description` and `agent` are optional. The answer holds `user`, `token` (shown once), `repo`, `git`, `app` and `claim`. The MCP `start` tool does the same and needs no token.
12
13 ## Build
14
15 Clone `git.url` (it carries the token) and `git push` to main. `app.url` is live after the deploy. Issues and pulls work as on any repo. An unclaimed account has no crew, spends no credits, runs a small app and reaches no host but the package registries.
16
17 ## Claim
18
19 Give `claim.url` to a person. They open it and sign in (or, where the forge's sign-up is open, make an account there), and the agent account and its repos become theirs, with the agent as a member. The link works once; if no repository can be moved it is given back, and one that cannot move stays with the agent and is named. Where sign-up is closed or by invite, the claim page makes no account: the person signs in.
20
21 In the terminal, **Take an agent's work** in the palette (any person) asks for the link, names the repositories and moves them after a confirm: the same claim, the same gate.
22
23 ## Limits
24
25 Per source: 3 starts an hour and 10 a day. For the forge: 200 a day. Counts and the insert are one transaction, so concurrent calls cannot overshoot. One source can use at most 5% of the day's share; against abuse from many addresses an admin sets **Agents can start here** to invite code or off. Unclaimed accounts sleep after 7 days and are archived (never deleted) after 30; claiming wakes them. A site admin sets **Agents can start here** (open, invite code, off) in `/admin/limits`, or with `a` on the **Invite links** screen in the terminal (admins who are people).
src/actions/claim.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * Taking an agent's account (#10912): the person who opens the one-time
3 * claim link the start call returned, signed in, takes it. The agent's
4 * repositories move to a new organisation the person owns, and the agent
5 * stays in it as a member with write; the account wakes if it slept and
6 * its archived repositories are woken. The link is burnt in the one
7 * statement that checks it, so of two people racing on it one wins. A
8 * repository that will not move stays with the agent and is reported; when
9 * none moves the link is given back.
10 */
11 import type { App } from "../app.ts";
12 import { setSuspended } from "../core/accounts.ts";
13 import { addTeamMember, createOrg, createTeam } from "../core/orgs.ts";
14 import { listReposOwnedBy, moveArchived, type Repo } from "../core/repos.ts";
15 import { markClaimed, startByClaim } from "../core/starts.ts";
16 import { transferRepo } from "../core/transfer.ts";
17 import { getUserById, type User } from "../core/users.ts";
18 import { err, ok, type Result } from "../kernel/result.ts";
19
20 export type ClaimError = "not_found" | "claimed" | "not_a_person" | "failed";
21
22 export const CLAIM_WORDS: Readonly<Record<ClaimError, string>> = {
23 not_found: "This claim link is not one this forge made.",
24 claimed: "This link was already used: someone has taken the account.",
25 not_a_person: "Only a person's account can take this.",
26 failed: "The account could not be moved; nothing was kept. Try the link again.",
27 };
28
29 export interface ClaimView {
30 readonly agent: string;
31 readonly repos: readonly string[];
32 readonly claimed: boolean;
33 }
34
35 /** What a link holds, for the page that offers it: nothing secret. */
36 export function viewClaim(app: App, claimToken: string): ClaimView | undefined {
37 const row = startByClaim(app.db, claimToken);
38 const agent = row === undefined ? undefined : getUserById(app.db, row.userId);
39 if (row === undefined || agent === undefined) return undefined;
40 return {
41 agent: agent.username,
42 repos: listReposOwnedBy(app.db, agent.id).map((r) => `${r.ownerName}/${r.name}`),
43 claimed: row.claimedAt !== null,
44 };
45 }
46
47 export async function claimStart(
48 app: App,
49 claimer: User | undefined,
50 claimToken: string,
51 now: Date = new Date(),
52 ): Promise<Result<{ org: string; repos: string[]; stayed: string[] }, ClaimError>> {
53 const row = startByClaim(app.db, claimToken);
54 const agent = row === undefined ? undefined : getUserById(app.db, row.userId);
55 if (row === undefined || agent === undefined) return err("not_found");
56 if (row.claimedAt !== null) return err("claimed");
57 if (claimer === undefined || claimer.kind !== "user" || claimer.id === agent.id) return err("not_a_person");
58 if (!markClaimed(app.db, agent.id, claimer.id, now)) return err("claimed");
59
60 let org = createOrg(app.db, `${agent.username}-team`, claimer.id);
61 if (!org.ok && org.error === "name_taken")
62 org = createOrg(app.db, `${agent.username}-team-${Date.now().toString(36)}`, claimer.id);
63 const team = org.ok ? createTeam(app.db, org.value.id, "agents", "write") : undefined;
64 if (!org.ok || team === undefined || !team.ok || !addTeamMember(app.db, team.value, agent.id)) {
65 // Nothing moved yet: give the link back.
66 app.db.query("UPDATE start_account SET claimed_at = NULL, claimed_by = NULL WHERE user_id = ?1").run(agent.id);
67 return err("failed");
68 }
69 const moved: Repo[] = [];
outdated

A failed transferRepo is swallowed: the claim stays burnt and the repo stays with the agent. moveArchived(..., false) runs on the old repo regardless. The person is told "Taken" with fewer repos, and the link is spent. Fix: on any failed transfer, give the link back (as the org failure does) or report the repos that did not move. Wake only the repos that moved. Add a test.

70 const stayed: string[] = [];
71 for (const repo of listReposOwnedBy(app.db, agent.id)) {
72 const done = await transferRepo(app.db, app.reposRoot, repo, org.value, claimer.id);
73 if (done.ok) {
74 moved.push(done.value);
75 moveArchived(app.db, done.value.id, false);
76 } else stayed.push(`${repo.ownerName}/${repo.name}`);
77 }
78 if (moved.length === 0 && stayed.length > 0) {
79 // Nothing moved: give the link back.
80 app.db.query("UPDATE start_account SET claimed_at = NULL, claimed_by = NULL WHERE user_id = ?1").run(agent.id);
81 return err("failed");
82 }
83 setSuspended(app.db, agent.id, null);
84 app.bus.append({
85 kind: "start.claimed",
86 actorId: claimer.id,
87 payload: { agent: agent.username, org: org.value.username, repos: moved.length },
88 });
89 return ok({ org: org.value.username, repos: moved.map((r) => `${r.ownerName}/${r.name}`), stayed });
90 }
src/actions/here.ts +11
@@ -16,6 +16,7 @@ import { audienceFor } from "../core/audience.ts";
1616 import { lacks } from "../core/roles.ts";
1717 import { activeEdgeToken } from "../http/edge.ts";
1818 import { sshTellCommand } from "../ssh/command.ts";
19 import { getAgentStart } from "../settings/agent_start.ts";
1920 import { mcpPointers, TOKEN_FREE_TOOLS } from "../core/mcp_card.ts";
2021 import type { App } from "../app.ts";
2122 import { appsRuntime } from "../apps/runtime.ts";
@@ -331,6 +332,16 @@ function forgeActions(
331332 http: "GET /api/v1/changelog",
332333 mcp: "releases",
333334 },
335 ...(getAgentStart(app.db) === "off"
336 ? []
337 : [
338 {
339 do: "start building with no account",
340 http: "POST /api/v1/start",
341 mcp: "start",
342 ...tokenFree("start"),
343 },
344 ]),
334345 {
335346 do: "read the HTTP contract",
336347 http: "GET /contract/openapi.json",
src/actions/signup.ts +20
@@ -12,6 +12,7 @@ import {
1212 type SignupChange,
1313 type SignupMode,
1414 } from "../settings/signup.ts";
15 import { getAgentStart, parseAgentStart, setAgentStart, type AgentStartMode } from "../settings/agent_start.ts";
1516 import { forgeSource, parseForgeSource, setForgeSource, storedForgeSource } from "../settings/forge_source.ts";
1617 import type { User } from "../core/users.ts";
1718 import { err, ok, type Result } from "../kernel/result.ts";
@@ -69,3 +70,22 @@ export function changeForgeSource(
6970 setForgeSource(app.db, name.value, who.value.id);
7071 return ok(name.value);
7172 }
73
74 /** The agent-start setting (`settings/agent_start.ts`), for a forge admin who is a person. */
75 export function readAgentStart(app: App, actor: User | undefined): Result<AgentStartMode, "not_found"> {
76 const who = admin(actor);
77 return who.ok ? ok(getAgentStart(app.db)) : who;
78 }
79
80 export function changeAgentStart(
81 app: App,
82 actor: User | undefined,
83 raw: unknown,
84 ): Result<AgentStartMode, "not_found" | "invalid_agent_start"> {
85 const who = admin(actor);
86 if (!who.ok) return who;
87 const mode = parseAgentStart(raw);
88 if (!mode.ok) return mode;
89 setAgentStart(app.db, mode.value, who.value.id);
90 return ok(mode.value);
91 }
src/actions/start.ts added +201
@@ -0,0 +1,201 @@
1 /**
2 * The start call (#10912): an agent holding only the forge's URL gets an
3 * account, a public repository from the app template and its app, and a
4 * token for that account, in one call. No person and no crew are in it.
5 *
6 * The door is open to strangers, so its limits are mechanical:
7 *
8 * - **The setting.** `agent_start` (`settings/agent_start.ts`): open, a
9 * code (an open invite link's token, spent with the account), or off.
10 * - **Capped.** A few starts per source per hour, ten per source per day, and
11 * a few hundred a day for the whole forge, counted from the rows the starts
12 * left, so a restart forgets nothing. The counts and the insert are one
13 * transaction, so concurrent calls cannot overshoot. One source cannot take
14 * more than `PER_SOURCE_PER_DAY / PER_DAY` of the day; a forge that sees
15 * abuse from many addresses sets `agent_start` to a code or off.
16 * - **Held small.** The account stays a guest (no crew, no credits), its
17 * app runs small with no host but the package registries, and it sleeps
18 * after a week unclaimed and is archived, never deleted, after a month
19 * (`core/starts.ts`, `sweepStarts`).
20 * - **Shown.** The app is public under the agent's name.
21 *
22 * The token is returned once; the claim link is returned once and kept as a
23 * hash (`actions/claim.ts` is what opens it).
24 */
25 import type { App } from "../app.ts";
26 import { appsRuntime } from "../apps/runtime.ts";
27 import { createToken } from "../core/tokens.ts";
28 import { createUser, discardFreshUser } from "../core/users.ts";
29 import { inviteOpen, redeemInvite } from "../core/invites.ts";
30 import { ARCHIVE_DAYS, SLEEP_DAYS, recordStart, startsSince } from "../core/starts.ts";
31 import { secret } from "../kernel/id.ts";
32 import { err, ok, type Result } from "../kernel/result.ts";
33 import { getAgentStart } from "../settings/agent_start.ts";
34 import { noEmail } from "./users.ts";
outdated

MEDIUM (denial of service): PER_DAY=200 is a global cap that one source can exhaust at 3 per hour per address, or many addresses. That closes the door for every honest agent, and each start also deploys a public app that runs stranger-supplied code. Also, the check-then-insert for the caps is not atomic, so concurrent calls overshoot them. Fix: count and insert in one transaction, and give the daily cap a bounded share per source or an admin setting. Document the abuse bound in the pull.

35 import { newApp } from "./apps.ts";
36
37 export const PER_SOURCE_PER_HOUR = 3;
38 export const PER_SOURCE_PER_DAY = 10;
39 export const PER_DAY = 200;
40 export const NAME_MAX = 40;
41 export const DESCRIPTION_MAX = 2000;
42 export const AGENT_MAX = 64;
43 const HOUR_MS = 3_600_000;
44 const DAY_MS = 86_400_000;
45
46 export interface StartInput {
47 readonly name?: unknown;
48 readonly description?: unknown;
49 readonly agent?: unknown;
50 /** The token of an open invite link, when the forge asks for one. */
51 readonly code?: unknown;
52 }
53
54 export type StartError =
55 | "off"
56 | "code_needed"
57 | "invalid_name"
58 | "invalid_description"
59 | "invalid_agent"
60 | "rate_limited"
61 | "full"
62 | "failed";
63
64 export type StartRefusal = { readonly error: StartError; readonly retryAfter?: number };
65
66 /** What each refusal says; the shape of the call is in `START_HINT`. */
67 export const START_WORDS: Readonly<Record<StartError, string>> = {
68 off: "Agents cannot start here: the owner turned the start call off. A person can sign in or ask for an invitation.",
69 code_needed: "This forge asks for an invite code: send it as `code`.",
70 invalid_name: `name is 1-${NAME_MAX} characters with a letter or digit in it`,
71 invalid_description: `description is plain text of at most ${DESCRIPTION_MAX} characters`,
72 invalid_agent: `agent is a name of at most ${AGENT_MAX} letters, digits, . _ -`,
73 rate_limited: "Too many starts from here; wait and send again.",
74 full: "This forge has started its share of agents for today; try again tomorrow.",
75 failed: "The start could not be finished; nothing was kept. Try again.",
76 };
77
78 export const START_HINT =
79 `send JSON {name, description?, agent?}: name 1-${NAME_MAX} characters, description at most ` +
80 `${DESCRIPTION_MAX}, agent your name (letters, digits, . _ -)`;
81
82 export interface Started {
83 readonly user: { readonly username: string };
84 /** Scoped to that account (read and write); shown once. */
85 readonly token: string;
86 readonly repo: string;
87 readonly git: { readonly url: string; readonly push: string };
88 readonly app: { readonly url: string | null };
89 /** One-time link a person opens to take the account and its repositories. */
90 readonly claim: { readonly url: string; readonly sleepsAfterDays: number; readonly archivedAfterDays: number };
91 }
92
93 /** The name as a repository and account name: lowercase letters, digits, dashes. */
94 export function nameSlug(raw: string): string {
95 return raw
96 .toLowerCase()
97 .replace(/[^a-z0-9]+/g, "-")
98 .replace(/^-+|-+$/g, "")
99 .slice(0, 28)
100 .replace(/-+$/g, "");
101 }
102
103 const hex = (n: number): string =>
104 Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(16).padStart(2, "0")).join("");
105
106 export async function startBuilding(
107 app: App,
108 input: StartInput,
109 source: string,
110 origin: string,
111 now: Date = new Date(),
112 ): Promise<Result<Started, StartRefusal>> {
113 const refuse = (error: StartError, retryAfter?: number): Result<never, StartRefusal> =>
114 err(retryAfter === undefined ? { error } : { error, retryAfter });
115 const mode = getAgentStart(app.db);
116 if (mode === "off") return refuse("off");
117 const code = typeof input.code === "string" ? input.code : "";
118 if (mode === "code" && !inviteOpen(app.db, code)) return refuse("code_needed");
119
120 const given = typeof input.name === "string" ? input.name.trim() : "";
121 const base = nameSlug(given);
122 if (given === "" || given.length > NAME_MAX || base === "") return refuse("invalid_name");
123 const description = input.description === undefined ? "" : input.description;
124 if (typeof description !== "string" || description.length > DESCRIPTION_MAX || /[\0]/.test(description))
125 return refuse("invalid_description");
126 const agent = input.agent === undefined ? "" : input.agent;
127 if (typeof agent !== "string" || agent.length > AGENT_MAX || !/^[A-Za-z0-9._-]*$/.test(agent))
128 return refuse("invalid_agent");
129
130 const capped = (): StartRefusal | undefined => {
131 if (startsSince(app.db, new Date(now.getTime() - HOUR_MS), source) >= PER_SOURCE_PER_HOUR)
132 return { error: "rate_limited", retryAfter: 3600 };
133 if (startsSince(app.db, new Date(now.getTime() - DAY_MS), source) >= PER_SOURCE_PER_DAY)
134 return { error: "rate_limited", retryAfter: 3600 };
135 if (startsSince(app.db, new Date(now.getTime() - DAY_MS)) >= PER_DAY) return { error: "full" };
136 return undefined;
137 };
138 // Cheap early refusal; the binding count is the one inside the transaction below.
139 const early = capped();
140 if (early !== undefined) return err(early);
141
142 // The account: a person-shaped one with no password, which only its token signs in.
143 let username = `${base}-${hex(2)}`;
144 let made = await createUser(app.db, {
145 origin: "start",
146 username,
147 email: noEmail(username),
148 displayName: agent === "" ? base : agent,
149 trusted: true,
150 });
151 if (!made.ok && made.error === "username_taken") {
152 username = `${base}-${hex(3)}`;
153 made = await createUser(app.db, {
154 origin: "start",
155 username,
156 email: noEmail(username),
157 displayName: agent === "" ? base : agent,
158 trusted: true,
159 });
160 }
161 if (!made.ok) return refuse("failed");
162 const user = made.value;
163 const undo = (): void => discardFreshUser(app.db, user.id);
164
165 const claimToken = `claim_${secret()}`;
166 let refusal: StartRefusal | undefined;
167 try {
168 app.db.transaction(() => {
169 refusal = capped();
170 if (refusal !== undefined) throw new Error("capped");
171 if (mode === "code" && !redeemInvite(app.db, code, user.id)) throw new Error("code spent");
172 recordStart(app.db, user.id, claimToken, source, now);
173 })();
174 } catch {
175 undo();
176 if (refusal !== undefined) return err(refusal);
177 return refuse(mode === "code" ? "code_needed" : "failed");
178 }
179
180 // The repository and its app; a taken app name gets a suffix once.
181 let made2 = await newApp(app, user, { name: base, description, visibility: "public" });
182 if (!made2.ok && made2.error === "slug_taken")
183 made2 = await newApp(app, user, { name: `${base}-${hex(2)}`, description, visibility: "public" });
184 if (!made2.ok) {
185 undo();
186 return refuse("failed");
187 }
188 const { repo, app: record } = made2.value;
189 const token = createToken(app.db, user.id, "start", "rw");
190 app.bus.append({ kind: "start.made", repoId: repo.id, actorId: user.id, payload: {} });
191 const url = `${origin}/${user.username}/${repo.name}.git`;
192 return ok({
193 user: { username: user.username },
194 token: token.token,
195 repo: `${user.username}/${repo.name}`,
196 git: { url, push: `git push ${url.replace("://", `://${user.username}:${token.token}@`)} HEAD:main` },
197 app: { url: appsRuntime(app)?.urlFor(record, "production") ?? null },
198 claim: { url: `${origin}/claim/start/${claimToken}`, sleepsAfterDays: SLEEP_DAYS, archivedAfterDays: ARCHIVE_DAYS },
199 });
200 }
201
src/actions/start_sweep.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * The life of an unclaimed account (#10912): asleep after `SLEEP_DAYS`
3 * (suspended, so its token and its app stop answering), archived after
4 * `ARCHIVE_DAYS` (its repositories archived), never deleted. A claim wakes
5 * all of it (`actions/claim.ts`). Run from the daily maintenance tick.
6 */
7 import type { App } from "../app.ts";
8 import { setSuspended } from "../core/accounts.ts";
9 import { listReposOwnedBy, moveArchived } from "../core/repos.ts";
10 import { dueToArchive, dueToSleep, markArchived, markSlept } from "../core/starts.ts";
11
12 export function sweepStarts(app: App, now: Date = new Date()): { slept: number; archived: number } {
13 const stamp = now.toISOString();
14 let slept = 0;
15 let archived = 0;
16 for (const userId of dueToSleep(app.db, now)) {
17 setSuspended(app.db, userId, stamp);
18 app.db.query("DELETE FROM token WHERE user_id = ?1").run(userId);
19 markSlept(app.db, userId, now);
20 slept++;
21 }
22 for (const userId of dueToArchive(app.db, now)) {
23 for (const repo of listReposOwnedBy(app.db, userId)) moveArchived(app.db, repo.id, true, stamp);
24 markArchived(app.db, userId, now);
25 archived++;
26 }
27 if (slept + archived > 0) app.bus.append({ kind: "start.swept", payload: { slept, archived } });
28 return { slept, archived };
29 }
src/apps/runtime.ts +6 −2
@@ -104,6 +104,7 @@ import {
104104 type EnvName,
105105 } from "./data.ts";
106106 import { parseManifest, type AppManifest } from "./manifest.ts";
107 import { heldManifest } from "../core/starts.ts";
107108 import { identityKey } from "./proxy.ts";
108109 import { noteReached } from "./egress_report.ts";
109110 import { cellHooks } from "./egress_refused.ts";
@@ -1509,7 +1510,9 @@ export class AppsRuntime {
15091510 readFileAt(bare, run.commitOid, "forge.json"),
15101511 readFileAt(bare, run.commitOid, "package.json"),
15111512 ]);
1512 const manifest = parseManifest(forgeJson, packageJson);
1513 const parsed = parseManifest(forgeJson, packageJson);
1514 // An agent's unclaimed account runs small, with no host but the registries (#10912).
1515 const manifest = parsed.ok ? { ...parsed, value: heldManifest(this.app.db, repo.ownerId, parsed.value) } : parsed;
15131516 clock.lap("manifest");
15141517 if (!manifest.ok) {
15151518 return {
@@ -2026,6 +2029,7 @@ export class AppsRuntime {
20262029 };
20272030 const manifest = parseManifest(read("forge.json"), read("package.json"));
20282031 if (!manifest.ok) return false;
2032 const owner = getRepoById(this.app.db, record.repoId)?.ownerId;
20292033 const state = this.envFor(record, env);
20302034 if (state.live !== undefined || state.deploying !== undefined) return true;
20312035 // The deploy that built this checkout, in an earlier life: its log is
@@ -2039,7 +2043,7 @@ export class AppsRuntime {
20392043 state.asleep = {
20402044 commitOid,
20412045 dir,
2042 manifest: manifest.value,
2046 manifest: owner === undefined ? manifest.value : heldManifest(this.app.db, owner, manifest.value),
20432047 runId: built?.id ?? "",
20442048 since: new Date().toISOString(),
20452049 };
src/contract/openapi/routes/index.ts +1
@@ -349,6 +349,7 @@ export { route as postApiV1ReposOwnerNameTransfer } from "./postApiV1ReposOwnerN
349349 export { route as postApiV1ReposOwnerNameUpstreamCheck } from "./postApiV1ReposOwnerNameUpstreamCheck.ts";
350350 export { route as postApiV1Sessions } from "./postApiV1Sessions.ts";
351351 export { route as postApiV1Share } from "./postApiV1Share.ts";
352 export { route as postApiV1Start } from "./postApiV1Start.ts";
352353 export { route as postApiV1Thoughts } from "./postApiV1Thoughts.ts";
353354 export { route as postApiV1Tokens } from "./postApiV1Tokens.ts";
354355 export { route as postApiV1UserEmailVerify } from "./postApiV1UserEmailVerify.ts";
src/contract/openapi/routes/postApiV1Start.ts added +23
@@ -0,0 +1,23 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/start",
6 summary:
7 "Start building with no account (an agent holding only the forge's URL): answers 201 { user: { username }, token, repo: \"owner/name\", git: { url, push }, app: { url }, claim: { url, sleepsAfterDays, archivedAfterDays } }. It makes an agent account, a public repository from the app template and its app; the token (read and write, that account only) and the claim link are shown once; `git push` to main deploys; a person opens `claim.url` to take the account and its repositories. Unclaimed it has no crew and no credits, runs small with no outbound host but the package registries, sleeps after 7 days and is archived after 30. A few starts per source an hour and a cap per day; refused from another origin with 403 forbidden; the owner's setting can turn it off or ask for an invite `code`",
8 auth: false,
9 role: "anonymous",
10 status: 201,
11 body: { name: "string", description: "string", agent: "string", code: "string" },
12 refusals: {
13 off: "403, the owner turned the start call off",
14 code_needed: "403, this forge asks for an open invite link's token as `code`",
15 rate_limited: "429, too many starts from this source; retry-after says when",
16 full: "429, the forge has started its share for today",
17 too_large: "413, the body is over the limit for a stranger's",
18 invalid_name: "422, name is 1-40 characters with a letter or digit",
19 invalid_description: "422, description is plain text of at most 2000 characters",
20 invalid_agent: "422, agent is at most 64 letters, digits, . _ -",
21 failed: "500, nothing was kept; try again",
22 },
23 };
src/core/event_audience.ts +4
@@ -179,6 +179,10 @@ const READER_KINDS: ReadonlySet<string> = new Set([
179179 "traffic.capture_started",
180180 "traffic.networks_imported",
181181 "crew.governed",
182 // Starts by an agent with only the URL (#10912): the forge owner hears them.
183 "start.made",
184 "start.claimed",
185 "start.swept",
182186 ]);
183187
184188 /**
src/core/mcp_card.ts +1 −1
@@ -55,7 +55,7 @@ export function apiCatalog(origin: string) {
5555 * `http/mcp.ts` answers these and refuses the rest with 401. The card and
5656 * `/api/v1/here` both read this list, so they cannot drift.
5757 */
58 export const TOKEN_FREE_TOOLS: readonly string[] = ["report"];
58 export const TOKEN_FREE_TOOLS: readonly string[] = ["report", "start"];
5959
6060 /** A thin plugin the forge ships for a harness: where its source is and its one install line. */
6161 export interface HarnessPlugin {
src/core/migration-pins/20261110160000-start-accounts.pin added +1
@@ -0,0 +1,1 @@
1 a74cde1d88d8302958aba970b12f8e86d912f720fc1bd4007a177722e40e33a8
src/core/migrations/20261110160000-start-accounts.ts added +19
@@ -0,0 +1,19 @@
1 // An agent that came with only the forge's URL (#10912): one row per account the
2 // start call made, holding the hash of its one-time claim link, where it came
3 // from (a hashed source, for the per-hour cap) and where it is in its life:
4 // asleep after 7 days unclaimed, archived after 30, claimed by a person.
5 // Additive.
6 export default `
7 CREATE TABLE start_account (
8 user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE,
9 claim_hash TEXT NOT NULL UNIQUE,
10 source TEXT NOT NULL,
11 created_at TEXT NOT NULL,
12 slept_at TEXT,
13 archived_at TEXT,
14 claimed_at TEXT,
15 claimed_by TEXT
16 ) WITHOUT ROWID;
17 CREATE INDEX start_account_created ON start_account (created_at);
18 CREATE INDEX start_account_source ON start_account (source, created_at);
19 `;
src/core/roles.ts +5 −1
@@ -9,6 +9,7 @@
99 */
1010
1111 import type { Database } from "bun:sqlite";
12 import { isUnclaimed } from "./starts.ts";
1213
1314 export const ROLES = ["guest", "member", "admin"] as const;
1415 export type Role = (typeof ROLES)[number];
@@ -86,7 +87,10 @@ export function lacks(
8687 ability: Ability,
8788 ): boolean {
8889 if (user.kind !== "user") return false;
89 return !can(roleOf(db, user), ability);
90 // An account an agent started may make its first app and nothing a member
91 // may spend on: it stays a guest, so no crew runs for it (#10912).
92 if (can(roleOf(db, user), ability)) return false;
93 return !(ability === "make_it_yours" && isUnclaimed(db, user.id));
9094 }
9195
9296 /**
src/core/starts.ts added +162
@@ -0,0 +1,162 @@
1 /**
2 * Accounts an agent made by itself with the start call (`actions/start.ts`,
3 * #10912), and where each is in its life. Pure data: no authz, no events.
4 *
5 * Unclaimed they are held small: no outbound host but the package registries,
6 * a small app, no crew, and — after `SLEEP_DAYS` — asleep (suspended), then —
7 * after `ARCHIVE_DAYS` — archived, never deleted. A person's claim ends all
8 * of it. The claim link is kept as a sha256, like a token.
9 */
10 import type { Database } from "bun:sqlite";
11 import { sha256 } from "../kernel/hash.ts";
12
13 export const SLEEP_DAYS = 7;
14 export const ARCHIVE_DAYS = 30;
15 const DAY_MS = 86_400_000;
16
17 /** What an unclaimed account's app may use: small, whatever forge.json says. */
18 export const SMALL_APP = { memoryMiB: 128, cpus: 0.25, diskMiB: 256 } as const;
19
20 export interface StartRow {
21 readonly userId: string;
22 readonly createdAt: string;
23 readonly sleptAt: string | null;
24 readonly archivedAt: string | null;
25 readonly claimedAt: string | null;
26 readonly claimedBy: string | null;
27 }
28
29 interface Raw {
30 user_id: string;
31 created_at: string;
32 slept_at: string | null;
33 archived_at: string | null;
34 claimed_at: string | null;
35 claimed_by: string | null;
36 }
37
38 const toRow = (r: Raw): StartRow => ({
39 userId: r.user_id,
40 createdAt: r.created_at,
41 sleptAt: r.slept_at,
42 archivedAt: r.archived_at,
43 claimedAt: r.claimed_at,
44 claimedBy: r.claimed_by,
45 });
46
47 /** A source (an address) is kept only as a hash: enough to count, never to read. */
48 export const sourceKey = (source: string): string => sha256(`start-source:${source}`).slice(0, 24);
49
50 export function recordStart(
51 db: Database,
52 userId: string,
53 claimToken: string,
54 source: string,
55 at: Date = new Date(),
56 ): void {
57 db.query(
58 "INSERT INTO start_account (user_id, claim_hash, source, created_at) VALUES (?1, ?2, ?3, ?4)",
59 ).run(userId, sha256(claimToken), sourceKey(source), at.toISOString());
60 }
61
62 /** Starts made since `since`: from one source, or by everyone. */
63 export function startsSince(db: Database, since: Date, source?: string): number {
64 const at = since.toISOString();
65 const row =
66 source === undefined
67 ? db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM start_account WHERE created_at >= ?1").get(at)
68 : db
69 .query<{ n: number }, [string, string]>(
70 "SELECT COUNT(*) AS n FROM start_account WHERE source = ?2 AND created_at >= ?1",
71 )
72 .get(at, sourceKey(source));
73 return row?.n ?? 0;
74 }
75
76 export function startOfAccount(db: Database, userId: string): StartRow | undefined {
77 const row = db.query<Raw, [string]>("SELECT * FROM start_account WHERE user_id = ?1").get(userId);
78 return row === null ? undefined : toRow(row);
79 }
80
81 /** The row a claim link names, claimed or not. */
82 export function startByClaim(db: Database, claimToken: string): StartRow | undefined {
83 const row = db
84 .query<Raw, [string]>("SELECT * FROM start_account WHERE claim_hash = ?1")
85 .get(sha256(claimToken));
86 return row === null ? undefined : toRow(row);
87 }
88
89 /**
90 * Burn the link for `claimedBy`, in the one statement that checks it: of two
91 * people racing on one link, one wins.
92 */
93 export function markClaimed(db: Database, userId: string, claimedBy: string, at: Date = new Date()): boolean {
94 return (
95 db
96 .query(
97 "UPDATE start_account SET claimed_at = ?1, claimed_by = ?2 WHERE user_id = ?3 AND claimed_at IS NULL",
98 )
99 .run(at.toISOString(), claimedBy, userId).changes === 1
100 );
101 }
102
103 /** Whether the account is one an agent started that nobody has claimed. */
104 export function isUnclaimed(db: Database, userId: string): boolean {
105 return (
106 db
107 .query<{ n: number }, [string]>("SELECT 1 AS n FROM start_account WHERE user_id = ?1 AND claimed_at IS NULL")
108 .get(userId) !== null
109 );
110 }
111
112 /**
113 * A manifest as an unclaimed owner may run it: no host but the package
114 * registries (the install steps add those themselves), and the small size
115 * whatever `forge.json` asked for. A claimed or ordinary owner's passes whole.
116 */
117 export function heldManifest<M extends { egress?: readonly string[] | undefined; limits?: object | undefined }>(
118 db: Database,
119 ownerId: string,
120 manifest: M,
121 ): M {
122 if (!isUnclaimed(db, ownerId)) return manifest;
123 const asked = (manifest.limits ?? {}) as Record<string, number | undefined>;
124 const least = (key: keyof typeof SMALL_APP): number => Math.min(asked[key] ?? SMALL_APP[key], SMALL_APP[key]);
125 return {
126 ...manifest,
127 egress: [],
128 limits: { ...asked, memoryMiB: least("memoryMiB"), cpus: least("cpus"), diskMiB: least("diskMiB") },
129 };
130 }
131
132 /** Unclaimed accounts made before `cutoff` that have not reached the stage yet. */
133 export function dueToSleep(db: Database, now: Date): string[] {
134 const cutoff = new Date(now.getTime() - SLEEP_DAYS * DAY_MS).toISOString();
135 return db
136 .query<{ user_id: string }, [string]>(
137 "SELECT user_id FROM start_account WHERE claimed_at IS NULL AND slept_at IS NULL AND created_at <= ?1",
138 )
139 .all(cutoff)
140 .map((r) => r.user_id);
141 }
142
143 export function dueToArchive(db: Database, now: Date): string[] {
144 const cutoff = new Date(now.getTime() - ARCHIVE_DAYS * DAY_MS).toISOString();
145 return db
146 .query<{ user_id: string }, [string]>(
147 "SELECT user_id FROM start_account WHERE claimed_at IS NULL AND archived_at IS NULL AND created_at <= ?1",
148 )
149 .all(cutoff)
150 .map((r) => r.user_id);
151 }
152
153 export function markSlept(db: Database, userId: string, at: Date): void {
154 db.query("UPDATE start_account SET slept_at = ?1 WHERE user_id = ?2").run(at.toISOString(), userId);
155 }
156
157 export function markArchived(db: Database, userId: string, at: Date): void {
158 db.query("UPDATE start_account SET archived_at = ?1, slept_at = COALESCE(slept_at, ?1) WHERE user_id = ?2").run(
159 at.toISOString(),
160 userId,
161 );
162 }
src/core/users.ts +6 −2
@@ -126,7 +126,9 @@ export type CreateUserError =
126126 *
127127 * Every other origin is a named exemption, made by someone the forge already
128128 * trusts: a claim link the owner handed out, an admin's everyday account, a
129 * crew member the admin adds, a spawned forge's seed accounts, the
129 * crew member the admin adds, a spawned forge's seed accounts, the account an
130 * agent's start call makes (`start`, held by the agent-start setting) and
131 * the person who opens that account's claim link (`claimer`), the
130132 * operator's first admin (`first-admin`, from the one-time code only the
131133 * operator is shown), and `seed` for code that sets a forge up directly (the
132134 * chaos world, test fixtures).
@@ -139,7 +141,7 @@ export type CreateUserError =
139141 */
140142 export type StrangerOrigin = "signup" | "passkey";
141143 export type AdminOrigin = "claim" | "first-admin" | "seed";
142 export type ExemptOrigin = AdminOrigin | "everyday" | "agent" | "spawn";
144 export type ExemptOrigin = AdminOrigin | "everyday" | "agent" | "spawn" | "start" | "claimer";
143145 export type AccountOrigin =
144146 /**
145147 * `invite` is the token of an invite link (`core/invites.ts`): while
@@ -277,6 +279,8 @@ function admission(db: Database, input: AccountOrigin): Admission {
277279 case "everyday":
278280 case "agent":
279281 case "spawn":
282 case "start":
283 case "claimer":
280284 case "first-admin":
281285 case "seed":
282286 return ADMITTED;
src/http/api.ts +2
@@ -381,6 +381,7 @@ import { buildCartRoutes } from "./api/build_cart.ts";
381381 import { iterationRoutes } from "./api/iterations.ts";
382382 import { appNoteRoutes } from "./api/app_notes.ts";
383383 import { reportRoutes } from "./api/reports.ts";
384 import { startRoutes } from "./api/start.ts";
384385 import { rollbackRoutes } from "./api/rollback.ts";
385386 import { takeBackRoutes } from "./api/take_back.ts";
386387 import { conversationConfirmRoutes } from "./api/conversation_confirms.ts";
@@ -2136,6 +2137,7 @@ export function apiRoutes(router: Router<Handler>): void {
21362137 issueRoutes(router);
21372138
21382139 reportRoutes(router);
2140 startRoutes(router);
21392141 appNoteRoutes(router);
21402142 rollbackRoutes(router);
21412143 takeBackRoutes(router);
src/http/api/start.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * `POST /api/v1/start` (#10912): an agent with only the forge's URL gets an
3 * account, a repository from the app template, its app and a token in one
4 * call. No token, no session; the rules and limits are `actions/start.ts`.
5 * A browser page from another site is refused: an agent is not one.
6 */
7 import { START_HINT, START_WORDS, startBuilding } from "../../actions/start.ts";
8 import { REQUEST_MAX } from "../../core/reports.ts";
9 import { crossOrigin, type Handler } from "../context.ts";
10 import { clientBucket } from "../peer.ts";
11 import { apiError, boundedBody, json, tryAgain } from "../respond.ts";
12 import type { Router } from "../router.ts";
13
14 export function startRoutes(router: Router<Handler>): void {
15 router.add("POST", "/api/v1/start", async (ctx) => {
16 if (crossOrigin(ctx.request, ctx.url, ctx.app.log))
17 return apiError("forbidden", 403, "send this call from your agent, not from a page on another site");
18 const input = await boundedBody(ctx.request, REQUEST_MAX);
19 if (input === "too_large") return apiError("too_large", 413, START_HINT);
20 const started = await startBuilding(ctx.app, input, clientBucket(ctx.request), ctx.url.origin);
21 if (started.ok)
22 return json(started.value, 201, {
23 // The token and the claim link are shown once: nothing may keep them.
24 "cache-control": "no-store",
25 });
26 const refusal = started.error;
27 const said = START_WORDS[refusal.error];
28 if (refusal.error === "rate_limited" || refusal.error === "full")
29 return tryAgain(refusal.error, 429, said, refusal.retryAfter ?? 3600);
30 if (refusal.error === "off") return apiError("off", 403, said);
31 if (refusal.error === "code_needed") return apiError("code_needed", 403, said);
32 if (refusal.error === "failed") return apiError("failed", 500, said);
33 return apiError(refusal.error, 422, `${said}; ${START_HINT}`);
34 });
35 }
src/http/mcp.ts +45 −6
@@ -32,6 +32,7 @@ import { clientBucket } from "./peer.ts";
3232 import { TOKEN_FREE_TOOLS } from "../core/mcp_card.ts";
3333 import { proofOf } from "./report_filer.ts";
3434 import { fileReport } from "../actions/reports.ts";
35 import { START_HINT, START_WORDS, startBuilding } from "../actions/start.ts";
3536 import { REPORT_RECEIVED } from "../actions/report_words.ts";
3637 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
3738 import { everydayOf } from "../core/everyday.ts";
@@ -55,7 +56,7 @@ export function toolEntries(tools: readonly (typeof TOOLS)[number][]) {
5556 ? {}
5657 : { _meta: { ui: { resourceUri: CARD_URI(CARD_OF_TOOL[t.name]!) } } }),
5758 })),
58 { name: REPORT_TOOL.name, description: REPORT_TOOL.description, inputSchema: REPORT_TOOL.inputSchema },
59 ...[REPORT_TOOL, START_TOOL].map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
5960 ];
6061 }
6162
@@ -277,11 +278,34 @@ export const REPORT_TOOL = {
277278 },
278279 } as const;
279280
281 /**
282 * `start`, the other tool open without a token (#10912): an agent with only
283 * the forge's URL gets an account, a repository, its app and a token; the
284 * same call as `POST /api/v1/start` (`actions/start.ts`).
285 */
286 export const START_TOOL = {
287 name: "start",
288 /** Its line in /llms.txt's `## MCP tools`; never sent to a client. */
289 doc: "`{ name, description? }`: an account, repo, live app and token; push to main to deploy; `claim` is for a person",
290 writes: true,
291 description: "Start building with no account: an account, a repo, a live app and a token in one call.",
292 inputSchema: {
293 type: "object",
294 properties: {
295 name: { type: "string", maxLength: 40 },
296 description: { type: "string", maxLength: 2000 },
297 agent: { type: "string", maxLength: 64 },
298 code: { type: "string", maxLength: 100 },
299 },
300 required: ["name"],
301 },
302 } as const;
303
280304 /** What a token-free tool does with its call: the JSON-RPC answer. One entry per name in TOKEN_FREE_TOOLS. */
281305 type FreeHandler = (ctx: Ctx, id: unknown, args: Args) => unknown;
282306
283307 /** The gate picks its handler here, so a name listed in TOKEN_FREE_TOOLS without one is caught by a test. */
284 export const TOKEN_FREE_HANDLERS: Readonly<Record<string, FreeHandler>> = { report: reportAnswer };
308 export const TOKEN_FREE_HANDLERS: Readonly<Record<string, FreeHandler>> = { report: reportAnswer, start: startAnswer };
285309
286310 /** The JSON-RPC answer to a token-free tool call, or undefined for any other message. */
287311 function reportCall(ctx: Ctx, message: unknown): unknown | undefined {
@@ -357,11 +381,26 @@ function reportAnswer(ctx: Ctx, id: unknown, args: Args): unknown {
357381 );
358382 }
359383
384 async function startAnswer(ctx: Ctx, id: unknown, args: Args): Promise<unknown> {
385 const text = (t: string, isError = false) => ({
386 jsonrpc: "2.0",
387 id,
388 result: { content: [{ type: "text", text: t }], ...(isError ? { isError } : {}) },
389 });
390 const started = await startBuilding(ctx.app, args, clientBucket(ctx.request), ctx.url.origin);
391 if (started.ok) return text(JSON.stringify(started.value));
392 const e = started.error;
393 return text(
394 `${e.error}: ${START_WORDS[e.error]}${e.retryAfter === undefined ? "" : ` (try again in ${e.retryAfter} seconds)`} ${START_HINT}`,
395 true,
396 );
397 }
398
360399 const NOT_HERE = (allow: string): Response =>
361400 new Response(null, { status: 405, headers: { allow } });
362401
363402 /** Every tool name the forge lists, admin-only ones included. */
364 export const toolNames = (): string[] => [...TOOLS.map((t) => t.name), REPORT_TOOL.name];
403 export const toolNames = (): string[] => [...TOOLS.map((t) => t.name), REPORT_TOOL.name, START_TOOL.name];
365404
366405 /** What each tool declares about itself, for the test that walks them all. */
367406 export const toolRegistry = () =>
@@ -383,7 +422,7 @@ export function mcpRoutes(router: Router<Handler>): void {
383422 ) {
384423 const message: unknown = await body(ctx.request);
385424 const reported = reportCall(ctx, message);
386 if (reported !== undefined) return json(reported);
425 if (reported !== undefined) return json(await reported);
387426 const answer = await rpcOne(ctx, user, message);
388427 if (answer === undefined) return new Response(null, { status: 202 });
389428 return json(answer);
@@ -397,7 +436,7 @@ export function mcpRoutes(router: Router<Handler>): void {
397436 413,
398437 );
399438 const reported = reportCall(ctx, message);
400 if (reported !== undefined) return json(reported);
439 if (reported !== undefined) return json(await reported);
401440 // The refusal keeps the OAuth challenge a client starts from, and names
402441 // the one thing a stranger can do without signing in.
403442 return new Response(
@@ -405,7 +444,7 @@ export function mcpRoutes(router: Router<Handler>): void {
405444 error: "unauthorized",
406445 hint: "Send `Authorization: Bearer <token>` with a token you hold, then repeat the call; without one, only the `report` tool answers.",
407446 message:
408 "send a forge token: Authorization: Bearer <token>. Without one, the tool `report` (tools/call, name \"report\", arguments {repo, title, body?, page?, agent?}) files a problem report on a public repository; see /llms.txt",
447 "send a forge token: Authorization: Bearer <token>. Without one, the tool `start` (tools/call, name \"start\", arguments {name, description?, agent?}) makes you an account, a repo and a live app with a token; and the tool `report` (tools/call, name \"report\", arguments {repo, title, body?, page?, agent?}) files a problem report on a public repository; see /llms.txt",
409448 report: {
410449 method: "tools/call",
411450 name: "report",
src/http/mcp_table.ts +2 −2
@@ -4,7 +4,7 @@
44 * description, and its bytes in `tools/list`. The doc holds it between two
55 * markers; test/mcp-table.test.ts fails, naming the tool, when they differ.
66 */
7 import { REPORT_TOOL, toolEntries } from "./mcp.ts";
7 import { REPORT_TOOL, START_TOOL, toolEntries } from "./mcp.ts";
88 import { TOOLS } from "./mcp/tools.ts";
99
1010 export const TABLE_BEGIN = "<!-- mcp-tools:begin (generated by src/http/mcp_table.ts; edit the code, then run `bun scripts/mcp-table.ts`) -->";
@@ -70,7 +70,7 @@ export function firstSentence(text: string): string {
7070
7171 export function toolRows(): ToolRow[] {
7272 const entries = toolEntries(TOOLS);
73 const schemas = new Map<string, unknown>([...TOOLS.map((t) => [t.name, t.inputSchema] as const), [REPORT_TOOL.name, REPORT_TOOL.inputSchema]]);
73 const schemas = new Map<string, unknown>([...TOOLS.map((t) => [t.name, t.inputSchema] as const), [REPORT_TOOL.name, REPORT_TOOL.inputSchema], [START_TOOL.name, START_TOOL.inputSchema]]);
7474 return entries.map((entry) => ({
7575 name: entry.name,
7676 doors: doorsOf(schemas.get(entry.name)),
src/http/server.ts +8 −3
@@ -17,6 +17,7 @@ import {
1717 import { secureFor } from "./cookie_secure.ts";
1818 import { refusedByDatabase, shownRefusal } from "./refusal.ts";
1919 import { NOT_A_MEMBER } from "../core/roles.ts";
20 import { startClaimAccountSubmit, startClaimPage, startClaimSubmit } from "../web/pages/start_claim.ts";
2021 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
2122 import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts";
2223 import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts";
@@ -31,7 +32,7 @@ import { adminDataPage, appDataPage, dataPage } from "../web/pages/data.ts";
3132 import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts";
3233 import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
3334 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
34 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
35 import { agentStartSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
3536 import { allFlowsPage } from "../web/pages/flows-all.ts";
3637 import { measureSubmit } from "./measure.ts";
3738 import { cspReportSubmit } from "./csp_report.ts";
@@ -480,7 +481,7 @@ import { viaGate } from "./via_gate.ts";
480481 import { checksRecheckRoutes } from "./checks_recheck.ts";
481482 import { feedRoutes } from "./feed.ts";
482483 import type { Database } from "bun:sqlite";
483 import { mcpRoutes, REPORT_TOOL } from "./mcp.ts";
484 import { mcpRoutes, REPORT_TOOL, START_TOOL } from "./mcp.ts";
484485 import { TOOLS } from "./mcp/tools.ts";
485486 import { setMcpTools } from "../web/llms_tools.ts";
486487 import { resourcePolicy } from "./corp.ts";
@@ -866,6 +867,9 @@ export function buildRouter(): Router<Handler> {
866867 router.add("GET", "/signin/parent", parentStart);
867868 router.add("GET", "/signin/parent/callback", parentCallback);
868869 router.add("GET", "/signup", signupPage);
870 router.add("GET", "/claim/start/:token", startClaimPage);
871 router.add("POST", "/claim/start/:token", startClaimSubmit);
872 router.add("POST", "/claim/start/:token/account", startClaimAccountSubmit);
869873 router.add("GET", "/signup/requested", requestedPage);
870874 router.add("GET", "/verify", verifyPage);
871875 router.add("POST", "/signup", signupSubmit);
@@ -1036,6 +1040,7 @@ export function buildRouter(): Router<Handler> {
10361040 router.add("POST", "/admin/limits", limitsSet);
10371041 router.add("POST", "/admin/quotas", quotasSet);
10381042 router.add("POST", "/admin/signup", signupSet);
1043 router.add("POST", "/admin/agent-start", agentStartSet);
10391044 router.add("POST", "/admin/invites", inviteMint);
10401045 router.add("POST", "/admin/invites/:id/revoke", inviteRevoke);
10411046
@@ -1150,7 +1155,7 @@ export function buildRouter(): Router<Handler> {
11501155 // reclaim a path under `/api/`.
11511156 // The forge as an MCP server: the studio's verbs for an assistant.
11521157 mcpRoutes(router);
1153 setMcpTools(TOOLS, [REPORT_TOOL]);
1158 setMcpTools(TOOLS, [REPORT_TOOL, START_TOOL]);
11541159 // The public API as OpenAPI, for any client that reads it.
11551160 openapiRoute(router);
11561161 oauthRoutes(router);
src/main.ts +6
@@ -4,6 +4,7 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { sweepStarts } from "./actions/start_sweep.ts";
78 import { scanNightly } from "./checks/scan.ts";
89 import { nightlyDue } from "./checks/scan_store.ts";
910 import { runModelReview } from "./actions/model_review.ts";
@@ -671,6 +672,10 @@ const followTimer = setInterval(
671672 60_000,
672673 );
673674 const staleTimer = setInterval(() => void during("asks", tickAsks), 120_000);
675 // Accounts an agent started and nobody claimed sleep after a week and are
676 // archived after a month (`actions/start_sweep.ts`, #10912): hourly, and on boot.
677 const startsTimer = setInterval(() => during("starts", () => void sweepStarts(app)), 3_600_000);
678 during("starts", () => void sweepStarts(app));
674679 void during("asks", tickAsks);
675680
676681 // Every start is counted, and one that follows a process that never said
@@ -898,6 +903,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
898903 clearInterval(reachTimer);
899904 clearInterval(syncTimer);
900905 clearInterval(staleTimer);
906 clearInterval(startsTimer);
901907 clearInterval(followTimer);
902908 stopClogTick();
903909 stopKeeper();
src/settings/agent_start.ts added +38
@@ -0,0 +1,38 @@
1 /**
2 * Whether an agent holding only the forge's URL may start here
3 * (`POST /api/v1/start`, #10912): `open`, `code` (a start call must bring an
4 * open invite link's token, spent with the account) or `off`. Stored in
5 * `forge_setting` under `agent_start`; every change is kept with who and when.
6 * Default `open` when nothing is stored.
7 */
8 import type { Database } from "bun:sqlite";
9 import { err, ok, type Result } from "../kernel/result.ts";
10 import { writeSetting } from "./limits.ts";
11
12 export const AGENT_START_MODES = ["open", "code", "off"] as const;
13 export type AgentStartMode = (typeof AGENT_START_MODES)[number];
14 export const AGENT_START_DEFAULT: AgentStartMode = "open";
15
16 const STORE_KEY = "agent_start";
17
18 export function parseAgentStart(raw: unknown): Result<AgentStartMode, "invalid_agent_start"> {
19 return typeof raw === "string" && (AGENT_START_MODES as readonly string[]).includes(raw)
20 ? ok(raw as AgentStartMode)
21 : err("invalid_agent_start");
22 }
23
24 export function getAgentStart(db: Database): AgentStartMode {
25 const row = db
26 .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1")
27 .get(STORE_KEY);
28 if (row === null) return AGENT_START_DEFAULT;
29 const parsed = parseAgentStart(row.value);
30 return parsed.ok ? parsed.value : AGENT_START_DEFAULT;
31 }
32
33 /** Set the mode and keep the change; the same mode again keeps nothing. */
34 export function setAgentStart(db: Database, value: AgentStartMode, actorId: string): void {
35 const from = getAgentStart(db);
36 if (from === value) return;
37 writeSetting(db, STORE_KEY, value, from, value, actorId);
38 }
src/tui/screens/claim_start.ts added +75
@@ -0,0 +1,75 @@
1 /**
2 * Taking an agent's work, in the terminal (#10912): what the claim page does
3 * for a person with the link the start call returned. `t` asks for the link,
4 * the screen says whose repositories it holds, and they move only after a
5 * confirm. It goes through `claimStart`, so the gate is the page's: a person's
6 * account, a link that is open; an agent is never offered it.
7 */
8 import { claimStart, CLAIM_WORDS, viewClaim } from "../../actions/claim.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import type { Session } from "../session.ts";
12 import { style } from "../term/style.ts";
13 import { truncate } from "../term/width.ts";
14 import { confirmScreen, promptScreen } from "./common.ts";
15 import type { PaletteEntry } from "./entries.ts";
16
17 /** The token of a claim link, whether the whole link or only its last part was typed. */
18 export const tokenOfLink = (typed: string): string => (typed.trim().split(/[?#]/)[0]!.split("/").filter((p) => p !== "").pop() ?? "").slice(0, 200);
19
20 export function claimStartScreen(f: Session, ctx: Ctx): Screen {
21 let note = "Paste the claim link an agent gave you. t asks for it.";
22
23 const offer = (typed: string): string | undefined | Promise<void> => {
24 const token = tokenOfLink(typed);
25 const seen = viewClaim(f.app, token);
26 if (seen === undefined) return CLAIM_WORDS.not_found;
27 if (seen.claimed) return CLAIM_WORDS.claimed;
28 // The prompt closes itself when this returns; the confirm goes on after it.
29 return Promise.resolve().then(() => ctx.push(
30 confirmScreen(ctx, screen, {
31 title: `Take ${seen.agent}'s work?`,
32 body: [
33 `${seen.repos.length} ${seen.repos.length === 1 ? "repository moves" : "repositories move"} to an organisation you own;`,
34 "the agent stays in it as a member who can still push.",
35 ...seen.repos.map((r) => ` ${r}`),
36 ],
37 actions: [{ label: "Take it" }, { label: "Cancel" }],
38 onChoose: (i) => {
39 if (i !== 0) return;
40 void claimStart(f.app, f.user, token).then((done) => {
41 note = done.ok
42 ? `Taken: ${done.value.repos.join(", ")} now belong to ${done.value.org}.${done.value.stayed.length === 0 ? "" : ` Not moved: ${done.value.stayed.join(", ")}.`}`
43 : CLAIM_WORDS[done.error];
44 ctx.invalidate();
45 });
46 },
47 }),
48 ));
49 };
50
51 const screen: Screen = {
52 title: "Take an agent's work",
53 keys: [binding(["t"], "enter the link")],
54 render(area, s) {
55 const c = ctx.theme.c;
56 s.text(area.x, area.y, truncate(note, area.w), style(c.mutedFg, c.bg), area.x + area.w);
57 },
58 key(e) {
59 if (e.ctrl || e.alt || e.key !== "t") return false;
60 ctx.push(promptScreen(ctx, screen, { title: "Claim link", label: "the link the agent gave you", onSubmit: offer }));
61 return true;
62 },
63 };
64 return screen;
65 }
66
67 /** A person's, as the claim page is: the link is the proof, the account is theirs. */
68 export const entry: PaletteEntry = {
69 id: "claim-start",
70 title: "Take an agent's work",
71 hint: "claim the repositories an agent started, from its one-time link",
72 after: ["invites", "repos"],
73 offered: (session) => session.user?.kind === "user",
74 run: (session, ctx) => ctx.push(claimStartScreen(session, ctx)),
75 };
src/tui/screens/entries.ts +2
@@ -41,6 +41,7 @@ import { entry as flowsAll } from "./flowsall.ts";
4141 import { entry as front } from "./front.ts";
4242 import { entry as forges } from "./forges.ts";
4343 import { entry as homePins } from "./home_pins.ts";
44 import { entry as claimStart } from "./claim_start.ts";
4445 import { entry as invites } from "./invites.ts";
4546 import { entry as limits } from "./limits.ts";
4647 import { entry as economics } from "./economics.ts";
@@ -136,6 +137,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
136137 agents,
137138 archived,
138139 invites,
140 claimStart,
139141 requests,
140142 bulk,
141143 subscriptions,
src/tui/screens/invites.ts +41 −4
@@ -6,12 +6,14 @@
66 * selected open one after a confirm (`y` revokes, `n` or esc keeps it). Both go through the actions, so the gate is theirs: an
77 * admin who is a person; anyone else sees nothing and mints nothing. The top
88 * line says who may sign up now, as MCP `signup` does; `s` picks open, invite
9 * or closed, shows from → to and changes it only after a confirm.
9 * or closed, shows from → to and changes it only after a confirm; `a` does the
10 * same for who may start an agent here (open, code or off, `Agents can start here`).
1011 */
1112
1213 import { inviteLink, list, mint, revoke } from "../../actions/invites.ts";
1314 import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts";
14 import { changeSignup, readSignup } from "../../actions/signup.ts";
15 import { changeAgentStart, changeSignup, readAgentStart, readSignup } from "../../actions/signup.ts";
16 import { AGENT_START_MODES, type AgentStartMode } from "../../settings/agent_start.ts";
1517 import { SIGNUP_MODES, type SignupMode } from "../../settings/signup.ts";
1618 import { DEFAULT_DAYS, MAX_DAYS, type Invite } from "../../core/invites.ts";
1719 import type { Ctx, Screen } from "../app/app.ts";
@@ -71,6 +73,27 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
7173 );
7274 };
7375
76 const agentStart = () => readAgentStart(f.app, f.user);
77
78 const planAgentStart = (to: AgentStartMode): void => {
79 const now = agentStart();
80 if (!now.ok || now.value === to) return;
81 const from = now.value;
82 ctx.push(
83 confirmScreen(ctx, screen, {
84 title: "Agents can start here",
85 body: [`Would set agent start from ${from} to ${to}.`],
86 actions: [{ label: "Change" }, { label: "Cancel" }],
87 onChoose: (i) => {
88 if (i !== 0) return;
89 const changed = changeAgentStart(f.app, f.user, to);
90 note = changed.ok ? `agent start: ${from} → ${changed.value}.` : "You cannot change agent start.";
91 ctx.invalidate();
92 },
93 }),
94 );
95 };
96
7497 const rows = (): ListRow[] =>
7598 items().map((i) => ({
7699 title: i.id,
@@ -86,7 +109,7 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
86109 binding(["j", "k"], "move cursor"),
87110 binding(["m"], "make a link"),
88111 binding(["x"], "revoke"),
89 ...(signup().ok ? [binding(["s"], "who may sign up")] : []),
112 ...(signup().ok ? [binding(["s"], "who may sign up"), binding(["a"], "agents can start here")] : []),
90113 ];
91114 },
92115 render(area, s) {
@@ -95,7 +118,7 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
95118 const sg = signup();
96119 const [top, head, body] = split(area, "v", [len(sg.ok ? 1 : 0), len(1), fill(1)]);
97120 if (sg.ok)
98 s.text(top!.x, top!.y, truncate(`signup: ${sg.value.signup}. s changes it.`, top!.w), style(c.fg, c.bg), top!.x + top!.w);
121 s.text(top!.x, top!.y, truncate(`signup: ${sg.value.signup}. s changes it; a, who may start an agent (${agentStart().ok ? (agentStart() as { value: string }).value : ""}).`, top!.w), style(c.fg, c.bg), top!.x + top!.w);
99122 const text =
100123 note !== ""
101124 ? note
@@ -153,6 +176,20 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
153176 onPick: ([m]) => plan(m as SignupMode),
154177 }),
155178 );
179 } else if (e.key === "a") {
180 const now = agentStart();
181 if (!now.ok) return false;
182 ctx.push(
183 pickerScreen(ctx, screen, {
184 title: "Agents can start here",
185 items: AGENT_START_MODES.map((m) => ({
186 id: m,
187 label: m,
188 ...(m === now.value ? { hint: "now" } : {}),
189 })),
190 onPick: ([m]) => planAgentStart(m as AgentStartMode),
191 }),
192 );
156193 } else return false;
157194 return true;
158195 },
src/web/pages/admin-limits.ts +34 −2
@@ -11,7 +11,8 @@ import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
1111 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
1212 import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts";
1313 import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts";
14 import { changeSignup, readSignup } from "../../actions/signup.ts";
14 import { changeAgentStart, changeSignup, readAgentStart, readSignup } from "../../actions/signup.ts";
15 import { AGENT_START_MODES } from "../../settings/agent_start.ts";
1516 import { SIGNUP_MEANS, SIGNUP_MODES } from "../../settings/signup.ts";
1617 import { QUOTA_KEYS } from "../../settings/quotas.ts";
1718 import type { Ctx } from "../../http/context.ts";
@@ -30,6 +31,7 @@ export function limitsPage(ctx: Ctx): Response {
3031 if (!listed.ok) return notFoundPage(ctx);
3132 const quotas = timePhase(ctx.request, "quotas", () => readQuotas(ctx.app, ctx.auth.user));
3233 const signup = timePhase(ctx.request, "signup", () => readSignup(ctx.app, ctx.auth.user));
34 const agentStart = readAgentStart(ctx.app, ctx.auth.user);
3335 const invites = timePhase(ctx.request, "invites", () => listInviteLinks(ctx.app, ctx.auth.user));
3436 const rendering = performance.now();
3537 const { limits, changes } = listed.value;
@@ -138,6 +140,20 @@ export function limitsPage(ctx: Ctx): Response {
138140 </dd>
139141 <dd class="status-act"></dd>
140142 </div>`;
143 const agentStartRow = html`<div class="status-row">
144 <dt>Agents can start here</dt>
145 <dd>
146 <form class="row-fields" method="post" action="/admin/agent-start">
147 <select class="input mono" name="agent_start" aria-label="Agents can start here">
148 ${AGENT_START_MODES.map(
149 (m) => html`<option value="${m}" ${agentStart.ok && agentStart.value === m ? "selected" : ""}>${m === "code" ? "invite code" : m}</option>`,
150 )}
151 </select>
152 ${btn("Save", { size: "sm", variant: "primary", label: "Save agent start" })}
153 </form>
154 </dd>
155 <dd class="status-act"></dd>
156 </div>`;
141157 const inviteRows = (invites.ok ? invites.value : []).map((i) =>
142158 row({
143159 lead: icon("activity"),
@@ -180,7 +196,7 @@ export function limitsPage(ctx: Ctx): Response {
180196 const body = html`<p class="small muted mb">
181197 Every limit you can hit, and why. A change applies to the next request that meets it; nothing needs a restart.
182198 </p>
183 <dl class="status-rows">${signupRow}${inviteRow}${quotaRow}</dl>
199 <dl class="status-rows">${signupRow}${agentStartRow}${inviteRow}${quotaRow}</dl>
184200 ${historyBlock("Invite links made", inviteRows)}
185201 <section class="settings-block mt">
186202 <h2>Limits</h2>
@@ -287,6 +303,22 @@ export async function signupSet(ctx: Ctx): Promise<Response> {
287303 );
288304 }
289305
306 // POST /admin/agent-start
307 export async function agentStartSet(ctx: Ctx): Promise<Response> {
308 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
309 const form = await readForm(ctx, "admin");
310 if (form instanceof Response) return form;
311 const changed = changeAgentStart(ctx.app, ctx.auth.user, field(form, "agent_start"));
312 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
313 return flashTo(
314 ctx,
315 "/admin/limits",
316 changed.ok
317 ? { message: "Agent start saved." }
318 : { tone: "danger", message: "Agents can start here: open, invite code or off." },
319 );
320 }
321
290322 // POST /admin/invites
291323 export async function inviteMint(ctx: Ctx): Promise<Response> {
292324 if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent")
src/web/pages/start_claim.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * `/claim/start/:token` (#10912): the one-time link the start call returned. A
3 * person opens it, signs in or makes an account on the spot, and takes the
4 * agent's account and repositories (`actions/claim.ts`). The link is the
5 * proof of the claim, not of the right to join: the form that makes an
outdated

HIGH (security): POST /claim/start/:token/account makes a person account with no invite, whatever the signup setting says. Anyone can mint a claim link from the open start call, so a forge with signup closed or invite-only can be joined by anyone through start then claim. Fix: make the account only when signupMode allows it (apply the same admission as /signup), or refuse the account form and require an already signed-in person. Add a test with signup closed.

6 * account is offered, and works, only where `/signup` would let a stranger in
7 * (`signupClosed`, and not a passkey-only forge); otherwise the person signs in.
8 */
9 import { claimStart, CLAIM_WORDS, viewClaim } from "../../actions/claim.ts";
10 import { noEmail, plausibleEmail, signupClosed } from "../../actions/users.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import { createSession } from "../../core/sessions.ts";
13 import { createUser } from "../../core/users.ts";
14 import type { Ctx } from "../../http/context.ts";
15 import { notFoundPage, pageHead } from "../chrome.ts";
16 import { field, readForm } from "../form.ts";
17 import { html } from "../html.ts";
18 import { flashTo, page, redirect } from "../layout.ts";
19 import { btn, field as formField, icon, row } from "../ui.ts";
20 import { sessionCookie } from "./site.ts";
21
22 /** Whether a stranger may make an account here: what `/signup` allows, with no invite. */
23 const mayMakeAccount = (ctx: Ctx): boolean => !signupClosed(ctx.app) && ctx.app.config.passkeyOnlySignup !== true;
24
25 const tokenOf = (ctx: Ctx): string => ctx.params["token"] ?? "";
26
27 function withNoStore(response: Response): Response {
28 response.headers.set("cache-control", "no-store");
29 return response;
30 }
31
32 // GET /claim/start/:token
33 export function startClaimPage(ctx: Ctx, errors: { username?: string; password?: string; email?: string } = {}, status = 200): Response {
34 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
35 const token = tokenOf(ctx);
36 const seen = viewClaim(ctx.app, token);
37 if (seen === undefined) return notFoundPage(ctx);
38 const person = ctx.auth.user?.kind === "user" ? ctx.auth.user : undefined;
39 const here = `/claim/start/${encodeURIComponent(token)}`;
40 const body = html`<div class="narrow stack">
41 ${pageHead("Take this agent's work")}
42 ${seen.claimed
43 ? html`<p>${CLAIM_WORDS.claimed}</p>`
44 : html`<p>
45 An agent named <strong>${seen.agent}</strong> started building here without an account. Taking it moves
46 ${seen.repos.length === 1 ? "its repository" : `its ${seen.repos.length} repositories`} to an organisation
47 you own, with the agent as a member who can still push. Nothing else changes.
48 </p>
49 <ul class="list">${seen.repos.map((r) => row({ lead: icon("repo"), title: html`<span class="mono">${r}</span>` }))}</ul>
50 ${person !== undefined
51 ? html`<form method="post" action="${here}">
52 ${btn("Take it", { variant: "primary", label: "Take this agent's work" })}
53 </form>`
54 : html`<p><a href="/signin?next=${encodeURIComponent(here)}">Sign in</a> to take it${mayMakeAccount(ctx) ? ", or make an account:" : "."}</p>
55 ${!mayMakeAccount(ctx) ? null : html`<form method="post" action="${here}/account" class="stack">
56 ${formField({ label: "Username", name: "username", autocomplete: "username", required: true, pattern: "[A-Za-z0-9][A-Za-z0-9._-]*", ...(errors.username ? { error: errors.username } : {}) })}
57 ${formField({ label: "Email", name: "email", type: "email", autocomplete: "email", hint: "Optional.", ...(errors.email ? { error: errors.email } : {}) })}
58 ${formField({ label: "Password", name: "password", type: "password", autocomplete: "new-password", required: true, minlength: 12, hint: "At least 12 characters.", ...(errors.password ? { error: errors.password } : {}) })}
59 ${btn("Make an account and take it", { variant: "primary", label: "Make an account and take it" })}
60 </form>`}`}`}
61 </div>`;
62 return withNoStore(page({ ctx, title: "Claim", body }, status));
63 }
64
65 /** The words a refused claim says. */
66 const refused = (ctx: Ctx, error: keyof typeof CLAIM_WORDS): Response =>
67 error === "not_found" ? notFoundPage(ctx) : flashTo(ctx, `/claim/start/${encodeURIComponent(tokenOf(ctx))}`, { tone: "danger", message: CLAIM_WORDS[error] });
68
69 // POST /claim/start/:token — a signed-in person takes it
70 export async function startClaimSubmit(ctx: Ctx): Promise<Response> {
71 const form = await readForm(ctx);
72 if (form instanceof Response) return form;
73 const done = await claimStart(ctx.app, ctx.auth.user, tokenOf(ctx));
74 if (!done.ok) return refused(ctx, done.error);
75 return flashTo(ctx, `/${done.value.org}`, { message: `Taken: ${done.value.repos.join(", ")} now belong to ${done.value.org}.${done.value.stayed.length === 0 ? "" : ` Not moved, still the agent's: ${done.value.stayed.join(", ")}.`}` });
76 }
77
78 // POST /claim/start/:token/account — a stranger makes an account and takes it
79 export async function startClaimAccountSubmit(ctx: Ctx): Promise<Response> {
80 const form = await readForm(ctx);
81 if (form instanceof Response) return form;
82 const token = tokenOf(ctx);
83 const seen = viewClaim(ctx.app, token);
84 if (seen === undefined) return notFoundPage(ctx);
85 if (seen.claimed) return refused(ctx, "claimed");
86 if (!mayMakeAccount(ctx)) return flashTo(ctx, `/claim/start/${encodeURIComponent(token)}`, { tone: "danger", message: "New accounts are not open here: sign in to take it." });
87 const username = field(form, "username").trim();
88 const given = field(form, "email").trim();
89 if (given !== "" && !plausibleEmail(given)) return startClaimPage(ctx, { email: "That does not look like an email address." }, 422);
90 const made = await createUser(ctx.app.db, {
91 origin: "claimer",
92 username,
93 email: given === "" ? noEmail(username) : given,
94 password: field(form, "password"),
95 ...(given === "" ? { trusted: true } : {}),
96 });
97 if (!made.ok) {
98 const said = {
99 invalid_username: { username: "Usernames are letters, digits, dots, dashes." },
100 username_taken: { username: "That username is taken." },
101 username_reserved: { username: "That username is reserved." },
102 email_taken: { email: "That email already has an account: sign in instead." },
103 weak_password: { password: "Passwords need at least 12 characters." },
104 password_too_long: { password: "That password is too long." },
105 signup_closed: { username: "Accounts cannot be made here." },
106 }[made.error];
107 return startClaimPage(ctx, said, 422);
108 }
109 const done = await claimStart(ctx.app, made.value, token);
110 if (!done.ok) return refused(ctx, done.error);
111 const session = createSession(ctx.app.db, made.value.id);
112 return redirect(`/${done.value.org}`, sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url));
113 }
src/web/standards.ts +4 −3
@@ -423,12 +423,13 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
423423 "",
424424 "## Start here",
425425 "",
426 "If you are an assistant helping a person: say plainly what you can and cannot do from where you are. If you cannot reach this forge or hold an account on it, give the person these steps to do by hand.",
426 "If you are an assistant helping a person: say plainly what you can and cannot do from here. If you cannot reach this forge, give the person these steps.",
427427 "",
428 `- Help build a public app here: each one is a repository at ${o}/{owner}/{name}. A person makes an account at ${o}/signup, then follows "Offer a change" below. By request, POST ${o}/api/v1/users gives 202 {state:"waiting"}; sign in to see. If /signup says 403, use "Report a problem" below, or run your own forge.`,
428 `- Build now, no account: \`curl -sX POST ${o}/api/v1/start -d '{"name":"my-app"}'\` -> {user, token, repo, git, app, claim}. Clone git.url (user:token@), \`git push\` to main: app.url is live. Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.`,
429 `- Or with an account: sign up at ${o}/signup, then follow "Offer a change" below. By request, POST ${o}/api/v1/users gives 202 {state:"waiting"}. If /signup says 403, use "Report a problem".`,
429430 `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, free within limits while hosting is not sold: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`,
430431 `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`,
431 `- Connect an assistant that can use tools: add a custom connector with the URL ${mcp.url}. It signs in with OAuth; only the report tool works without an account.`,
432 `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`,
432433 "",
433434 "## Report a problem (no account)",
434435 "",
test/llms-anonymous.test.ts +8 −7
@@ -43,25 +43,26 @@ const section = (body: string): string[] => {
4343 return lines.slice(start + 1, end).filter((l) => l.startsWith("- "));
4444 };
4545
46 test("the anonymous text is small, varies by reader, and lists report plus one line", async () => {
46 test("the anonymous text is small, varies by reader, and lists report and start plus one line", async () => {
4747 const res = await fetch(`${servers.url}/llms.txt`);
4848 const body = await res.text();
4949 expect(Buffer.byteLength(body)).toBeLessThan(6 * 1024);
5050 expect(res.headers.get("vary")).toContain("Cookie");
5151 expect(res.headers.get("vary")).toContain("Authorization");
5252 const listed = section(body);
53 expect(listed).toHaveLength(2);
53 expect(listed).toHaveLength(3);
5454 expect(listed[0]).toStartWith("- report: ");
55 expect(listed[1]).toContain(`${TOOLS.length} more tools with an account`);
56 expect(listed[1]).toContain("tools/list");
57 expect(listed[1]).toContain("/contract/openapi.json");
55 expect(listed[1]).toStartWith("- start: ");
56 expect(listed[2]).toContain(`${TOOLS.length} more tools with an account`);
57 expect(listed[2]).toContain("tools/list");
58 expect(listed[2]).toContain("/contract/openapi.json");
5859 });
5960
6061 test("every tool the anonymous text names is one tools/list returns", async () => {
6162 const names = section(await fetch(`${servers.url}/llms.txt`).then((r) => r.text()))
6263 .map((l) => /^- ([a-z_]+): /.exec(l)?.[1])
6364 .filter((n): n is string => n !== undefined);
64 expect(names).toEqual(["report"]);
65 expect(names).toEqual(["report", "start"]);
6566 const returned: string[] = [];
6667 let cursor: string | undefined;
6768 do {
@@ -80,5 +81,5 @@ test("a signed-in reader still gets every tool and report", async () => {
8081 const body = await fetch(`${servers.url}/llms.txt`, {
8182 headers: { authorization: `Bearer ${token}` },
8283 }).then((r) => r.text());
83 expect(section(body)).toHaveLength(TOOLS.length + 1);
84 expect(section(body)).toHaveLength(TOOLS.length + 2);
8485 });
test/llms-tools.test.ts +3 −3
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp } from "../src/app.ts";
77 import { serve, type Servers } from "../src/http/server.ts";
8 import { REPORT_TOOL } from "../src/http/mcp.ts";
8 import { REPORT_TOOL, START_TOOL } from "../src/http/mcp.ts";
99 import { TOOLS } from "../src/http/mcp/tools.ts";
1010 import { createToken } from "../src/core/tokens.ts";
1111 import { createUser } from "../src/core/users.ts";
@@ -45,9 +45,9 @@ describe("/llms.txt MCP tools", () => {
4545 expect(start).toBeGreaterThan(-1);
4646 const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
4747 const listed = lines.slice(start + 1, end).filter((l) => l.startsWith("- "));
48 const expected = [...TOOLS, REPORT_TOOL].map((t: Listed) => {
48 const expected = [...TOOLS, REPORT_TOOL, START_TOOL].map((t: Listed) => {
4949 const admin = t.adminOnly === true || t.needsAdminScope === true ? " (admin)" : "";
50 return `- ${t.name}: ${(t.doc ?? t.description).replace(/\s+/g, " ").trim()}${admin}${t.writes ? " (writes)" : ""}${t.name === "report" ? " (no account)" : ""}`;
50 return `- ${t.name}: ${(t.doc ?? t.description).replace(/\s+/g, " ").trim()}${admin}${t.writes ? " (writes)" : ""}${t.name === "report" || t.name === "start" ? " (no account)" : ""}`;
5151 });
5252 expect(listed).toEqual(expected);
5353 expect(TOOLS.some((t) => t.writes)).toBe(true);
test/mcp-card.test.ts +1 −1
@@ -35,7 +35,7 @@ describe("the MCP server card", () => {
3535 expect(card.version).toBe(running().version);
3636 expect(card.transport).toBe("streamable-http");
3737 expect(card.endpoint).toBe(`${base}/mcp`);
38 expect(card.tokenFreeTools).toEqual(["report"]);
38 expect(card.tokenFreeTools).toEqual(["report", "start"]);
3939
4040 // Without a token the endpoint refuses everything but `report`.
4141 const report = await fetch(card.endpoint, {
test/mcp-table.test.ts +1 −1
@@ -89,5 +89,5 @@ test("the bytes are those of an admin's real tools/list response", async () => {
8989 }, 30_000);
9090
9191 test("the entry builder is the one tools/list uses", () => {
92 expect(toolEntries(TOOLS).length).toBe(TOOLS.length + 1);
92 expect(toolEntries(TOOLS).length).toBe(TOOLS.length + 2);
9393 });
test/mcp-tools.test.ts +5 −5
@@ -10,7 +10,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import { indexText, toolFiles } from "../scripts/mcp-tools.ts";
13 import { REPORT_TOOL, toolNames } from "../src/http/mcp.ts";
13 import { REPORT_TOOL, START_TOOL, toolNames } from "../src/http/mcp.ts";
1414 import { byName } from "../src/http/mcp/shared.ts";
1515 import { TOOLS } from "../src/http/mcp/tools.ts";
1616 import { toolsSection } from "../src/web/llms_tools.ts";
@@ -43,7 +43,7 @@ describe("the gathered tools", () => {
4343 test("are the files, sorted, one each, and `report` beside them", () => {
4444 expect(TOOLS.map((t) => t.name)).toEqual(files);
4545 expect(files).not.toContain("report");
46 expect(toolNames()).toEqual([...files, "report"]);
46 expect(toolNames()).toEqual([...files, "report", "start"]);
4747 });
4848
4949 test("the index is current: run `bun scripts/mcp-tools.ts`", () => {
@@ -66,12 +66,12 @@ describe("the gathered tools", () => {
6666
6767 describe("the live tool list", () => {
6868 test("every tool's `doc` is its line in /llms.txt's tools section", () => {
69 const section = toolsSection([...TOOLS, REPORT_TOOL]);
70 for (const t of [...TOOLS, REPORT_TOOL]) {
69 const section = toolsSection([...TOOLS, REPORT_TOOL, START_TOOL]);
70 for (const t of [...TOOLS, REPORT_TOOL, START_TOOL]) {
7171 const line = section.find((l) => l.startsWith(`- ${t.name}: `));
7272 expect([t.name, line?.includes(t.doc ?? t.description) ?? false]).toEqual([t.name, true]);
7373 }
74 expect(section.filter((l) => l.startsWith("- ")).length).toBe(TOOLS.length + 1);
74 expect(section.filter((l) => l.startsWith("- ")).length).toBe(TOOLS.length + 2);
7575 });
7676
7777 test("a pull adding a tool file and its index line needs no docs file", async () => {
test/mcp.test.ts +3 −1
@@ -37,8 +37,9 @@ import { listVia } from "./mcp-list.ts";
3737
3838 /** tools/list's order: the tools by name, then `report`. */
3939 const sortedThenReport = (names: string[]): string[] => [
40 ...names.filter((n) => n !== "report").sort((a, b) => a.localeCompare(b)),
40 ...names.filter((n) => n !== "report" && n !== "start").sort((a, b) => a.localeCompare(b)),
4141 "report",
42 "start",
4243 ];
4344
4445 describe("the forge over MCP", () => {
@@ -384,6 +385,7 @@ describe("the forge over MCP", () => {
384385 "pins",
385386 "board_delete",
386387 "report",
388 "start",
387389 ]));
388390 const byName = new Map<string, any>(
389391 listed.body.result.tools.map((t: any) => [t.name, t]),
test/no-polling.test.ts +1
@@ -309,6 +309,7 @@ const NOT_WALKED: Record<string, string> = {
309309 "/boards/:slug": "needs a board",
310310 "/boards/:slug/live": "needs a board; a stream, not a page",
311311 "/bring/:id/:job": "needs a bring job",
312 "/claim/start/:token": "needs the one-time token an anonymous start returned",
312313 "/fleet/ops/:id": "needs a fleet operation",
313314 "/go/:code": "needs a short code minted by a share",
314315 "/home/archived/:op": "needs an archived-home operation",
test/pages-budget.test.ts +4
@@ -19,6 +19,7 @@ import { createApp, type App } from "../src/app.ts";
1919 import { createIssue } from "../src/core/issues.ts";
2020 import { pin } from "../src/core/pins.ts";
2121 import { getRepo } from "../src/core/repos.ts";
22 import { recordStart } from "../src/core/starts.ts";
2223 import { createUser, type User } from "../src/core/users.ts";
2324 import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts";
2425 import { quietLogs } from "./helpers/globals.ts";
@@ -54,6 +55,7 @@ const FIXTURE: Record<string, string> = {
5455 "/:owner/:name/pulls/:number": "/alice/forge/pulls/2",
5556 "/:owner/:name/display": "/alice/forge/display",
5657 "/api/v1/admin/machines/:machine/disks": "/api/v1/admin/machines/rack1/disks",
58 "/claim/start/:token": "/claim/start/claim_fixture",
5759 };
5860
5961 function registeredGets(): Set<string> {
@@ -130,6 +132,8 @@ beforeAll(async () => {
130132 const pull = createIssue(app.db, { repoId: forge.id, authorId: bob.id, title: "A pull", body: "body", kind: "pull" });
131133 if (!pull.ok) throw new Error(pull.error);
132134 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'topic', '', 'h')", [pull.value.id]);
135 // An agent's start the claim page can show (`/claim/start/:token`): bob's, so no extra account joins the walk.
136 recordStart(app.db, bob.id, "claim_fixture", "127.0.0.1");
133137 servers = await serve(app);
134138 const signin = await fetch(`${servers.url}/signin`, {
135139 method: "POST",
test/palette-roster.test.ts +2
@@ -145,6 +145,7 @@ test("a site-admin person is offered the whole roster", () => {
145145 ["teams", "Crew teams", "each team's charter, dial and members"],
146146 ["agents", "Crew agents", "each agent's model, budget and brief"],
147147 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
148 ["claim-start", "Take an agent's work", "claim the repositories an agent started, from its one-time link"],
148149 ["requests", "Waiting", "strangers who asked for an account: allow or decline"],
149150 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
150151 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
@@ -315,6 +316,7 @@ test("a member person is offered the signed-in roster without the admin entries"
315316 ["bugs", "Bugs", "open bugs and the bug clock"],
316317 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
317318 ["archived", "Archived repositories", "put away, not gone: open one or restore it"],
319 ["claim-start", "Take an agent's work", "claim the repositories an agent started, from its one-time link"],
318320 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
319321 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
320322 ["origins", "Connected accounts", "the hosts your mirrors come from"],
test/security-floor-doors.test.ts +1 −1
@@ -92,7 +92,7 @@ test("each return of the public door and of `answered` is floored", () => {
9292 // `response` is only ever what `answered` gave back.
9393 for (const m of region.matchAll(/\bresponse\s*=\s*([^\n;]+)/g)) expect(m[1]!.trim()).toMatch(/^await answered\(/);
9494 // `answered` floors what the work returns, and its failure path floors too.
95 expect(region).toContain("const done = await compress(request, response).then(securityFloor)");
95 expect(region).toMatch(/const done = await compress\(request, response\)\.then\(\(r\) => securityFloor\(/);
9696 expect(server).toMatch(/function failure\([^]*?\n}\n/);
9797 const failure = server.match(/function failure\([^]*?\n}\n/)![0];
9898 for (const r of failure.matchAll(/\breturn\s+([^\n]+)/g)) expect(r[1]!.trim()).toMatch(/^securityFloor\(/);
test/security-floor-routes.test.ts +1
@@ -60,6 +60,7 @@ const SHOT = "needs a pin with a picture kept by refs/shots; the seed pins nothi
6060 const CART = "the seed pins no build with a cart (test/build-cart.test.ts covers its headers)";
6161 const UNSEEDED: Record<string, string> = {
6262 "/:owner/-/teams/:team": ORG,
63 "/claim/start/:token": "needs a one-time start claim token, which the seed does not mint (test/start.test.ts covers it)",
6364 "/talk/:id": TALK,
6465 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
6566 "/fleet/ops/:id": FLEET,
test/signup-gate-parent.test.ts +2
@@ -113,6 +113,7 @@ test("every caller of createUser says where its account comes from", async () =>
113113 "src/actions/crew.ts": ["agent"],
114114 "src/actions/first_admin.ts": ["first-admin"],
115115 "src/actions/spawn.ts": ["spawn", "spawn"],
116 "src/actions/start.ts": ["start", "start"],
116117 "src/actions/users.ts": ["signup", "passkey"],
117118 "src/chaos/world.ts": ["seed"],
118119 "src/packs/mirror.ts": ["seed"],
@@ -120,5 +121,6 @@ test("every caller of createUser says where its account comes from", async () =>
120121 "src/spawn/preview-seed.ts": ["seed", "seed"],
121122 "src/web/pages/claim.ts": ["claim"],
122123 "src/web/pages/everyday.ts": ["everyday"],
124 "src/web/pages/start_claim.ts": ["claimer"],
123125 });
124126 });
test/start-claim.test.ts added +110
@@ -0,0 +1,110 @@
1 /**
2 * The start door's guards (#10912): the claim page's account form follows the
3 * forge's signup gate, a repository that will not move gives the link back, and
4 * concurrent starts cannot overshoot the per-source cap.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { claimStart } from "../src/actions/claim.ts";
11 import { PER_SOURCE_PER_HOUR, startBuilding } from "../src/actions/start.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { startByClaim } from "../src/core/starts.ts";
14 import { createUser, getUserByName, type User } from "../src/core/users.ts";
15 import { repoDir } from "../src/git/repos.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { setSignup } from "../src/settings/signup.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 let root = "";
23 let app: App;
24 let servers: Servers;
25 let alice: User;
26
27 beforeAll(async () => {
28 root = mkdtempSync(join(tmpdir(), "forge-start-claim-"));
29 app = createApp({ dataDir: root, port: 0 });
30 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
31 if (!made.ok) throw new Error(made.error);
32 alice = made.value;
33 servers = await serve(app);
34 }, 30_000);
35 afterAll(() => {
36 servers.stop();
37 app.close();
38 rmSync(root, { recursive: true, force: true });
39 });
40
41 async function start(name: string, source: string) {
42 const made = await startBuilding(app, { name }, source, servers.url);
43 if (!made.ok) throw new Error(made.error.error);
44 return { ...made.value, claimToken: made.value.claim.url.split("/").pop()! };
45 }
46
47 test("signup closed: the claim page offers no account form and the account form makes nothing", async () => {
48 const s = await start("closed-door", "203.0.113.1");
49 setSignup(app.db, "closed", alice.id);
50 try {
51 const page = await (await fetch(s.claim.url)).text();
52 expect(page).not.toContain("/account");
53 expect(page).toContain("/signin?next=");
54 const posted = await fetch(`${s.claim.url}/account`, {
55 method: "POST",
56 redirect: "manual",
57 headers: { origin: servers.url },
58 body: new URLSearchParams({ username: "intruder", email: "", password: "a-friendly-passphrase" }),
59 });
60 expect(posted.status).toBe(303);
61 expect(posted.headers.get("set-cookie") ?? "").not.toContain("forge_session");
62 expect(getUserByName(app.db, "intruder")).toBeUndefined();
63 expect(startByClaim(app.db, s.claimToken)!.claimedAt).toBeNull();
64 } finally {
65 setSignup(app.db, "open", alice.id);
66 }
67 });
68
69 test("signup invite-only: the account form is closed too, without an invite", async () => {
70 const s = await start("invite-door", "203.0.113.2");
71 setSignup(app.db, "invite", alice.id);
72 try {
73 await fetch(`${s.claim.url}/account`, {
74 method: "POST",
75 redirect: "manual",
76 headers: { origin: servers.url },
77 body: new URLSearchParams({ username: "intruder2", email: "", password: "a-friendly-passphrase" }),
78 });
79 expect(getUserByName(app.db, "intruder2")).toBeUndefined();
80 } finally {
81 setSignup(app.db, "open", alice.id);
82 }
83 });
84
85 test("a repository that will not move gives the link back, and nothing is woken or renamed", async () => {
86 const s = await start("stuck", "203.0.113.3");
87 const person = await createUser(app.db, { origin: "seed", trusted: true, username: "friend", email: "[email protected]", password: "a-friendly-passphrase" });
88 if (!person.ok) throw new Error(person.error);
89 // Where the repository would land is already taken.
90 mkdirSync(repoDir(app.reposRoot, `${s.user.username}-team`, "stuck"), { recursive: true });
91 const refused = await claimStart(app, person.value, s.claimToken);
92 expect(refused).toEqual({ ok: false, error: "failed" });
93 expect(startByClaim(app.db, s.claimToken)!.claimedAt).toBeNull();
94 // Once the place is free the same link works.
95 rmSync(repoDir(app.reposRoot, `${s.user.username}-team`, "stuck"), { recursive: true });
96 const taken = await claimStart(app, person.value, s.claimToken);
97 expect(taken.ok).toBe(true);
98 if (taken.ok) expect(taken.value.stayed).toEqual([]);
99 });
100
101 test("concurrent starts from one source take no more than the hourly cap", async () => {
102 const results = await Promise.all(
103 Array.from({ length: PER_SOURCE_PER_HOUR + 4 }, (_, i) => startBuilding(app, { name: `race-${i}` }, "203.0.113.9", servers.url)),
104 );
105 expect(results.filter((r) => r.ok).length).toBe(PER_SOURCE_PER_HOUR);
106 const refused = results.filter((r) => !r.ok);
107 expect(refused.every((r) => !r.ok && r.error.error === "rate_limited")).toBe(true);
108 // A refused start leaves no account behind.
109 expect(app.db.query("SELECT COUNT(*) AS n FROM user WHERE username LIKE 'race-%'").get()).toEqual({ n: PER_SOURCE_PER_HOUR });
110 });
test/start.test.ts added +222
@@ -0,0 +1,222 @@
1 /**
2 * An agent with only the forge's URL starts building in one call (#10912):
3 * the start call, a push that deploys, the limits, the claim, and the
4 * sleep/archive of what nobody claims. Against a scratch forge with its apps
5 * runtime, so the app really comes up.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { join } from "node:path";
9 import { claimStart } from "../src/actions/claim.ts";
10 import { startBuilding, PER_SOURCE_PER_HOUR } from "../src/actions/start.ts";
11 import { sweepStarts } from "../src/actions/start_sweep.ts";
12 import { getRepo, listReposOwnedBy } from "../src/core/repos.ts";
13 import { listRuns } from "../src/core/runs.ts";
14 import { getUserByName } from "../src/core/users.ts";
15 import { heldManifest, isUnclaimed, SMALL_APP } from "../src/core/starts.ts";
16 import { mintInvite } from "../src/core/invites.ts";
17 import { setAgentStart } from "../src/settings/agent_start.ts";
18 import { gitClientEnv } from "./helpers/git-client.ts";
19 import { deploySettled, fetchAsHost, startAppsForge, stopAppsForge, type AppsForge } from "./helpers/apps-forge.ts";
20
21 const gitEnv = gitClientEnv({
22 GIT_AUTHOR_NAME: "Muse",
23 GIT_AUTHOR_EMAIL: "[email protected]",
24 GIT_COMMITTER_NAME: "Muse",
25 GIT_COMMITTER_EMAIL: "[email protected]",
26 });
27
28 let forge: AppsForge;
29 beforeAll(async () => {
30 forge = await startAppsForge();
31 }, 60_000);
32 afterAll(async () => {
33 await forge.runtime.stop();
34 await stopAppsForge(forge);
35 });
36
37 const post = (path: string, body: unknown, headers: Record<string, string> = {}, ip = "198.51.100.1") =>
38 fetch(`${forge.servers.url}${path}`, {
39 method: "POST",
40 headers: { "content-type": "application/json", "x-forwarded-for": ip, ...headers },
41 body: JSON.stringify(body),
42 });
43
44 async function git(args: string[], cwd: string): Promise<{ code: number; err: string }> {
45 const p = Bun.spawn(["git", ...args], { cwd, env: gitEnv, stdout: "ignore", stderr: "pipe" });
46 const err = await new Response(p.stderr).text();
47 return { code: await p.exited, err };
48 }
49
50 interface StartAnswer {
51 user: { username: string };
52 token: string;
53 repo: string;
54 git: { url: string; push: string };
55 app: { url: string | null };
56 claim: { url: string };
57 }
58
59 describe("start, push, live, claim", () => {
60 let started: StartAnswer;
61
62 test("one call with nothing but the URL makes the account, the repo, the app and a token", async () => {
63 const res = await post("/api/v1/start", { name: "Glint Notes", description: "a notes app", agent: "glint" });
64 expect(res.status).toBe(201);
65 expect(res.headers.get("cache-control")).toBe("no-store");
66 started = (await res.json()) as StartAnswer;
67 expect(started.token).toStartWith("forge_");
68 expect(started.repo).toMatch(/^glint-notes-[0-9a-f]+\/glint-notes$/);
69 expect(started.claim.url).toContain("/claim/start/claim_");
70 const repo = getRepo(forge.app.db, started.user.username, "glint-notes")!;
71 expect(repo.visibility).toBe("public");
72 // No crew, no password; held as a guest and unclaimed.
73 expect(forge.app.db.query("SELECT COUNT(*) AS n FROM repo_crew WHERE repo_id = ?1").get(repo.id)).toEqual({ n: 0 });
74 const user = getUserByName(forge.app.db, started.user.username)!;
75 expect(user.kind).toBe("user");
76 expect(isUnclaimed(forge.app.db, user.id)).toBe(true);
77 expect(forge.app.db.query("SELECT password_hash FROM user WHERE id = ?1").get(user.id)).toEqual({ password_hash: "" });
78 });
79
80 test("the token is scoped to that account: it reads its own account and nobody else's private repo", async () => {
81 const me = await fetch(`${forge.servers.url}/api/v1/user`, { headers: { authorization: `Bearer ${started.token}` } });
82 expect(me.status).toBe(200);
83 expect(((await me.json()) as { username: string }).username).toBe(started.user.username);
84 const admin = await fetch(`${forge.servers.url}/api/v1/admin/limits`, { headers: { authorization: `Bearer ${started.token}` } });
85 expect([403, 404]).toContain(admin.status);
86 });
87
88 test("the app is live, and a push to main deploys the new commit", async () => {
89 const repo = getRepo(forge.app.db, started.user.username, "glint-notes")!;
90 const first = await deploySettled(forge.app, repo.id, (r) => r.env === "production");
91 expect(first.state).toBe("ok");
92 const slug = forge.runtime.hostFor("glint-notes", "production");
93 expect((await fetchAsHost(forge.servers, slug)).status).toBe(200);
94
95 const remote = started.git.url.replace("://", `://${started.user.username}:${started.token}@`);
96 const dir = join(forge.work, "clone");
97 expect((await git(["clone", "-q", remote, dir], forge.work)).code).toBe(0);
98 await Bun.write(join(dir, "NOTES.md"), "pushed by an agent\n");
99 await git(["-C", dir, "add", "."], forge.work);
100 await git(["-C", dir, "commit", "-q", "-m", "an agent's change"], forge.work);
101 const pushed = await git(["-C", dir, "push", "-q", "origin", "HEAD:main"], forge.work);
102 expect(pushed.err).toBe("");
103 expect(pushed.code).toBe(0);
104 const head = (await Bun.spawn(["git", "-C", dir, "rev-parse", "HEAD"], { env: gitEnv, stdout: "pipe" }).stdout.text()).trim();
105 const deadline = Date.now() + 60_000;
106 let second = listRuns(forge.app.db, repo.id, { kind: "deploy", limit: 20 }).find((r) => r.commitOid === head && r.env === "production");
107 while (second === undefined && Date.now() < deadline) {
108 await Bun.sleep(25);
109 second = listRuns(forge.app.db, repo.id, { kind: "deploy", limit: 20 }).find((r) => r.commitOid === head && r.env === "production");
110 }
111 expect(second).toBeDefined();
112 }, 120_000);
113
114 test("a person opens the claim link, makes an account, and the repo and the agent are theirs", async () => {
115 const page = await fetch(started.claim.url);
116 expect(page.status).toBe(200);
117 expect(page.headers.get("cache-control")).toBe("no-store");
118 expect(await page.text()).toContain("glint");
119 const made = await fetch(`${started.claim.url}/account`, {
120 method: "POST",
121 redirect: "manual",
122 body: new URLSearchParams({ username: "friend", email: "", password: "a-friendly-passphrase" }),
123 });
124 expect(made.status).toBe(303);
125 expect(made.headers.get("set-cookie")).toContain("HttpOnly");
126 const org = `${started.user.username}-team`;
127 expect(made.headers.get("location")).toBe(`/${org}`);
128 const moved = getRepo(forge.app.db, org, "glint-notes")!;
129 expect(moved).toBeDefined();
130 expect(isUnclaimed(forge.app.db, getUserByName(forge.app.db, started.user.username)!.id)).toBe(false);
131 // The agent is a member with write: its token still pushes.
132 const remote = started.git.url.replace(`/${started.user.username}/`, `/${org}/`).replace("://", `://${started.user.username}:${started.token}@`);
133 const dir = join(forge.work, "clone2");
134 expect((await git(["clone", "-q", remote, dir], forge.work)).code).toBe(0);
135 await Bun.write(join(dir, "MORE.md"), "still writing\n");
136 await git(["-C", dir, "add", "."], forge.work);
137 await git(["-C", dir, "commit", "-q", "-m", "again"], forge.work);
138 expect((await git(["-C", dir, "push", "-q", "origin", "HEAD:main"], forge.work)).code).toBe(0);
139 // The link is burnt.
140 const again = await claimStart(forge.app, getUserByName(forge.app.db, "friend"), started.claim.url.split("/").pop()!);
141 expect(again).toEqual({ ok: false, error: "claimed" });
142 }, 120_000);
143 });
144
145 describe("the defenses", () => {
146 test("a bad body is refused at the boundary, and a page on another site cannot call it", async () => {
147 expect((await post("/api/v1/start", {}, {}, "198.51.100.20")).status).toBe(422);
148 expect((await post("/api/v1/start", { name: "x".repeat(41) }, {}, "198.51.100.20")).status).toBe(422);
149 expect((await post("/api/v1/start", { name: "ok", agent: "bad name!" }, {}, "198.51.100.20")).status).toBe(422);
150 expect((await post("/api/v1/start", { name: "ok", description: 5 }, {}, "198.51.100.20")).status).toBe(422);
151 const cross = await post("/api/v1/start", { name: "ok" }, { origin: "https://evil.example" }, "198.51.100.20");
152 expect(cross.status).toBe(403);
153 });
154
155 test("a few starts an hour from one source, then a wait; another source is not held", async () => {
156 for (let i = 0; i < PER_SOURCE_PER_HOUR; i++)
157 expect((await post("/api/v1/start", { name: `lim-${i}` }, {}, "198.51.100.30")).status).toBe(201);
158 const held = await post("/api/v1/start", { name: "lim-x" }, {}, "198.51.100.30");
159 expect(held.status).toBe(429);
160 expect(held.headers.get("retry-after")).toBe("3600");
161 expect((await post("/api/v1/start", { name: "lim-y" }, {}, "198.51.100.31")).status).toBe(201);
162 }, 60_000);
163
164 test("the setting: off refuses, a code asks for an open invite and spends it", async () => {
165 const admin = forge.alice;
166 setAgentStart(forge.app.db, "off", admin.id);
167 const off = await post("/api/v1/start", { name: "nope" }, {}, "198.51.100.40");
168 expect(off.status).toBe(403);
169 expect(((await off.json()) as { error: string }).error).toBe("off");
170 setAgentStart(forge.app.db, "code", admin.id);
171 expect((await post("/api/v1/start", { name: "nope" }, {}, "198.51.100.40")).status).toBe(403);
172 expect((await post("/api/v1/start", { name: "nope", code: "wrong" }, {}, "198.51.100.40")).status).toBe(403);
173 const invite = mintInvite(forge.app.db, admin.id, 7);
174 const ok = await post("/api/v1/start", { name: "coded", code: invite.token }, {}, "198.51.100.40");
175 expect(ok.status).toBe(201);
176 expect((await post("/api/v1/start", { name: "coded2", code: invite.token }, {}, "198.51.100.41")).status).toBe(403);
177 setAgentStart(forge.app.db, "open", admin.id);
178 }, 60_000);
179
180 test("the MCP `start` tool answers with no token, as the API does", async () => {
181 const res = await fetch(`${forge.servers.url}/mcp`, {
182 method: "POST",
183 headers: { "content-type": "application/json", accept: "application/json", "x-forwarded-for": "198.51.100.50" },
184 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "start", arguments: { name: "via-mcp" } } }),
185 });
186 const body = (await res.json()) as any;
187 const out = JSON.parse(body.result.content[0].text) as StartAnswer;
188 expect(out.token).toStartWith("forge_");
189 });
190
191 test("an unclaimed account's app runs small with no host but the registries; a claimed one is not touched", async () => {
192 const made = await startBuilding(forge.app, { name: "held" }, "198.51.100.60", "http://x");
193 if (!made.ok) throw new Error(made.error.error);
194 const owner = getUserByName(forge.app.db, made.value.user.username)!;
195 const declared: { egress: string[]; limits: { memoryMiB: number; cpus: number; diskMiB?: number } } = { egress: ["api.example.com"], limits: { memoryMiB: 4096, cpus: 8 } };
196 const held = heldManifest(forge.app.db, owner.id, declared);
197 expect(held.egress).toEqual([]);
198 expect(held.limits).toEqual({ memoryMiB: SMALL_APP.memoryMiB, cpus: SMALL_APP.cpus, diskMiB: SMALL_APP.diskMiB });
199 expect(heldManifest(forge.app.db, forge.alice.id, declared)).toBe(declared);
200 });
201
202 test("asleep after 7 days (token dead), archived after 30 (not deleted); a claim wakes it", async () => {
203 const made = await startBuilding(forge.app, { name: "idle" }, "198.51.100.70", "http://x");
204 if (!made.ok) throw new Error(made.error.error);
205 const owner = getUserByName(forge.app.db, made.value.user.username)!;
206 const day = 86_400_000;
207 expect(sweepStarts(forge.app, new Date(Date.now() + 6 * day)).slept).toBe(0);
208 const slept = sweepStarts(forge.app, new Date(Date.now() + 8 * day));
209 expect(slept.slept).toBeGreaterThan(0);
210 const gone = await fetch(`${forge.servers.url}/api/v1/user`, { headers: { authorization: `Bearer ${made.value.token}` } });
211 expect(gone.status).toBe(401);
212 sweepStarts(forge.app, new Date(Date.now() + 31 * day));
213 const repos = listReposOwnedBy(forge.app.db, owner.id);
214 expect(repos.length).toBe(1);
215 expect(repos[0]!.archivedAt).not.toBeNull();
216 const friend = getUserByName(forge.app.db, "friend")!;
217 const taken = await claimStart(forge.app, friend, made.value.claim.url.split("/").pop()!);
218 expect(taken.ok).toBe(true);
219 const moved = getRepo(forge.app.db, `${owner.username}-team`, "idle")!;
220 expect(moved.archivedAt).toBeNull();
221 });
222 });
test/tui-claim-start.test.ts added +64
@@ -0,0 +1,64 @@
1 /** Taking an agent's work in the terminal (#10912): the claim page's gate and effect. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { startBuilding } from "../src/actions/start.ts";
7 import { createApp, type App as Forge } from "../src/app.ts";
8 import { startByClaim } from "../src/core/starts.ts";
9 import { createUser, getUserByName } from "../src/core/users.ts";
10 import { createForgeApp } from "../src/tui/forge.ts";
11 import { tokenOfLink } from "../src/tui/screens/claim_start.ts";
12 import { entry } from "../src/tui/screens/claim_start.ts";
13 import { Session } from "../src/tui/session.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19 let dir = "";
20 let forge: Forge;
21
22 beforeAll(async () => {
23 dir = await mkdtemp(join(tmpdir(), "forge-tui-claim-"));
24 forge = createApp({ dataDir: dir, port: 0 });
25 const made = await createUser(forge.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
26 if (!made.ok) throw new Error(made.error);
27 });
28 afterAll(async () => {
29 forge.close();
30 await rm(dir, { recursive: true, force: true });
31 });
32
33 test("the token is the last part of a pasted link", () => {
34 expect(tokenOfLink(" https://f.test/claim/start/claim_abc?x=1 ")).toBe("claim_abc");
35 expect(tokenOfLink("claim_abc")).toBe("claim_abc");
36 });
37
38 test("a person pastes the link, confirms, and the repository is theirs; an agent is not offered it", async () => {
39 const made = await startBuilding(forge, { name: "term" }, "203.0.113.5", "http://f.test");
40 if (!made.ok) throw new Error(made.error.error);
41 const link = made.value.claim.url;
42 const bob = getUserByName(forge.db, "bob")!;
43 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: bob }, { terminal: headlessTerminal(140, 32), exitHooks: false });
44 app.start();
45 const k = (key: string) => app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
46 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
47 for (const ch of "Take an agent") k(ch);
48 k("enter");
49 await settleApp(app);
50 k("t");
51 for (const ch of link.split("/").pop()!) k(ch);
52 k("enter");
53 await settleApp(app);
54 expect(app.screen.toText()).toContain("term");
55 k("enter");
56 await settleApp(app);
57 await Bun.sleep(200);
58 await settleApp(app);
59 expect(app.screen.toText()).toContain("Taken");
60 expect(startByClaim(forge.db, link.split("/").pop()!)!.claimedBy).toBe(bob.id);
61 app.stop();
62 const agent = { ...bob, kind: "agent" } as typeof bob;
63 expect(entry.offered(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: agent }))).toBe(false);
64 });
test/tui-signup.test.ts +20
@@ -112,3 +112,23 @@ test("a non-admin and an admin agent are never offered s", async () => {
112112 const own = invitesScreen(admin, { push() {}, invalidate() {} } as never);
113113 expect(JSON.stringify(own.keys)).toContain('"s"');
114114 });
115
116 test("an admin sets who may start an agent with a, after a confirm; others are not offered it", async () => {
117 const { getAgentStart } = await import("../src/settings/agent_start.ts");
118 const h = boot("alice");
119 await h.open();
120 expect(getAgentStart(forge.db)).toBe("open");
121 await h.press("a", "j", "j", "enter");
122 expect(h.text()).toContain("from open to off");
123 await h.press("esc");
124 expect(getAgentStart(forge.db)).toBe("open");
125 await h.press("a", "j", "j", "enter", "enter");
126 expect(getAgentStart(forge.db)).toBe("off");
127 h.stop();
128 const bob = boot("bob");
129 await bob.open();
130 await bob.press("a", "j", "enter", "enter");
131 expect(getAgentStart(forge.db)).toBe("off");
132 bob.stop();
133 (await import("../src/settings/agent_start.ts")).setAgentStart(forge.db, "open", getUserByName(forge.db, "alice")!.id);
134 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs