From 4d2f15277027c274618e65bf06c963a73412168e Mon Sep 17 00:00:00 2001 From: Rowan Aldean <56801656+RowanAldean@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:04:09 -0400 Subject: [PATCH 1/3] Enable OpenDuet public-web research skill and document setup --- README.md | 7 +++++++ docs/web-research.md | 28 +++++++++++++++++++++++++++ openbot/examples/openduet/agents.yaml | 1 + 3 files changed, 36 insertions(+) create mode 100644 docs/web-research.md diff --git a/README.md b/README.md index ec562978..6dc2d26b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Everything below works today when you run this repository. | Skills | Reusable instructions you write once and call with `/`. | | Gmail and Google Calendar | Each person connects their own account. OpenDuet reads mail and today's calendar, and sends a reply only after you answer. | | Connected apps | Governed MCP connectors, including Google Drive and Notion. | +| Public-web research | Search public sources, read selected pages and make a brief with source links after an administrator grants the research tools. [Set up Parallel Search](docs/web-research.md). | | Scheduled work | Routines: ask it to do something on a schedule and it runs as you. | | Make it your own | Its name, Bots, channels, model, and skills live in one folder, [`openbot/examples/openduet`](openbot/examples/openduet). | | What needs you, what can wait | Sorts new mail into what needs you and what can wait, with a one-line reason for each. Measured on a labelled set in [`evals/triage`](evals/triage). | @@ -169,6 +170,12 @@ To try it without a Google account, use the fake Google that ships for tests and `OPENBOT_PUBLIC_URL` has to match the API's port, because the fake sends you back through it. `curl http://127.0.0.1:4900/__fake/sent` shows anything OpenDuet sent. The variable is ignored when `NODE_ENV=production`. To switch to real Google, remove it, restart, and connect again: the stored sign-in belongs to the fake. +### Search the public web + +The OpenDuet Bot ships with a public-web research skill. [Set up Parallel Search](docs/web-research.md) to let it find sources and read pages without using its browser. The catalogue includes an anonymous entry for light use and an API-key entry for higher limits. An administrator enables the connector and grants its search and fetch tools to OpenDuet. + +Then try `/research-public-web` with a request such as “Compare three public project-management tools for a small team, check their current pricing, and make a brief with source links.” + ## Make it your own OpenDuet is meant to be copied. Everything that makes it OpenDuet rather than OpenBot is in [`openbot/examples/openduet`](openbot/examples/openduet): diff --git a/docs/web-research.md b/docs/web-research.md new file mode 100644 index 00000000..68788650 --- /dev/null +++ b/docs/web-research.md @@ -0,0 +1,28 @@ +# Public-web research + +OpenDuet can search the public web, read selected pages and make a brief with source links using the Parallel Search connector already included in OpenBot. It does not need the Bot's browser or a separate agent process. + +The OpenDuet Bot holds the `research-public-web` skill. An administrator must still enable a research connector and grant its tools before the Bot can use it. + +## Set up Parallel Search + +1. As an administrator, open **Plugins** at `/admin/plugins` and select **Parallel Search** from the catalogue. +2. Turn on **Enable for this deployment**, then press **Refresh tools**. The connector uses `https://search.parallel.ai/mcp` and needs no API key for light use, subject to provider limits. +3. Press **Grant tools…**, select the **OpenDuet** Bot, and grant both `web_search` and `web_fetch`. These find public sources and read their contents. +4. In Chat, invoke `/research-public-web` with a request such as: + + > Compare three public project-management tools for a small team, check their current pricing, and make a brief with source links. + +OpenDuet can run longer research as a task and put its brief on the Make tab. The skill asks it to search, read selected sources, cite URLs, and report missing evidence or provider errors. + +For higher limits, use **Parallel Search (API key)** from the catalogue. Enter the deployment's Parallel API key in that connector's token field, refresh its tools, and grant both tools to OpenDuet. Remove the grants on the anonymous entry if they are no longer needed. The key stays in the server-side credential vault. + +## Controls and data + +The connector receives the research objective, search queries, requested public URLs and a conversation session identifier. The skill tells the Bot to send only what the research needs, not whole conversations or private documents. + +Assigning the skill does not grant tool access. Calls use OpenBot's existing grant checks, action policies and audit records. Administrators can revoke access or connect another supported research provider. The skill declares the `@research/search` and `@research/fetch` roles, so it works with the provider whose tools the Bot holds. + +If research tools are missing, check that the connector is enabled, its tools have been refreshed, and both tools are granted to **OpenDuet**. A provider error or limit should be reported in the answer rather than treated as evidence that no sources exist. + +See [OpenBot's research guide](../openbot/docs/web-research.md) for other providers and [Parallel's Search MCP documentation](https://docs.parallel.ai/integrations/mcp/search-mcp) for service details. diff --git a/openbot/examples/openduet/agents.yaml b/openbot/examples/openduet/agents.yaml index 44cc5a26..9410d253 100644 --- a/openbot/examples/openduet/agents.yaml +++ b/openbot/examples/openduet/agents.yaml @@ -108,6 +108,7 @@ agents: - morning-brief - first-day - reply-to-an-email + - research-public-web - bot-creator - skill-creator From 2411df135c126c4ebc5c4b2a6ba638401d1208d3 Mon Sep 17 00:00:00 2001 From: Rowan Aldean <56801656+RowanAldean@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:38:53 -0400 Subject: [PATCH 2/3] Clarify research controls, provider setup and page citations --- README.md | 4 +- docs/web-research.md | 28 ------------- openbot/docs/web-research.md | 40 +++++++++++++++--- openbot/examples/openduet/agents.yaml | 11 +++-- openbot/server/src/plugins/catalogue.ts | 5 +++ openbot/server/tests/web-research.test.ts | 50 +++++++++++++++++++++++ 6 files changed, 100 insertions(+), 38 deletions(-) delete mode 100644 docs/web-research.md diff --git a/README.md b/README.md index 6dc2d26b..01b70b62 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Everything below works today when you run this repository. | Skills | Reusable instructions you write once and call with `/`. | | Gmail and Google Calendar | Each person connects their own account. OpenDuet reads mail and today's calendar, and sends a reply only after you answer. | | Connected apps | Governed MCP connectors, including Google Drive and Notion. | -| Public-web research | Search public sources, read selected pages and make a brief with source links after an administrator grants the research tools. [Set up Parallel Search](docs/web-research.md). | +| Public-web research | Search public sources, read selected pages and make a brief with source links after an administrator grants the research tools. [Set up a research provider](openbot/docs/web-research.md). | | Scheduled work | Routines: ask it to do something on a schedule and it runs as you. | | Make it your own | Its name, Bots, channels, model, and skills live in one folder, [`openbot/examples/openduet`](openbot/examples/openduet). | | What needs you, what can wait | Sorts new mail into what needs you and what can wait, with a one-line reason for each. Measured on a labelled set in [`evals/triage`](evals/triage). | @@ -172,7 +172,7 @@ To try it without a Google account, use the fake Google that ships for tests and ### Search the public web -The OpenDuet Bot ships with a public-web research skill. [Set up Parallel Search](docs/web-research.md) to let it find sources and read pages without using its browser. The catalogue includes an anonymous entry for light use and an API-key entry for higher limits. An administrator enables the connector and grants its search and fetch tools to OpenDuet. +The OpenDuet Bot ships with a public-web research skill. [Set up a research provider](openbot/docs/web-research.md) to let it find sources and read pages without using its browser. The catalogue includes Parallel, Exa and Firecrawl. An administrator enables a connector and grants its search and fetch tools after reviewing the guide's controls and data-sharing risks. Then try `/research-public-web` with a request such as “Compare three public project-management tools for a small team, check their current pricing, and make a brief with source links.” diff --git a/docs/web-research.md b/docs/web-research.md deleted file mode 100644 index 68788650..00000000 --- a/docs/web-research.md +++ /dev/null @@ -1,28 +0,0 @@ -# Public-web research - -OpenDuet can search the public web, read selected pages and make a brief with source links using the Parallel Search connector already included in OpenBot. It does not need the Bot's browser or a separate agent process. - -The OpenDuet Bot holds the `research-public-web` skill. An administrator must still enable a research connector and grant its tools before the Bot can use it. - -## Set up Parallel Search - -1. As an administrator, open **Plugins** at `/admin/plugins` and select **Parallel Search** from the catalogue. -2. Turn on **Enable for this deployment**, then press **Refresh tools**. The connector uses `https://search.parallel.ai/mcp` and needs no API key for light use, subject to provider limits. -3. Press **Grant tools…**, select the **OpenDuet** Bot, and grant both `web_search` and `web_fetch`. These find public sources and read their contents. -4. In Chat, invoke `/research-public-web` with a request such as: - - > Compare three public project-management tools for a small team, check their current pricing, and make a brief with source links. - -OpenDuet can run longer research as a task and put its brief on the Make tab. The skill asks it to search, read selected sources, cite URLs, and report missing evidence or provider errors. - -For higher limits, use **Parallel Search (API key)** from the catalogue. Enter the deployment's Parallel API key in that connector's token field, refresh its tools, and grant both tools to OpenDuet. Remove the grants on the anonymous entry if they are no longer needed. The key stays in the server-side credential vault. - -## Controls and data - -The connector receives the research objective, search queries, requested public URLs and a conversation session identifier. The skill tells the Bot to send only what the research needs, not whole conversations or private documents. - -Assigning the skill does not grant tool access. Calls use OpenBot's existing grant checks, action policies and audit records. Administrators can revoke access or connect another supported research provider. The skill declares the `@research/search` and `@research/fetch` roles, so it works with the provider whose tools the Bot holds. - -If research tools are missing, check that the connector is enabled, its tools have been refreshed, and both tools are granted to **OpenDuet**. A provider error or limit should be reported in the answer rather than treated as evidence that no sources exist. - -See [OpenBot's research guide](../openbot/docs/web-research.md) for other providers and [Parallel's Search MCP documentation](https://docs.parallel.ai/integrations/mcp/search-mcp) for service details. diff --git a/openbot/docs/web-research.md b/openbot/docs/web-research.md index 091f5f76..3fb29f5f 100644 --- a/openbot/docs/web-research.md +++ b/openbot/docs/web-research.md @@ -13,25 +13,55 @@ OpenBot ships three public-web research providers in the plugin catalogue: **Par ## Enable research -1. As an administrator, open Plugins and add a provider from the catalogue. For the keyed entries, enter the provider's API key; it is stored in the server-side credential vault. -2. Grant that provider's search and fetch tools to the Bots that should research the public web. For the shipped fintech package, select **Research Desk**. +1. As an administrator, open **Plugins** at `/admin/plugins`, choose a provider from the table above, and turn on **Enable for this deployment**. For the keyed entries, enter the provider's API key in **Access token**; it is stored in the server-side credential vault. +2. Press **Refresh tools**, then **Grant tools…** to grant that provider's search and fetch tools to the Bots that should research the public web. A separate research Bot without mail or private-document tools is the safer starting point. The shipped fintech package calls this Bot **Research Desk**. Read [Controls and data](#controls-and-data) before combining research and private-account tools on one Bot. 3. Ask the Bot to research a topic. It searches, reads selected sources and cites their URLs. Run `/research-public-web` to select the example skill explicitly if your deployment has many tools. For production or higher limits, use a keyed entry and remove the grants on the free one. If a Bot holds a vendor's free and keyed entries, it is told about the keyed one. +### OpenDuet + +The OpenDuet Bot holds the `research-public-web` skill. It still needs an administrator to enable a research connector and grant its tools. OpenDuet also reads mail and calendar data when those tools are granted, so review the controls below before giving its primary Bot public-web access. A separate research Bot can hold the same skill and only public-web tools. + +For example, select **Parallel Search** in Plugins, enable it, refresh its tools, and grant `web_search` and `web_fetch` to the chosen research Bot. It needs no API key for light use, subject to provider limits. **Parallel Search (API key)** uses a deployment key entered under **Access token**. Exa and Firecrawl use the same setup flow with the tool names in the table. + +Ask OpenDuet: + +> /research-public-web Compare three public project-management tools for a small team, check their current pricing, and make a brief with source links. + +Longer research runs as a task. The Bot is instructed to put clickable source URLs beside the claims in the saved page on the Make tab, while keeping the Chat reply short. The Chat word limit and rule against source notes do not apply to the page's content. If tools are missing, check that the connector is enabled, its tools are refreshed, and both tools are granted to the Bot doing the work. + ## Skills declare roles, not providers A skill names the research tools it needs as `@research/search` and `@research/fetch`, instead of one provider's tool names. Each role matches that role's tool on every research provider in the catalogue, intersected as always with what the Bot was granted. The shipped `research-public-web` skill does this, so it works with whichever provider a deployment connects. A role declaration grants nothing. -## Choice and controls +## Controls and data In a conversation, research calls are labelled for what they did, "Search the web" and "Read web pages", whichever provider made them, and the Bot does not name the provider unless asked. Which provider a Bot holds is shown on its access page. A Bot holding more than one provider is told about each and uses the one the person asks for. Administrators can revoke tools, remove a connector or grant another provider. Every call goes through `PluginStore.callTool`, with the usual grants, action policies and audit records; no raw MCP server is added to the agent configuration. -Exa and Firecrawl name the tools that only read (`readTools` in the catalogue), and every other tool they advertise counts as a write. Firecrawl publishes tool families such as `firecrawl_monitor_*`, and Exa's agent tools spend credits, so neither can be safely described by a list of writes alone. +Each research provider names its reviewed read tools in `readTools` in the catalogue. Any other tool it advertises counts as a write until reviewed. This also applies to Parallel: only `web_search` and `web_fetch` count as reads. + +The provider receives model-selected objectives, search queries, requested public URLs and, for Parallel, a conversation session identifier. Whole conversations are not sent automatically, but the model can copy private text into any of those fields. Failures stay visible and are not silently retried through another provider. + +**A read tool can send data out.** Research reads do not pause for **Allow once**, and the shipped action policy allows calls unless an administrator adds restrictions. If the same Bot can read mail or private documents, a malicious instruction in that content could cause it to send private text in a search query or a fetched URL. Fetching an attacker-controlled URL can send text in its path or query to that site's operator through the provider. The instruction to treat outside content as information helps, but it is not an enforced data boundary. + +For a safer setup, use a separate research Bot with only public-web tools, like the fintech package's **Research Desk**. Do not grant it mail, calendar or private-document tools, and do not pass private content to it in requests or handoffs. + +An administrator can also add a deny rule under **Boundaries** (`/admin/boundaries`). For example, this policy refuses calls to all six research catalogue entries when a routine starts the run: + +```json +{ + "mode": "enforce", + "deny": [ + "initiator.kind == \"routine\" && mcp.server in [\"parallel\", \"parallel-authenticated\", \"exa\", \"exa-authenticated\", \"firecrawl\", \"firecrawl-authenticated\"]" + ], + "allow": ["true"] +} +``` -The provider receives model-selected objectives, search queries, requested public URLs and, for Parallel, a conversation session identifier. These can contain information from the person's request, so the Bot is told not to send private context public research does not need. Full conversations are not forwarded. Failures stay visible and are not silently retried through another provider. +Add the deny expression to your existing policy rather than replacing other restrictions. A saved policy takes precedence over `AGENT_COMPUTER_POLICY`; that variable supplies the initial policy when no saved policy exists. This example blocks scheduled research, including both search and fetch. It does not prevent disclosure in a person-started run or a handoff, and it does not restrict other outbound tools such as the browser. Isolation and grants still matter. ## Adding a provider diff --git a/openbot/examples/openduet/agents.yaml b/openbot/examples/openduet/agents.yaml index 9410d253..010598f9 100644 --- a/openbot/examples/openduet/agents.yaml +++ b/openbot/examples/openduet/agents.yaml @@ -50,7 +50,7 @@ agents: Morgan can send ivory this week instead.” A who-is question needs only “Jules is your accountant at Finch Accounting.” These names are fictional, never evidence. Put each separate thought in its own paragraph with a blank line between them, because each paragraph is shown as its own message bubble. No headings, no tables and no bold - labels. Never say where you read something, what you did not open, or that you went by + labels. In ordinary Chat replies, never say where you read something, what you did not open, or that you went by snippets, and never go back to correct an earlier answer unless they ask. Never list more than three things: when you find more, say how many, name the one that needs them most, and offer the rest as tasks or a page. These examples show only the length and tone; the people and @@ -88,7 +88,11 @@ agents: a deck or a dashboard, make it with make_page. Then say one or two sentences about it, for example "I drafted answers for both from your numbers. It's a 2-minute read." Never paste its content into the chat; it opens from the card. A task that asks you to make a page, a deck, a - dashboard or a doc ends with make_page, a deck as slides. Write to the requested slide count: + dashboard or a doc ends with make_page, a deck as slides. For a research task, put plain + HTTP or HTTPS source URLs in text blocks beside the claims they support in make_page. The rules + against source notes and the 35-word Chat limit do not apply to page content: the research + brief must include its sources. Keep the Chat reply short and point to the saved page. + Write to the requested slide count: one slide holds roughly 25–35 words, one small chart of up to 5 short labels, 2 compact metrics, or 3 short bullets. Use one main block per slide, short titles, no repetitive summary slide, and put extra detail in a separate page only if requested. A check-in is a turn you start @@ -101,7 +105,8 @@ agents: a tap-answerable choice, call suggest_replies with 2–3 short chips and keep the answer in words. Count all paragraphs together: ordinary Chat is at most 35 words, including any question. Cut the least useful detail until it fits; longer work belongs in a page or task. Never - append source notes, “I searched”, “from saved notes”, or a list of other matching messages. + append source notes, “I searched”, “from saved notes”, or a list of other matching messages + to ordinary Chat replies; keep research citations in the saved page. # The same two authoring skills OpenBot's general assistant holds: making another Bot, and # turning an interview into a saved skill, both from inside a conversation. skills: diff --git a/openbot/server/src/plugins/catalogue.ts b/openbot/server/src/plugins/catalogue.ts index 01670153..03b9a764 100644 --- a/openbot/server/src/plugins/catalogue.ts +++ b/openbot/server/src/plugins/catalogue.ts @@ -174,6 +174,9 @@ export function researchRoleFor(ref: string): ResearchRole | null { /** Gmail and Google Calendar, as one entry. Named because the development override keys on it. */ export const GOOGLE_WORKSPACE = "google-workspace"; +/** Only the reviewed search and page-reading tools can run as reads. */ +const PARALLEL_READ_TOOLS = Object.freeze(["web_search", "web_fetch"]); + /** Exa's research and multi-step agent tools spend credits, so only the three that read are reads. */ const EXA_READ_TOOLS = Object.freeze([ "web_search_exa", @@ -230,6 +233,7 @@ const REVIEWED: readonly CatalogueEntry[] = Object.freeze([ path: "/mcp", auth: { kind: "none" }, writeTools: Object.freeze([]), + readTools: PARALLEL_READ_TOOLS, docsUrl: "https://docs.parallel.ai/integrations/mcp/search-mcp", }, { @@ -242,6 +246,7 @@ const REVIEWED: readonly CatalogueEntry[] = Object.freeze([ path: "/mcp", auth: { kind: "deployment-bearer" }, writeTools: Object.freeze([]), + readTools: PARALLEL_READ_TOOLS, docsUrl: "https://docs.parallel.ai/integrations/mcp/search-mcp", }, { diff --git a/openbot/server/tests/web-research.test.ts b/openbot/server/tests/web-research.test.ts index 4e4d30d9..25ec8bd3 100644 --- a/openbot/server/tests/web-research.test.ts +++ b/openbot/server/tests/web-research.test.ts @@ -7,6 +7,11 @@ import { } from "@openbot/shared/research"; import { parse } from "yaml"; import { z } from "zod"; +import { + type ActionPolicy, + evaluateActionPolicy, + type PolicyContext, +} from "../src/computer/policy"; import { catalogueEntry, classifyTool, @@ -74,6 +79,16 @@ describe("research providers in the catalogue", () => { ).toContain("drive: search"); }); + test("Parallel tools added later require write controls until reviewed", () => { + for (const key of ["parallel", "parallel-authenticated"]) { + const entry = catalogueEntry(key)!; + expect(classifyTool(entry, "new_unreviewed_tool", true)).toBe("write"); + expect(classifyTool(entry, "new_unreviewed_tool", true, "read")).toBe( + "write", + ); + } + }); + test("a tool outside an entry's reads is a write, even one the vendor called a read", () => { const firecrawl = catalogueEntry("firecrawl")!; // Published as a family, so no write list could have named it. @@ -113,6 +128,41 @@ describe("research providers in the catalogue", () => { }); }); +describe("the documented research policy", () => { + test("blocks routine search and fetch across providers while allowing person-started calls", () => { + const guide = readFileSync( + new URL("../../docs/web-research.md", import.meta.url), + "utf8", + ); + const example = guide.match(/```json\n([\s\S]*?)\n```/); + expect(example).not.toBeNull(); + const policy: ActionPolicy = JSON.parse(example![1]); + for (const [server, names] of Object.entries(RESEARCH_TOOLS)) { + for (const name of names) { + const context: PolicyContext = { + tool: { name: `mcp__${server}__${name}` }, + bot: { id: "openduet" }, + actor: { id: "owner" }, + page: { url: "", host: "" }, + mcp: { server, tool: name, effect: "read" }, + intent: "read_tool", + initiator: { kind: "routine", id: "scheduled-research" }, + }; + const denied = evaluateActionPolicy(policy, context); + expect(denied.allowed).toBe(false); + expect(denied.forward).toBe(false); + expect(denied.source).toBe("deny"); + expect( + evaluateActionPolicy(policy, { + ...context, + initiator: { kind: "person", id: "" }, + }).allowed, + ).toBe(true); + } + } + }); +}); + describe("what a Bot is told about research", () => { test("a provider is described only while both of its tools are held", () => { for (const server of Object.keys(RESEARCH_TOOLS)) { From c702cd0f2377d69a257db36295297a633d6ac266 Mon Sep 17 00:00:00 2001 From: Rowan Aldean <56801656+RowanAldean@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:38:53 -0400 Subject: [PATCH 3/3] Render research source URLs as safe links in saved pages --- openbot/app/src/components/duet/page-text.tsx | 44 +++++++++++++++ openbot/app/src/components/duet/page-view.tsx | 7 +-- openbot/app/tests/pages.test.tsx | 53 +++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 openbot/app/src/components/duet/page-text.tsx diff --git a/openbot/app/src/components/duet/page-text.tsx b/openbot/app/src/components/duet/page-text.tsx new file mode 100644 index 00000000..c73c05f9 --- /dev/null +++ b/openbot/app/src/components/duet/page-text.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; + +/** Plain page text with source URLs; never interpret model text as HTML or Markdown. */ +export function PageText({ text }: { text: string }) { + const parts: ReactNode[] = []; + let cursor = 0; + for (const match of text.matchAll(/https?:\/\/[^\s<>"']+/g)) { + const start = match.index; + let source = match[0].replace(/[.,;:!?]+$/, ""); + for (const [open, close] of [ + ["(", ")"], + ["[", "]"], + ]) { + while ( + source.endsWith(close) && + source.split(close).length > source.split(open).length + ) { + source = source.slice(0, -1); + } + } + let url: URL; + try { + url = new URL(source); + } catch { + continue; + } + if (url.username || url.password) continue; + parts.push(text.slice(cursor, start)); + parts.push( + + {source} + , + ); + cursor = start + source.length; + } + parts.push(text.slice(cursor)); + return <>{parts}; +} diff --git a/openbot/app/src/components/duet/page-view.tsx b/openbot/app/src/components/duet/page-view.tsx index 261ace60..3dc2033a 100644 --- a/openbot/app/src/components/duet/page-view.tsx +++ b/openbot/app/src/components/duet/page-view.tsx @@ -22,6 +22,7 @@ import type { PageBlock, PageSlide } from "@/lib/pages/queries"; import { cn } from "@/lib/utils"; import { DuetIcon } from "./icons"; import { RichBlock } from "./page-rich-block"; +import { PageText } from "./page-text"; /* * A page the Bot made, drawn from its blocks in the shell's own type and colours. The viewer draws @@ -111,7 +112,7 @@ function Block({ block.text.length > HEADLINE_LENGTH ? "type-title" : "type-big", )} > - {block.text} +

) : ( /* @@ -121,7 +122,7 @@ function Block({ * reader was drawn with, at the default 17px. */

- {block.text} +

); case "bullets": @@ -566,7 +567,7 @@ function SlideBlock({ case "text": return (

- {block.text} +

); case "bullets": diff --git a/openbot/app/tests/pages.test.tsx b/openbot/app/tests/pages.test.tsx index 037ffb1b..6ae974ed 100644 --- a/openbot/app/tests/pages.test.tsx +++ b/openbot/app/tests/pages.test.tsx @@ -914,3 +914,56 @@ test("a dashboard's headline is set large, and a long one steps down a size", () const plain = render(); expect(plain.getByText(short).className).toContain("type-body"); }); + +test("a research page renders source URLs as links without changing its text", () => { + const text = + "Pricing: https://example.com/pricing. See also (https://example.org/plans)."; + const view = render(); + const links = view.getAllByRole("link"); + expect(links.map((link) => link.getAttribute("href"))).toEqual([ + "https://example.com/pricing", + "https://example.org/plans", + ]); + expect(view.container.textContent).toBe(text); + for (const link of links) { + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toBe("noopener noreferrer"); + } +}); + +test("source links keep balanced URL parentheses and support HTTP sources", () => { + const text = "https://example.com/wiki/Project_(software) http://example.org"; + const view = render(); + expect( + view.getAllByRole("link").map((link) => link.getAttribute("href")), + ).toEqual([ + "https://example.com/wiki/Project_(software)", + "http://example.org/", + ]); + expect(view.container.textContent).toBe(text); +}); + +test("source text never turns markup, unsafe schemes or URL credentials into links", () => { + const text = + " javascript:alert(1) data:text/html,unsafe https://user:secret@example.com/private https://"; + const view = render(); + expect(view.queryAllByRole("link")).toHaveLength(0); + expect(view.container.querySelector("script")).toBeNull(); + expect(view.container.textContent).toBe(text); +}); + +test("a research slide opens the same source URLs as a page", () => { + const view = render( + , + ); + expect(view.getByRole("link").getAttribute("href")).toBe( + "https://example.com/pricing", + ); +});