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

Merged opened this

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

Merged

scout/forge:scout/9581-hosted-forges-per-person-are-limited-by → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/9581-hosted-forges-per-person-are-limited-by was merged into main as 13a5c72.

Review

Sign in to review this change.
6 files changed +247 −22
docs/features/forge-sleep.md +1 −1
@@ -3,7 +3,7 @@
33 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.
44
55 - **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.
77 - **Owner.** `/forges` shows awake/asleep, size, last activity, with Stop (sleep now) and Remove (ledger and data).
88 - **Secrets.** A child starts from `childEnv` and never sees this forge's model credential; the person brings their own or its crew stays off.
99
src/actions/spawn.ts +1 −1
@@ -259,7 +259,7 @@ async function make(
259259 app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } });
260260 } else {
261261 if (owner !== undefined) {
262 const many = personRefusal(app.config.dataDir, owner);
262 const many = personRefusal(app.db, app.config.dataDir, owner);
263263 if (many !== undefined) return err({ kind: "refused", reason: "quota", message: many });
264264 }
265265 const over = quotaRefusal(app.config.dataDir, plan.size ?? DEFAULT_SIZE);
src/settings/limits.ts +18
@@ -41,6 +41,8 @@ export type LimitKey =
4141 | "guest.forks_per_day"
4242 | "guest.forks_forge_per_day"
4343 | "guest.pulls_per_day"
44 | "forges.per_person"
45 | "forges.storage_mb"
4446 | "guest.storage_mb"
4547 | "guest.fork_storage_mb"
4648 | "guest.cooldown_minutes"
@@ -211,6 +213,22 @@ export const LIMITS: Record<LimitKey, LimitDef> = {
211213 max: 1_000,
212214 reason: "Every pull asks a person to read it, so a guest's pulls have a daily count.",
213215 },
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 },
214232 "guest.storage_mb": {
215233 label: "Storage one guest may hold in their forks",
216234 unit: "MB",
src/spawn/sleep.ts +67 −11
@@ -1,13 +1,20 @@
11 /**
22 * 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.
712 */
813
9 import { readdirSync, readFileSync } from "node:fs";
14 import type { Database } from "bun:sqlite";
15 import { readdirSync, readFileSync, statSync } from "node:fs";
1016 import { join } from "node:path";
17 import { limitValue } from "../settings/limits.ts";
1118
1219 type Env = Record<string, string | undefined>;
1320
@@ -24,8 +31,6 @@ export const idleAfterMs = (env: Env = process.env): number => whole(env, "FORGE
2431 export const awakeCeiling = (env: Env = process.env): number => whole(env, "FORGE_CHILD_AWAKE_MAX", 8);
2532 /** Most wakes of one forge in an hour: FORGE_CHILD_WAKES_PER_HOUR. */
2633 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);
2934
3035 const HOUR_MS = 3_600_000;
3136
@@ -77,12 +82,63 @@ export function ownedBy(dataDir: string, ownerId: string): number {
7782 return n;
7883 }
7984
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 */
outdated

"you already have 1 forges": use the singular when has is 1.

132 export function personRefusal(db: Database, dataDir: string, owner: { id: string; isAdmin: boolean }): string | undefined {
82133 if (owner.isAdmin) return undefined;
83 const max = forgesPerPerson(env);
134 const max = limitValue(db, "forges.per_person");
84135 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;
86142 }
87143
88144 /** 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";
1717 import { serve, type Servers } from "../src/http/server.ts";
1818 import { awakeCount, childAwake, holdChild, resolveHostedChild, sleepIdleChildren, wakeGate } from "../src/spawn/host.ts";
1919 import { removeForge, stopForge } from "../src/spawn/manage.ts";
20 import { setLimit } from "../src/settings/limits.ts";
2021 import { awakeRefusal, personRefusal, WakeRate } from "../src/spawn/sleep.ts";
2122 import { wakingResponse } from "../src/web/pages/waking.ts";
2223 import { hostedPlacement } from "../src/spawn/placement.ts";
@@ -148,11 +149,15 @@ describe("a hosted forge scales to zero", () => {
148149
149150 test("a person owns at most so many forges; a site admin is not limited", () => {
150151 // 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 }
156161 });
157162
158163 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", () => {
261266 });
262267
263268 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);
266270 try {
267271 const carol = await createUser(parent.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]" });
268272 if (!carol.ok) throw new Error("carol");
@@ -283,8 +287,7 @@ describe("a hosted forge scales to zero", () => {
283287 expect(second.ok).toBe(false);
284288 if (!second.ok) expect(JSON.stringify(second.error)).toContain("remove one first");
285289 } 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");
288291 }
289292 });
290293
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&#39;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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs