Files
PaperClipAI/doc/cloud-ui-snippet.md
T
Devin FoleyandPaperclip 544c3476a8 feat(server): wrap a bare Cloud UI snippet body in a <script> element (#13496)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A Cloud-managed instance injects an operator-owned HTML snippet
before `</body>` through `injectCloudUiSnippet`.
> - The Cloud control plane delivers that snippet to each instance as an
environment variable through a provider API.
> - The provider edge firewall now base64-decodes the request payload
and blocks any value whose decoded form contains a `<script` marker.
> - A working snippet needs a script tag, so every delivery is now
blocked and the operator cannot ship the snippet at all.
> - This pull request treats a resolved value that does not start with
`<` as a bare script body and wraps it in a `<script>` element at
injection time.
> - The benefit is that the operator can deliver a tag-free body that
the firewall passes, and the instance restores the script element on the
page.

## Linked Issues or Issue Description

No public issue exists. The problem is described below.

Related PRs (searched the PR list; none duplicate this change):
- Refs #13168 — added `injectCloudUiSnippet`, the mechanism this
extends.
- Refs #13245 — added the base64 `_B64` path on the assumption that
base64 clears provider WAFs. That assumption no longer holds; this PR is
the successor.
- Refs #13441 — the in-product feedback approach that the Cloud-owned
snippet replaced (closed).

**What happened?**

`injectCloudUiSnippet` injects `PAPERCLIP_CLOUD_UI_SNIPPET` (or the
base64 `_B64` form) verbatim before `</body>`. A working value must
therefore contain a `<script>` tag. The Cloud control plane delivers
this value as an environment variable through a provider API that sits
behind an edge firewall. The firewall now base64-decodes the payload and
rejects any value whose decoded form contains `<script`. The delivery
request fails, so the snippet cannot reach the instance.

**Expected behavior**

The operator can deliver the snippet through the provider API, and the
instance runs it.

**Steps to reproduce**

1. Build a snippet that contains a `<script>` element.
2. Deliver it to a Cloud instance through the provider variable API, in
plain or base64 form.
3. The firewall rejects the request. The instance never receives the
snippet.

## What Changed

- `injectCloudUiSnippet` now wraps a resolved value that does not start
with `<` in a `<script>...</script>` element. A value that already looks
like markup is injected byte-for-byte, so existing full-`<script>`
snippets are unchanged.
- Added two unit tests: one for the wrap path (plain and base64), one
that confirms `$&`-style replacement tokens in the body survive the
wrap.

## Verification

- `pnpm exec vitest run src/__tests__/cloud-ui-snippet.test.ts` — 17
pass.
- `pnpm exec vitest run src/__tests__/static-index-html.test.ts` — 2
pass.
- Confirmed the changed file has no type errors. The full `pnpm run
typecheck` needs the Rust runner toolchain (`cargo`), which is absent on
this machine; CI runs it in full.
- Verified out of band that a tag-free body clears the provider firewall
and wraps into valid, executable standalone JavaScript with no premature
`</script>` close.

## Risks

Low risk. The change adds a branch that only affects values that do not
start with `<` — previously injected as inert text, never as a running
script. Values that start with `<` keep their exact bytes. The content
is trusted operator HTML, consistent with the existing contract. Roll
back by reverting this commit.

## Model Used

Claude — `claude-fable-5` (Fable 5), extended thinking, with tool use
and code execution in Claude Code. A human author reviewed and verified
the change before submission.

## 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
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-15 15:09:19 -07:00

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

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:

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:

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