forked from farhoodlabs/paperclip
73ef40e7be
## Thinking Path > - Paperclip is a control plane for AI-agent companies. > - External adapters can provide UI parser code that the board loads dynamically for run transcript rendering. > - Running adapter-provided parser code directly in the board page gives that parser access to same-origin browser state. > - This PR narrows that surface by evaluating dynamically loaded external adapter UI parser code in a dedicated browser Web Worker with a constrained postMessage protocol. > - The worker here is a frontend isolation boundary for adapter UI parser JavaScript; it is not Paperclip's server plugin-worker system and it is not a server-side job runner. ## What Changed - Runs dynamically loaded external adapter UI parsers inside a dedicated Web Worker instead of importing/evaluating them directly in the board page. - Adds a narrow postMessage protocol for parser initialization and line parsing. - Caches completed async parse results and notifies the adapter registry so transcript recomputation can synchronously drain the final parsed line. - Disables common worker network, persistence, child worker, Blob/object URL, and WebRTC escape APIs inside the parser worker bootstrap. - Handles worker error messages after initialization and drains pending callbacks on worker termination or mid-session worker error. - Adds focused regression coverage for the parser worker lockdown and unused protocol removal. ## Verification - `pnpm exec vitest run --config ui/vitest.config.ts ui/src/adapters/sandboxed-parser-worker.test.ts` - `pnpm exec tsc --noEmit --target es2021 --moduleResolution bundler --module esnext --jsx react-jsx --lib dom,es2021 --skipLibCheck ui/src/adapters/dynamic-loader.ts ui/src/adapters/sandboxed-parser-worker.ts ui/src/adapters/sandboxed-parser-worker.test.ts` - `pnpm --filter @paperclipai/ui typecheck` was attempted; it reached existing unrelated failures in HeartbeatRun test/storybook fixtures and missing Storybook type resolution, with no adapter-module errors surfaced. - PR #4225 checks on current head `34c9da00`: `policy`, `e2e`, `verify`, `security/snyk`, and `Greptile Review` are all `SUCCESS`. - Greptile Review on current head `34c9da00` reached 5/5. ## Risks - Medium risk: parser execution is now asynchronous through a worker while the existing parser interface is synchronous, so transcript updates should be watched with external adapters. - Some adapter parser bundles may rely on direct ESM `export` syntax or browser APIs that are no longer available inside the worker lockdown. - The worker lockdown is a hardening layer around external parser code, not a complete browser security sandbox for arbitrary untrusted applications. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent runtime, shell/git tool use enabled. Exact hosted model build and context window are not exposed in this Paperclip heartbeat environment. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
/**
|
|
* Dynamic UI parser loading for external adapters — sandboxed execution.
|
|
*
|
|
* When the Paperclip UI encounters an adapter type that doesn't have a
|
|
* built-in parser (e.g., an external adapter loaded via the plugin system),
|
|
* it fetches the parser JS from `/api/adapters/:type/ui-parser.js` and
|
|
* executes it **inside a dedicated Web Worker** so it cannot access the
|
|
* board UI's same-origin state (cookies, localStorage, DOM, authenticated
|
|
* fetch, etc.).
|
|
*
|
|
* The worker communicates via a narrow postMessage protocol:
|
|
* Main → Worker: { type: "init", source }
|
|
* Worker → Main: { type: "ready" } | { type: "error", message }
|
|
* Main → Worker: { type: "parse", id, line, ts }
|
|
* Worker → Main: { type: "result", id, entries }
|
|
*
|
|
* Because the parse call is async (cross-thread postMessage), but the
|
|
* existing `parseStdoutLine` contract is synchronous, we cache completed
|
|
* worker results and ask the adapter registry to recompute transcripts when
|
|
* a new result arrives.
|
|
*
|
|
* **Synchronous fast-path**: After init, parse requests are sent to the
|
|
* worker which responds asynchronously. The `parseStdoutLine` wrapper
|
|
* returns cached results synchronously on the next transcript recomputation.
|
|
* In practice this adds ~1 frame of latency which is imperceptible.
|
|
*
|
|
* Security: see `sandboxed-parser-worker.ts` for the full lockdown.
|
|
*/
|
|
|
|
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
|
import type { StdoutLineParser, StdoutParserFactory } from "./types";
|
|
import { createSandboxedWorker } from "./sandboxed-parser-worker";
|
|
import type { SandboxRequest, SandboxResponse } from "./sandboxed-parser-worker";
|
|
|
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
interface DynamicParserModule {
|
|
parseStdoutLine: StdoutLineParser;
|
|
createStdoutParser?: StdoutParserFactory;
|
|
}
|
|
|
|
interface SandboxedParser {
|
|
worker: Worker;
|
|
ready: boolean;
|
|
nextId: number;
|
|
pendingResolves: Map<number, (entries: TranscriptEntry[]) => void>;
|
|
}
|
|
|
|
// ── State ───────────────────────────────────────────────────────────────────
|
|
|
|
/** Cache of fully initialised sandboxed parsers by adapter type. */
|
|
const sandboxedParsers = new Map<string, SandboxedParser>();
|
|
|
|
/** Cache of the public DynamicParserModule wrappers. */
|
|
const dynamicParserCache = new Map<string, DynamicParserModule>();
|
|
|
|
/** Track which types we've already attempted to load (to avoid repeat 404s). */
|
|
const failedLoads = new Set<string>();
|
|
|
|
/** In-flight init promises so concurrent callers share the same load. */
|
|
const loadPromises = new Map<string, Promise<DynamicParserModule | null>>();
|
|
|
|
let resultNotifier: (() => void) | null = null;
|
|
|
|
export function setDynamicParserResultNotifier(fn: (() => void) | null): void {
|
|
resultNotifier = fn;
|
|
}
|
|
|
|
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
|
|
function sendToWorker(sandbox: SandboxedParser, msg: SandboxRequest): void {
|
|
sandbox.worker.postMessage(msg);
|
|
}
|
|
|
|
function nextRequestId(sandbox: SandboxedParser): number {
|
|
return sandbox.nextId++;
|
|
}
|
|
|
|
function lineCacheKey(line: string, ts: string): string {
|
|
return `${ts}\u0000${line}`;
|
|
}
|
|
|
|
function notifyResultReady(): void {
|
|
resultNotifier?.();
|
|
}
|
|
|
|
/**
|
|
* Parse a single line synchronously by delegating to the worker.
|
|
* Returns a Promise that resolves with the TranscriptEntry[] from the worker.
|
|
*/
|
|
function parseLineAsync(sandbox: SandboxedParser, line: string, ts: string): Promise<TranscriptEntry[]> {
|
|
return new Promise((resolve) => {
|
|
const id = nextRequestId(sandbox);
|
|
sandbox.pendingResolves.set(id, resolve);
|
|
sendToWorker(sandbox, { type: "parse", id, line, ts });
|
|
});
|
|
}
|
|
|
|
function drainPendingRequests(sandbox: SandboxedParser): void {
|
|
for (const resolver of sandbox.pendingResolves.values()) {
|
|
resolver([]);
|
|
}
|
|
sandbox.pendingResolves.clear();
|
|
}
|
|
|
|
/**
|
|
* Create a sandboxed worker, send the parser source, and wait for init.
|
|
*/
|
|
function initSandboxedWorker(source: string): Promise<SandboxedParser> {
|
|
return new Promise((resolve, reject) => {
|
|
const worker = createSandboxedWorker();
|
|
const sandbox: SandboxedParser = {
|
|
worker,
|
|
ready: false,
|
|
nextId: 1,
|
|
pendingResolves: new Map(),
|
|
};
|
|
|
|
// Timeout if the worker doesn't respond within 5s
|
|
const timeout = setTimeout(() => {
|
|
drainPendingRequests(sandbox);
|
|
worker.terminate();
|
|
reject(new Error("Parser worker init timed out"));
|
|
}, 5000);
|
|
|
|
worker.onmessage = (e: MessageEvent<SandboxResponse>) => {
|
|
const msg = e.data;
|
|
|
|
if (msg.type === "ready") {
|
|
clearTimeout(timeout);
|
|
sandbox.ready = true;
|
|
|
|
// Switch to the steady-state message handler.
|
|
worker.onmessage = (ev: MessageEvent<SandboxResponse>) => {
|
|
const resp = ev.data;
|
|
if (resp.type === "result") {
|
|
const resolver = sandbox.pendingResolves.get(resp.id);
|
|
if (resolver) {
|
|
sandbox.pendingResolves.delete(resp.id);
|
|
resolver(resp.entries as TranscriptEntry[]);
|
|
}
|
|
} else if (resp.type === "error") {
|
|
console.error("[adapter-ui-loader] Worker reported error:", resp.message);
|
|
drainPendingRequests(sandbox);
|
|
}
|
|
};
|
|
|
|
resolve(sandbox);
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "error") {
|
|
clearTimeout(timeout);
|
|
drainPendingRequests(sandbox);
|
|
worker.terminate();
|
|
reject(new Error(msg.message));
|
|
return;
|
|
}
|
|
};
|
|
|
|
worker.onerror = (ev) => {
|
|
clearTimeout(timeout);
|
|
drainPendingRequests(sandbox);
|
|
worker.terminate();
|
|
reject(new Error(`Worker error: ${ev.message}`));
|
|
};
|
|
|
|
// Send the parser source to the worker for evaluation.
|
|
sendToWorker(sandbox, { type: "init", source });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build a DynamicParserModule that delegates all calls to the sandboxed worker.
|
|
*
|
|
* The parseStdoutLine wrapper is **synchronous** to match the existing contract.
|
|
* Cache misses send a parse request to the worker and return `[]`; when the
|
|
* worker responds, the registry notification path recomputes transcripts and
|
|
* this wrapper returns the cached result synchronously.
|
|
*
|
|
* In practice, because the existing codebase already handles the "bridge"
|
|
* pattern where parseStdoutLine returns [] until the dynamic parser loads,
|
|
* the same UX applies here: the first render may show raw lines, and a
|
|
* subsequent render shows the parsed entries.
|
|
*/
|
|
function buildParserModule(sandbox: SandboxedParser): DynamicParserModule {
|
|
const parseCache = new Map<string, TranscriptEntry[]>();
|
|
const pendingParseKeys = new Set<string>();
|
|
|
|
const parseStdoutLine: StdoutLineParser = (line: string, ts: string) => {
|
|
const key = lineCacheKey(line, ts);
|
|
const cached = parseCache.get(key);
|
|
if (cached) return cached.slice();
|
|
|
|
if (!pendingParseKeys.has(key)) {
|
|
pendingParseKeys.add(key);
|
|
parseLineAsync(sandbox, line, ts).then((entries) => {
|
|
pendingParseKeys.delete(key);
|
|
parseCache.set(key, entries);
|
|
notifyResultReady();
|
|
});
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
return { parseStdoutLine };
|
|
}
|
|
|
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Dynamically load a UI parser for an adapter type from the server API,
|
|
* executing it inside a sandboxed Web Worker.
|
|
*
|
|
* @returns A DynamicParserModule, or null if unavailable.
|
|
*/
|
|
export async function loadDynamicParser(adapterType: string): Promise<DynamicParserModule | null> {
|
|
// Return cached parser if already loaded.
|
|
const cached = dynamicParserCache.get(adapterType);
|
|
if (cached) return cached;
|
|
|
|
// Don't retry types that previously failed.
|
|
if (failedLoads.has(adapterType)) return null;
|
|
|
|
// Coalesce concurrent loads.
|
|
const inflight = loadPromises.get(adapterType);
|
|
if (inflight) return inflight;
|
|
|
|
const loadPromise = (async (): Promise<DynamicParserModule | null> => {
|
|
try {
|
|
const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`);
|
|
if (!response.ok) {
|
|
failedLoads.add(adapterType);
|
|
return null;
|
|
}
|
|
|
|
const source = await response.text();
|
|
|
|
// Initialise the sandboxed worker with the parser source.
|
|
const sandbox = await initSandboxedWorker(source);
|
|
sandboxedParsers.set(adapterType, sandbox);
|
|
|
|
const parserModule = buildParserModule(sandbox);
|
|
dynamicParserCache.set(adapterType, parserModule);
|
|
|
|
console.info(`[adapter-ui-loader] Loaded sandboxed UI parser for "${adapterType}"`);
|
|
return parserModule;
|
|
} catch (err) {
|
|
console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err);
|
|
failedLoads.add(adapterType);
|
|
return null;
|
|
} finally {
|
|
loadPromises.delete(adapterType);
|
|
}
|
|
})();
|
|
|
|
loadPromises.set(adapterType, loadPromise);
|
|
return loadPromise;
|
|
}
|
|
|
|
/**
|
|
* Invalidate a cached dynamic parser, removing it from both the parser cache
|
|
* and the failed-loads set so that the next load attempt will try again.
|
|
* Also terminates the sandboxed worker if one exists.
|
|
*/
|
|
export function invalidateDynamicParser(adapterType: string): boolean {
|
|
const wasCached = dynamicParserCache.has(adapterType);
|
|
dynamicParserCache.delete(adapterType);
|
|
failedLoads.delete(adapterType);
|
|
loadPromises.delete(adapterType);
|
|
|
|
// Terminate the worker to free resources.
|
|
const sandbox = sandboxedParsers.get(adapterType);
|
|
if (sandbox) {
|
|
drainPendingRequests(sandbox);
|
|
sandbox.worker.terminate();
|
|
sandboxedParsers.delete(adapterType);
|
|
}
|
|
|
|
if (wasCached) {
|
|
console.info(`[adapter-ui-loader] Invalidated sandboxed UI parser for "${adapterType}"`);
|
|
}
|
|
return wasCached;
|
|
}
|