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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10912-any-agent-with-only-the-url-starts-build → main 13 commits fork 4 threads · 4 unresolved
docs/MCP.md +1
| @@ -233,6 +233,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 233 | 233 | | `whoami_can` | `role`, `passkeys` | What you can do: your role and its roles-table row, yes or no each | 231 | |
| 234 | 234 | | `write_file` | — | Commit one file to a branch | 302 | |
| 235 | 235 | | `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 | | |
| 236 | 237 | <!-- mcp-tools:end --> |
| 237 | 238 | |
| 238 | 239 | 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 | |
| 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 |
||
| 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"; | ||
| 16 | 16 | import { lacks } from "../core/roles.ts"; |
| 17 | 17 | import { activeEdgeToken } from "../http/edge.ts"; |
| 18 | 18 | import { sshTellCommand } from "../ssh/command.ts"; |
| 19 | import { getAgentStart } from "../settings/agent_start.ts"; | |
| 19 | 20 | import { mcpPointers, TOKEN_FREE_TOOLS } from "../core/mcp_card.ts"; |
| 20 | 21 | import type { App } from "../app.ts"; |
| 21 | 22 | import { appsRuntime } from "../apps/runtime.ts"; |
| @@ -331,6 +332,16 @@ function forgeActions( | ||
| 331 | 332 | http: "GET /api/v1/changelog", |
| 332 | 333 | mcp: "releases", |
| 333 | 334 | }, |
| 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 | ]), | |
| 334 | 345 | { |
| 335 | 346 | do: "read the HTTP contract", |
| 336 | 347 | http: "GET /contract/openapi.json", |
src/actions/signup.ts +20
| @@ -12,6 +12,7 @@ import { | ||
| 12 | 12 | type SignupChange, |
| 13 | 13 | type SignupMode, |
| 14 | 14 | } from "../settings/signup.ts"; |
| 15 | import { getAgentStart, parseAgentStart, setAgentStart, type AgentStartMode } from "../settings/agent_start.ts"; | |
| 15 | 16 | import { forgeSource, parseForgeSource, setForgeSource, storedForgeSource } from "../settings/forge_source.ts"; |
| 16 | 17 | import type { User } from "../core/users.ts"; |
| 17 | 18 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -69,3 +70,22 @@ export function changeForgeSource( | ||
| 69 | 70 | setForgeSource(app.db, name.value, who.value.id); |
| 70 | 71 | return ok(name.value); |
| 71 | 72 | } |
| 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 { | ||
| 104 | 104 | type EnvName, |
| 105 | 105 | } from "./data.ts"; |
| 106 | 106 | import { parseManifest, type AppManifest } from "./manifest.ts"; |
| 107 | import { heldManifest } from "../core/starts.ts"; | |
| 107 | 108 | import { identityKey } from "./proxy.ts"; |
| 108 | 109 | import { noteReached } from "./egress_report.ts"; |
| 109 | 110 | import { cellHooks } from "./egress_refused.ts"; |
| @@ -1509,7 +1510,9 @@ export class AppsRuntime { | ||
| 1509 | 1510 | readFileAt(bare, run.commitOid, "forge.json"), |
| 1510 | 1511 | readFileAt(bare, run.commitOid, "package.json"), |
| 1511 | 1512 | ]); |
| 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; | |
| 1513 | 1516 | clock.lap("manifest"); |
| 1514 | 1517 | if (!manifest.ok) { |
| 1515 | 1518 | return { |
| @@ -2026,6 +2029,7 @@ export class AppsRuntime { | ||
| 2026 | 2029 | }; |
| 2027 | 2030 | const manifest = parseManifest(read("forge.json"), read("package.json")); |
| 2028 | 2031 | if (!manifest.ok) return false; |
| 2032 | const owner = getRepoById(this.app.db, record.repoId)?.ownerId; | |
| 2029 | 2033 | const state = this.envFor(record, env); |
| 2030 | 2034 | if (state.live !== undefined || state.deploying !== undefined) return true; |
| 2031 | 2035 | // The deploy that built this checkout, in an earlier life: its log is |
| @@ -2039,7 +2043,7 @@ export class AppsRuntime { | ||
| 2039 | 2043 | state.asleep = { |
| 2040 | 2044 | commitOid, |
| 2041 | 2045 | dir, |
| 2042 | manifest: manifest.value, | |
| 2046 | manifest: owner === undefined ? manifest.value : heldManifest(this.app.db, owner, manifest.value), | |
| 2043 | 2047 | runId: built?.id ?? "", |
| 2044 | 2048 | since: new Date().toISOString(), |
| 2045 | 2049 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -349,6 +349,7 @@ export { route as postApiV1ReposOwnerNameTransfer } from "./postApiV1ReposOwnerN | ||
| 349 | 349 | export { route as postApiV1ReposOwnerNameUpstreamCheck } from "./postApiV1ReposOwnerNameUpstreamCheck.ts"; |
| 350 | 350 | export { route as postApiV1Sessions } from "./postApiV1Sessions.ts"; |
| 351 | 351 | export { route as postApiV1Share } from "./postApiV1Share.ts"; |
| 352 | export { route as postApiV1Start } from "./postApiV1Start.ts"; | |
| 352 | 353 | export { route as postApiV1Thoughts } from "./postApiV1Thoughts.ts"; |
| 353 | 354 | export { route as postApiV1Tokens } from "./postApiV1Tokens.ts"; |
| 354 | 355 | 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([ | ||
| 179 | 179 | "traffic.capture_started", |
| 180 | 180 | "traffic.networks_imported", |
| 181 | 181 | "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", | |
| 182 | 186 | ]); |
| 183 | 187 | |
| 184 | 188 | /** |
src/core/mcp_card.ts +1 −1
| @@ -55,7 +55,7 @@ export function apiCatalog(origin: string) { | ||
| 55 | 55 | * `http/mcp.ts` answers these and refuses the rest with 401. The card and |
| 56 | 56 | * `/api/v1/here` both read this list, so they cannot drift. |
| 57 | 57 | */ |
| 58 | export const TOKEN_FREE_TOOLS: readonly string[] = ["report"]; | |
| 58 | export const TOKEN_FREE_TOOLS: readonly string[] = ["report", "start"]; | |
| 59 | 59 | |
| 60 | 60 | /** A thin plugin the forge ships for a harness: where its source is and its one install line. */ |
| 61 | 61 | 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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | import { isUnclaimed } from "./starts.ts"; | |
| 12 | 13 | |
| 13 | 14 | export const ROLES = ["guest", "member", "admin"] as const; |
| 14 | 15 | export type Role = (typeof ROLES)[number]; |
| @@ -86,7 +87,10 @@ export function lacks( | ||
| 86 | 87 | ability: Ability, |
| 87 | 88 | ): boolean { |
| 88 | 89 | 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)); | |
| 90 | 94 | } |
| 91 | 95 | |
| 92 | 96 | /** |
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 = | ||
| 126 | 126 | * |
| 127 | 127 | * Every other origin is a named exemption, made by someone the forge already |
| 128 | 128 | * 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 | |
| 130 | 132 | * operator's first admin (`first-admin`, from the one-time code only the |
| 131 | 133 | * operator is shown), and `seed` for code that sets a forge up directly (the |
| 132 | 134 | * chaos world, test fixtures). |
| @@ -139,7 +141,7 @@ export type CreateUserError = | ||
| 139 | 141 | */ |
| 140 | 142 | export type StrangerOrigin = "signup" | "passkey"; |
| 141 | 143 | 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"; | |
| 143 | 145 | export type AccountOrigin = |
| 144 | 146 | /** |
| 145 | 147 | * `invite` is the token of an invite link (`core/invites.ts`): while |
| @@ -277,6 +279,8 @@ function admission(db: Database, input: AccountOrigin): Admission { | ||
| 277 | 279 | case "everyday": |
| 278 | 280 | case "agent": |
| 279 | 281 | case "spawn": |
| 282 | case "start": | |
| 283 | case "claimer": | |
| 280 | 284 | case "first-admin": |
| 281 | 285 | case "seed": |
| 282 | 286 | return ADMITTED; |
src/http/api.ts +2
| @@ -381,6 +381,7 @@ import { buildCartRoutes } from "./api/build_cart.ts"; | ||
| 381 | 381 | import { iterationRoutes } from "./api/iterations.ts"; |
| 382 | 382 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| 383 | 383 | import { reportRoutes } from "./api/reports.ts"; |
| 384 | import { startRoutes } from "./api/start.ts"; | |
| 384 | 385 | import { rollbackRoutes } from "./api/rollback.ts"; |
| 385 | 386 | import { takeBackRoutes } from "./api/take_back.ts"; |
| 386 | 387 | import { conversationConfirmRoutes } from "./api/conversation_confirms.ts"; |
| @@ -2136,6 +2137,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2136 | 2137 | issueRoutes(router); |
| 2137 | 2138 | |
| 2138 | 2139 | reportRoutes(router); |
| 2140 | startRoutes(router); | |
| 2139 | 2141 | appNoteRoutes(router); |
| 2140 | 2142 | rollbackRoutes(router); |
| 2141 | 2143 | 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"; | ||
| 32 | 32 | import { TOKEN_FREE_TOOLS } from "../core/mcp_card.ts"; |
| 33 | 33 | import { proofOf } from "./report_filer.ts"; |
| 34 | 34 | import { fileReport } from "../actions/reports.ts"; |
| 35 | import { START_HINT, START_WORDS, startBuilding } from "../actions/start.ts"; | |
| 35 | 36 | import { REPORT_RECEIVED } from "../actions/report_words.ts"; |
| 36 | 37 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 37 | 38 | import { everydayOf } from "../core/everyday.ts"; |
| @@ -55,7 +56,7 @@ export function toolEntries(tools: readonly (typeof TOOLS)[number][]) { | ||
| 55 | 56 | ? {} |
| 56 | 57 | : { _meta: { ui: { resourceUri: CARD_URI(CARD_OF_TOOL[t.name]!) } } }), |
| 57 | 58 | })), |
| 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 })), | |
| 59 | 60 | ]; |
| 60 | 61 | } |
| 61 | 62 | |
| @@ -277,11 +278,34 @@ export const REPORT_TOOL = { | ||
| 277 | 278 | }, |
| 278 | 279 | } as const; |
| 279 | 280 | |
| 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 | ||
| 280 | 304 | /** What a token-free tool does with its call: the JSON-RPC answer. One entry per name in TOKEN_FREE_TOOLS. */ |
| 281 | 305 | type FreeHandler = (ctx: Ctx, id: unknown, args: Args) => unknown; |
| 282 | 306 | |
| 283 | 307 | /** 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 }; | |
| 285 | 309 | |
| 286 | 310 | /** The JSON-RPC answer to a token-free tool call, or undefined for any other message. */ |
| 287 | 311 | function reportCall(ctx: Ctx, message: unknown): unknown | undefined { |
| @@ -357,11 +381,26 @@ function reportAnswer(ctx: Ctx, id: unknown, args: Args): unknown { | ||
| 357 | 381 | ); |
| 358 | 382 | } |
| 359 | 383 | |
| 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 | ||
| 360 | 399 | const NOT_HERE = (allow: string): Response => |
| 361 | 400 | new Response(null, { status: 405, headers: { allow } }); |
| 362 | 401 | |
| 363 | 402 | /** 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]; | |
| 365 | 404 | |
| 366 | 405 | /** What each tool declares about itself, for the test that walks them all. */ |
| 367 | 406 | export const toolRegistry = () => |
| @@ -383,7 +422,7 @@ export function mcpRoutes(router: Router<Handler>): void { | ||
| 383 | 422 | ) { |
| 384 | 423 | const message: unknown = await body(ctx.request); |
| 385 | 424 | const reported = reportCall(ctx, message); |
| 386 | if (reported !== undefined) return json(reported); | |
| 425 | if (reported !== undefined) return json(await reported); | |
| 387 | 426 | const answer = await rpcOne(ctx, user, message); |
| 388 | 427 | if (answer === undefined) return new Response(null, { status: 202 }); |
| 389 | 428 | return json(answer); |
| @@ -397,7 +436,7 @@ export function mcpRoutes(router: Router<Handler>): void { | ||
| 397 | 436 | 413, |
| 398 | 437 | ); |
| 399 | 438 | const reported = reportCall(ctx, message); |
| 400 | if (reported !== undefined) return json(reported); | |
| 439 | if (reported !== undefined) return json(await reported); | |
| 401 | 440 | // The refusal keeps the OAuth challenge a client starts from, and names |
| 402 | 441 | // the one thing a stranger can do without signing in. |
| 403 | 442 | return new Response( |
| @@ -405,7 +444,7 @@ export function mcpRoutes(router: Router<Handler>): void { | ||
| 405 | 444 | error: "unauthorized", |
| 406 | 445 | hint: "Send `Authorization: Bearer <token>` with a token you hold, then repeat the call; without one, only the `report` tool answers.", |
| 407 | 446 | 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", | |
| 409 | 448 | report: { |
| 410 | 449 | method: "tools/call", |
| 411 | 450 | name: "report", |
src/http/mcp_table.ts +2 −2
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * description, and its bytes in `tools/list`. The doc holds it between two |
| 5 | 5 | * markers; test/mcp-table.test.ts fails, naming the tool, when they differ. |
| 6 | 6 | */ |
| 7 | import { REPORT_TOOL, toolEntries } from "./mcp.ts"; | |
| 7 | import { REPORT_TOOL, START_TOOL, toolEntries } from "./mcp.ts"; | |
| 8 | 8 | import { TOOLS } from "./mcp/tools.ts"; |
| 9 | 9 | |
| 10 | 10 | 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 { | ||
| 70 | 70 | |
| 71 | 71 | export function toolRows(): ToolRow[] { |
| 72 | 72 | 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]]); | |
| 74 | 74 | return entries.map((entry) => ({ |
| 75 | 75 | name: entry.name, |
| 76 | 76 | doors: doorsOf(schemas.get(entry.name)), |
src/http/server.ts +8 −3
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | import { secureFor } from "./cookie_secure.ts"; |
| 18 | 18 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; |
| 19 | 19 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 20 | import { startClaimAccountSubmit, startClaimPage, startClaimSubmit } from "../web/pages/start_claim.ts"; | |
| 20 | 21 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 21 | 22 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; |
| 22 | 23 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| @@ -31,7 +32,7 @@ import { adminDataPage, appDataPage, dataPage } from "../web/pages/data.ts"; | ||
| 31 | 32 | import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts"; |
| 32 | 33 | import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts"; |
| 33 | 34 | 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"; | |
| 35 | 36 | import { allFlowsPage } from "../web/pages/flows-all.ts"; |
| 36 | 37 | import { measureSubmit } from "./measure.ts"; |
| 37 | 38 | import { cspReportSubmit } from "./csp_report.ts"; |
| @@ -480,7 +481,7 @@ import { viaGate } from "./via_gate.ts"; | ||
| 480 | 481 | import { checksRecheckRoutes } from "./checks_recheck.ts"; |
| 481 | 482 | import { feedRoutes } from "./feed.ts"; |
| 482 | 483 | import type { Database } from "bun:sqlite"; |
| 483 | import { mcpRoutes, REPORT_TOOL } from "./mcp.ts"; | |
| 484 | import { mcpRoutes, REPORT_TOOL, START_TOOL } from "./mcp.ts"; | |
| 484 | 485 | import { TOOLS } from "./mcp/tools.ts"; |
| 485 | 486 | import { setMcpTools } from "../web/llms_tools.ts"; |
| 486 | 487 | import { resourcePolicy } from "./corp.ts"; |
| @@ -866,6 +867,9 @@ export function buildRouter(): Router<Handler> { | ||
| 866 | 867 | router.add("GET", "/signin/parent", parentStart); |
| 867 | 868 | router.add("GET", "/signin/parent/callback", parentCallback); |
| 868 | 869 | 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); | |
| 869 | 873 | router.add("GET", "/signup/requested", requestedPage); |
| 870 | 874 | router.add("GET", "/verify", verifyPage); |
| 871 | 875 | router.add("POST", "/signup", signupSubmit); |
| @@ -1036,6 +1040,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1036 | 1040 | router.add("POST", "/admin/limits", limitsSet); |
| 1037 | 1041 | router.add("POST", "/admin/quotas", quotasSet); |
| 1038 | 1042 | router.add("POST", "/admin/signup", signupSet); |
| 1043 | router.add("POST", "/admin/agent-start", agentStartSet); | |
| 1039 | 1044 | router.add("POST", "/admin/invites", inviteMint); |
| 1040 | 1045 | router.add("POST", "/admin/invites/:id/revoke", inviteRevoke); |
| 1041 | 1046 | |
| @@ -1150,7 +1155,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1150 | 1155 | // reclaim a path under `/api/`. |
| 1151 | 1156 | // The forge as an MCP server: the studio's verbs for an assistant. |
| 1152 | 1157 | mcpRoutes(router); |
| 1153 | setMcpTools(TOOLS, [REPORT_TOOL]); | |
| 1158 | setMcpTools(TOOLS, [REPORT_TOOL, START_TOOL]); | |
| 1154 | 1159 | // The public API as OpenAPI, for any client that reads it. |
| 1155 | 1160 | openapiRoute(router); |
| 1156 | 1161 | oauthRoutes(router); |
src/main.ts +6
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * environment-only — a handful of variables, all with sane defaults. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { sweepStarts } from "./actions/start_sweep.ts"; | |
| 7 | 8 | import { scanNightly } from "./checks/scan.ts"; |
| 8 | 9 | import { nightlyDue } from "./checks/scan_store.ts"; |
| 9 | 10 | import { runModelReview } from "./actions/model_review.ts"; |
| @@ -671,6 +672,10 @@ const followTimer = setInterval( | ||
| 671 | 672 | 60_000, |
| 672 | 673 | ); |
| 673 | 674 | 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)); | |
| 674 | 679 | void during("asks", tickAsks); |
| 675 | 680 | |
| 676 | 681 | // Every start is counted, and one that follows a process that never said |
| @@ -898,6 +903,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 898 | 903 | clearInterval(reachTimer); |
| 899 | 904 | clearInterval(syncTimer); |
| 900 | 905 | clearInterval(staleTimer); |
| 906 | clearInterval(startsTimer); | |
| 901 | 907 | clearInterval(followTimer); |
| 902 | 908 | stopClogTick(); |
| 903 | 909 | 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"; | ||
| 41 | 41 | import { entry as front } from "./front.ts"; |
| 42 | 42 | import { entry as forges } from "./forges.ts"; |
| 43 | 43 | import { entry as homePins } from "./home_pins.ts"; |
| 44 | import { entry as claimStart } from "./claim_start.ts"; | |
| 44 | 45 | import { entry as invites } from "./invites.ts"; |
| 45 | 46 | import { entry as limits } from "./limits.ts"; |
| 46 | 47 | import { entry as economics } from "./economics.ts"; |
| @@ -136,6 +137,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 136 | 137 | agents, |
| 137 | 138 | archived, |
| 138 | 139 | invites, |
| 140 | claimStart, | |
| 139 | 141 | requests, |
| 140 | 142 | bulk, |
| 141 | 143 | subscriptions, |
src/tui/screens/invites.ts +41 −4
| @@ -6,12 +6,14 @@ | ||
| 6 | 6 | * selected open one after a confirm (`y` revokes, `n` or esc keeps it). Both go through the actions, so the gate is theirs: an |
| 7 | 7 | * admin who is a person; anyone else sees nothing and mints nothing. The top |
| 8 | 8 | * 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`). | |
| 10 | 11 | */ |
| 11 | 12 | |
| 12 | 13 | import { inviteLink, list, mint, revoke } from "../../actions/invites.ts"; |
| 13 | 14 | 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"; | |
| 15 | 17 | import { SIGNUP_MODES, type SignupMode } from "../../settings/signup.ts"; |
| 16 | 18 | import { DEFAULT_DAYS, MAX_DAYS, type Invite } from "../../core/invites.ts"; |
| 17 | 19 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -71,6 +73,27 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 71 | 73 | ); |
| 72 | 74 | }; |
| 73 | 75 | |
| 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 | ||
| 74 | 97 | const rows = (): ListRow[] => |
| 75 | 98 | items().map((i) => ({ |
| 76 | 99 | title: i.id, |
| @@ -86,7 +109,7 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 86 | 109 | binding(["j", "k"], "move cursor"), |
| 87 | 110 | binding(["m"], "make a link"), |
| 88 | 111 | 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")] : []), | |
| 90 | 113 | ]; |
| 91 | 114 | }, |
| 92 | 115 | render(area, s) { |
| @@ -95,7 +118,7 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 95 | 118 | const sg = signup(); |
| 96 | 119 | const [top, head, body] = split(area, "v", [len(sg.ok ? 1 : 0), len(1), fill(1)]); |
| 97 | 120 | 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); | |
| 99 | 122 | const text = |
| 100 | 123 | note !== "" |
| 101 | 124 | ? note |
| @@ -153,6 +176,20 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 153 | 176 | onPick: ([m]) => plan(m as SignupMode), |
| 154 | 177 | }), |
| 155 | 178 | ); |
| 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 | ); | |
| 156 | 193 | } else return false; |
| 157 | 194 | return true; |
| 158 | 195 | }, |
src/web/pages/admin-limits.ts +34 −2
| @@ -11,7 +11,8 @@ import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; | ||
| 11 | 11 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; |
| 12 | 12 | import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts"; |
| 13 | 13 | 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"; | |
| 15 | 16 | import { SIGNUP_MEANS, SIGNUP_MODES } from "../../settings/signup.ts"; |
| 16 | 17 | import { QUOTA_KEYS } from "../../settings/quotas.ts"; |
| 17 | 18 | import type { Ctx } from "../../http/context.ts"; |
| @@ -30,6 +31,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 30 | 31 | if (!listed.ok) return notFoundPage(ctx); |
| 31 | 32 | const quotas = timePhase(ctx.request, "quotas", () => readQuotas(ctx.app, ctx.auth.user)); |
| 32 | 33 | const signup = timePhase(ctx.request, "signup", () => readSignup(ctx.app, ctx.auth.user)); |
| 34 | const agentStart = readAgentStart(ctx.app, ctx.auth.user); | |
| 33 | 35 | const invites = timePhase(ctx.request, "invites", () => listInviteLinks(ctx.app, ctx.auth.user)); |
| 34 | 36 | const rendering = performance.now(); |
| 35 | 37 | const { limits, changes } = listed.value; |
| @@ -138,6 +140,20 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 138 | 140 | </dd> |
| 139 | 141 | <dd class="status-act"></dd> |
| 140 | 142 | </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>`; | |
| 141 | 157 | const inviteRows = (invites.ok ? invites.value : []).map((i) => |
| 142 | 158 | row({ |
| 143 | 159 | lead: icon("activity"), |
| @@ -180,7 +196,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 180 | 196 | const body = html`<p class="small muted mb"> |
| 181 | 197 | Every limit you can hit, and why. A change applies to the next request that meets it; nothing needs a restart. |
| 182 | 198 | </p> |
| 183 | <dl class="status-rows">${signupRow}${inviteRow}${quotaRow}</dl> | |
| 199 | <dl class="status-rows">${signupRow}${agentStartRow}${inviteRow}${quotaRow}</dl> | |
| 184 | 200 | ${historyBlock("Invite links made", inviteRows)} |
| 185 | 201 | <section class="settings-block mt"> |
| 186 | 202 | <h2>Limits</h2> |
| @@ -287,6 +303,22 @@ export async function signupSet(ctx: Ctx): Promise<Response> { | ||
| 287 | 303 | ); |
| 288 | 304 | } |
| 289 | 305 | |
| 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 | ||
| 290 | 322 | // POST /admin/invites |
| 291 | 323 | export async function inviteMint(ctx: Ctx): Promise<Response> { |
| 292 | 324 | 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): |
||
| 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 | ||
| 423 | 423 | "", |
| 424 | 424 | "## Start here", |
| 425 | 425 | "", |
| 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.", | |
| 427 | 427 | "", |
| 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".`, | |
| 429 | 430 | `- 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.`, |
| 430 | 431 | `- 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.`, | |
| 432 | 433 | "", |
| 433 | 434 | "## Report a problem (no account)", |
| 434 | 435 | "", |
test/cookie-flags.test.ts +1
| @@ -46,6 +46,7 @@ const UNREACHED: Record<string, { sites: number; why: string }> = { | ||
| 46 | 46 | "src/apps/feedback.ts": { sites: 2, why: "the visitor cookie is set on a deployed app's own host from its feedback widget; covered by the feedback tests" }, |
| 47 | 47 | "src/web/pages/claim.ts": { sites: 1, why: "claiming the first admin seat needs a fresh forge with a claim link; same sessionCookie() as the walked doors" }, |
| 48 | 48 | "src/web/pages/first-admin.ts": { sites: 1, why: "first-admin recovery needs a forge with no admin; same sessionCookie() as the walked doors" }, |
| 49 | "src/web/pages/start_claim.ts": { sites: 1, why: "the claim of an agent's start needs a one-time token; same sessionCookie() as the walked doors; covered by test/start.test.ts" }, | |
| 49 | 50 | "src/web/pages/signup-passkey.ts": { sites: 2, why: "sign-up by passkey needs an invite; same sessionCookie() as the walked doors" }, |
| 50 | 51 | "src/web/pages/map.ts": { sites: 1, why: "the map's last-looked cookie holds two timestamps, HttpOnly and Lax, and grants nothing; covered by test/roadmap.test.ts (name, HttpOnly, SameSite, no Secure over http)" }, |
| 51 | 52 | "src/web/pages/everyday.ts": { sites: 2, why: "the admin/everyday switch needs a paired account; same sessionCookie() as the walked doors" }, |
test/llms-anonymous.test.ts +8 −7
| @@ -43,25 +43,26 @@ const section = (body: string): string[] => { | ||
| 43 | 43 | return lines.slice(start + 1, end).filter((l) => l.startsWith("- ")); |
| 44 | 44 | }; |
| 45 | 45 | |
| 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 () => { | |
| 47 | 47 | const res = await fetch(`${servers.url}/llms.txt`); |
| 48 | 48 | const body = await res.text(); |
| 49 | 49 | expect(Buffer.byteLength(body)).toBeLessThan(6 * 1024); |
| 50 | 50 | expect(res.headers.get("vary")).toContain("Cookie"); |
| 51 | 51 | expect(res.headers.get("vary")).toContain("Authorization"); |
| 52 | 52 | const listed = section(body); |
| 53 | expect(listed).toHaveLength(2); | |
| 53 | expect(listed).toHaveLength(3); | |
| 54 | 54 | 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"); | |
| 58 | 59 | }); |
| 59 | 60 | |
| 60 | 61 | test("every tool the anonymous text names is one tools/list returns", async () => { |
| 61 | 62 | const names = section(await fetch(`${servers.url}/llms.txt`).then((r) => r.text())) |
| 62 | 63 | .map((l) => /^- ([a-z_]+): /.exec(l)?.[1]) |
| 63 | 64 | .filter((n): n is string => n !== undefined); |
| 64 | expect(names).toEqual(["report"]); | |
| 65 | expect(names).toEqual(["report", "start"]); | |
| 65 | 66 | const returned: string[] = []; |
| 66 | 67 | let cursor: string | undefined; |
| 67 | 68 | do { |
| @@ -80,5 +81,5 @@ test("a signed-in reader still gets every tool and report", async () => { | ||
| 80 | 81 | const body = await fetch(`${servers.url}/llms.txt`, { |
| 81 | 82 | headers: { authorization: `Bearer ${token}` }, |
| 82 | 83 | }).then((r) => r.text()); |
| 83 | expect(section(body)).toHaveLength(TOOLS.length + 1); | |
| 84 | expect(section(body)).toHaveLength(TOOLS.length + 2); | |
| 84 | 85 | }); |
test/llms-tools.test.ts +3 −3
| @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp } from "../src/app.ts"; |
| 7 | 7 | 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"; | |
| 9 | 9 | import { TOOLS } from "../src/http/mcp/tools.ts"; |
| 10 | 10 | import { createToken } from "../src/core/tokens.ts"; |
| 11 | 11 | import { createUser } from "../src/core/users.ts"; |
| @@ -45,9 +45,9 @@ describe("/llms.txt MCP tools", () => { | ||
| 45 | 45 | expect(start).toBeGreaterThan(-1); |
| 46 | 46 | const end = lines.findIndex((l, i) => i > start && l.startsWith("## ")); |
| 47 | 47 | 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) => { | |
| 49 | 49 | 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)" : ""}`; | |
| 51 | 51 | }); |
| 52 | 52 | expect(listed).toEqual(expected); |
| 53 | 53 | expect(TOOLS.some((t) => t.writes)).toBe(true); |
test/mcp-card.test.ts +1 −1
| @@ -35,7 +35,7 @@ describe("the MCP server card", () => { | ||
| 35 | 35 | expect(card.version).toBe(running().version); |
| 36 | 36 | expect(card.transport).toBe("streamable-http"); |
| 37 | 37 | expect(card.endpoint).toBe(`${base}/mcp`); |
| 38 | expect(card.tokenFreeTools).toEqual(["report"]); | |
| 38 | expect(card.tokenFreeTools).toEqual(["report", "start"]); | |
| 39 | 39 | |
| 40 | 40 | // Without a token the endpoint refuses everything but `report`. |
| 41 | 41 | 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 () => { | ||
| 89 | 89 | }, 30_000); |
| 90 | 90 | |
| 91 | 91 | 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); | |
| 93 | 93 | }); |
test/mcp-tools.test.ts +5 −5
| @@ -10,7 +10,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import { byName } from "../src/http/mcp/shared.ts"; |
| 15 | 15 | import { TOOLS } from "../src/http/mcp/tools.ts"; |
| 16 | 16 | import { toolsSection } from "../src/web/llms_tools.ts"; |
| @@ -43,7 +43,7 @@ describe("the gathered tools", () => { | ||
| 43 | 43 | test("are the files, sorted, one each, and `report` beside them", () => { |
| 44 | 44 | expect(TOOLS.map((t) => t.name)).toEqual(files); |
| 45 | 45 | expect(files).not.toContain("report"); |
| 46 | expect(toolNames()).toEqual([...files, "report"]); | |
| 46 | expect(toolNames()).toEqual([...files, "report", "start"]); | |
| 47 | 47 | }); |
| 48 | 48 | |
| 49 | 49 | test("the index is current: run `bun scripts/mcp-tools.ts`", () => { |
| @@ -66,12 +66,12 @@ describe("the gathered tools", () => { | ||
| 66 | 66 | |
| 67 | 67 | describe("the live tool list", () => { |
| 68 | 68 | 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]) { | |
| 71 | 71 | const line = section.find((l) => l.startsWith(`- ${t.name}: `)); |
| 72 | 72 | expect([t.name, line?.includes(t.doc ?? t.description) ?? false]).toEqual([t.name, true]); |
| 73 | 73 | } |
| 74 | expect(section.filter((l) => l.startsWith("- ")).length).toBe(TOOLS.length + 1); | |
| 74 | expect(section.filter((l) => l.startsWith("- ")).length).toBe(TOOLS.length + 2); | |
| 75 | 75 | }); |
| 76 | 76 | |
| 77 | 77 | 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"; | ||
| 37 | 37 | |
| 38 | 38 | /** tools/list's order: the tools by name, then `report`. */ |
| 39 | 39 | 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)), | |
| 41 | 41 | "report", |
| 42 | "start", | |
| 42 | 43 | ]; |
| 43 | 44 | |
| 44 | 45 | describe("the forge over MCP", () => { |
| @@ -384,6 +385,7 @@ describe("the forge over MCP", () => { | ||
| 384 | 385 | "pins", |
| 385 | 386 | "board_delete", |
| 386 | 387 | "report", |
| 388 | "start", | |
| 387 | 389 | ])); |
| 388 | 390 | const byName = new Map<string, any>( |
| 389 | 391 | 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> = { | ||
| 309 | 309 | "/boards/:slug": "needs a board", |
| 310 | 310 | "/boards/:slug/live": "needs a board; a stream, not a page", |
| 311 | 311 | "/bring/:id/:job": "needs a bring job", |
| 312 | "/claim/start/:token": "needs the one-time token an anonymous start returned", | |
| 312 | 313 | "/fleet/ops/:id": "needs a fleet operation", |
| 313 | 314 | "/go/:code": "needs a short code minted by a share", |
| 314 | 315 | "/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"; | ||
| 19 | 19 | import { createIssue } from "../src/core/issues.ts"; |
| 20 | 20 | import { pin } from "../src/core/pins.ts"; |
| 21 | 21 | import { getRepo } from "../src/core/repos.ts"; |
| 22 | import { recordStart } from "../src/core/starts.ts"; | |
| 22 | 23 | import { createUser, type User } from "../src/core/users.ts"; |
| 23 | 24 | import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts"; |
| 24 | 25 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -54,6 +55,7 @@ const FIXTURE: Record<string, string> = { | ||
| 54 | 55 | "/:owner/:name/pulls/:number": "/alice/forge/pulls/2", |
| 55 | 56 | "/:owner/:name/display": "/alice/forge/display", |
| 56 | 57 | "/api/v1/admin/machines/:machine/disks": "/api/v1/admin/machines/rack1/disks", |
| 58 | "/claim/start/:token": "/claim/start/claim_fixture", | |
| 57 | 59 | }; |
| 58 | 60 | |
| 59 | 61 | function registeredGets(): Set<string> { |
| @@ -130,6 +132,8 @@ beforeAll(async () => { | ||
| 130 | 132 | const pull = createIssue(app.db, { repoId: forge.id, authorId: bob.id, title: "A pull", body: "body", kind: "pull" }); |
| 131 | 133 | if (!pull.ok) throw new Error(pull.error); |
| 132 | 134 | 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"); | |
| 133 | 137 | servers = await serve(app); |
| 134 | 138 | const signin = await fetch(`${servers.url}/signin`, { |
| 135 | 139 | method: "POST", |
test/palette-roster.test.ts +2
| @@ -145,6 +145,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 145 | 145 | ["teams", "Crew teams", "each team's charter, dial and members"], |
| 146 | 146 | ["agents", "Crew agents", "each agent's model, budget and brief"], |
| 147 | 147 | ["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"], | |
| 148 | 149 | ["requests", "Waiting", "strangers who asked for an account: allow or decline"], |
| 149 | 150 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 150 | 151 | ["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" | ||
| 315 | 316 | ["bugs", "Bugs", "open bugs and the bug clock"], |
| 316 | 317 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 317 | 318 | ["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"], | |
| 318 | 320 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 319 | 321 | ["bets", "Bets", "back, stop and finish a repository\'s bets"], |
| 320 | 322 | ["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", () => { | ||
| 92 | 92 | // `response` is only ever what `answered` gave back. |
| 93 | 93 | for (const m of region.matchAll(/\bresponse\s*=\s*([^\n;]+)/g)) expect(m[1]!.trim()).toMatch(/^await answered\(/); |
| 94 | 94 | // `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\(/); | |
| 96 | 96 | expect(server).toMatch(/function failure\([^]*?\n}\n/); |
| 97 | 97 | const failure = server.match(/function failure\([^]*?\n}\n/)![0]; |
| 98 | 98 | 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 | ||
| 60 | 60 | const CART = "the seed pins no build with a cart (test/build-cart.test.ts covers its headers)"; |
| 61 | 61 | const UNSEEDED: Record<string, string> = { |
| 62 | 62 | "/: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)", | |
| 63 | 64 | "/talk/:id": TALK, |
| 64 | 65 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 65 | 66 | "/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 () => | ||
| 113 | 113 | "src/actions/crew.ts": ["agent"], |
| 114 | 114 | "src/actions/first_admin.ts": ["first-admin"], |
| 115 | 115 | "src/actions/spawn.ts": ["spawn", "spawn"], |
| 116 | "src/actions/start.ts": ["start", "start"], | |
| 116 | 117 | "src/actions/users.ts": ["signup", "passkey"], |
| 117 | 118 | "src/chaos/world.ts": ["seed"], |
| 118 | 119 | "src/packs/mirror.ts": ["seed"], |
| @@ -120,5 +121,6 @@ test("every caller of createUser says where its account comes from", async () => | ||
| 120 | 121 | "src/spawn/preview-seed.ts": ["seed", "seed"], |
| 121 | 122 | "src/web/pages/claim.ts": ["claim"], |
| 122 | 123 | "src/web/pages/everyday.ts": ["everyday"], |
| 124 | "src/web/pages/start_claim.ts": ["claimer"], | |
| 123 | 125 | }); |
| 124 | 126 | }); |
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 () => { | ||
| 112 | 112 | const own = invitesScreen(admin, { push() {}, invalidate() {} } as never); |
| 113 | 113 | expect(JSON.stringify(own.keys)).toContain('"s"'); |
| 114 | 114 | }); |
| 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 | }); |
Both doors: no
src/tui/screen for claiming or for theagent_startsetting. 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.