Files
PaperClipAI/doc/cloud-ui-snippet.md
T
Devin Foley 778ac33308 feat: accept a base64-encoded Cloud UI snippet (#13245)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip Cloud instances can inject a trusted operator HTML snippet
before `</body>` in served UI pages (#13168)
> - Operators deliver env vars to managed instances through
hosting-provider APIs
> - Web application firewalls in front of those APIs reject request
bodies that contain raw `<script` markup
> - The snippet value always contains a script tag, so the firewall
blocks its delivery every time
> - This pull request adds `PAPERCLIP_CLOUD_UI_SNIPPET_B64`, which
carries the same snippet as standard base64
> - The benefit is that operators can ship the snippet through
WAF-fronted delivery pipelines without firewall exceptions

## Linked Issues or Issue Description

Refs #13168 (introduced the Cloud UI snippet).

**Subsystem affected**

Server UI shell serving: `server/src/cloud-ui-snippet.ts`, used by
`static-index-html.ts` and `app.ts`.

**Current behavior**

`PAPERCLIP_CLOUD_UI_SNIPPET` is the only way to configure the Cloud UI
snippet. The value is raw HTML. Delivery pipelines that write env vars
through provider APIs can fail to deliver it: web application firewalls
classify a request body that contains `<script` as an injection attempt
and block it. Verified against Railway's GraphQL API, which sits behind
Cloudflare: a variable value with a bare `<script></script>` returns
HTTP 403 before authentication, and JSON unicode escapes (`<script`) do
not bypass the block. The snippet is exactly the kind of value that
always contains a script tag, so such pipelines cannot deliver it at
all.

**Proposed behavior**

A new optional `PAPERCLIP_CLOUD_UI_SNIPPET_B64` carries the same snippet
as standard base64 of the UTF-8 HTML. The server decodes it and injects
the result through the existing path. The plain variable wins when both
are set. Whitespace and line wrapping in the value are tolerated. A
value that is not canonical base64, or that decodes to blank, is ignored
instead of injected as garbage.

**Reason and benefit**

Operators can deliver the snippet through WAF-fronted APIs without
requesting firewall exceptions. Base64 contains no markup, so the
firewall has nothing to match. Existing deployments see no behavior
change.

**Breaking changes**

None. The new variable is optional. The existing variable is unchanged
and takes precedence.

## What Changed

- `server/src/cloud-ui-snippet.ts`: resolve the snippet from
`PAPERCLIP_CLOUD_UI_SNIPPET` first, then from base64-decoded
`PAPERCLIP_CLOUD_UI_SNIPPET_B64`; ignore non-canonical or blank-decoding
values.
- `server/src/__tests__/cloud-ui-snippet.test.ts`: cover
decode-and-inject, plain-wins precedence, whitespace tolerance,
invalid/blank values, and the self-hosted no-op.
- `doc/cloud-ui-snippet.md`: document the variant, how to produce the
value, and the ignore rules.

## Verification

- `pnpm vitest run server/src/__tests__/cloud-ui-snippet.test.ts
server/src/__tests__/static-index-html.test.ts` — 13 tests pass.
- `tsc --noEmit` in `server/` passes.
- Manual check: on a Cloud-managed instance, set
`PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.html)"`, restart,
open `/`, and confirm the decoded snippet appears before `</body>`. On a
self-hosted instance, confirm no snippet is injected.

## Risks

Low risk. The injection path and the Cloud-managed gate are unchanged. A
malformed base64 value is ignored, so the failure mode is a missing
widget, not corrupted HTML. The decoded value is trusted operator
configuration, the same trust model as the plain variable.

## Model Used

- Claude Fable 5 (Anthropic, `claude-fable-5`) via Claude Code CLI,
agentic workflow with tool use (code edits, test runs, and live API
verification of the WAF behavior).

## 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
- [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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-09-11 14:13:11 -07:00

2.6 KiB

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.

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:

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.

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:

<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.

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.