From 60ee13a0f78e9506e8d9bcee5fc18038450ec843 Mon Sep 17 00:00:00 2001 From: Michael Nguyen <13559011+nguyenm7@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:30:36 -0700 Subject: [PATCH] feat: allow operator UI snippets on Cloud instances (#13168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional Cloud-only HTML snippet so operators can load Plain’s standard chat bubble. **6 files, 16 implementation lines added; 102 additions including tests and docs.** ## Thinking Path > - Paperclip serves Cloud and self-hosted users. > - Closed beta users need a way to report problems. > - Plain provides a ready-made chat widget. > - Cloud operators can load it through a generic deployment setting. > - Self-hosted instances ignore that setting. ## Linked Issues or Issue Description **Subsystem affected** Server-served UI HTML. **Problem or motivation** Enable a chat bubble in Cloud without adding a support feature to the React app. **Proposed solution** Insert trusted `PAPERCLIP_CLOUD_UI_SNIPPET` HTML before `` when the existing Cloud-managed predicate is true. The setting is off by default. Related Cloud-gated integration: #12190. ## What Changed Review the [final diff](https://github.com/paperclipai/paperclip/pull/13168/files) in this order: 1. `server/src/cloud-ui-snippet.ts`: the eight-line Cloud gate and HTML insertion. 2. `server/src/static-index-html.ts` and `server/src/app.ts`: apply it to static root/index, SPA routes, and Vite HTML. 3. Two test files and `doc/cloud-ui-snippet.md`: boundary checks and setup instructions. React UI, customer identity, and database behavior are unchanged. The existing feedback flag remains. Plain chat is anonymous; no Paperclip name, email, or organization is supplied. ## Verification - **Greptile: 5/5**, no actionable findings, reviewed commit `04bb44515`. - **[CI passed](https://github.com/paperclipai/paperclip/actions/runs/34535763243)**, including build, typecheck, server tests, and end-to-end tests. - Local: six focused tests, full typecheck, and build passed. The full local suite has not produced a final result; CI is the completed full verification. - Staging deployment and live chat testing remain to be done. ### Staging setup Set **one server environment variable**, `PAPERCLIP_CLOUD_UI_SNIPPET`, to: ```html ``` This is the public staging app ID. **No API key or signing secret is needed.** Deploy to staging and restart the app with this setting. Test `/`, `/index.html`, and an organization dashboard; send a message and confirm a support reply returns. Production rollout is separate. [Plain embed docs](https://www.plain.com/docs/product/channels/chat) · [Configuration and rollback](https://github.com/paperclipai/paperclip/blob/04bb4451510b124f21b6771b7a8163faad61d8ec/doc/cloud-ui-snippet.md) ## Risks Only trusted operators should set this value. The HTML is public and scripts execute in the app origin; do not include secrets or user-provided HTML. Plain owns the anonymous browser session, with no Paperclip account-switch integration. To roll back, unset the variable, restart, and refresh open tabs. ## Model Used OpenAI Codex (GPT-6), with repository inspection and code execution. Exact runtime model identifier and context size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: Claude Fable 5 --- doc/cloud-ui-snippet.md | 42 +++++++++++++++++++ server/src/__tests__/cloud-ui-snippet.test.ts | 30 +++++++++++++ .../src/__tests__/static-index-html.test.ts | 15 ++++++- server/src/app.ts | 7 +++- server/src/cloud-ui-snippet.ts | 8 ++++ server/src/static-index-html.ts | 3 +- 6 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 doc/cloud-ui-snippet.md create mode 100644 server/src/__tests__/cloud-ui-snippet.test.ts create mode 100644 server/src/cloud-ui-snippet.ts diff --git a/doc/cloud-ui-snippet.md b/doc/cloud-ui-snippet.md new file mode 100644 index 0000000000..28deab517c --- /dev/null +++ b/doc/cloud-ui-snippet.md @@ -0,0 +1,42 @@ +# Cloud UI snippet + +Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet. +The server inserts it before `` in static and Vite-served UI pages. +It requires the existing Cloud-managed instance signal. Self-hosted instances +ignore this setting. No snippet is enabled by default. + +This is trusted deployment configuration, not user input. It executes in the +application origin and is visible to every browser that receives the UI shell. +Do not include secrets or customer data. Restart the app after changing it. +Operators must review scripts and any required CSP changes before deployment. + +## Plain closed beta + +Set the value to this standard embed, replacing `YOUR_CHAT_APP_ID` with the +public chat app ID for the target environment: + +```html + +``` + +No signing secret or Plain API key is required. No Paperclip customer identity +or organization data is passed. Plain manages the anonymous browser session; +there is no Paperclip account-switch integration. Ask users for identifying +information when needed. The existing feedback flag remains unchanged. + +Docs: [Plain chat](https://www.plain.com/docs/product/channels/chat). + +## Verification and rollback + +On staging, open `/`, `/index.html`, and an organization dashboard directly. +Confirm the bubble appears and a test message reaches Plain. Verify the support +reply returns. On a self-hosted instance, confirm no snippet or widget is loaded. +Unset the snippet and restart to remove it on the next page load. Existing open +tabs retain the widget until refreshed. No production deployment is implied. diff --git a/server/src/__tests__/cloud-ui-snippet.test.ts b/server/src/__tests__/cloud-ui-snippet.test.ts new file mode 100644 index 0000000000..8d64f7182b --- /dev/null +++ b/server/src/__tests__/cloud-ui-snippet.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { injectCloudUiSnippet } from "../cloud-ui-snippet.js"; + +const html = '
'; +const snippet = ''; + +describe("Cloud UI snippet", () => { + it("leaves self-hosted HTML unchanged even when a snippet is configured", () => { + expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET: snippet })).toBe(html); + }); + + it.each([ + { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-token" }, + { PAPERCLIP_MANAGED_CONFIG: "{}" }, + ])("injects only on a configured Cloud instance: %j", (cloud) => { + expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: snippet })) + .toBe(html.replace("", `${snippet}\n`)); + expect(injectCloudUiSnippet(html, cloud)).toBe(html); + expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: " " })).toBe(html); + }); + + it("preserves literal replacement tokens in operator JavaScript", () => { + const script = ''; + const result = injectCloudUiSnippet(html, { + PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: script, + }); + expect(result).toContain(script); + expect(result).not.toContain("test-token"); + }); +}); diff --git a/server/src/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts index 1ad3c40501..9d21f8dfca 100644 --- a/server/src/__tests__/static-index-html.test.ts +++ b/server/src/__tests__/static-index-html.test.ts @@ -3,18 +3,31 @@ import os from "node:os"; import path from "node:path"; import express from "express"; import request from "supertest"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { readBrandedStaticIndexHtml } from "../static-index-html.js"; describe("static SPA fallback HTML", () => { const tempDirs: string[] = []; afterEach(() => { + vi.unstubAllEnvs(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); + it("includes the operator snippet only in Cloud-served static HTML", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-html-")); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, "index.html"), "App"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", ''); + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined); + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); + expect(readBrandedStaticIndexHtml(dir)).not.toContain("chat.js"); + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}"); + expect(readBrandedStaticIndexHtml(dir)).toContain('chat.js">\n'); + }); + it("serves the current index.html instead of reusing stale asset hashes", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-static-index-")); tempDirs.push(tempDir); diff --git a/server/src/app.ts b/server/src/app.ts index 0cf096b17d..d0943c1bb0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -113,6 +113,7 @@ import { adapterRoutes } from "./routes/adapters.js"; import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js"; import { remoteAgentProfileRoutes } from "./routes/remote-agent-profiles.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; +import { injectCloudUiSnippet } from "./cloud-ui-snippet.js"; import { readBrandedStaticIndexHtml } from "./static-index-html.js"; import { staticUiCacheControl } from "./static-ui-cache.js"; import { applyUiBranding } from "./ui-branding.js"; @@ -950,6 +951,10 @@ export async function createApp( immutable: true, }), ); + // Serve root/index through the same runtime HTML transform as SPA routes. + app.get(["/", "/index.html"], (_req, res) => { + res.type("html").set("Cache-Control", "no-cache").send(readBrandedStaticIndexHtml(uiDist)); + }); // Non-hashed static files (favicon.ico, manifest, robots.txt, etc.): // short cache so operators who swap them out see the new version // reasonably fast, with must-revalidate overrides for index.html and @@ -1058,7 +1063,7 @@ export async function createApp( viteHtmlRenderer = createCachedViteHtmlRenderer({ vite, uiRoot, - brandHtml: applyUiBranding, + brandHtml: (html) => injectCloudUiSnippet(applyUiBranding(html)), }); const renderViteHtml = viteHtmlRenderer; diff --git a/server/src/cloud-ui-snippet.ts b/server/src/cloud-ui-snippet.ts new file mode 100644 index 0000000000..41f8732599 --- /dev/null +++ b/server/src/cloud-ui-snippet.ts @@ -0,0 +1,8 @@ +import { isCloudManagedInstance, type CloudInstanceEnv } from "./services/cloud-instance.js"; + +/** Trusted operator HTML only. This content is public and runs in the app origin. */ +export function injectCloudUiSnippet(html: string, env: CloudInstanceEnv = process.env): string { + const snippet = env.PAPERCLIP_CLOUD_UI_SNIPPET; + if (!isCloudManagedInstance(env) || !snippet?.trim()) return html; + return html.replace(/<\/body>/i, () => `${snippet}\n`); +} diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts index 13fa592c09..7bbc16f4c1 100644 --- a/server/src/static-index-html.ts +++ b/server/src/static-index-html.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; import path from "node:path"; +import { injectCloudUiSnippet } from "./cloud-ui-snippet.js"; import { applyUiBranding } from "./ui-branding.js"; export function readBrandedStaticIndexHtml(uiDist: string): string { - return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); + return injectCloudUiSnippet(applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"))); }