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 <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-09-22 11:18:46 -07:00
committed by GitHub
co-authored by Paperclip
parent 3d78e3a4ec
commit 5f1100e3b3
7 changed files with 48 additions and 247 deletions
+14 -80
View File
@@ -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 `</body>` 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
`<script>` element:
```sh
PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.body.js)"
```
The server wraps a bare body in a `<script>` element before it inserts it. The
first non-whitespace character decides the form: a value that starts with `<`
is treated as markup and injected unchanged; any other value is treated as a
body and wrapped. This applies to both the plain and the base64 variant.
The body must be safe to embed inline. It must not contain a literal
`</script>`, which would close the wrapper early. Because the value carries no
`<script` marker, a firewall that decodes base64 before matching passes it.
## 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
<script>
(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);
</script>
```
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.
@@ -1,102 +0,0 @@
import { describe, expect, it } from "vitest";
import { injectCloudUiSnippet } from "../cloud-ui-snippet.js";
const html = '<html><body><div id="root"></div></body></html>';
const snippet = '<script src="https://example.com/widget.js"></script>';
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("</body>", `${snippet}\n</body>`));
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 = '<script>console.log("$&", "$`", "$\'");</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("</body>", `${snippet}\n</body>`));
});
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("</body>", `${snippet}\n</body>`));
});
it("wraps a bare script body that carries no markup in a <script> element", () => {
const body = '(()=>{window.__feedback=true;})();';
const wrapped = `<script>${body}</script>`;
expect(injectCloudUiSnippet(html, { PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: body }))
.toBe(html.replace("</body>", `${wrapped}\n</body>`));
expect(injectCloudUiSnippet(html, {
PAPERCLIP_MANAGED_CONFIG: "{}",
PAPERCLIP_CLOUD_UI_SNIPPET_B64: Buffer.from(body, "utf-8").toString("base64"),
})).toBe(html.replace("</body>", `${wrapped}\n</body>`));
});
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(`<script>${body}</script>`);
});
it("prefers the plain snippet when both variables are set", () => {
const other = Buffer.from("<script>other()</script>", "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("<b>x</b>", "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);
});
});
@@ -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"), "<html><body>App</body></html>");
vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", '<script src="https://example.com/chat.js"></script>');
vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET_B64", Buffer.from('<script src="https://example.com/legacy.js"></script>').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"></script>\n</body>');
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 () => {
@@ -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"), "<html><body>App</body></html>");
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}");
vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", '<script src="https://example.com/legacy-plain.js"></script>');
vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET_B64", Buffer.from('<script src="https://example.com/legacy-encoded.js"></script>').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);
+1 -2
View File
@@ -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;
-59
View File
@@ -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</body>`);
}
/**
* 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 `<script>` element.
*
* The bare-body form exists for the WAF case above: even base64 no longer
* carries raw script markup past every provider firewall, because some now
* base64-decode the value before matching. A body with no `<script` marker
* clears them, and the tenant restores the element here — the one place the
* value is trusted app-origin HTML rather than a provider API payload.
* Blank in either form stays disabled.
*/
function asInjectableMarkup(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.startsWith("<") ? value : `<script>${value}</script>`;
}
/**
* 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;
}
}
+1 -2
View File
@@ -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"));
}