feat: allow operator UI snippets on Cloud instances (#13168)

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 `</body>` 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
<script>
(function(d) {
  var script = d.createElement('script');
  script.src = 'https://chat.cdn-plain.com/index.js';
  script.onload = function() {
    Plain.init({ appId: 'liveChatApp_01M26J213F6RR53YRARZVAFCZZ' });
  };
  d.head.appendChild(script);
})(document);
</script>
```

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](04bb445151/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 <noreply@paperclip.ing>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael Nguyen 2026-09-10 15:30:36 -07:00 committed by GitHub
parent 4042eb1c48
commit 60ee13a0f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 102 additions and 3 deletions

42
doc/cloud-ui-snippet.md Normal file
View File

@ -0,0 +1,42 @@
# Cloud UI snippet
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.
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
<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.

View File

@ -0,0 +1,30 @@
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>';
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("</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");
});
});

View File

@ -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"), "<html><body>App</body></html>");
vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", '<script src="https://example.com/chat.js"></script>');
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>');
});
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);

View File

@ -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;

View File

@ -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</body>`);
}

View File

@ -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")));
}