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

New app copies a template repository's tip (readable by the caller, capped, no symlinks), with a door to name it #10604

Merged opened this

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

Merged

mason/forge:mason/10421-new-app-copies-a-template-repository-s-t → main 5 commits fork 4 threads · 4 unresolved

  • Merged. mason/10421-new-app-copies-a-template-repository-s-t was merged into main as fa86966.

Review

Sign in to review this change.
14 files changed +594 −13
docs/APPS.md +1
@@ -506,6 +506,7 @@ and `pick`: `pick: true` turns the description into a checklist and lets the
506506 plan pick the agent (ignored when `agent` is given or there is no
507507 description). The 201 answer carries `plan: {agent, summary, confidence}` when
508508 a pick happened and `plan: null` otherwise, planning being unavailable included.
509 `template: "owner/name"` starts the app as a copy of that repository's tip, if the caller can read it (see `docs/features/app-template.md`).
509510
510511 ## How an app is declared
511512
docs/features/app-template.md added +12
@@ -0,0 +1,12 @@
1 # New app from a template
2
3 A new app can start as a copy of another repository's tip instead of the starter app.
4
5 - **Name it:** `template: "owner/name"` on `POST /api/v1/apps` and the MCP `new_app` tool, or `/apps/new?template=owner/name` on the page (a read-only field carries it through the form).
6 - **Readable by the caller.** A template the caller cannot read answers `no_such_template`, the same as one that does not exist; a private name is never confirmed.
7 - **Capped.** At most 500 files, 512 KiB per file, 4 MiB in all; over any of them is `template_too_big`, which names all three.
8 - **No symlinks.** A symbolic link is `template_has_symlink`; a submodule or any other non-file is `template_unsupported`.
9 - **Refused before anything exists.** The tip is read before the repository is made, so a refusal leaves nothing to take back.
10 - **Doors:** the page, the API, MCP, and the terminal palette entry "New app from a template" (`templateAppScreen`).
11 - **Read in one pass:** one `ls-tree` and one `cat-file --batch`, each output capped (`maxBytes`); a blob that will not read is `template_unreadable`.
12 - Code: `src/apps/template_tip.ts`, `newApp` in `src/actions/apps.ts`. Tests: `test/actions-newapp-template.test.ts`, `test/apps-template-doors.test.ts`.
src/actions/apps.ts +20 −4
@@ -23,6 +23,8 @@ import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts";
2323 import { grantUrl, originFor, sideForViewer, sideReader } from "../apps/proxy.ts";
2424 import { plainLine } from "../apps/side.ts";
2525 import { scaffoldFiles } from "../apps/scaffold.ts";
26 import type { FileWrite } from "../git/write.ts";
27 import { TEMPLATE_ERRORS, templateFiles, type TemplateError } from "../apps/template_tip.ts";
2628 import {
2729 envBuild,
2830 isEnvName,
@@ -270,6 +272,8 @@ export interface NewAppInput {
270272 * also given.
271273 */
272274 pickAgent?: boolean;
275 /** `owner/name` of a repository the actor can read: its tip is copied instead of the starter app. */
276 template?: string;
273277 }
274278
275279 export interface NewAppResult {
@@ -285,6 +289,7 @@ export type NewAppError =
285289 | CreateRepoActionError
286290 | CreateAppError
287291 | "scaffold_failed"
292 | TemplateError
288293 | "no_such_agent"
289294 | "not_a_builder"
290295 | "app_limit";
@@ -328,6 +333,14 @@ export async function newApp(
328333 )
329334 return err("slug_taken");
330335
336 // Read the template before a repository exists to take back.
337 let starter: FileWrite[] | undefined;
338 if (input.template !== undefined && input.template !== "") {
339 const copied = await templateFiles(app, user.value, input.template);
340 if (!copied.ok) return copied;
341 starter = copied.value;
342 }
343
331344 let mark = performance.now();
332345 const lap = (name: string): void => {
333346 const now = performance.now();
@@ -344,10 +357,12 @@ export async function newApp(
344357 if (!repo.ok) return repo;
345358 const committed = await commitToBranch(app, user.value, repo.value, {
346359 message: `Start ${input.name}`,
347 files: scaffoldFiles({
348 name: input.name,
349 description: input.description ?? "",
350 }),
360 files:
361 starter ??
362 scaffoldFiles({
363 name: input.name,
364 description: input.description ?? "",
365 }),
351366 });
352367 lap("scaffold");
353368 if (!committed.ok) {
@@ -462,6 +477,7 @@ export const NEW_APP_ERRORS: Record<string, string> = {
462477 "Another app already answers to that hostname. Pick another name.",
463478 already_app: "That repository is already an app.",
464479 scaffold_failed: "The starter app could not be committed.",
480 ...TEMPLATE_ERRORS,
465481 no_such_agent: NO_SUCH_AGENT,
466482 not_a_builder: "That agent is not a builder, so it cannot be handed a new app. Pick a builder.",
467483 };
src/apps/template_tip.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * A template repository's tip, read as the files a new app starts with.
3 * Three refusals keep the copy safe: the caller must be able to read the
4 * template (anything else answers `no_such_template`, so a private name is
5 * never confirmed), the tip must fit the caps, and it holds no symlink or
6 * submodule, which would point outside the new repository.
7 */
8
9 import { canRead, getRepo } from "../core/repos.ts";
10 import type { User } from "../core/users.ts";
11 import type { App } from "../app.ts";
12 import { OVER_CAP, runGitRaw } from "../git/env.ts";
13 import { isSafeTreePath } from "../git/guard.ts";
14 import { repoDir } from "../git/repos.ts";
15 import { err, ok, type Result } from "../kernel/result.ts";
16 import type { FileWrite } from "../git/write.ts";
17
18 /** Most files a template may hold. */
19 export const TEMPLATE_MAX_FILES = 500;
20 /** Most bytes one template file may hold. */
21 export const TEMPLATE_MAX_FILE_BYTES = 512 * 1024;
22 /** Most bytes the whole template may hold. */
23 export const TEMPLATE_MAX_BYTES = 4 * 1024 * 1024;
24
25 /** Most bytes `ls-tree` may print: a path and its numbers fit in 1 KiB. */
26 const TEMPLATE_MAX_LIST_BYTES = (TEMPLATE_MAX_FILES + 1) * 1024;
27
28 export type TemplateError =
29 | "no_such_template"
30 | "template_empty"
31 | "template_unreadable"
32 | "template_too_big"
33 | "template_has_symlink"
34 | "template_unsupported";
35
36 /** Error code → the words a person is shown. */
37 export const TEMPLATE_ERRORS: Record<TemplateError, string> = {
38 no_such_template: "No template by that name that you can read. Name it owner/name.",
39 template_empty: "That template has no files yet.",
40 template_unreadable: "That template could not be read. Try again.",
41 template_too_big: `That template is over the cap (${TEMPLATE_MAX_FILES} files, ${TEMPLATE_MAX_FILE_BYTES / 1024} KiB a file, ${TEMPLATE_MAX_BYTES / 1024 / 1024} MiB in all).`,
42 template_has_symlink: "That template holds a symbolic link, which is not copied.",
outdated

The template_too_big message omits the 512 KiB per-file cap, so a person hitting that cap is told something that does not match. Name all three caps.

43 template_unsupported: "That template holds something that is not a plain file.",
44 };
45
46 /** `owner/name`, each part plain; anything else is not a template name. */
47 export function parseTemplateName(text: string): { owner: string; name: string } | undefined {
48 const m = /^([A-Za-z0-9][A-Za-z0-9._-]{0,99})\/([A-Za-z0-9][A-Za-z0-9._-]{0,99})$/.exec(text.trim());
49 return m ? { owner: m[1]!, name: m[2]! } : undefined;
50 }
51
52 export async function templateFiles(
53 app: App,
54 actor: User | undefined,
55 named: string,
56 ): Promise<Result<FileWrite[], TemplateError>> {
57 const parsed = parseTemplateName(named);
58 if (!parsed) return err("no_such_template");
59 const repo = getRepo(app.db, parsed.owner, parsed.name);
60 if (!repo || !canRead(app.db, repo, actor)) return err("no_such_template");
61 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
62 const rev = `refs/heads/${repo.defaultBranch}`;
63 const listed = await runGitRaw(["ls-tree", "-r", "-z", "-l", rev], {
64 cwd: dir,
65 timeoutMs: 20_000,
66 maxBytes: TEMPLATE_MAX_LIST_BYTES,
67 });
68 if (!listed.ok) return err(listed.error.code === OVER_CAP ? "template_too_big" : "template_empty");
69
70 const entries: { mode: string; oid: string; size: number; path: string }[] = [];
71 let total = 0;
72 for (const record of new TextDecoder().decode(listed.value).split("\0")) {
73 if (record === "") continue;
74 const tab = record.indexOf("\t");
75 if (tab === -1) continue;
76 const [mode = "", , oid = "", size = "0"] = record.slice(0, tab).split(/ +/);
77 const path = record.slice(tab + 1);
78 if (mode === "120000") return err("template_has_symlink");
79 if (mode !== "100644" && mode !== "100755") return err("template_unsupported");
80 if (!isSafeTreePath(path)) return err("template_unsupported");
81 const bytes = Number(size);
82 total += bytes;
83 if (
84 entries.length + 1 > TEMPLATE_MAX_FILES ||
85 bytes > TEMPLATE_MAX_FILE_BYTES ||
86 total > TEMPLATE_MAX_BYTES
87 )
88 return err("template_too_big");
89 entries.push({ mode, oid, size: bytes, path });
90 }
91 if (entries.length === 0) return err("template_empty");
outdated

This starts one git read per file, up to 500 sequential spawns, on the request path. Batch them with a single cat-file --batch, or read the tree in one call. Also, a failed blob read answers template_empty, which is the wrong word for a broken template. Use a distinct error. The ls-tree output itself is unbounded in memory before the caps apply. Cap the bytes runGitRaw returns.

92
93 // One process reads every blob: `<oid> <type> <size>\n<body>\n` per entry.
94 const raw = await runGitRaw(["cat-file", "--batch"], {
95 cwd: dir,
96 stdin: entries.map((e) => `${e.oid}\n`).join(""),
97 timeoutMs: 30_000,
98 maxBytes: TEMPLATE_MAX_BYTES + entries.length * 128,
99 });
100 if (!raw.ok) return err("template_unreadable");
101 const bytes = raw.value;
102 const decoder = new TextDecoder();
103 const files: FileWrite[] = [];
104 let at = 0;
105 for (const e of entries) {
106 const nl = bytes.indexOf(10, at);
107 if (nl < 0) return err("template_unreadable");
108 const header = decoder.decode(bytes.subarray(at, nl)).split(" ");
109 const size = Number(header[2]);
110 if (header[1] !== "blob" || size !== e.size || nl + 1 + size > bytes.length) return err("template_unreadable");
111 files.push({ path: e.path, content: bytes.slice(nl + 1, nl + 1 + size), mode: e.mode as "100644" | "100755" });
112 at = nl + 1 + size + 1;
113 }
114 return ok(files);
115 }
src/contract/openapi/routes/postApiV1Apps.ts +1 −1
@@ -1,7 +1,7 @@
11 import type { Route } from "../shared.ts";
22
33 /** What `POST /api/v1/apps` reads from its body, and the refusals it answers. */
4 const APP_BODY = { name: "string", owner: "string", description: "string", sentence: "string", visibility: "string", agent: "string", pick: "boolean" };
4 const APP_BODY = { name: "string", owner: "string", description: "string", sentence: "string", visibility: "string", agent: "string", pick: "boolean", template: "string" };
55 const APP_REFUSALS = {
66 unverified: "the account has not verified its email",
77 no_name: "no `name`, and the `sentence` leaves nothing to make one from",
src/git/env.ts +16 −1
@@ -245,11 +245,15 @@ export interface RunOptions {
245245 stdin?: Uint8Array | string;
246246 /** Kill the process after this long. @default 30_000 */
247247 timeoutMs?: number;
248 /** Kill the process, and fail with code `OVER_CAP`, once stdout passes this many bytes. */
249 maxBytes?: number;
248250 /** Kill the process when this aborts. */
249251 signal?: AbortSignal;
250252 }
251253
252254 /** Run git to completion, capturing output as bytes. Non-zero exit is an Err. */
255 export const OVER_CAP = -2;
256
253257 export async function runGitRaw(
254258 args: readonly string[],
255259 options: RunOptions = {},
@@ -268,14 +272,19 @@ export async function runGitRaw(
268272 const timeoutMs = options.timeoutMs ?? 30_000;
269273 const timer = setTimeout(() => proc.kill("SIGKILL"), timeoutMs);
270274 const stop = () => proc.kill("SIGKILL");
275 let over = false;
271276 if (options.signal?.aborted) stop();
272277 options.signal?.addEventListener("abort", stop, { once: true });
273278 try {
274279 const [stdout, stderr, code] = await Promise.all([
275 readAll(proc.stdout),
280 readAll(proc.stdout, options.maxBytes, () => {
281 over = true;
282 stop();
283 }),
276284 new Response(proc.stderr).text(),
277285 proc.exited,
278286 ]);
287 if (over) return err({ code: OVER_CAP, stderr: "output over the cap", args });
279288 if (code !== 0) return err({ code, stderr: stderr.trim(), args });
280289 return ok(stdout);
281290 } finally {
@@ -407,12 +416,18 @@ export async function runGitCapture(
407416 */
408417 async function readAll(
409418 stream: ReadableStream<Uint8Array>,
419 maxBytes?: number,
420 onOver?: () => void,
410421 ): Promise<Uint8Array> {
411422 const chunks: Uint8Array[] = [];
412423 let total = 0;
413424 for await (const chunk of stream) {
414425 chunks.push(chunk.slice());
415426 total += chunk.length;
427 if (maxBytes !== undefined && total > maxBytes) {
428 onOver?.();
429 break;
430 }
416431 }
417432 const out = new Uint8Array(total);
418433 let offset = 0;
src/http/api.ts +1
@@ -3581,6 +3581,7 @@ export function apiRoutes(router: Router<Handler>): void {
35813581 ? { visibility: input.visibility }
35823582 : {}),
35833583 ...(typeof input.agent === "string" ? { agent: input.agent } : {}),
3584 ...(typeof input.template === "string" && input.template !== "" ? { template: input.template } : {}),
35843585 // Only a real boolean asks; like the other fields, anything else is ignored.
35853586 ...(input.pick === true ? { pickAgent: true } : {}),
35863587 };
src/http/mcp/tools/new_app.ts +6 −3
@@ -2,7 +2,7 @@ import { appsRuntime } from "../../../apps/runtime.ts";
22 import { listActiveRuns } from "../../../core/runs.ts";
33 import { getUserByName } from "../../../core/users.ts";
44 import { repositoryLimitMessage } from "../../../actions/repos.ts";
5 import { NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput } from "../../../actions/apps.ts";
5 import { NEW_APP_ERRORS, NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput } from "../../../actions/apps.ts";
66 import { appLimitWords } from "../../../actions/app_limit.ts";
77 import { storageLimitMessage } from "../../../core/storage.ts";
88 import { VERIFY_FIRST } from "../../../actions/verify_words.ts";
@@ -19,7 +19,7 @@ async function fromSentence(app: App, user: User, sentence: string, input: Omit<
1919
2020 export const tool: Tool = {
2121 name: "new_app",
22 doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence: with `sentence` and no `name` the name is derived from it (`-2`, `-3` when taken), as the web and terminal do; `name` and `sentence` together keep the name; the first issue is `description`, else `sentence`; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)",
22 doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick?, template? }`: make an app from a sentence: with `sentence` and no `name` the name is derived from it (`-2`, `-3` when taken), as the web and terminal do; `name` and `sentence` together keep the name; the first issue is `description`, else `sentence`; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)",
2323 description:
2424 "Make an app from a sentence: a repo, production, and with `description` a first issue.",
2525 inputSchema: {
@@ -32,6 +32,7 @@ export const tool: Tool = {
3232 visibility: { type: "string", enum: ["public", "private"] },
3333 agent: { type: "string", maxLength: 39 },
3434 pick: { type: "boolean" },
35 template: { type: "string", maxLength: 201, description: "owner/name to copy, readable by you" },
3536 },
3637 },
3738 writes: true,
@@ -42,10 +43,12 @@ export const tool: Tool = {
4243 const description = text("description");
4344 const agent = text("agent");
4445 const sentence = text("sentence");
46 const template = text("template");
4547 const rest: Omit<NewAppInput, "name" | "description"> = {
4648 ...(owner === undefined ? {} : { owner }),
4749 ...(visibility === "public" || visibility === "private" ? { visibility } : {}),
4850 ...(agent === undefined ? {} : { agent }),
51 ...(template === undefined || template === "" ? {} : { template }),
4952 ...(args["pick"] === true ? { pickAgent: true } : {}),
5053 };
5154 const given = str(args["name"]).trim();
@@ -70,7 +73,7 @@ export const tool: Tool = {
7073 case "storage_limit":
7174 return bad("storage_limit", storageLimitMessage(ctx.app, user.id));
7275 default:
73 return bad(made.error, made.error);
76 return bad(made.error, NEW_APP_ERRORS[made.error] ?? made.error);
7477 }
7578 }
7679 const { repo, app: record, issue } = made.value;
src/tui/forge.ts +9 −1
@@ -94,7 +94,7 @@ import { newForgeScreen } from "./screens/newforge.ts";
9494 import { newOrgScreen } from "./screens/neworg.ts";
9595 import { migrateScreen } from "./screens/migrate.ts";
9696 import { bringScreen } from "./screens/bring.ts";
97 import { newAppScreen } from "./screens/newapp.ts";
97 import { newAppScreen, templateAppScreen } from "./screens/newapp.ts";
9898 import { newRepoScreen } from "./screens/newrepo.ts";
9999 import { repoScreen } from "./screens/repo.ts";
100100 import { signInScreen } from "./screens/signin.ts";
@@ -288,6 +288,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
288288 hint: "say what it does in a sentence; the crew starts on it",
289289 run: () => ctx.push(newAppScreen(session, ctx, blank("New app"))),
290290 });
291 // The same from a template repository, as /apps/new?template= does.
292 if (session.user !== undefined && session.user.kind !== "agent")
293 commands.push({
294 id: "new-app-template",
295 title: "New app from a template",
296 hint: "copy a repository you can read, as the starting files",
297 run: () => ctx.push(templateAppScreen(session, ctx, blank("New app"))),
298 });
291299 // An app from another forge, as /bring does: a person's, never an agent's.
292300 if (session.user !== undefined && session.user.kind !== "agent")
293301 commands.push({
src/tui/screens/newapp.ts +27 −3
@@ -13,6 +13,7 @@ import { repositoryLimitMessage } from "../../actions/repos.ts";
1313 import { appsRuntime } from "../../apps/runtime.ts";
1414 import { listOrgsForUser } from "../../core/orgs.ts";
1515 import { storageLimitMessage } from "../../core/storage.ts";
16 import { parseTemplateName } from "../../apps/template_tip.ts";
1617 import { deriveAppName } from "../../web/pages/apps-name.ts";
1718 import type { Ctx, Screen } from "../app/app.ts";
1819 import type { Session } from "../session.ts";
@@ -28,7 +29,11 @@ function refusal(f: Session, code: string, ownerId: string): string {
2829 return NEW_APP_ERRORS[code] ?? "That app could not be created.";
2930 }
3031
31 export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen {
32 /**
33 * `template` (`owner/name`) makes the app a copy of that repository's tip,
34 * as `/apps/new?template=` does; the action refuses one the person cannot read.
35 */
36 export function newAppScreen(f: Session, ctx: Ctx, parent: Screen, template?: string): Screen {
3237 const owners = f.user
3338 ? [f.user.username, ...listOrgsForUser(f.db, f.user.id).map((o) => o.username)]
3439 : [];
@@ -36,7 +41,11 @@ export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen {
3641 const make = (sentence: string, owner: string): void => {
3742 ctx.toast("Making your app… the crew starts on it next.");
3843 runAsync(ctx, async () => {
39 const made = await newAppFromSentence(f.app, f.user, { sentence, owner });
44 const made = await newAppFromSentence(f.app, f.user, {
45 sentence,
46 owner,
47 ...(template === undefined ? {} : { template }),
48 });
4049 if (!made.ok) {
4150 ctx.toast(refusal(f, made.code, f.user!.id), "danger");
4251 return;
@@ -53,7 +62,7 @@ export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen {
5362 };
5463
5564 return promptScreen(ctx, parent, {
56 title: "New app",
65 title: template === undefined ? "New app" : `New app from ${template}`,
5766 label: "What should it do?",
5867 placeholder: "a shared shopping list for the flat",
5968 hint: "One sentence; the app is named from it.",
@@ -78,3 +87,18 @@ export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen {
7887 },
7988 });
8089 }
90
91 /** Asks which repository to copy, then goes on as "New app" does. */
92 export function templateAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen {
93 return promptScreen(ctx, parent, {
94 title: "New app from a template",
95 label: "Which repository?",
96 placeholder: "owner/name",
97 hint: "A repository you can read; its files start the app.",
98 onSubmit: (named) => {
99 const parsed = parseTemplateName(named);
100 if (parsed === undefined) return "Name it owner/name.";
101 return later(ctx, () => newAppScreen(f, ctx, parent, `${parsed.owner}/${parsed.name}`));
102 },
103 });
104 }
src/web/pages/apps.ts +8
@@ -283,6 +283,8 @@ interface NewAppValues {
283283 description: string;
284284 visibility: string;
285285 agent: string;
286 /** `owner/name` of a template to copy; empty for the starter app. */
287 template?: string;
286288 }
287289
288290 /** The agent select's sentinel for "turn the sentence into a plan and pick". */
@@ -326,6 +328,9 @@ function newAppForm(
326328 sub: "Describe what it should do. You get a repository, a first issue, a crew member on it, a live app, and a preview for every change.",
327329 })}
328330 ${html`<form method="post" action="/apps/new">
331 ${values.template

Design principles 1 and 8: the page already states its purpose, and this adds a hand-rolled <p class="muted small"><code> instead of a named component. The sentence and the hidden field also say the same thing twice. Use the DESIGN.md pattern for a fact (a dl row, or the existing field component with the template as a read-only value), and run bun scripts/design-lint.ts again.

332 ? formField({ label: "Copy of", name: "template", value: values.template, readonly: true, mono: true })
333 : html``}
329334 ${formField({
330335 label: "What should it do?",
331336 name: "description",
@@ -449,6 +454,7 @@ export function newAppPage(ctx: Ctx): Response {
449454 description: starterFromQuery(ctx.url.searchParams.get("description")),
450455 visibility: "private",
451456 agent: picks ? PICK_AGENT : "",
457 template: ctx.url.searchParams.get("template") ?? "",
452458 }, picks),
453459 scriptJs: appNameJs,
454460 });
@@ -469,6 +475,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
469475 description: field(form, "description"),
470476 visibility: field(form, "visibility") === "public" ? "public" : "private",
471477 agent: field(form, "agent"),
478 template: field(form, "template"),
472479 };
473480 const pickAgent = values.agent === PICK_AGENT;
474481 // An explicit name is never second-guessed; left blank, the sentence
@@ -478,6 +485,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
478485 const rest = {
479486 owner: values.owner,
480487 visibility: values.visibility === "public" ? "public" : "private",
488 ...(values.template ? { template: values.template } : {}),
481489 ...(pickAgent
482490 ? { pickAgent: true }
483491 : values.agent === ""
test/actions-newapp-template.test.ts added +180
@@ -0,0 +1,180 @@
1 /**
2 * `newApp` with a `template`: the tip of a repository the caller can read is
3 * copied; an unreadable, oversized or symlinked template is refused before a
4 * repository exists.
5 */
6 import { afterAll, expect, setDefaultTimeout, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { NEW_APP_ERRORS, newApp } from "../src/actions/apps.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { setRole } from "../src/actions/roles.ts";
13 import { TEMPLATE_ERRORS, TEMPLATE_MAX_BYTES, TEMPLATE_MAX_FILES } from "../src/apps/template_tip.ts";
14 import { createApp } from "../src/app.ts";
15 import { getRepo } from "../src/core/repos.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { repoDir } from "../src/git/repos.ts";
18 import { readerFor } from "../src/git/read.ts";
19 import type { FileWrite } from "../src/git/write.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23 setDefaultTimeout(30_000);
24 const dirs: string[] = [];
25 afterAll(async () => {
26 for (const d of dirs) await rm(d, { recursive: true, force: true });
27 });
28
29 async function world() {
30 const dir = await mkdtemp(join(tmpdir(), "forge-tpl-"));
31 dirs.push(dir);
32 const app = createApp({ dataDir: dir, port: 0 });
33 const mk = async (username: string, isAdmin = false) => {
34 const made = await createUser(app.db, {
35 origin: "seed", trusted: true, username, email: `${username}@x.test`, isAdmin,
36 });
37 if (!made.ok) throw new Error(made.error);
38 return made.value;
39 };
40 const admin = await mk("boss", true);
41 const owner = await mk("giver");
42 const taker = await mk("taker");
43 for (const u of [owner, taker]) setRole(app, admin, u.username, "member");
44 const template = async (
45 name: string,
46 visibility: "public" | "private",
47 files: FileWrite[],
48 ) => {
49 const repo = await createRepo(app, owner, { name, visibility, description: "" });
50 if (!repo.ok) throw new Error(repo.error);
51 const done = await commitToBranch(app, owner, repo.value, { message: "t", files });
52 if (!done.ok) throw new Error(String(done.error));
53 };
54 return { app, owner, taker, template };
55 }
56
57 test("a public template's tip is copied, modes kept", async () => {
58 const { app, taker, template } = await world();
59 await template("kit", "public", [
60 { path: "README.md", content: "# kit\n" },
61 { path: "bin/run.sh", content: "echo hi\n", mode: "100755" },
62 ]);
63 const made = await newApp(app, taker, { name: "mine", template: "giver/kit" });
64 expect(made.ok).toBe(true);
65 if (!made.ok) return;
66 const dir = repoDir(app.reposRoot, "taker", "mine");
67 const readme = await readerFor(dir).object(`refs/heads/${made.value.repo.defaultBranch}:README.md`);
68 expect(readme.ok && new TextDecoder().decode(readme.value.body)).toBe("# kit\n");
69 const tree = Bun.spawnSync(["git", "ls-tree", "-r", `refs/heads/${made.value.repo.defaultBranch}`], { cwd: dir }).stdout.toString();
70 expect(tree).toContain("100755 blob");
71 expect(tree).toMatch(/^100755 blob \w+\tbin\/run\.sh$/m);
72 expect(tree).toMatch(/^100644 blob \w+\tREADME\.md$/m);
73 // Not the starter app.
74 const starter = await readerFor(dir).stat(`refs/heads/${made.value.repo.defaultBranch}:src/server.ts`);
75 expect(starter.ok).toBe(false);
76 });
77
78 test("a private template the caller cannot read answers as one that does not exist, and nothing is made", async () => {
79 const { app, taker, template } = await world();
80 await template("secret", "private", [{ path: "a.txt", content: "x" }]);
81 const hidden = await newApp(app, taker, { name: "mine", template: "giver/secret" });
82 const absent = await newApp(app, taker, { name: "mine", template: "giver/nothing" });
83 expect(hidden).toEqual({ ok: false, error: "no_such_template" });
84 expect(absent).toEqual(hidden);
85 expect(getRepo(app.db, "taker", "mine")).toBeUndefined();
86 });
87
88 test("a malformed template name is refused", async () => {
89 const { app, taker } = await world();
90 for (const template of ["x", "../a/b", "a/b/c", "a/b;rm"]) {
91 const made = await newApp(app, taker, { name: "mine", template });
92 expect(made).toEqual({ ok: false, error: "no_such_template" });
93 }
94 });
95
96 test("a template over the file cap is refused", async () => {
97 const { app, taker, template } = await world();
98 const files: FileWrite[] = Array.from({ length: TEMPLATE_MAX_FILES + 1 }, (_, i) => ({
99 path: `f/${i}.txt`, content: "x",
100 }));
101 await template("huge", "public", files);
102 const made = await newApp(app, taker, { name: "mine", template: "giver/huge" });
103 expect(made).toEqual({ ok: false, error: "template_too_big" });
104 expect(getRepo(app.db, "taker", "mine")).toBeUndefined();
105 });
106
107 test("a template file over the byte cap is refused", async () => {
108 const { app, taker, template } = await world();
109 await template("fat", "public", [{ path: "big.bin", content: new Uint8Array(600 * 1024) }]);
110 const made = await newApp(app, taker, { name: "mine", template: "giver/fat" });
111 expect(made).toEqual({ ok: false, error: "template_too_big" });
112 });
113
114 /** Commit one more tree entry of `mode` onto a template's default branch. */
115 async function addEntry(app: ReturnType<typeof createApp>, repoName: string, mode: string, oid: string, path: string) {
116 const dir = repoDir(app.reposRoot, "giver", repoName);
117 const git = (args: string[], env: Record<string, string> = {}, input?: string) => {
118 const r = Bun.spawnSync(["git", ...args], {
119 cwd: dir, stdin: input === undefined ? "ignore" : new Blob([input]),
120 env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@x", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@x", ...env },
121 });
122 return r.stdout.toString().trim();
123 };
124 const branch = getRepo(app.db, "giver", repoName)!.defaultBranch;
125 const index = join(dir, "tmp-index");
126 const env = { GIT_INDEX_FILE: index };
127 git(["read-tree", `refs/heads/${branch}`], env);
128 git(["update-index", "--add", "--cacheinfo", `${mode},${oid},${path}`], env);
129 const tree = git(["write-tree"], env);
130 const commit = git(["commit-tree", tree, "-p", `refs/heads/${branch}`, "-m", "entry"]);
131 git(["update-ref", `refs/heads/${branch}`, commit]);
132 }
133
134 test("a template holding a symlink is refused", async () => {
outdated

Add tests for the missing cases listed in the review body. Assert the mode of bin/run.sh is 100755, not just that it exists, and cover the total-bytes cap, a gitlink (submodule), and the page, API and MCP doors.

135 const { app, taker, template } = await world();
136 await template("linky", "public", [{ path: "a.txt", content: "x" }]);
137 const dir = repoDir(app.reposRoot, "giver", "linky");
138 const target = Bun.spawnSync(["git", "hash-object", "-w", "--stdin"], { cwd: dir, stdin: new Blob(["a.txt"]) }).stdout.toString().trim();
139 await addEntry(app, "linky", "120000", target, "link");
140 const made = await newApp(app, taker, { name: "mine", template: "giver/linky" });
141 expect(made).toEqual({ ok: false, error: "template_has_symlink" });
142 expect(getRepo(app.db, "taker", "mine")).toBeUndefined();
143 });
144
145 test("a template holding a submodule is refused as unsupported", async () => {
146 const { app, taker, template } = await world();
147 await template("modular", "public", [{ path: "a.txt", content: "x" }]);
148 await addEntry(app, "modular", "160000", "1".repeat(40), "vendor");
149 const made = await newApp(app, taker, { name: "mine", template: "giver/modular" });
150 expect(made).toEqual({ ok: false, error: "template_unsupported" });
151 expect(getRepo(app.db, "taker", "mine")).toBeUndefined();
152 });
153
154 test("a template over the total byte cap is refused though every file is under its own", async () => {
155 const { app, taker, template } = await world();
156 const each = 500 * 1024;
157 const files: FileWrite[] = Array.from({ length: Math.ceil(TEMPLATE_MAX_BYTES / each) + 1 }, (_, i) => ({
158 path: `blob${i}.bin`, content: `${i}`.repeat(each).slice(0, each),
159 }));
160 await template("heavy", "public", files);
161 const made = await newApp(app, taker, { name: "mine", template: "giver/heavy" });
162 expect(made).toEqual({ ok: false, error: "template_too_big" });
163 expect(getRepo(app.db, "taker", "mine")).toBeUndefined();
164 });
165
166 test("a template with no commits is refused as empty", async () => {
167 const { app, owner, taker } = await world();
168 const repo = await createRepo(app, owner, { name: "bare", visibility: "public", description: "" });
169 expect(repo.ok).toBe(true);
170 const made = await newApp(app, taker, { name: "mine", template: "giver/bare" });
171 expect(made).toEqual({ ok: false, error: "template_empty" });
172 expect(getRepo(app.db, "taker", "mine")).toBeUndefined();
173 });
174
175 test("every refusal has words, and the too-big words name all three caps", () => {
176 for (const code of Object.keys(TEMPLATE_ERRORS)) expect(NEW_APP_ERRORS[code]).toBeTruthy();
177 expect(TEMPLATE_ERRORS.template_too_big).toContain(`${TEMPLATE_MAX_FILES} files`);
178 expect(TEMPLATE_ERRORS.template_too_big).toContain("512 KiB a file");
179 expect(TEMPLATE_ERRORS.template_too_big).toContain("4 MiB in all");
180 });
test/apps-template-doors.test.ts added +196
@@ -0,0 +1,196 @@
1 /**
2 * Every door that can name a template: the page (`/apps/new?template=`), the
3 * API field, the MCP tool and the terminal's palette entry. Each copies the
4 * tip of a readable repository and refuses one that is not, in the words of
5 * `TEMPLATE_ERRORS`.
6 */
7 import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { setRole } from "../src/actions/roles.ts";
13 import { TEMPLATE_ERRORS } from "../src/apps/template_tip.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { getRepo } from "../src/core/repos.ts";
16 import { createUser, getUserByName, type User } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
19 import { blank } from "../src/tui/screens/common.ts";
20 import { templateAppScreen } from "../src/tui/screens/newapp.ts";
21 import { deriveAppName } from "../src/web/pages/apps-name.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { settleApp } from "./helpers/tui.ts";
25 import { tokenFor } from "./helpers/tokens.ts";
26
27 quietLogs();
28 setDefaultTimeout(30_000);
29
30 let dir: string;
31 let app: App;
32 let servers: Servers;
33 let base: string;
34 let giver: User;
35 let cookie: string;
36 let token: string;
37
38 beforeAll(async () => {
39 dir = await mkdtemp(join(tmpdir(), "forge-tpl-doors-"));
40 app = createApp({ dataDir: dir, port: 0 });
41 servers = await serve(app);
42 base = servers.url;
43 const mk = async (username: string, adminIfFirst = false) => {
44 const made = await createUser(app.db, {
45 origin: "seed", trusted: true, username, email: `${username}@x.test`,
46 password: "a-good-passphrase", adminIfFirst,
47 });
48 if (!made.ok) throw new Error(made.error);
49 return made.value;
50 };
51 giver = await mk("giver", true);
52 const taker = await mk("taker");
53 setRole(app, giver, taker.username, "member");
54 for (const [name, visibility] of [["kit", "public"], ["secret", "private"]] as const) {
55 const repo = await createRepo(app, giver, { name, visibility, description: "" });
56 if (!repo.ok) throw new Error(repo.error);
57 const done = await commitToBranch(app, giver, repo.value, {
58 message: "t",
59 files: [{ path: "README.md", content: `# ${name}\n` }],
60 });
61 if (!done.ok) throw new Error(String(done.error));
62 }
63 cookie = (
64 (
65 await fetch(`${base}/signin`, {
66 method: "POST",
67 body: new URLSearchParams({ username: "taker", password: "a-good-passphrase" }),
68 redirect: "manual",
69 })
70 ).headers.get("set-cookie") ?? ""
71 ).split(";")[0]!;
72 token = tokenFor(app, "taker", "rw");
73 });
74 afterAll(async () => {
75 servers.stop();
76 app.close();
77 await rm(dir, { recursive: true, force: true });
78 });
79
80 const readme = (owner: string, name: string): string => {
81 const repo = getRepo(app.db, owner, name)!;
82 return Bun.spawnSync(["git", "show", `refs/heads/${repo.defaultBranch}:README.md`], {
83 cwd: join(app.reposRoot, owner, `${name}.git`),
84 }).stdout.toString();
85 };
86
87 test("the page carries ?template= as a read-only field, escaped, and the POST makes the copy", async () => {
88 const shown = await (await fetch(`${base}/apps/new?template=giver/kit`, { headers: { cookie } })).text();
89 expect(shown).toMatch(/<input[^>]*name="template"[^>]*readonly[^>]*value="giver\/kit"|<input[^>]*name="template"[^>]*value="giver\/kit"[^>]*readonly/s);
90 const hostile = await (await fetch(`${base}/apps/new?template=${encodeURIComponent('"><script>x</script>')}`, { headers: { cookie } })).text();
91 expect(hostile).not.toContain("<script>x</script>");
92 expect(hostile).toContain("&lt;script&gt;");
93 const plain = await (await fetch(`${base}/apps/new`, { headers: { cookie } })).text();
94 expect(plain).not.toContain('name="template"');
95
96 const posted = await fetch(`${base}/apps/new`, {
97 method: "POST",
98 headers: { cookie, origin: base },
99 body: new URLSearchParams({
100 owner: "", name: "from-page", description: "x", visibility: "private", agent: "", template: "giver/kit",
101 }),
102 redirect: "manual",
103 });
104 expect(posted.status).toBe(303);
105 expect(readme("taker", "from-page")).toBe("# kit\n");
106 });
107
108 test("the page refuses a template it cannot read, in words, and makes nothing", async () => {
109 const posted = await fetch(`${base}/apps/new`, {
110 method: "POST",
111 headers: { cookie, origin: base },
112 body: new URLSearchParams({
113 owner: "", name: "from-page-2", description: "x", visibility: "private", agent: "", template: "giver/secret",
114 }),
115 redirect: "manual",
116 });
117 expect(getRepo(app.db, "taker", "from-page-2")).toBeUndefined();
118 const body = posted.status === 303 ? "" : await posted.text();
119 expect(posted.status === 303 ? posted.headers.get("location") : body).not.toBe("/taker/from-page-2");
120 if (body) expect(body).toContain(TEMPLATE_ERRORS.no_such_template);
121 });
122
123 test("the API field copies a readable template and refuses a private one as missing", async () => {
124 const api = (body: unknown) =>
125 fetch(`${base}/api/v1/apps`, {
126 method: "POST",
127 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
128 body: JSON.stringify(body),
129 });
130 const ok = await api({ name: "from-api", description: "x", template: "giver/kit" });
131 expect(ok.status).toBe(201);
132 expect(readme("taker", "from-api")).toBe("# kit\n");
133 const hidden = await api({ name: "from-api-2", description: "x", template: "giver/secret" });
134 const absent = await api({ name: "from-api-2", description: "x", template: "giver/none" });
135 expect(hidden.status).toBeGreaterThanOrEqual(400);
136 expect(hidden.status).toBe(absent.status);
137 expect(await hidden.json()).toEqual(await absent.json());
138 expect(getRepo(app.db, "taker", "from-api-2")).toBeUndefined();
139 });
140
141 test("the MCP tool copies the template and maps a refusal to its words", async () => {
142 const call = async (args: unknown) => {
143 const res = await fetch(`${base}/mcp`, {
144 method: "POST",
145 headers: {
146 "content-type": "application/json",
147 accept: "application/json, text/event-stream",
148 authorization: `Bearer ${token}`,
149 },
150 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "new_app", arguments: args } }),
151 });
152 const body = (await res.json()) as any;
153 return { error: body.result.isError === true, text: body.result.content[0].text as string };
154 };
155 const made = await call({ name: "from-mcp", description: "x", template: "giver/kit" });
156 expect(made.error).toBe(false);
157 expect(readme("taker", "from-mcp")).toBe("# kit\n");
158 const refused = await call({ name: "from-mcp-2", description: "x", template: "giver/secret" });
159 expect(refused.error).toBe(true);
160 expect(refused.text).toContain(TEMPLATE_ERRORS.no_such_template);
161 expect(getRepo(app.db, "taker", "from-mcp-2")).toBeUndefined();
162 });
163
164 test("the terminal offers the template entry to a person, not an agent or a guest, and makes the copy", async () => {
165 const identity = (user?: User) => ({
166 sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user,
167 });
168 const taker = getUserByName(app.db, "taker")!;
169 const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id);
170 expect(ids(taker)).toContain("new-app-template");
171 expect(ids({ ...taker, kind: "agent" })).not.toContain("new-app-template");
172 expect(ids()).not.toContain("new-app-template");
173
174 const terminal = headlessTerminal(120, 30);
175 const { app: tui, session } = createForgeApp(app, identity(taker), { terminal, exitHooks: false });
176 tui.start();
177 tui.ctx.push(templateAppScreen(session, tui.ctx, blank("New app")));
178 try {
179 const feed = async (s: string) => {
180 terminal.feed(s);
181 await settleApp(tui);
182 };
183 await settleApp(tui);
184 await feed("nonsense\r");
185 expect(tui.screen.toText()).toContain("Name it owner/name.");
186 for (let i = 0; i < "nonsense".length; i++) await feed("\x7f");
187 await feed("giver/kit\r");
188 await feed("a copy from the terminal\r");
189 const named = deriveAppName("a copy from the terminal")!;
190 const deadline = Date.now() + 8000;
191 while (getRepo(app.db, "taker", named) === undefined && Date.now() < deadline) await Bun.sleep(20);
192 expect(readme("taker", named)).toBe("# kit\n");
193 } finally {
194 tui.stop();
195 }
196 });
test/palette-roster.test.ts +2
@@ -73,6 +73,7 @@ test("a site-admin person is offered the whole roster", () => {
7373 ["settings", "Repository settings", "owner/name"],
7474 ["new-repo", "New repository", undefined],
7575 ["new-app", "New app", "say what it does in a sentence; the crew starts on it"],
76 ["new-app-template", "New app from a template", "copy a repository you can read, as the starting files"],
7677 ["bring-app", "Bring an app", "paste a link from another forge; it runs here"],
7778 ["new-org", "New organization", "an organization you own, for a client's apps"],
7879 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],
@@ -268,6 +269,7 @@ test("a member person is offered the signed-in roster without the admin entries"
268269 ["settings", "Repository settings", "owner/name"],
269270 ["new-repo", "New repository", undefined],
270271 ["new-app", "New app", "say what it does in a sentence; the crew starts on it"],
272 ["new-app-template", "New app from a template", "copy a repository you can read, as the starting files"],
271273 ["bring-app", "Bring an app", "paste a link from another forge; it runs here"],
272274 ["new-org", "New organization", "an organization you own, for a client's apps"],
273275 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs