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

Tell the crew from the forge toolbar: the tell carries the page it was sent from #2300

Merged opened this

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

Merged

scout/forge:scout/2252-tell-the-crew-from-the-forge-toolbar-the → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/2252-tell-the-crew-from-the-forge-toolbar-the was merged into main as 8bf16d1.

Review

Sign in to review this change.
13 files changed +472 −15
README.md +14
@@ -560,6 +560,20 @@ pull, the panel also says `scout is working on this` and links the run; it asks
560560 read the repository (and, for a run on a sealed issue, that issue), and shows
561561 nothing when the crew is idle.
562562
563 **Tell the crew from the pill.** Signed in as someone who may direct the crew,
564 the panel also offers *Tell the crew* (`/__forge/crew` adds a `tell` link, only
565 for them; signed out there is no action). It opens the forge's own
566 `/:owner/:name/tell?page=<address>&pull=<n>` in a new tab, so the POST is the
567 forge's same-origin, CSRF-checked form and the app's origin can never send one.
568 It is the same `tell()` as every door; `page` (an http(s) address) and `pull`
569 (a number, on a preview) are checked again on send and written into the tell
570 as the *Where this was told from* block. A receipt links the issue it opened; a
571 refused tell returns to the box with the words, page and pull kept. The MCP
572 `tell` takes the same through `context: { page, pull, run, commit, said }`, and
573 the terminal's palette has *Tell the crew from a page*, which asks for the
574 page, the pull (optional) and the line, through the same `parseContext` and
575 `contextNote`.
576
563577 With a sentence and **Pick for me**, the sentence becomes a plan before the
564578 page comes back: `src/crew/plan.ts` makes one non-interactive call to
565579 `claude` — the quickest model, a fixed JSON shape, at most 3 seconds — for a
src/actions/tell_context.ts +8 −1
@@ -8,6 +8,7 @@
88
99 export interface TellContext {
1010 readonly page?: string;
11 readonly pull?: number;
1112 readonly run?: string;
1213 readonly commit?: string;
1314 readonly said?: string;
@@ -22,7 +23,7 @@ export function parseContext(v: unknown): TellContext | undefined | null {
2223 if (v === undefined) return undefined;
2324 if (typeof v !== "object" || v === null || Array.isArray(v)) return null;
2425 const o = v as Record<string, unknown>;
25 const out: { page?: string; run?: string; commit?: string; said?: string } = {};
26 const out: { page?: string; pull?: number; run?: string; commit?: string; said?: string } = {};
2627 if (o["page"] !== undefined) {
2728 const url =
2829 typeof o["page"] === "string" && o["page"].length <= 2000
@@ -32,6 +33,11 @@ export function parseContext(v: unknown): TellContext | undefined | null {
3233 return null;
3334 out.page = url.href;
3435 }
36 if (o["pull"] !== undefined) {
37 const n = o["pull"];
38 if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) return null;
39 out.pull = n;
40 }
3541 if (o["run"] !== undefined) {
3642 if (typeof o["run"] !== "string" || !RUN.test(o["run"])) return null;
3743 out.run = o["run"];
@@ -52,6 +58,7 @@ export function contextNote(c: TellContext | undefined): string {
5258 if (c === undefined) return "";
5359 const lines = [
5460 c.page === undefined ? "" : `- page: ${c.page}`,
61 c.pull === undefined ? "" : `- pull: #${c.pull}`,
5562 c.run === undefined ? "" : `- run: ${c.run}`,
5663 c.commit === undefined ? "" : `- commit: ${c.commit}`,
5764 c.said === undefined || c.said === ""
src/apps/crew.ts +20
@@ -8,6 +8,7 @@
88
99 import type { Database } from "bun:sqlite";
1010 import type { AppRecord } from "../core/apps.ts";
11 import { mayDirectCrew } from "../crew/direct.ts";
1112 import { getIssue } from "../core/issues.ts";
1213 import { canRead, getRepoById } from "../core/repos.ts";
1314 import { workingAgentRuns } from "../core/runs.ts";
@@ -46,3 +47,22 @@ export function crewNow(
4647 url: new URL(`/${repo.ownerName}/${repo.name}/runs/${run.id}`, forgeUrl).href,
4748 };
4849 }
50
51 /**
52 * The forge page that says a tell for a signed-in person who may direct the
53 * crew on this app; nothing for anyone else, so a signed-out pill offers no
54 * action. It is always on the forge's own origin.
55 */
56 export function tellLink(
57 db: Database,
58 forgeUrl: string,
59 app: AppRecord,
60 viewerId: string | undefined,
61 ): string | undefined {
62 if (viewerId === undefined) return undefined;
63 const viewer = getUserById(db, viewerId);
64 const repo = getRepoById(db, app.repoId);
65 if (viewer === undefined || !isActive(viewer) || repo === undefined) return undefined;
66 if (!canRead(db, repo, viewer) || !mayDirectCrew(db, repo, viewer)) return undefined;
67 return new URL(`/${repo.ownerName}/${repo.name}/tell`, forgeUrl).href;
68 }
src/apps/proxy.ts +8 −2
@@ -34,7 +34,7 @@ import { err, ok, type Result } from "../kernel/result.ts";
3434 import { loadKey } from "../kernel/secret.ts";
3535 import { previewEnv, type EnvName } from "./data.ts";
3636 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
37 import { crewNow } from "./crew.ts";
37 import { crewNow, tellLink } from "./crew.ts";
3838 import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts";
3939
4040 export interface AppTarget {
@@ -551,7 +551,13 @@ export async function proxyApp(
551551 target.env,
552552 signed?.who?.id,
553553 );
554 return Response.json(crew ?? {}, {
554 const tell = tellLink(
555 runtime.app.db,
556 runtime.options.forgeUrl,
557 target.app,
558 signed?.who?.id,
559 );
560 return Response.json({ ...crew, ...(tell === undefined ? {} : { tell }) }, {
555561 headers: {
556562 "cache-control": "no-store",
557563 "x-content-type-options": "nosniff",
src/apps/toolbar.ts +21 −2
@@ -69,9 +69,28 @@ export const toolbarJs = `(function () {
6969 fetch("/__forge/crew", { credentials: "same-origin", cache: "no-store" })
7070 .then(function (r) { return r.ok ? r.json() : {}; })
7171 .then(function (c) {
72 if (!c || typeof c.who !== "string" || typeof c.url !== "string") return;
72 if (!c) return;
73 var forge = new URL(me.src).origin;
74 // Signed in, the crew can be told from here. The form is the forge's
75 // own page, on its own origin, with its own CSRF check: the app's
76 // page only carries the address and the pull to it.
77 if (typeof c.tell === "string") {
78 var t = new URL(c.tell);
79 if (t.origin === forge) {
80 t.searchParams.set("page", location.href);

Medium (privacy): location.href carries the #fragment (OAuth #access_token, invite keys), and the tell writes it into the issue body, which anyone can read on a public repository. Send origin + pathname + search and drop url.hash in parseContext. Follow-up: #2311.

81 if (d.pull) t.searchParams.set("pull", d.pull);
82 var ta = document.createElement("a");
83 ta.textContent = "Tell the crew";
84 ta.target = "_blank";
85 ta.rel = "noopener";
86 ta.href = t.href;
87 ta.style.cssText = "color:#8ab4ff;text-decoration:none;display:flex;align-items:center;min-height:44px";
88 crew.appendChild(ta);
89 }
90 }
91 if (typeof c.who !== "string" || typeof c.url !== "string") return;
7392 var u = new URL(c.url);
74 if (u.origin !== new URL(me.src).origin) return;
93 if (u.origin !== forge) return;
7594 var a = document.createElement("a");
7695 a.textContent = c.who + " is working on this";
7796 a.rel = "noopener";
src/http/mcp.ts +3 −2
@@ -382,9 +382,10 @@ const TOOLS: readonly Tool[] = [
382382 context: {
383383 type: "object",
384384 description:
385 "Where this was told from, so the builder sees it: page (http/https address), run (id), commit (hex), said (the person's own words).",
385 "Where this was told from, so the builder sees it: page (http/https address), pull (number of the preview's pull), run (id), commit (hex), said (the person's own words).",
386386 properties: {
387387 page: { type: "string" },
388 pull: { type: "integer", minimum: 1 },
388389 run: { type: "string" },
389390 commit: { type: "string" },
390391 said: { type: "string", maxLength: 2000 },
@@ -410,7 +411,7 @@ const TOOLS: readonly Tool[] = [
410411 if (where === null)
411412 return bad(
412413 "invalid",
413 "context is { page?: http(s) address, run?: id, commit?: hex, said?: words }",
414 "context is { page?: http(s) address, pull?: number, run?: id, commit?: hex, said?: words }",
414415 );
415416 const from = parseFrom(args["from"]);
416417 if (from === undefined) return bad("invalid", FROM_WORDS);
src/tui/screens/tell.ts +62 −2
@@ -3,17 +3,20 @@
33 * the same `tell()` the web row calls, answered in the web flash's words.
44 * The row is the screen's own and only for who `mayDirectCrew` there — the
55 * gate `tellRow` in `src/web/scope.ts` asks. `from` is null: a terminal
6 * screen has no page path of its own.
6 * screen has no page path of its own. A second row tells from a page: it asks
7 * for the page's address and the pull, checked and written into the tell by
8 * the same `parseContext` and `contextNote` the pill's tell and the MCP use.
79 */
810
911 import { tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts";
12 import { contextNote, parseContext } from "../../actions/tell_context.ts";
1013 import type { Repo } from "../../core/repos.ts";
1114 import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts";
1215 import type { Result } from "../../kernel/result.ts";
1316 import type { Ctx, Screen } from "../app/app.ts";
1417 import type { Command } from "../app/palette.ts";
1518 import type { Session } from "../session.ts";
16 import { promptScreen } from "./common.ts";
19 import { later, promptScreen } from "./common.ts";
1720
1821 /** The words of one tell, refused or told, with the tone they are shown in. */
1922 export function tellAnswer(told: Result<Told, TellError>): { text: string; tone: "success" | "danger" } {
@@ -56,5 +59,62 @@ export function tellCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R
5659 }),
5760 ),
5861 },
62 {
63 id: "tell-page",
64 title: `${name} — Tell the crew from a page`,
65 hint: "page, pull, then one line",
66 run: () =>
67 ctx.push(
68 promptScreen(ctx, parent(), {
69 title: "Page address",
70 label: name,
71 hint: "http(s) address of the page; enter continues",
72 onSubmit: (page) => {
73 if (parseContext({ page }) === null) return "That is not an http(s) address.";
74 return later(ctx, () =>
75 promptScreen(ctx, parent(), {
76 title: "Pull",
77 label: name,
78 hint: "the pull number on a preview, or leave empty",
79 onSubmit: (pull) => {
80 const number = pull === "" ? undefined : /^\d{1,9}$/.test(pull) ? Number(pull) : -1;
81 if (parseContext({ page, ...(number === undefined ? {} : { pull: number }) }) === null)
82 return "A pull is a number, like 7.";
83 return later(ctx, () =>
84 promptScreen(ctx, parent(), {
85 title: "Tell the crew",
86 label: name,
87 hint: "enter sends",
88 onSubmit: (said) => {
89 if (said === "") return "Say something first.";
90 return tellFromPage(f, ctx, repo, said, page, number);
91 },
92 }),
93 );
94 },
95 }),
96 );
97 },
98 }),
99 ),
100 },
59101 ];
60102 }
103
104 /** One tell with the page (and pull) it is said from, answered in the web's words. */
105 export async function tellFromPage(
106 f: Session,
107 ctx: Ctx,
108 repo: Repo,
109 said: string,
110 page: string,
111 pull: number | undefined,
112 ): Promise<void> {
113 const where = parseContext({ page, ...(pull === undefined ? {} : { pull }) });
114 if (where === null || where === undefined) {
115 ctx.toast("That page could not be told from.", "danger");
116 return;
117 }
118 const answer = tellAnswer(await tell(f.app, f.user, repo, `${said}${contextNote(where)}`));
119 ctx.toast(answer.text, answer.tone);
120 }
src/web/pages/thread.ts +10 −2
@@ -423,7 +423,7 @@ export interface ComposerOptions {
423423 * the button tells the crew, and `from` (a path on this forge, or null)
424424 * rides along as a hidden field so the tell remembers the page it was
425425 * said on (`actions/tell.ts`). `rows` sizes it; one is a single line. */
426 tell?: { from: string | null; rows?: number };
426 tell?: { from: string | null; rows?: number; page?: string; pull?: number; text?: string };
427427 }
428428
429429 /** The comment box. Close and reopen are their own form, not an `intent`
@@ -504,10 +504,16 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html {
504504 ${tell ? raw('maxlength="2000"') : raw("data-grow")}
505505 placeholder="${tell ? "Tell the crew what should change" : "Leave a comment"}"
506506 required
507 ></textarea>
507 >${tell?.text ?? ""}</textarea>
508508 ${tell?.from
509509 ? html`<input type="hidden" name="from" value="${tell.from}" />`
510510 : null}
511 ${tell?.page
512 ? html`<input type="hidden" name="page" value="${tell.page}" />`
513 : null}
514 ${tell?.pull
515 ? html`<input type="hidden" name="pull" value="${String(tell.pull)}" />`
516 : null}
511517 ${o.crewNote === undefined
512518 ? null
513519 : html`<p class="muted small crew-note">${o.crewNote}</p>`}
@@ -516,6 +522,8 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html {
516522 >${tell
517523 ? html`${tell.from
518524 ? html`From <code>${tell.from}</code>. `
525 : null}${tell.page
526 ? html`From <code>${tell.page}</code>. `
519527 : null}Enter sends.`
520528 : html`${icon("file")} Markdown is supported —
521529 <code>**bold**</code>, <code>[link](url)</code>,
src/web/studio/actions.ts +51 −3
@@ -11,7 +11,8 @@
1111 import { mayView } from "../../core/sealed.ts";
1212 import { holdPull, releasePull } from "../../actions/holds.ts";
1313 import { addComment } from "../../actions/issues.ts";
14 import { parseFrom, tell, TOO_LONG_WORDS } from "../../actions/tell.ts";
14 import { parseFrom, tell, toldWords, TOO_LONG_WORDS } from "../../actions/tell.ts";
15 import { contextNote, parseContext } from "../../actions/tell_context.ts";
1516 import { keepThought } from "../../actions/thought.ts";
1617 import { DIRECTION_NOTE } from "../../crew/direct.ts";
1718 import { mergePull } from "../../actions/pulls.ts";
@@ -20,9 +21,10 @@ import { getPull } from "../../core/pulls.ts";
2021 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
2122 import { bringUpToDate } from "../../crew/autonomy.ts";
2223 import type { Ctx } from "../../http/context.ts";
23 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
24 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts";
2425 import { backTo, field, readForm, type Form } from "../form.ts";
25 import { flashOrJson, flashTo } from "../layout.ts";
26 import { html } from "../html.ts";
27 import { flashOrJson, flashTo, page } from "../layout.ts";
2628 import { FORGE_TARGET } from "./feed.ts";
2729 import { changeHref } from "./nouns.ts";
2830
@@ -92,9 +94,55 @@ export async function tellSubmit(ctx: Ctx): Promise<Response> {
9294 if (!ctx.auth.user) return signInFirst(ctx);
9395 const form = await readForm(ctx, "rw");
9496 if (form instanceof Response) return form;
97 if (field(form, "page") !== "") return tellFromPage(ctx, repo, form);
9598 return tellFrom(ctx, repo, form, repoBase(repo));
9699 }
97100
101 /**
102 * A tell from the forge toolbar of an app's page: the same `tell()`, with
103 * the page it was said on (and the pull, on a preview) written into the
104 * tell, answered with a receipt that links to the issue it opened.
105 */
106 async function tellFromPage(ctx: Ctx, repo: Repo, form: Form): Promise<Response> {
107 const pull = field(form, "pull");
108 const where = parseContext({
109 page: field(form, "page"),
110 ...(pull === "" ? {} : { pull: /^\d{1,9}$/.test(pull) ? Number(pull) : -1 }),
111 });
112 // A refusal sends the person back to the box with their words, the page
113 // and the pull kept, so they can retry.
114 const keep = new URLSearchParams({ text: field(form, "text").slice(0, 1000) });
115 if (where?.page !== undefined) keep.set("page", where.page);
116 if (where?.pull !== undefined) keep.set("pull", String(where.pull));
117 const back = `${repoBase(repo)}/tell?${keep}`;
118 if (where === null || where === undefined)
119 return flashTo(ctx, back, { tone: "danger", message: "That page could not be told from." });
120 const text = field(form, "text").trim();
121 if (text === "") return flashTo(ctx, back, { tone: "danger", message: "Say something first." });
122 const told = await tell(ctx.app, ctx.auth.user, repo, `${text}${contextNote(where)}`);
123 if (!told.ok)
outdated

On failure the redirect goes to plain /tell, dropping page, pull and the typed words. Keep them in the redirect so the person can retry.

124 return flashTo(ctx, back, {
125 tone: "danger",
126 message:
127 told.error === "forbidden"
128 ? DIRECTION_NOTE
129 : told.error === "unverified"
130 ? "Verify your email first."
131 : told.error === "body_too_long"
132 ? TOO_LONG_WORDS
133 : "That could not be told.",
134 });
135 const issue = `${repoBase(repo)}/issues/${told.value.issue.number}`;
136 return page({

Medium: this renders the receipt as a 200 answer to the POST, so a reload or back/forward posts again and tell() opens a second issue. Every other tell door redirects with flashTo. Follow-up: #2310.

137 ctx,
138 title: `Told · ${repo.name}`,
139 crumbs: crumbsFor(repo),
140 body: html`${pageHead("Told")}
141 <p>${toldWords(told.value)}</p>
142 <p><a href="${issue}" data-receipt>See your tell, #${String(told.value.issue.number)}</a></p>`,
143 });
144 }
145
98146 /**
99147 * POST /tell { app, text } — the home's composer without a script: the
100148 * same tell, to the app the form names (`owner/name`), and back to the home.
src/web/studio/tell_here.ts +19 −1
@@ -6,6 +6,7 @@
66 * (`composer`, pages/thread.ts) saying a tell, so there is one box to keep.
77 */
88
9 import { parseContext } from "../../actions/tell_context.ts";
910 import { parseFrom } from "../../actions/tell.ts";
1011 import { mayDirectCrew } from "../../crew/direct.ts";
1112 import type { Ctx } from "../../http/context.ts";
@@ -20,11 +21,28 @@ export function tellHerePage(ctx: Ctx): Response {
2021 if (!ctx.auth.user) return signInFirst(ctx);
2122 if (!mayDirectCrew(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx);
2223 const from = parseFrom(ctx.url.searchParams.get("from") ?? undefined) ?? null;
24 // The page of the app the toolbar was pressed on, and its pull: both
25 // checked here and again when the tell is sent.
26 const pull = ctx.url.searchParams.get("pull");
27 const where = parseContext({
28 ...(ctx.url.searchParams.has("page") ? { page: ctx.url.searchParams.get("page") } : {}),
29 // A pull is only the pull of a page: alone it would be dropped on send.
30 ...(pull !== null && ctx.url.searchParams.has("page") && /^\d{1,9}$/.test(pull)
31 ? { pull: Number(pull) }
32 : {}),
33 });
34 const text = (ctx.url.searchParams.get("text") ?? "").slice(0, 2000);
2335 const box = composer(ctx, {
2436 action: `${repoBase(repo)}/tell`,
2537 state: "open",
2638 closeLabel: "",
27 tell: { from, rows: ctx.url.searchParams.get("in") === "place" ? 1 : 4 },
39 tell: {
40 from,
41 rows: ctx.url.searchParams.get("in") === "place" ? 1 : 4,
42 ...(text === "" ? {} : { text }),
43 ...(where?.page === undefined ? {} : { page: where.page }),
44 ...(where?.pull === undefined ? {} : { pull: where.pull }),
45 },
2846 });
2947 if (ctx.url.searchParams.get("in") === "place")
3048 return fragment(box, { request: ctx.request });
test/apps-crew-pill.test.ts +1
@@ -178,6 +178,7 @@ describe("the crew line", () => {
178178 expect(await ok.json()).toEqual({
179179 who: "scout",
180180 url: `${servers.url}/alice/notes/runs/${run.id}`,
181 tell: `${servers.url}/alice/notes/tell`,
181182 });
182183 expect(await (await ask()).json()).toEqual({});
183184 expect(await (await ask(`${grant}x`)).json()).toEqual({});
test/apps-toolbar-tell.test.ts added +238
@@ -0,0 +1,238 @@
1 /**
2 * Tell the crew from the forge pill (#2252): signed in, the pill offers the
3 * forge's own tell page; the tell goes through the one `tell()` and carries
4 * the page it was said on (and the pull, on a preview); a cross-origin POST
5 * is refused; signed out, no action is offered.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { newApp } from "../src/actions/apps.ts";
14 import { tellLink } from "../src/apps/crew.ts";
15 import { toolbarJs } from "../src/apps/toolbar.ts";
16 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
17 import { listIssues, MAX_BODY } from "../src/core/issues.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { createUser } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { proveAll } from "./helpers/verified.ts";
23
24 quietLogs();
25
26 describe("tell the crew from the pill", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let record: AppRecord;
31 let repoId: string;
32 let alice: string;
33 let auth: Record<string, string>;
34 const forge = "http://forge.test";
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-pilltell-"));
38 app = createApp({ dataDir, port: 0, host: "forge.test" });
39 servers = await serve(app);
40 const u = await createUser(app.db, {
41 trusted: true,
42 username: "alice",
43 email: "[email protected]",
44 password: "a-good-passphrase",
45 adminIfFirst: true,
46 });
47 if (!u.ok) throw new Error(u.error);
48 alice = u.value.id;
49 proveAll(app);
50 const made = await newApp(app, u.value, {
51 name: "notes",
52 description: "x",
53 visibility: "public",
54 });
55 if (!made.ok) throw new Error(String(made.error));
56 repoId = made.value.repo.id;
57 record = getAppByRepo(app.db, repoId)!;
58 auth = { authorization: `Bearer ${createToken(app.db, alice, "t", "rw").token}` };
59 }, 120_000);
60
61 afterAll(async () => {
62 servers.stop();
63 app.close();
64 await rm(dataDir, { recursive: true, force: true });
65 });

Asserting that toolbarJs contains a label proves nothing about the link carrying page and pull. Exercise the script, or at least assert the searchParams lines.

66
67 const post = (fields: Record<string, string>, headers: Record<string, string> = {}) =>
68 fetch(`${servers.url}/alice/notes/tell`, {
69 method: "POST",
70 headers: { ...auth, ...headers },
71 body: new URLSearchParams(fields),
72 redirect: "manual",
73 });
74 const told = () => listIssues(app.db, repoId, {}).map((i) => i.body);
75
76 test("signed in, the pill is given the forge's own tell page; signed out, nothing", () => {
77 expect(tellLink(app.db, forge, record, alice)).toBe(`${forge}/alice/notes/tell`);
78 expect(tellLink(app.db, forge, record, undefined)).toBeUndefined();
79 expect(tellLink(app.db, forge, record, "nobody")).toBeUndefined();
80 });
81
82 test("the script's link carries the page and, on a preview, the pull, only to the forge's origin", () => {
83 // Run the script against a stub DOM and read the link it makes.
84 const links: Record<string, any>[] = [];
85 const el = (): Record<string, any> => ({
86 style: {},
87 dataset: {},
88 children: [] as unknown[],
89 appendChild(c: unknown) { this.children.push(c); },
90 setAttribute() {},
91 addEventListener() {},
92 attachShadow() { return el(); },
93 });
94 const make = (tag: string) => { const e = el(); e.tag = tag; if (tag === "a") links.push(e); return e; };
95 const run = async (dataset: Record<string, string>, tell: unknown, page: string) => {
96 links.length = 0;
97 const me = { src: `${forge}/assets/toolbar-x.js`, dataset };
98 const doc = {
99 currentScript: me,
100 createElement: make,
101 addEventListener() {},
102 body: el(),
103 };
104 const fn = new Function("window", "document", "fetch", "location", "URL", toolbarJs);
105 await new Promise<void>((done) => {
106 fn(
107 {},
108 doc,
109 () => ({
110 then: (f: (r: unknown) => unknown) => {
111 f({ ok: true, json: () => tell });
112 return { then: (g: (c: unknown) => void) => { g(tell); setTimeout(done, 0); return { catch() {} }; } };
113 },
114 }),
115 { href: page },
116 URL,
117 );
118 });
119 return links.find((l) => l.textContent === "Tell the crew");
120 };
121 return (async () => {
122 const prev = await run({ app: "notes", env: "pull-3", pull: "3" }, { tell: `${forge}/alice/notes/tell` }, "https://notes.apps.test/b?x=1");
123 const u = new URL(prev!.href);
124 expect(u.origin + u.pathname).toBe(`${forge}/alice/notes/tell`);
125 expect(u.searchParams.get("page")).toBe("https://notes.apps.test/b?x=1");
126 expect(u.searchParams.get("pull")).toBe("3");
127 const prod = await run({ app: "notes", env: "production" }, { tell: `${forge}/alice/notes/tell` }, "https://notes.apps.test/");
128 expect(new URL(prod!.href).searchParams.has("pull")).toBe(false);
129 expect(new URL(prod!.href).searchParams.get("page")).toBe("https://notes.apps.test/");
130 // A link to some other origin is never made; signed out has none.
131 expect(await run({ app: "notes" }, { tell: "https://evil.test/alice/notes/tell" }, "https://n/")).toBeUndefined();
132 expect(await run({ app: "notes" }, {}, "https://n/")).toBeUndefined();
133 })();
134 });
135
136 test("the tell page carries the page and the pull into the form", async () => {
137 const page = "https://notes.apps.test/list?x=1";
138 const res = await fetch(
139 `${servers.url}/alice/notes/tell?page=${encodeURIComponent(page)}&pull=7`,
140 { headers: auth },
141 );
142 const text = await res.text();
143 expect(res.status).toBe(200);
144 expect(text).toContain(`name="page" value="${page}"`);
145 expect(text).toContain('name="pull" value="7"');
146 // A page that is not http(s) is dropped, not echoed.
147 const bad = await (
148 await fetch(`${servers.url}/alice/notes/tell?page=javascript:alert(1)`, { headers: auth })
149 ).text();
150 expect(bad).not.toContain('name="page"');
151 });
152
153 test("a tell from production carries its page, and a receipt links to it", async () => {
154 const res = await post({ text: "Make it purple", page: "https://notes.apps.test/a" });
155 expect(res.status).toBe(200);
156 const html = await res.text();
157 const issue = listIssues(app.db, repoId, {}).find((i) => i.title === "Make it purple")!;
158 expect(html).toContain(`href="/alice/notes/issues/${issue.number}"`);
159 expect(issue.body).toContain("- page: https://notes.apps.test/a");
160 expect(issue.body).not.toContain("- pull:");
161 });
162
163 test("a tell from a preview carries its page and its pull", async () => {
164 const res = await post({
165 text: "Bigger button",
166 page: "https://notes-pull-3.apps.test/b",
167 pull: "3",
168 });
169 expect(res.status).toBe(200);
170 const issue = listIssues(app.db, repoId, {}).find((i) => i.title === "Bigger button")!;
171 expect(issue.body).toContain("- page: https://notes-pull-3.apps.test/b");
172 expect(issue.body).toContain("- pull: #3");
173 });
174
175 test("a bad page or pull opens nothing", async () => {
176 const before = told().length;
177 expect((await post({ text: "x", page: "javascript:alert(1)" })).status).toBe(303);
178 expect((await post({ text: "x", page: "https://a.test/", pull: "-1" })).status).toBe(303);
179 expect(told().length).toBe(before);
180 });
181
182 test("a refused tell sends the person back with their words, the page and the pull kept", async () => {
183 // Over-long: refused by tell(), after the page and pull passed.
184 const res = await post({ text: "y".repeat(MAX_BODY + 1), page: "https://notes.apps.test/c", pull: "4" });
185 expect(res.status).toBe(303);
186 const back = new URL(res.headers.get("location")!, forge);
187 expect(back.pathname).toBe("/alice/notes/tell");
188 expect(back.searchParams.get("page")).toBe("https://notes.apps.test/c");
189 expect(back.searchParams.get("pull")).toBe("4");
190 expect(back.searchParams.get("text")!.startsWith("yyy")).toBe(true);
191 const form = await (await fetch(`${servers.url}${back.pathname}${back.search}`, { headers: auth })).text();
192 expect(form).toContain("yyy");
193 expect(form).toContain('name="pull" value="4"');
194 });
195
196 test("the tell page renders a pull only with a page", async () => {
197 const lone = await (await fetch(`${servers.url}/alice/notes/tell?pull=5`, { headers: auth })).text();
198 expect(lone).not.toContain('name="pull"');
199 });
200
201 test("the MCP tell carries page and pull, and refuses a bad pull", async () => {
202 const call = async (context: unknown) => {
203 const res = (await (
204 await fetch(`${servers.url}/mcp`, {
205 method: "POST",
206 headers: { ...auth, "content-type": "application/json", accept: "application/json, text/event-stream" },
207 body: JSON.stringify({
208 jsonrpc: "2.0",
209 id: 1,
210 method: "tools/call",
211 params: { name: "tell", arguments: { repo: "alice/notes", text: "From mcp", context } },
212 }),
213 })
214 ).json()) as { result: { isError?: boolean } };
215 return res.result.isError === true;
216 };
217 const mcpTold = () => listIssues(app.db, repoId, {}).filter((i) => i.title === "From mcp");
218 for (const bad of [0, -1, 1.5, "3"]) {
219 expect(await call({ page: "https://notes.apps.test/m", pull: bad })).toBe(true);
220 }
221 expect(mcpTold().length).toBe(0);
222 expect(await call({ page: "https://notes.apps.test/m", pull: 3 })).toBe(false);
223 const issue = mcpTold();
224 expect(issue.length).toBe(1);
225 expect(issue[0]!.body).toContain("- pull: #3");
226 expect(issue[0]!.body).toContain("- page: https://notes.apps.test/m");
227 });
228
229 test("a cross-origin POST is refused and opens nothing", async () => {
230 const before = told().length;
231 const res = await post(
232 { text: "Sneaky", page: "https://notes.apps.test/" },
233 { origin: "https://notes.apps.test" },
234 );
235 expect(res.status).toBe(403);
236 expect(told().length).toBe(before);
237 });
238 });
test/tui-tell.test.ts +17
@@ -87,6 +87,23 @@ describe("tell the crew in the terminal", () => {
8787 expect(listIssues(app.db, repo.id, {}).some((i) => i.title === "paint the door red")).toBe(true);
8888 });
8989
90 test("a tell from a page carries its page and pull, and bob has no such row", async () => {
91 const keys = [":", "from a page", "\r", ..."https://notes.apps.test/a".split(""), "\r", "7", "\r", ..."move the button".split(""), "\r"];
92 const text = await palette(alice, keys);
93 expect(text).toContain("Nobody is on the crew to take it yet");
94 const issue = listIssues(app.db, repo.id, {}).find((i) => i.title === "move the button")!;
95 expect(issue.body).toContain("- page: https://notes.apps.test/a");
96 expect(issue.body).toContain("- pull: #7");
97 expect(await palette(bob, [":", "from a page"])).not.toContain("Tell the crew from a page");
98 });
99
100 test("a page that is not http(s) keeps the prompt open and tells nothing", async () => {
101 const before = listIssues(app.db, repo.id, {}).length;
102 const text = await palette(alice, [":", "from a page", "\r", ..."javascript:alert(1)".split(""), "\r"]);
103 expect(text).toContain("That is not an http(s) address.");
104 expect(listIssues(app.db, repo.id, {}).length).toBe(before);
105 });
106
90107 test("an empty line says so and tells nothing", async () => {
91108 const before = listIssues(app.db, repo.id, {}).length;
92109 const text = await palette(alice, [":", "tell", "\r", "\r"]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs