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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2252-tell-the-crew-from-the-forge-toolbar-the → main 3 commits fork 4 threads · 4 unresolved
README.md +14
| @@ -560,6 +560,20 @@ pull, the panel also says `scout is working on this` and links the run; it asks | ||
| 560 | 560 | read the repository (and, for a run on a sealed issue, that issue), and shows |
| 561 | 561 | nothing when the crew is idle. |
| 562 | 562 | |
| 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 | ||
| 563 | 577 | With a sentence and **Pick for me**, the sentence becomes a plan before the |
| 564 | 578 | page comes back: `src/crew/plan.ts` makes one non-interactive call to |
| 565 | 579 | `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 @@ | ||
| 8 | 8 | |
| 9 | 9 | export interface TellContext { |
| 10 | 10 | readonly page?: string; |
| 11 | readonly pull?: number; | |
| 11 | 12 | readonly run?: string; |
| 12 | 13 | readonly commit?: string; |
| 13 | 14 | readonly said?: string; |
| @@ -22,7 +23,7 @@ export function parseContext(v: unknown): TellContext | undefined | null { | ||
| 22 | 23 | if (v === undefined) return undefined; |
| 23 | 24 | if (typeof v !== "object" || v === null || Array.isArray(v)) return null; |
| 24 | 25 | 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 } = {}; | |
| 26 | 27 | if (o["page"] !== undefined) { |
| 27 | 28 | const url = |
| 28 | 29 | typeof o["page"] === "string" && o["page"].length <= 2000 |
| @@ -32,6 +33,11 @@ export function parseContext(v: unknown): TellContext | undefined | null { | ||
| 32 | 33 | return null; |
| 33 | 34 | out.page = url.href; |
| 34 | 35 | } |
| 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 | } | |
| 35 | 41 | if (o["run"] !== undefined) { |
| 36 | 42 | if (typeof o["run"] !== "string" || !RUN.test(o["run"])) return null; |
| 37 | 43 | out.run = o["run"]; |
| @@ -52,6 +58,7 @@ export function contextNote(c: TellContext | undefined): string { | ||
| 52 | 58 | if (c === undefined) return ""; |
| 53 | 59 | const lines = [ |
| 54 | 60 | c.page === undefined ? "" : `- page: ${c.page}`, |
| 61 | c.pull === undefined ? "" : `- pull: #${c.pull}`, | |
| 55 | 62 | c.run === undefined ? "" : `- run: ${c.run}`, |
| 56 | 63 | c.commit === undefined ? "" : `- commit: ${c.commit}`, |
| 57 | 64 | c.said === undefined || c.said === "" |
src/apps/crew.ts +20
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import type { AppRecord } from "../core/apps.ts"; |
| 11 | import { mayDirectCrew } from "../crew/direct.ts"; | |
| 11 | 12 | import { getIssue } from "../core/issues.ts"; |
| 12 | 13 | import { canRead, getRepoById } from "../core/repos.ts"; |
| 13 | 14 | import { workingAgentRuns } from "../core/runs.ts"; |
| @@ -46,3 +47,22 @@ export function crewNow( | ||
| 46 | 47 | url: new URL(`/${repo.ownerName}/${repo.name}/runs/${run.id}`, forgeUrl).href, |
| 47 | 48 | }; |
| 48 | 49 | } |
| 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"; | ||
| 34 | 34 | import { loadKey } from "../kernel/secret.ts"; |
| 35 | 35 | import { previewEnv, type EnvName } from "./data.ts"; |
| 36 | 36 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 37 | import { crewNow } from "./crew.ts"; | |
| 37 | import { crewNow, tellLink } from "./crew.ts"; | |
| 38 | 38 | import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts"; |
| 39 | 39 | |
| 40 | 40 | export interface AppTarget { |
| @@ -551,7 +551,13 @@ export async function proxyApp( | ||
| 551 | 551 | target.env, |
| 552 | 552 | signed?.who?.id, |
| 553 | 553 | ); |
| 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 }) }, { | |
| 555 | 561 | headers: { |
| 556 | 562 | "cache-control": "no-store", |
| 557 | 563 | "x-content-type-options": "nosniff", |
src/apps/toolbar.ts +21 −2
| @@ -69,9 +69,28 @@ export const toolbarJs = `(function () { | ||
| 69 | 69 | fetch("/__forge/crew", { credentials: "same-origin", cache: "no-store" }) |
| 70 | 70 | .then(function (r) { return r.ok ? r.json() : {}; }) |
| 71 | 71 | .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); | |
| 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; | |
| 73 | 92 | var u = new URL(c.url); |
| 74 | if (u.origin !== new URL(me.src).origin) return; | |
| 93 | if (u.origin !== forge) return; | |
| 75 | 94 | var a = document.createElement("a"); |
| 76 | 95 | a.textContent = c.who + " is working on this"; |
| 77 | 96 | a.rel = "noopener"; |
src/http/mcp.ts +3 −2
| @@ -382,9 +382,10 @@ const TOOLS: readonly Tool[] = [ | ||
| 382 | 382 | context: { |
| 383 | 383 | type: "object", |
| 384 | 384 | 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).", | |
| 386 | 386 | properties: { |
| 387 | 387 | page: { type: "string" }, |
| 388 | pull: { type: "integer", minimum: 1 }, | |
| 388 | 389 | run: { type: "string" }, |
| 389 | 390 | commit: { type: "string" }, |
| 390 | 391 | said: { type: "string", maxLength: 2000 }, |
| @@ -410,7 +411,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 410 | 411 | if (where === null) |
| 411 | 412 | return bad( |
| 412 | 413 | "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 }", | |
| 414 | 415 | ); |
| 415 | 416 | const from = parseFrom(args["from"]); |
| 416 | 417 | if (from === undefined) return bad("invalid", FROM_WORDS); |
src/tui/screens/tell.ts +62 −2
| @@ -3,17 +3,20 @@ | ||
| 3 | 3 | * the same `tell()` the web row calls, answered in the web flash's words. |
| 4 | 4 | * The row is the screen's own and only for who `mayDirectCrew` there — the |
| 5 | 5 | * 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. | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | 11 | 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"; | |
| 10 | 13 | import type { Repo } from "../../core/repos.ts"; |
| 11 | 14 | import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts"; |
| 12 | 15 | import type { Result } from "../../kernel/result.ts"; |
| 13 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 17 | import type { Command } from "../app/palette.ts"; |
| 15 | 18 | import type { Session } from "../session.ts"; |
| 16 | import { promptScreen } from "./common.ts"; | |
| 19 | import { later, promptScreen } from "./common.ts"; | |
| 17 | 20 | |
| 18 | 21 | /** The words of one tell, refused or told, with the tone they are shown in. */ |
| 19 | 22 | 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 | ||
| 56 | 59 | }), |
| 57 | 60 | ), |
| 58 | 61 | }, |
| 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 | }, | |
| 59 | 101 | ]; |
| 60 | 102 | } |
| 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 { | ||
| 423 | 423 | * the button tells the crew, and `from` (a path on this forge, or null) |
| 424 | 424 | * rides along as a hidden field so the tell remembers the page it was |
| 425 | 425 | * 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 }; | |
| 427 | 427 | } |
| 428 | 428 | |
| 429 | 429 | /** 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 { | ||
| 504 | 504 | ${tell ? raw('maxlength="2000"') : raw("data-grow")} |
| 505 | 505 | placeholder="${tell ? "Tell the crew what should change" : "Leave a comment"}" |
| 506 | 506 | required |
| 507 | ></textarea> | |
| 507 | >${tell?.text ?? ""}</textarea> | |
| 508 | 508 | ${tell?.from |
| 509 | 509 | ? html`<input type="hidden" name="from" value="${tell.from}" />` |
| 510 | 510 | : 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} | |
| 511 | 517 | ${o.crewNote === undefined |
| 512 | 518 | ? null |
| 513 | 519 | : html`<p class="muted small crew-note">${o.crewNote}</p>`} |
| @@ -516,6 +522,8 @@ export function composer(ctx: Ctx, o: ComposerOptions): Html { | ||
| 516 | 522 | >${tell |
| 517 | 523 | ? html`${tell.from |
| 518 | 524 | ? html`From <code>${tell.from}</code>. ` |
| 525 | : null}${tell.page | |
| 526 | ? html`From <code>${tell.page}</code>. ` | |
| 519 | 527 | : null}Enter sends.` |
| 520 | 528 | : html`${icon("file")} Markdown is supported — |
| 521 | 529 | <code>**bold**</code>, <code>[link](url)</code>, |
src/web/studio/actions.ts +51 −3
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | import { mayView } from "../../core/sealed.ts"; |
| 12 | 12 | import { holdPull, releasePull } from "../../actions/holds.ts"; |
| 13 | 13 | 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"; | |
| 15 | 16 | import { keepThought } from "../../actions/thought.ts"; |
| 16 | 17 | import { DIRECTION_NOTE } from "../../crew/direct.ts"; |
| 17 | 18 | import { mergePull } from "../../actions/pulls.ts"; |
| @@ -20,9 +21,10 @@ import { getPull } from "../../core/pulls.ts"; | ||
| 20 | 21 | import { canRead, getRepo, type Repo } from "../../core/repos.ts"; |
| 21 | 22 | import { bringUpToDate } from "../../crew/autonomy.ts"; |
| 22 | 23 | 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"; | |
| 24 | 25 | 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"; | |
| 26 | 28 | import { FORGE_TARGET } from "./feed.ts"; |
| 27 | 29 | import { changeHref } from "./nouns.ts"; |
| 28 | 30 | |
| @@ -92,9 +94,55 @@ export async function tellSubmit(ctx: Ctx): Promise<Response> { | ||
| 92 | 94 | if (!ctx.auth.user) return signInFirst(ctx); |
| 93 | 95 | const form = await readForm(ctx, "rw"); |
| 94 | 96 | if (form instanceof Response) return form; |
| 97 | if (field(form, "page") !== "") return tellFromPage(ctx, repo, form); | |
| 95 | 98 | return tellFrom(ctx, repo, form, repoBase(repo)); |
| 96 | 99 | } |
| 97 | 100 | |
| 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 |
||
| 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 | ||
| 98 | 146 | /** |
| 99 | 147 | * POST /tell { app, text } — the home's composer without a script: the |
| 100 | 148 | * 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 @@ | ||
| 6 | 6 | * (`composer`, pages/thread.ts) saying a tell, so there is one box to keep. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { parseContext } from "../../actions/tell_context.ts"; | |
| 9 | 10 | import { parseFrom } from "../../actions/tell.ts"; |
| 10 | 11 | import { mayDirectCrew } from "../../crew/direct.ts"; |
| 11 | 12 | import type { Ctx } from "../../http/context.ts"; |
| @@ -20,11 +21,28 @@ export function tellHerePage(ctx: Ctx): Response { | ||
| 20 | 21 | if (!ctx.auth.user) return signInFirst(ctx); |
| 21 | 22 | if (!mayDirectCrew(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx); |
| 22 | 23 | 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); | |
| 23 | 35 | const box = composer(ctx, { |
| 24 | 36 | action: `${repoBase(repo)}/tell`, |
| 25 | 37 | state: "open", |
| 26 | 38 | 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 | }, | |
| 28 | 46 | }); |
| 29 | 47 | if (ctx.url.searchParams.get("in") === "place") |
| 30 | 48 | return fragment(box, { request: ctx.request }); |
test/apps-crew-pill.test.ts +1
| @@ -178,6 +178,7 @@ describe("the crew line", () => { | ||
| 178 | 178 | expect(await ok.json()).toEqual({ |
| 179 | 179 | who: "scout", |
| 180 | 180 | url: `${servers.url}/alice/notes/runs/${run.id}`, |
| 181 | tell: `${servers.url}/alice/notes/tell`, | |
| 181 | 182 | }); |
| 182 | 183 | expect(await (await ask()).json()).toEqual({}); |
| 183 | 184 | 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", () => { | ||
| 87 | 87 | expect(listIssues(app.db, repo.id, {}).some((i) => i.title === "paint the door red")).toBe(true); |
| 88 | 88 | }); |
| 89 | 89 | |
| 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 | ||
| 90 | 107 | test("an empty line says so and tells nothing", async () => { |
| 91 | 108 | const before = listIssues(app.db, repo.id, {}).length; |
| 92 | 109 | const text = await palette(alice, [":", "tell", "\r", "\r"]); |
Medium (privacy):
location.hrefcarries the#fragment(OAuth#access_token, invite keys), and the tell writes it into the issue body, which anyone can read on a public repository. Sendorigin + pathname + searchand dropurl.hashinparseContext. Follow-up: #2311.