mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## 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>
60 lines
2.6 KiB
TypeScript
60 lines
2.6 KiB
TypeScript
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;
|
|
}
|
|
}
|