From 8dd610f574e2c1f7d3ade790001f03e2d4661391 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Fri, 28 Aug 2026 17:15:30 -0400
Subject: [PATCH] fix(plan-canvas): harden PDF export concurrency
---
docs/design/plan-canvas.md | 14 ++-
docs/testing/plan-canvas-pdf-export.tdd.md | 20 ++--
scripts/lib/plan-canvas/pdf.js | 40 +++++++-
scripts/lib/plan-canvas/sdk.js | 15 +++
scripts/lib/plan-canvas/server.js | 58 +++++++++--
scripts/lib/plan-canvas/ui.js | 30 +++++-
scripts/plan-canvas.js | 108 +++++++++++++++++----
tests/integration/plan-canvas-e2e.test.js | 29 +++++-
tests/scripts/plan-canvas-pdf.test.js | 29 +++---
tests/scripts/plan-canvas.test.js | 49 +++++++++-
10 files changed, 335 insertions(+), 57 deletions(-)
diff --git a/docs/design/plan-canvas.md b/docs/design/plan-canvas.md
index e3a3d0f0e..077110343 100644
--- a/docs/design/plan-canvas.md
+++ b/docs/design/plan-canvas.md
@@ -82,7 +82,7 @@ after 30 min, `ECC_PLAN_CANVAS_IDLE_MS`). Feedback is deliver-and-drain: queued
handed to exactly one `await` call and persisted to disk until then, so nothing is lost if
the poll is interrupted.
-- `GET /health` — `{ok, app, version, protocolVersion, runtimeId}`; the CLI reuses a detached server only when its package, protocol, and Canvas-module fingerprint match, preventing an older same-version worktree from serving stale browser code
+- `GET /health` — `{ok, app, version, protocolVersion, runtimeId}`; the CLI reuses a detached server only when its package, protocol, and Canvas-module fingerprint match, preventing an older same-version worktree from serving stale browser code. A per-user, port-scoped startup lock serializes compatibility checks and replacement so concurrent opens reuse the winning server instead of racing two detached launches.
- `GET /` — session list (ECC chrome)
- `POST /api/sessions` `{file, reopen?}` — open/resume; `409 user-ended` unless `reopen`
- `GET /canvas/` — editor chrome; `GET /artifact//` — rendered artifact
@@ -127,7 +127,11 @@ unavailable, and can be repointed at a local mirror via
PDF export also stays local. The server launches an installed Chrome, Chromium, or Edge
executable with a new temporary profile, restricts the print target to the loopback Canvas
-origin, waits for a complete `%PDF` document, terminates that private renderer, and removes
-its temporary profile. Set `ECC_PLAN_CANVAS_CHROME_PATH` when auto-discovery cannot find the
-browser. The export endpoint returns an actionable error instead of uploading the plan or
-silently falling back to a remote service.
+origin, applies an export-only CSP that disables scripts and outbound resource classes, and
+routes every non-origin browser request into a local deny proxy. It waits for a complete
+`%PDF` document, terminates that private renderer, closes the deny proxy, and removes its
+temporary profile. One renderer is admitted at a time; overlapping requests receive HTTP 429
+with `Retry-After: 1` instead of launching unbounded browser processes. Set
+`ECC_PLAN_CANVAS_CHROME_PATH` when auto-discovery cannot find the browser. The export endpoint
+returns an actionable error instead of uploading the plan or silently falling back to a
+remote service.
diff --git a/docs/testing/plan-canvas-pdf-export.tdd.md b/docs/testing/plan-canvas-pdf-export.tdd.md
index 0da4bd212..18ac1c295 100644
--- a/docs/testing/plan-canvas-pdf-export.tdd.md
+++ b/docs/testing/plan-canvas-pdf-export.tdd.md
@@ -14,17 +14,20 @@ artifact as a real PDF file without sending the plan to an external converter.
| Only a loopback artifact URL can reach the renderer | `assertLoopbackUrl` tests | PASS |
| Filenames are useful and safe across platforms | `pdfFileName` tests | PASS |
| Incomplete output is never served as a PDF | `%PDF` header and `%%EOF` completion tests | PASS |
+| Validation uses the opened file handle | File-descriptor regression test and CodeQL rerun | PASS |
| Renderer state is private and temporary | Fake-process lifecycle test and live process/temp-state inspection | PASS |
+| Artifact HTML cannot make outbound export requests | PDF-only CSP and loopback-origin deny-proxy regression tests | PASS |
+| Browser renderer concurrency is bounded | Concurrent endpoint test expects HTTP 429 and `Retry-After` | PASS |
| Export adds no cloud converter or npm runtime dependency | Implementation and package diff inspection | PASS |
## Red and green
- RED: the focused server suite produced 30 passes and 2 failures because the
Canvas had no Download PDF control or PDF endpoint.
-- GREEN: renderer unit tests pass 6/6, Plan Canvas server tests pass 32/32,
- and the end-to-end review workflow passes 10/10.
-- FULL SUITE: `npm test` passes all 4,003 discovered tests; full ESLint,
- Markdown lint, package dry-run, and `git diff --check` also pass.
+- GREEN: renderer unit tests pass 7/7, Plan Canvas server tests pass 34/34,
+ and the end-to-end review workflow passes 11/11.
+- FULL SUITE: the final review-hardened implementation passes all 4,007
+ discovered tests; hosted security reruns are recorded on PR #2894.
- COVERAGE: `npm run coverage` passes 4,003/4,003 with 88.97% statements,
80.58% branches, 94.22% functions, and 88.97% lines. The Plan Canvas
module group reaches 96.72% statements and lines, 84.47% branches, and
@@ -42,9 +45,12 @@ artifact as a real PDF file without sending the plan to an external converter.
The loopback server discovers Google Chrome, Chromium, or Microsoft Edge, or
uses `ECC_PLAN_CANVAS_CHROME_PATH`. It launches the executable without a shell,
-with a private temporary profile and a loopback-only artifact URL. Completion
-requires both a `%PDF-` header and `%%EOF` marker. The renderer is terminated and
-temporary state is removed before the response is handed to the browser.
+with a private temporary profile, a loopback-only artifact URL, an export-only
+CSP, and a local deny proxy that allows only the exact Canvas origin. Completion
+requires both a `%PDF-` header and `%%EOF` marker read from the already-open file
+handle. The renderer is terminated and temporary state is removed before the
+response is handed to the browser. Concurrent export attempts fail quickly with
+a retryable HTTP 429 response while one renderer is active.
If no renderer exists, the browser receives an actionable local error. Plan
Canvas does not upload the artifact or add a hosted conversion dependency.
diff --git a/scripts/lib/plan-canvas/pdf.js b/scripts/lib/plan-canvas/pdf.js
index ac3828ea0..d3b370580 100644
--- a/scripts/lib/plan-canvas/pdf.js
+++ b/scripts/lib/plan-canvas/pdf.js
@@ -11,6 +11,7 @@
const { spawn } = require('child_process');
const fs = require('fs');
+const net = require('net');
const os = require('os');
const path = require('path');
@@ -137,6 +138,33 @@ function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
+function startDenyProxy(netImpl = net) {
+ return new Promise((resolve, reject) => {
+ const server = netImpl.createServer(socket => {
+ socket.on('error', () => {});
+ socket.destroy();
+ });
+ const onError = error => reject(errorWithCode(
+ `Could not isolate PDF renderer network access (${error.message})`,
+ 'PDF_EXPORT_FAILED'
+ ));
+ server.once('error', onError);
+ server.listen(0, '127.0.0.1', () => {
+ server.removeListener('error', onError);
+ server.on('error', () => {});
+ server.unref();
+ resolve({ server, url: `http://127.0.0.1:${server.address().port}` });
+ });
+ });
+}
+
+function stopDenyProxy(server) {
+ if (!server) return Promise.resolve();
+ return new Promise(resolve => {
+ try { server.close(resolve); } catch { resolve(); }
+ });
+}
+
async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode) return;
const closed = new Promise(resolve => child.once('close', resolve));
@@ -195,9 +223,11 @@ async function exportPdf({
timeoutMs = DEFAULT_EXPORT_TIMEOUT_MS,
spawnImpl = spawn,
fsImpl = fs,
- osImpl = os
+ osImpl = os,
+ netImpl = net
} = {}) {
const safeUrl = assertLoopbackUrl(url);
+ const exportUrl = new URL(safeUrl);
const browser = executable || resolveChromiumExecutable({ env, platform, fsImpl });
if (!browser) {
const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim();
@@ -214,14 +244,18 @@ async function exportPdf({
const profileDir = path.join(tempDir, 'profile');
fsImpl.mkdirSync(profileDir, { recursive: true });
let child = null;
+ let denyProxy = null;
try {
+ denyProxy = await startDenyProxy(netImpl);
const args = [
'--headless=new',
+ '--disable-background-networking',
'--disable-gpu',
'--disable-component-update',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
+ '--disable-quic',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
@@ -229,6 +263,9 @@ async function exportPdf({
'--no-pdf-header-footer',
'--print-to-pdf-no-header',
'--hide-scrollbars',
+ `--proxy-server=${denyProxy.url}`,
+ `--proxy-bypass-list=<-loopback>;${exportUrl.origin}`,
+ `--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE ${exportUrl.hostname}`,
`--user-data-dir=${profileDir}`,
`--print-to-pdf=${outputFile}`,
'--virtual-time-budget=5000',
@@ -247,6 +284,7 @@ async function exportPdf({
return { buffer, filename: pdfFileName(artifactFile) };
} finally {
await stopChild(child);
+ await stopDenyProxy(denyProxy && denyProxy.server);
fsImpl.rmSync(tempDir, { recursive: true, force: true });
}
}
diff --git a/scripts/lib/plan-canvas/sdk.js b/scripts/lib/plan-canvas/sdk.js
index a5110914d..3996446d9 100644
--- a/scripts/lib/plan-canvas/sdk.js
+++ b/scripts/lib/plan-canvas/sdk.js
@@ -205,6 +205,19 @@ function artifactSdkJs() {
});
// --- chrome bridge ---------------------------------------------------------
+ function exportSnapshot() {
+ const clone = document.documentElement.cloneNode(true);
+ clone.querySelectorAll('script,iframe,object,embed,form,base,meta[http-equiv],[data-ecc-plan-canvas]').forEach(el => el.remove());
+ clone.querySelectorAll('*').forEach(el => {
+ for (const attr of [...el.attributes]) {
+ if (attr.name.toLowerCase().startsWith('on') || attr.name.toLowerCase() === 'srcdoc') {
+ el.removeAttribute(attr.name);
+ }
+ }
+ });
+ return '\\n' + clone.outerHTML;
+ }
+
window.addEventListener('message', e => {
const msg = e.data || {};
if (msg.type === 'pc:set-mode') {
@@ -212,6 +225,8 @@ function artifactSdkJs() {
if (!annotate) { hl.style.display = 'none'; selhint.style.display = 'none'; closeCard(); }
} else if (msg.type === 'pc:restore-scroll') {
window.scrollTo(msg.x || 0, msg.y || 0);
+ } else if (msg.type === 'pc:export-snapshot' && typeof msg.requestId === 'string') {
+ post({ type: 'pc:export-snapshot-result', requestId: msg.requestId, html: exportSnapshot() });
}
});
document.addEventListener('keydown', e => {
diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js
index 7237a4da0..c93135531 100644
--- a/scripts/lib/plan-canvas/server.js
+++ b/scripts/lib/plan-canvas/server.js
@@ -31,6 +31,7 @@ const DEFAULT_PORT = 4517;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const MAX_BODY_BYTES = 1024 * 1024;
+const MAX_PDF_SNAPSHOT_BYTES = 5 * 1024 * 1024;
// How long the "agent is thinking" indicator survives without the agent
// checking back in, before presence decays to the honest queued/waiting.
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
@@ -38,6 +39,19 @@ const DEFAULT_THINKING_STALE_MS = 90 * 1000;
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
const PLAN_CANVAS_PROTOCOL_VERSION = 4;
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
+const PDF_EXPORT_CSP = [
+ "default-src 'none'",
+ "base-uri 'none'",
+ "connect-src 'none'",
+ "font-src 'self' data:",
+ "form-action 'none'",
+ "frame-src 'none'",
+ "img-src 'self' data:",
+ "media-src 'self' data:",
+ "object-src 'none'",
+ "script-src 'none'",
+ "style-src 'self' 'unsafe-inline'"
+].join('; ');
// Package versions do not distinguish two worktrees on the same release.
// Fingerprint every module loaded into the detached server so a current CLI
@@ -96,13 +110,13 @@ function resolveIdleTimeoutMs(env = process.env) {
return Number.isInteger(value) && value > 0 ? value : DEFAULT_IDLE_TIMEOUT_MS;
}
-function readJsonBody(req) {
+function readJsonBody(req, maxBytes = MAX_BODY_BYTES) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', chunk => {
size += chunk.length;
- if (size > MAX_BODY_BYTES) {
+ if (size > maxBytes) {
reject(new Error('body too large'));
req.destroy();
return;
@@ -130,8 +144,9 @@ function sendJson(res, statusCode, payload) {
function sendHtml(res, statusCode, html, { csp = true } = {}) {
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
if (csp) {
- headers['content-security-policy'] =
- "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
+ headers['content-security-policy'] = typeof csp === 'string'
+ ? csp
+ : "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
}
res.writeHead(statusCode, headers);
res.end(html);
@@ -175,6 +190,8 @@ function createPlanCanvasServer({
const typingKeys = new Map(); // key -> ms timestamp the agent signalled composing
let idleTimer = null;
let closed = false;
+ let pdfExportActive = false;
+ let pdfSnapshot = null;
// --- presence ---------------------------------------------------------
@@ -391,10 +408,25 @@ function createPlanCanvasServer({
}
const pdfMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/pdf$/);
- if (pdfMatch && req.method === 'GET') {
+ if (pdfMatch && (req.method === 'GET' || req.method === 'POST')) {
const session = store.get(pdfMatch[1]);
if (!session) return sendJson(res, 404, { error: 'unknown session' });
+ if (pdfExportActive) {
+ res.setHeader('retry-after', '1');
+ return sendJson(res, 429, {
+ error: 'another PDF export is already in progress',
+ code: 'PDF_EXPORT_BUSY'
+ });
+ }
+ pdfExportActive = true;
try {
+ if (req.method === 'POST') {
+ const body = await readJsonBody(req, MAX_PDF_SNAPSHOT_BYTES);
+ if (typeof body.html !== 'string' || !body.html.trim()) {
+ return sendJson(res, 400, { error: 'html snapshot is required' });
+ }
+ pdfSnapshot = { key: session.key, html: body.html };
+ }
const pdf = await pdfExporter({
url: `http://${req.headers.host}/artifact/${session.key}/?pdf=1`,
artifactFile: session.file
@@ -403,6 +435,9 @@ function createPlanCanvasServer({
} catch (error) {
const statusCode = error.code === 'PDF_BROWSER_NOT_FOUND' ? 503 : 500;
return sendJson(res, statusCode, { error: error.message, code: error.code || 'PDF_EXPORT_FAILED' });
+ } finally {
+ pdfSnapshot = null;
+ pdfExportActive = false;
}
}
@@ -474,11 +509,14 @@ function createPlanCanvasServer({
res.end();
}
- function serveArtifact(res, key, assetPath) {
+ function serveArtifact(res, key, assetPath, { pdfExport = false } = {}) {
const session = store.get(key);
if (!session) return sendHtml(res, 404, 'Unknown session
');
if (!assetPath) {
+ if (pdfExport && pdfSnapshot && pdfSnapshot.key === key) {
+ return sendHtml(res, 200, pdfSnapshot.html, { csp: PDF_EXPORT_CSP });
+ }
let content;
try {
content = fs.readFileSync(session.file, 'utf8');
@@ -491,13 +529,13 @@ function createPlanCanvasServer({
title: path.basename(session.file),
sdkSrc: '/sdk.js'
});
- return sendHtml(res, 200, html, { csp: false });
+ return sendHtml(res, 200, html, { csp: pdfExport ? PDF_EXPORT_CSP : false });
}
const sdkTag = '';
const injected = content.includes('
Report