From 5f1100e3b32bac1890cc093a25c95e13e7e2105e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 22 Sep 2026 11:18:46 -0700 Subject: [PATCH] refactor(server): remove retired operator UI snippet injection (#13789) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators can extend its interface through trusted plugin UI contributions. > - The server also accepts executable HTML through two legacy environment settings. > - This older path bypasses the plugin installation and lifecycle model. > - This pull request removes snippet injection from static and development pages. > - Operators must migrate existing integrations before upgrading. ## Linked Issues or Issue Description **What existing behavior does this improve?** Retire the operator HTML injection path from the server. Related public changes: #13168, #13245 and #13496 introduced the legacy settings; #13646 supplies the generic plugin host contract. **Current behavior** Managed instances append operator-supplied HTML or a decoded script body to every page. The same integration can use supported trusted plugin UI slots. **Proposed behavior** Ignore both retired settings. Serve normal branded static and development HTML, and keep plugin contributions unchanged. **Breaking changes** Installations that rely on `PAPERCLIP_CLOUD_UI_SNIPPET` or `PAPERCLIP_CLOUD_UI_SNIPPET_B64` must migrate before upgrading. The maintainer-owned staging and production deployments have completed the migration prerequisite. Other operators must migrate their integrations before adopting this change. ## What Changed - Remove the snippet injector and its static/dev rendering integration. - Remove injector-specific tests and retain a regression that old settings no longer change served HTML. - Replace setup instructions with a retirement and plugin-migration note. ## Verification - Rebased onto current master; `pnpm exec vitest run server/src/__tests__/static-index-html.test.ts server/src/__tests__/vite-html-renderer.test.ts`: 5 tests passed. - With repository-pinned Rust/Cargo installed, full `pnpm -r typecheck` and `pnpm build` pass after rebase. - Previous full local `pnpm test:run` encountered unrelated macOS runtime-cache rename `EACCES` errors and a missing AgentMail skill path; a focused reproduction confirmed 5 failures / 95 passes. That is not a passing full-suite result. The unchanged focused suites, build and typecheck were repeated after rebase; the full local suite was not repeated. All 54 refreshed GitHub checks/contexts passed on `bd62bff63f9d7980bfd10e54cb0102693d27ecfb`; fresh Greptile is 5/5 with no unresolved threads. - No UI component styling, database or API contract changed. - Maintainer approved the remaining rollout and cleanup. Staging snippet retirement and sleep/wake verification are complete. Production migration and removal of the legacy settings are complete for serving tenant instances. Remaining old warm inventory is excluded from new signups until configuration reconciliation completes. The maintainer authorized upgrading the remaining old deployments and clearing their pins. ## Risks - Removing the settings disables integrations that still depend on them; operators outside the completed maintainer rollout must migrate before upgrading. This is an intentional behavior change, documented at the existing setup-doc path. - Keep a previous image and its configuration for rollback. Existing browser tabs need a refresh to unload already-injected code. - Plugin UI remains trusted same-origin code. This does not add a security sandbox or change ordinary branding. ## Model Used - OpenAI GPT-6 (Codex; exact deployment variant and context-window size are not exposed in this session). Reasoning, repository inspection, local code execution and GitHub tools. ## 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 (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused rendering tests; unrelated local full-suite failures are disclosed above) - [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 refreshed checks pass on `bd62bff63f9d7980bfd10e54cb0102693d27ecfb` - [x] Fresh Greptile is 5/5 on the current head, with no unresolved findings - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/cloud-ui-snippet.md | 94 +++------------- server/src/__tests__/cloud-ui-snippet.test.ts | 102 ------------------ .../src/__tests__/static-index-html.test.ts | 7 +- .../src/__tests__/vite-html-renderer.test.ts | 27 +++++ server/src/app.ts | 3 +- server/src/cloud-ui-snippet.ts | 59 ---------- server/src/static-index-html.ts | 3 +- 7 files changed, 48 insertions(+), 247 deletions(-) delete mode 100644 server/src/__tests__/cloud-ui-snippet.test.ts delete mode 100644 server/src/cloud-ui-snippet.ts diff --git a/doc/cloud-ui-snippet.md b/doc/cloud-ui-snippet.md index 4787f3b9e0..2d0b591888 100644 --- a/doc/cloud-ui-snippet.md +++ b/doc/cloud-ui-snippet.md @@ -1,83 +1,17 @@ -# Cloud UI snippet +# Retired Cloud UI snippet settings -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. +`PAPERCLIP_CLOUD_UI_SNIPPET` and `PAPERCLIP_CLOUD_UI_SNIPPET_B64` are no longer +read by the server. They do not inject HTML or JavaScript into static or dev +pages. This removes an operator-controlled executable HTML path from the app. +Existing ordinary branding and plugin UI contributions are unchanged. -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. +Before upgrading an installation that uses either setting, move the integration +to a trusted plugin using the supported [plugin UI slots](plugins/PLUGIN_AUTHORING_GUIDE.md). +Plugin UI runs as trusted same-origin code; a plugin is not a sandbox for +untrusted JavaScript. Remove the retired settings from the operator's desired +configuration and verify any provision/restart/wake mechanism cannot restore them. +Refresh existing browser tabs to unload previously injected code. -## Base64 variant - -Delivery pipelines that write env vars through provider APIs can sit behind -web application firewalls that reject values containing raw script markup. -`PAPERCLIP_CLOUD_UI_SNIPPET_B64` carries the same snippet through them as -standard base64 of the UTF-8 HTML: - -```sh -PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.html)" -``` - -Whitespace and line wrapping in the value are tolerated. A value that is not -canonical padded base64 of UTF-8 text, or that decodes to blank, is ignored — -if the widget does not appear, check that the value round-trips through -`base64 -d`. A present `PAPERCLIP_CLOUD_UI_SNIPPET` always wins, blank -included: clearing the plain variable to blank disables injection even while -a base64 value is still deployed. Everything else about the snippet is -unchanged. - -Base64 does not defeat every firewall. Some decode the value before matching, -so they reject a base64 snippet whose decoded bytes still contain script -markup. Deliver a bare script body (below) through one of these. - -## Bare script body - -Set the value to the script body alone — the JavaScript with no surrounding -``, which would close the wrapper early. Because the value carries no -` -(function(d) { - var script = d.createElement('script'); - script.src = 'https://chat.cdn-plain.com/index.js'; - script.onload = function() { Plain.init({ appId: 'YOUR_CHAT_APP_ID' }); }; - d.head.appendChild(script); -})(document); - -``` - -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. +For rollback, retain the prior image and configuration outside source control. +An older image can still use these settings. Do not activate the old integration +and its replacement together. diff --git a/server/src/__tests__/cloud-ui-snippet.test.ts b/server/src/__tests__/cloud-ui-snippet.test.ts deleted file mode 100644 index 484b8343cb..0000000000 --- a/server/src/__tests__/cloud-ui-snippet.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { injectCloudUiSnippet } from "../cloud-ui-snippet.js"; - -const html = '
'; -const snippet = ''; -const encoded = Buffer.from(snippet, "utf-8").toString("base64"); - -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); - expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded })).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"); - }); - - it("decodes a base64 snippet on a Cloud instance", () => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html.replace("", `${snippet}\n`)); - }); - - it("tolerates whitespace and line wrapping in the base64 value", () => { - const wrapped = ` ${encoded.slice(0, 20)}\n${encoded.slice(20)}\n`; - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: wrapped, - })).toBe(html.replace("", `${snippet}\n`)); - }); - - it("wraps a bare script body that carries no markup in a `; - expect(injectCloudUiSnippet(html, { PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: body })) - .toBe(html.replace("", `${wrapped}\n`)); - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: Buffer.from(body, "utf-8").toString("base64"), - })).toBe(html.replace("", `${wrapped}\n`)); - }); - - it("preserves literal replacement tokens when wrapping a bare script body", () => { - const body = 'console.log("$&", "$`", "$\'");'; - const result = injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: body, - }); - expect(result).toContain(``); - }); - - it("prefers the plain snippet when both variables are set", () => { - const other = Buffer.from("", "utf-8").toString("base64"); - const result = injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: snippet, - PAPERCLIP_CLOUD_UI_SNIPPET_B64: other, - }); - expect(result).toContain(snippet); - expect(result).not.toContain("other()"); - }); - - it("treats a blank plain variable as disabled even when a base64 value is set", () => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: " ", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html); - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: "", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html); - }); - - it.each([ - { label: "invalid characters", value: "!!!not-base64!!!" }, - { label: "wrong length", value: "abcde" }, - { label: "unpadded", value: Buffer.from("x", "utf-8").toString("base64").replace(/=+$/, "") }, - { label: "carrying nonzero padding bits", value: "PB==" }, - { label: "not valid UTF-8 once decoded", value: "/w==" }, - { label: "blank once decoded", value: Buffer.from(" \n ", "utf-8").toString("base64") }, - { label: "blank", value: " " }, - ])("ignores a base64 value that is $label", ({ value }) => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: value, - })).toBe(html); - }); -}); diff --git a/server/src/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts index 9d21f8dfca..baddb97a76 100644 --- a/server/src/__tests__/static-index-html.test.ts +++ b/server/src/__tests__/static-index-html.test.ts @@ -16,16 +16,19 @@ describe("static SPA fallback HTML", () => { } }); - it("includes the operator snippet only in Cloud-served static HTML", () => { + it("ignores retired snippet settings in managed and self-hosted 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_UI_SNIPPET_B64", Buffer.from('').toString("base64")); 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'); + expect(readBrandedStaticIndexHtml(dir)).not.toContain("chat.js"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", undefined); + expect(readBrandedStaticIndexHtml(dir)).not.toContain("legacy.js"); }); it("serves the current index.html instead of reusing stale asset hashes", async () => { diff --git a/server/src/__tests__/vite-html-renderer.test.ts b/server/src/__tests__/vite-html-renderer.test.ts index 46b065f913..937c371cc6 100644 --- a/server/src/__tests__/vite-html-renderer.test.ts +++ b/server/src/__tests__/vite-html-renderer.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyUiBranding } from "../ui-branding.js"; import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js"; function createWatcher() { @@ -27,11 +28,37 @@ describe("createCachedViteHtmlRenderer", () => { const tempDirs: string[] = []; afterEach(() => { + vi.unstubAllEnvs(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); + it("ignores retired snippet settings in branded development HTML", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); + tempDirs.push(tempDir); + fs.writeFileSync(path.join(tempDir, "index.html"), "App"); + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", ''); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET_B64", Buffer.from('').toString("base64")); + const renderer = createCachedViteHtmlRenderer({ + vite: { watcher: createWatcher(), transformIndexHtml: async (_url, html) => html }, + uiRoot: tempDir, + brandHtml: applyUiBranding, + }); + try { + const html = await renderer.render("/"); + expect(html).toContain("App"); + expect(html).not.toContain("legacy-plain.js"); + expect(html).not.toContain("legacy-encoded.js"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", undefined); + + expect(await renderer.render("/issues")).not.toContain("legacy-encoded.js"); + } finally { + renderer.dispose(); + } + }); + it("caches the branded template until index.html changes while transforming every request", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); tempDirs.push(tempDir); diff --git a/server/src/app.ts b/server/src/app.ts index 13b56381da..439350c09b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -120,7 +120,6 @@ 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"; @@ -1092,7 +1091,7 @@ export async function createApp( viteHtmlRenderer = createCachedViteHtmlRenderer({ vite, uiRoot, - brandHtml: (html) => injectCloudUiSnippet(applyUiBranding(html)), + brandHtml: applyUiBranding, }); const renderViteHtml = viteHtmlRenderer; diff --git a/server/src/cloud-ui-snippet.ts b/server/src/cloud-ui-snippet.ts deleted file mode 100644 index 2d3e39454f..0000000000 --- a/server/src/cloud-ui-snippet.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 = resolveCloudUiSnippet(env); - if (!isCloudManagedInstance(env) || !snippet) return html; - return html.replace(/<\/body>/i, () => `${snippet}\n`); -} - -/** - * A present plain variable always wins — blank included, so clearing it to - * blank disables injection even when a base64 value is still deployed. The - * base64 variant exists because delivery pipelines that write env vars - * through provider APIs can sit behind web application firewalls that - * reject values containing raw script markup; base64 carries the same - * snippet through them unchanged. - */ -function resolveCloudUiSnippet(env: CloudInstanceEnv): string | null { - const plain = env.PAPERCLIP_CLOUD_UI_SNIPPET; - if (plain !== undefined) return asInjectableMarkup(plain); - const encoded = env.PAPERCLIP_CLOUD_UI_SNIPPET_B64?.replace(/\s+/g, ""); - if (!encoded) return null; - const decoded = decodeBase64(encoded); - return decoded !== null ? asInjectableMarkup(decoded) : null; -} - -/** - * A configured value that already looks like markup (it starts with `<`) is - * injected verbatim, preserving the original bytes. A value that does not is - * treated as a bare script body and wrapped in a ``; -} - -/** - * A value that is not canonical, padded base64 of valid UTF-8 is ignored - * rather than injected as garbage: the round trip rejects stray padding - * bits, and the fatal decoder rejects byte sequences that are not UTF-8. - */ -function decodeBase64(encoded: string): string | null { - if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) return null; - const bytes = Buffer.from(encoded, "base64"); - if (bytes.toString("base64") !== encoded) return null; - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - return null; - } -} diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts index 7bbc16f4c1..13fa592c09 100644 --- a/server/src/static-index-html.ts +++ b/server/src/static-index-html.ts @@ -1,8 +1,7 @@ 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 injectCloudUiSnippet(applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"))); + return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); }