Public: anyone can read this; the crew can change it.
Hosted forges per person are limited by count and storage, each refused in plain words #9602
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9581-hosted-forges-per-person-are-limited-by → main 4 commits fork 2 threads · 2 unresolved
6 files
changed +247
−22
docs/features/forge-sleep.md +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | A person (or their assistant, over the MCP `spawn` tool with their consent) makes a forge at `/forges/new`, the address `<name>--<this host>`. Free within the limits below; no payment path yet. |
| 4 | 4 | |
| 5 | 5 | - **Sleep.** A forge with no request for `FORGE_CHILD_IDLE_S` (600) sleeps: a hosted child's App and apps close, a cell child's process stops. Data stays under `children/<bodyId>`. The next page load, `git push` or API call wakes it. A page for a sleeping cell forge answers a plain "waking up" page at once (git and API calls wait up to 4 s) (503, `retry-after`, reloads itself); a hosted one opens in-process. |
| 6 | - **Limits, each refused in words.** `FORGE_FORGES_PER_PERSON` (3; site admins exempt), `FORGE_CHILD_AWAKE_MAX` (8 awake at once; sleeping forges do not count), `FORGE_CHILD_WAKES_PER_HOUR` (20 per forge), plus the existing size/quota limits (`FORGE_CHILD_*_MIB`), and each cell's own disk cap. | |
| 6 | - **Limits, each refused in words.** The admin limits `forges.per_person` (3) and `forges.storage_mb` (1000 MB), set at `/admin/limits` (the same list is on the terminal's limits screen), each refused in a sentence naming the limit and the person's use (site admins exempt), `FORGE_CHILD_AWAKE_MAX` (8 awake at once; sleeping forges do not count), `FORGE_CHILD_WAKES_PER_HOUR` (20 per forge), plus the existing size/quota limits (`FORGE_CHILD_*_MIB`), and each cell's own disk cap. | |
| 7 | 7 | - **Owner.** `/forges` shows awake/asleep, size, last activity, with Stop (sleep now) and Remove (ledger and data). |
| 8 | 8 | - **Secrets.** A child starts from `childEnv` and never sees this forge's model credential; the person brings their own or its crew stays off. |
| 9 | 9 |
src/actions/spawn.ts +1 −1
| @@ -259,7 +259,7 @@ async function make( | ||
| 259 | 259 | app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } }); |
| 260 | 260 | } else { |
| 261 | 261 | if (owner !== undefined) { |
| 262 | const many = personRefusal(app.config.dataDir, owner); | |
| 262 | const many = personRefusal(app.db, app.config.dataDir, owner); | |
| 263 | 263 | if (many !== undefined) return err({ kind: "refused", reason: "quota", message: many }); |
| 264 | 264 | } |
| 265 | 265 | const over = quotaRefusal(app.config.dataDir, plan.size ?? DEFAULT_SIZE); |
src/settings/limits.ts +18
| @@ -41,6 +41,8 @@ export type LimitKey = | ||
| 41 | 41 | | "guest.forks_per_day" |
| 42 | 42 | | "guest.forks_forge_per_day" |
| 43 | 43 | | "guest.pulls_per_day" |
| 44 | | "forges.per_person" | |
| 45 | | "forges.storage_mb" | |
| 44 | 46 | | "guest.storage_mb" |
| 45 | 47 | | "guest.fork_storage_mb" |
| 46 | 48 | | "guest.cooldown_minutes" |
| @@ -211,6 +213,22 @@ export const LIMITS: Record<LimitKey, LimitDef> = { | ||
| 211 | 213 | max: 1_000, |
| 212 | 214 | reason: "Every pull asks a person to read it, so a guest's pulls have a daily count.", |
| 213 | 215 | }, |
| 216 | "forges.per_person": { | |
| 217 | label: "Hosted forges one person may own", | |
| 218 | unit: "forges", | |
| 219 | default: 3, | |
| 220 | min: 1, | |
| 221 | max: 1_000, | |
| 222 | reason: "Every hosted forge holds a database and its repositories on this machine's disk; site admins are not counted.", | |
| 223 | }, | |
| 224 | "forges.storage_mb": { | |
| 225 | label: "Storage a person can hold in hosted forges", | |
| 226 | unit: "MB", | |
| 227 | default: 1_000, | |
| 228 | min: 1, | |
| 229 | max: 10_000_000, | |
| 230 | reason: "A hosted forge keeps its data here even asleep, so what one person's forges hold in all has a ceiling.", | |
| 231 | }, | |
| 214 | 232 | "guest.storage_mb": { |
| 215 | 233 | label: "Storage one guest may hold in their forks", |
| 216 | 234 | unit: "MB", |
src/spawn/sleep.ts +67 −11
| @@ -1,13 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Hosted forges that scale to zero: how long a forge may be quiet before it |
| 3 | * sleeps, how many may be awake at once, how often one may be woken, and how | |
| 4 | * many a person may own. Each limit is declared by an environment variable | |
| 5 | * with a default, and each refusal is a sentence a person can read. A sleeping | |
| 6 | * forge keeps its data on disk (`children/<bodyId>`); only its process goes. | |
| 3 | * sleeps, how many may be awake at once, and how often one may be woken. Each | |
| 4 | * is declared by an environment variable with a default. How many forges, and | |
| 5 | * how much storage, a person may have are admin limits (`/admin/limits`), not | |
| 6 | * environment. Each refusal is a sentence a person can read. A sleeping forge | |
| 7 | * keeps its data on disk (`children/<bodyId>`); only its process goes. | |
| 8 | * | |
| 9 | * `heldByPerson` walks and stats the files of the person's forges | |
| 10 | * synchronously on the spawn request; it runs only after the count check | |
| 11 | * passes, and spawning is rare. | |
| 7 | 12 | */ |
| 8 | 13 | |
| 9 | import { readdirSync, readFileSync } from "node:fs"; | |
| 14 | import type { Database } from "bun:sqlite"; | |
| 15 | import { readdirSync, readFileSync, statSync } from "node:fs"; | |
| 10 | 16 | import { join } from "node:path"; |
| 17 | import { limitValue } from "../settings/limits.ts"; | |
| 11 | 18 | |
| 12 | 19 | type Env = Record<string, string | undefined>; |
| 13 | 20 | |
| @@ -24,8 +31,6 @@ export const idleAfterMs = (env: Env = process.env): number => whole(env, "FORGE | ||
| 24 | 31 | export const awakeCeiling = (env: Env = process.env): number => whole(env, "FORGE_CHILD_AWAKE_MAX", 8); |
| 25 | 32 | /** Most wakes of one forge in an hour: FORGE_CHILD_WAKES_PER_HOUR. */ |
| 26 | 33 | export const wakesPerHour = (env: Env = process.env): number => whole(env, "FORGE_CHILD_WAKES_PER_HOUR", 20); |
| 27 | /** Most forges one person may own: FORGE_FORGES_PER_PERSON. */ | |
| 28 | export const forgesPerPerson = (env: Env = process.env): number => whole(env, "FORGE_FORGES_PER_PERSON", 3); | |
| 29 | 34 | |
| 30 | 35 | const HOUR_MS = 3_600_000; |
| 31 | 36 | |
| @@ -77,12 +82,63 @@ export function ownedBy(dataDir: string, ownerId: string): number { | ||
| 77 | 82 | return n; |
| 78 | 83 | } |
| 79 | 84 | |
| 80 | /** Why this person may not make another forge, or undefined. Site admins are not limited. */ | |
| 81 | export function personRefusal(dataDir: string, owner: { id: string; isAdmin: boolean }, env: Env = process.env): string | undefined { | |
| 85 | /** The ledger lines this person made: the forge name and its body id. */ | |
| 86 | function ownedLedgers(dataDir: string, ownerId: string): { name: string; bodyId: string }[] { | |
| 87 | const found: { name: string; bodyId: string }[] = []; | |
| 88 | let files: string[] = []; | |
| 89 | try { | |
| 90 | files = readdirSync(join(dataDir, "spawn")); | |
| 91 | } catch { | |
| 92 | return found; | |
| 93 | } | |
| 94 | for (const f of files) { | |
| 95 | if (!f.endsWith(".json")) continue; | |
| 96 | try { | |
| 97 | const l = JSON.parse(readFileSync(join(dataDir, "spawn", f), "utf8")) as { ownerId?: unknown; bodyId?: unknown }; | |
| 98 | if (l.ownerId === ownerId && typeof l.bodyId === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(l.bodyId)) | |
| 99 | found.push({ name: f.slice(0, -5), bodyId: l.bodyId }); | |
| 100 | } catch { | |
| 101 | // a damaged ledger counts for no one | |
| 102 | } | |
| 103 | } | |
| 104 | return found; | |
| 105 | } | |
| 106 | ||
| 107 | /** Bytes under a directory, files only. */ | |
| 108 | function bytesUnder(dir: string): number { | |
| 109 | let n = 0; | |
| 110 | try { | |
| 111 | for (const e of readdirSync(dir, { recursive: true, withFileTypes: true })) { | |
| 112 | if (e.isFile()) n += statSync(join(e.parentPath, e.name), { throwIfNoEntry: false })?.size ?? 0; | |
| 113 | } | |
| 114 | } catch { | |
| 115 | // a forge with no data yet holds nothing | |
| 116 | } | |
| 117 | return n; | |
| 118 | } | |
| 119 | ||
| 120 | /** What this person's forges hold on disk, in bytes. */ | |
| 121 | export function heldByPerson(dataDir: string, ownerId: string): number { | |
| 122 | return ownedLedgers(dataDir, ownerId).reduce((sum, l) => sum + bytesUnder(join(dataDir, "children", l.bodyId)), 0); | |
| 123 | } | |
| 124 | ||
| 125 | const MB = 1_000_000; | |
| 126 | ||
| 127 | /** | |
| 128 | * Why this person may not make another forge, or undefined: one sentence | |
| 129 | * naming the limit (set in admin limits) and what they use. Site admins are | |
| 130 | * not limited. | |
| 131 | */ | |
| 132 | export function personRefusal(db: Database, dataDir: string, owner: { id: string; isAdmin: boolean }): string | undefined { | |
| 82 | 133 | if (owner.isAdmin) return undefined; |
| 83 | const max = forgesPerPerson(env); | |
| 134 | const max = limitValue(db, "forges.per_person"); | |
| 84 | 135 | const has = ownedBy(dataDir, owner.id); |
| 85 | return has >= max ? `you already have ${has} forges, the most one person may have (${max}); remove one first` : undefined; | |
| 136 | if (has >= max) return `you already have ${has} ${has === 1 ? "forge" : "forges"}, the most one person may have (${max}); remove one first`; | |
| 137 | const maxMb = limitValue(db, "forges.storage_mb"); | |
| 138 | const held = heldByPerson(dataDir, owner.id); | |
| 139 | if (held >= maxMb * MB) | |
| 140 | return `your forges already hold ${Math.ceil(held / MB)} MB, the most one person's forges may hold (${maxMb} MB); remove one first`; | |
| 141 | return undefined; | |
| 86 | 142 | } |
| 87 | 143 | |
| 88 | 144 | /** The plain-text "waking up" or refusal answer: for a git client or an API call (a page is `web/pages/waking.ts`). */ |
test/forge-sleep.test.ts +12 −9
| @@ -17,6 +17,7 @@ import { createUser } from "../src/core/users.ts"; | ||
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | 18 | import { awakeCount, childAwake, holdChild, resolveHostedChild, sleepIdleChildren, wakeGate } from "../src/spawn/host.ts"; |
| 19 | 19 | import { removeForge, stopForge } from "../src/spawn/manage.ts"; |
| 20 | import { setLimit } from "../src/settings/limits.ts"; | |
| 20 | 21 | import { awakeRefusal, personRefusal, WakeRate } from "../src/spawn/sleep.ts"; |
| 21 | 22 | import { wakingResponse } from "../src/web/pages/waking.ts"; |
| 22 | 23 | import { hostedPlacement } from "../src/spawn/placement.ts"; |
| @@ -148,11 +149,15 @@ describe("a hosted forge scales to zero", () => { | ||
| 148 | 149 | |
| 149 | 150 | test("a person owns at most so many forges; a site admin is not limited", () => { |
| 150 | 151 | // Alice made homie: with a ceiling of 1 she (an admin) is still free, a person is not. |
| 151 | const env = { FORGE_FORGES_PER_PERSON: "1" }; | |
| 152 | const owner = parent.db.query("SELECT id FROM user WHERE username = 'alice'").get() as { id: string }; | |
| 153 | expect(personRefusal(dir, { id: owner.id, isAdmin: true }, env)).toBeUndefined(); | |
| 154 | expect(personRefusal(dir, { id: owner.id, isAdmin: false }, env)).toContain("remove one first"); | |
| 155 | expect(personRefusal(dir, { id: "someone-else", isAdmin: false }, env)).toBeUndefined(); | |
| 152 | expect(setLimit(parent.db, "forges.per_person", 1, "seed").ok).toBe(true); | |
| 153 | try { | |
| 154 | const owner = parent.db.query("SELECT id FROM user WHERE username = 'alice'").get() as { id: string }; | |
| 155 | expect(personRefusal(parent.db, dir, { id: owner.id, isAdmin: true })).toBeUndefined(); | |
| 156 | expect(personRefusal(parent.db, dir, { id: owner.id, isAdmin: false })).toContain("1 forge, the most"); | |
| 157 | expect(personRefusal(parent.db, dir, { id: "someone-else", isAdmin: false })).toBeUndefined(); | |
| 158 | } finally { | |
| 159 | setLimit(parent.db, "forges.per_person", null, "seed"); | |
| 160 | } | |
| 156 | 161 | }); |
| 157 | 162 | |
| 158 | 163 | test("a waking page is plain for a browser, and the gate lets an awake forge through", async () => { |
| @@ -261,8 +266,7 @@ describe("a hosted forge scales to zero", () => { | ||
| 261 | 266 | }); |
| 262 | 267 | |
| 263 | 268 | test("a person's forge limit holds through spawnForge", async () => { |
| 264 | const was = process.env["FORGE_FORGES_PER_PERSON"]; | |
| 265 | process.env["FORGE_FORGES_PER_PERSON"] = "1"; | |
| 269 | expect(setLimit(parent.db, "forges.per_person", 1, "seed").ok).toBe(true); | |
| 266 | 270 | try { |
| 267 | 271 | const carol = await createUser(parent.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]" }); |
| 268 | 272 | if (!carol.ok) throw new Error("carol"); |
| @@ -283,8 +287,7 @@ describe("a hosted forge scales to zero", () => { | ||
| 283 | 287 | expect(second.ok).toBe(false); |
| 284 | 288 | if (!second.ok) expect(JSON.stringify(second.error)).toContain("remove one first"); |
| 285 | 289 | } finally { |
| 286 | if (was === undefined) delete process.env["FORGE_FORGES_PER_PERSON"]; | |
| 287 | else process.env["FORGE_FORGES_PER_PERSON"] = was; | |
| 290 | setLimit(parent.db, "forges.per_person", null, "seed"); | |
| 288 | 291 | } |
| 289 | 292 | }); |
| 290 | 293 |
test/spawn-person-limits.test.ts added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | /** | |
| 2 | * A person's hosted forges are limited by count and by storage (admin | |
| 3 | * limits `forges.per_person` and `forges.storage_mb`). Past either, the web | |
| 4 | * form, the API and the MCP `spawn` tool each refuse in one plain sentence | |
| 5 | * naming the limit and the person's use. A site admin is not limited. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, readdir, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; | |
| 16 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 17 | import { LIMITS, limitValue, setLimit } from "../src/settings/limits.ts"; | |
| 18 | import { heldByPerson, personRefusal } from "../src/spawn/sleep.ts"; | |
| 19 | ||
| 20 | const PASSWORD = "a-long-new-forge-passphrase"; | |
| 21 | ||
| 22 | describe("per-person forge limits", () => { | |
| 23 | let dir = ""; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let cookie = ""; | |
| 27 | let token = ""; | |
| 28 | let adminToken = ""; | |
| 29 | let bobId = ""; | |
| 30 | let adminId = ""; | |
| 31 | const url = (p: string) => `${servers.url}${p}`; | |
| 32 | const api = (tok: string, name: string) => | |
| 33 | fetch(url("/api/v1/forges"), { | |
| 34 | method: "POST", | |
| 35 | headers: { "content-type": "application/json", authorization: `Bearer ${tok}` }, | |
| 36 | body: JSON.stringify({ name, apps: [], dryRun: false }), | |
| 37 | }); | |
| 38 | const mcp = async (tok: string, name: string) => { | |
| 39 | const res = await fetch(url("/mcp"), { | |
| 40 | method: "POST", | |
| 41 | headers: { "content-type": "application/json", authorization: `Bearer ${tok}` }, | |
| 42 | body: JSON.stringify({ | |
| 43 | jsonrpc: "2.0", | |
| 44 | id: 1, | |
| 45 | method: "tools/call", | |
| 46 | params: { name: "spawn", arguments: { name, apps: [], dryRun: false } }, | |
| 47 | }), | |
| 48 | }); | |
| 49 | const r = ((await res.json()) as any).result; | |
| 50 | return { error: r.isError === true, text: r.content[0].text as string }; | |
| 51 | }; | |
| 52 | const web = (name: string) => | |
| 53 | fetch(url("/forges/new"), { | |
| 54 | method: "POST", | |
| 55 | headers: { cookie, origin: "https://forge.test" }, | |
| 56 | body: new URLSearchParams([["name", name], ["fresh", "a tiny app"], ["do", "spawn"]]), | |
| 57 | redirect: "manual", | |
| 58 | }); | |
| 59 | const limit = (key: "forges.per_person" | "forges.storage_mb", n: number | null) => { | |
| 60 | const r = setLimit(app.db, key, n, adminId); | |
| 61 | if (!r.ok) throw new Error(`limit ${key}`); | |
| 62 | }; | |
| 63 | ||
| 64 | beforeAll(async () => { | |
| 65 | dir = await mkdtemp(join(tmpdir(), "forge-person-limits-")); | |
| 66 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 67 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, isAdmin: true }); | |
| 68 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: PASSWORD }); | |
| 69 | if (!a.ok || !b.ok) throw new Error("users"); | |
| 70 | adminId = a.value.id; | |
| 71 | bobId = b.value.id; | |
| 72 | token = createToken(app.db, b.value.id, "rw", "rw").token; | |
| 73 | adminToken = createToken(app.db, a.value.id, "rw", "rw").token; | |
| 74 | usePlacement(app, hostedPlacement({ host: "https://forge.test", store: memoryBodies(), maxChildren: 50 })); | |
| 75 | useProbe(app, () => ({ live: async () => true }) as never); | |
| 76 | servers = await serve(app); | |
| 77 | const res = await fetch(url("/signin"), { | |
| 78 | method: "POST", | |
| 79 | body: new URLSearchParams({ username: "bob", password: PASSWORD }), | |
| 80 | redirect: "manual", | |
| 81 | }); | |
| 82 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 83 | }, 30_000); | |
| 84 | ||
| 85 | afterAll(async () => { | |
| 86 | servers.stop(); | |
| 87 | app.close(); | |
| 88 | await rm(dir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | test("the limits are in admin settings with defaults", () => { | |
| 92 | expect(limitValue(app.db, "forges.per_person")).toBe(3); | |
| 93 | expect(limitValue(app.db, "forges.storage_mb")).toBe(1000); | |
| 94 | expect(LIMITS["forges.per_person"].reason.length).toBeGreaterThan(10); | |
| 95 | }); | |
| 96 | ||
| 97 | test("past the count, the API, the MCP and the web each refuse in one sentence", async () => { | |
| 98 | limit("forges.per_person", 1); | |
| 99 | const first = await api(token, "bobs-one"); | |
| 100 | expect(first.status).toBe(201); | |
| 101 | const sentence = "you already have 1 forge, the most one person may have (1); remove one first"; | |
| 102 | ||
| 103 | const viaApi = await api(token, "bobs-two"); | |
| 104 | expect(viaApi.status).toBe(409); | |
| 105 | expect(((await viaApi.json()) as any).message).toBe(sentence); | |
| 106 | ||
| 107 | const viaMcp = await mcp(token, "bobs-three"); | |
| 108 | expect(viaMcp.error).toBe(true); | |
| 109 | expect(viaMcp.text).toContain(sentence); | |
| 110 | ||
| 111 | const viaWeb = await web("bobs-four"); | |
| 112 | expect(viaWeb.status).toBe(409); | |
| 113 | expect(await viaWeb.text()).toContain("the most one person may have (1)"); | |
| 114 | ||
| 115 | expect((await readdir(join(dir, "spawn"))).sort()).toEqual(["bobs-one.json"]); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a site admin is not limited", async () => { | |
| 119 | expect((await api(adminToken, "alices-one")).status).toBe(201); | |
| 120 | expect((await api(adminToken, "alices-two")).status).toBe(201); | |
| 121 | }); | |
| 122 | ||
| 123 | test("past the storage, the API, the MCP and the web each refuse and name the use", async () => { | |
| 124 | limit("forges.per_person", 50); | |
| 125 | limit("forges.storage_mb", 1); | |
| 126 | // A real child's database and repositories already weigh more than a megabyte. | |
| 127 | const mb = Math.ceil(heldByPerson(dir, bobId) / 1_000_000); | |
| 128 | expect(mb).toBeGreaterThanOrEqual(2); | |
| 129 | expect(personRefusal(app.db, dir, { id: adminId, isAdmin: true })).toBeUndefined(); | |
| 130 | const sentence = `your forges already hold ${mb} MB, the most one person's forges may hold (1 MB); remove one first`; | |
| 131 | ||
| 132 | const viaApi = await api(token, "bobs-five"); | |
| 133 | expect(viaApi.status).toBe(409); | |
| 134 | expect(((await viaApi.json()) as any).message).toContain(sentence); | |
| 135 | ||
| 136 | const viaMcp = await mcp(token, "bobs-six"); | |
| 137 | expect(viaMcp.error).toBe(true); | |
| 138 | expect(viaMcp.text).toContain(sentence); | |
| 139 | ||
| 140 | const viaWeb = await web("bobs-seven"); | |
| 141 | expect(viaWeb.status).toBe(409); | |
| 142 | expect(await viaWeb.text()).toContain("the most one person's forges may hold (1 MB)"); | |
| 143 | ||
| 144 | // Room again once the limit is raised. | |
| 145 | limit("forges.storage_mb", 100); | |
| 146 | expect((await api(token, "bobs-eight")).status).toBe(201); | |
| 147 | }); | |
| 148 | }); |
"you already have 1 forges": use the singular when has is 1.