diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js index 11b44062a..ae0a83468 100644 --- a/scripts/lib/plan-canvas/server.js +++ b/scripts/lib/plan-canvas/server.js @@ -15,7 +15,7 @@ const http = require('http'); const path = require('path'); const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard'); -const { renderMarkdown } = require('./markdown'); +const { escapeHtml, renderMarkdown } = require('./markdown'); const { artifactSdkJs } = require('./sdk'); const { canvasCss, @@ -101,9 +101,21 @@ function sendJson(res, statusCode, payload) { res.end(body); } +// Artifact pages render inside a sandboxed iframe (no allow-same-origin) and +// legitimately run CDN scripts (Mermaid) plus inline loaders, so the default +// restrictive CSP cannot apply. A sandbox-only CSP mirrors the iframe +// attribute instead: scripts keep working, but the document gets an opaque +// origin, which neuters direct-navigation abuse of the loopback API (no CORS +// reads, JSON POSTs are preflight-blocked) without changing in-iframe +// behavior. A hostile CSP in a raw HTML artifact can only narrow this +// further, never loosen it. +const ARTIFACT_CSP = 'sandbox allow-scripts allow-forms allow-popups'; + function sendHtml(res, statusCode, html, { csp = true } = {}) { const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }; - if (csp) { + if (csp === 'artifact') { + headers['content-security-policy'] = ARTIFACT_CSP; + } else if (csp) { headers['content-security-policy'] = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'"; } @@ -111,6 +123,112 @@ function sendHtml(res, statusCode, html, { csp = true } = {}) { res.end(html); } +// Sibling assets have an explicit 64 MiB resource limit (413 above it), separate +// from request-body limits. Reads use at most the sampled size plus one sentinel. +const MAX_ARTIFACT_ASSET_BYTES = 64 * 1024 * 1024; + +function assetReadError(code = 'EARTIFACT_UNSAFE') { + return Object.assign(new Error('Artifact sibling read refused'), { code }); +} + +function sameAssetIdentity(before, after) { + return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode; +} + +function sameAssetFile(before, after) { + return after.isFile() && sameAssetIdentity(before, after) && before.size === after.size + && (before.mtimeNs ?? before.mtimeMs) === (after.mtimeNs ?? after.mtimeMs) + && (before.ctimeNs ?? before.ctimeMs) === (after.ctimeNs ?? after.ctimeMs); +} + +function assetByteLength(stats) { + if (typeof stats.size !== 'bigint' && !Number.isSafeInteger(stats.size)) throw assetReadError(); + const size = BigInt(stats.size); + if (size < 0n) throw assetReadError(); + if (size > BigInt(MAX_ARTIFACT_ASSET_BYTES)) throw assetReadError('EARTIFACT_TOO_LARGE'); + return Number(size); +} + +function snapshotAssetChain(realTarget) { + const root = path.parse(realTarget).root; + const parts = path.relative(root, realTarget).split(path.sep).filter(Boolean); + const paths = [root]; + for (const part of parts) paths.push(path.join(paths[paths.length - 1], part)); + return paths.map((entryPath, index) => { + const stats = fs.lstatSync(entryPath, { bigint: true }); + const leaf = index === paths.length - 1; + if (stats.isSymbolicLink() || !(leaf ? stats.isFile() : stats.isDirectory())) throw assetReadError(); + return { path: entryPath, stats, leaf }; + }); +} + +function revalidateAssetPath({ baseDir, resolved, realBase, realTarget, chain }) { + if (fs.realpathSync(baseDir) !== realBase || fs.realpathSync(resolved) !== realTarget) throw assetReadError(); + for (const entry of chain) { + const current = fs.lstatSync(entry.path, { bigint: true }); + if (current.isSymbolicLink() || !(entry.leaf ? sameAssetFile(entry.stats, current) + : current.isDirectory() && sameAssetIdentity(entry.stats, current))) throw assetReadError(); + } +} + +function readAssetDescriptor(fd, size) { + const buffer = Buffer.alloc(size + 1); + let bytes = 0; + while (bytes < buffer.length) { + const requested = buffer.length - bytes; + const count = fs.readSync(fd, buffer, bytes, requested, bytes); + if (!Number.isInteger(count) || count < 0 || count > requested) throw assetReadError(); + if (count === 0) break; + bytes += count; + } + if (bytes !== size) throw assetReadError(); + return buffer.subarray(0, bytes); +} + +// Canonical ancestors are sampled from the filesystem root, not just realBase. +// Revalidation detects observed replacements, but portable pathname operations +// are not atomic openat confinement: repeated swap/restore or same-inode content +// races can evade observations. This helper covers siblings only, not the direct +// session.file read. O_NOFOLLOW protects the leaf only where the flag exists. +function readSiblingAsset(baseDir, resolved) { + const realBase = fs.realpathSync(baseDir); + const realTarget = fs.realpathSync(resolved); + const relative = path.relative(realBase, realTarget); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw assetReadError(); + const chain = snapshotAssetChain(realTarget); + const expected = chain[chain.length - 1].stats; + const size = assetByteLength(expected); + const snapshot = { baseDir, resolved, realBase, realTarget, chain }; + revalidateAssetPath(snapshot); + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0); + let fd; + try { + fd = fs.openSync(realTarget, flags); + } catch (error) { + if (error.code === 'ELOOP' || error.code === 'ENOTDIR') throw assetReadError(); + throw error; + } + let primaryError; + let data; + try { + if (!sameAssetFile(expected, fs.fstatSync(fd, { bigint: true }))) throw assetReadError(); + revalidateAssetPath(snapshot); + data = readAssetDescriptor(fd, size); + if (!sameAssetFile(expected, fs.fstatSync(fd, { bigint: true }))) throw assetReadError(); + revalidateAssetPath(snapshot); + } catch (error) { + primaryError = error; + } finally { + try { fs.closeSync(fd); } catch (error) { + // A close error may mean the fd is already released; never retry it or + // replace the primary read/validation failure. Close-only failure refuses. + if (!primaryError) primaryError = error; + } + } + if (primaryError) throw primaryError; + return { data, realTarget }; +} + function createPlanCanvasServer({ store, host = DEFAULT_HOST, @@ -493,7 +611,7 @@ function createPlanCanvasServer({ try { content = fs.readFileSync(session.file, 'utf8'); } catch { - return sendHtml(res, 404, `

Artifact missing

${session.file} no longer exists.

`, { csp: false }); + return sendHtml(res, 404, `

Artifact missing

${escapeHtml(session.file)} no longer exists.

`); } const ext = path.extname(session.file).toLowerCase(); if (ext === '.md' || ext === '.markdown') { @@ -501,30 +619,43 @@ function createPlanCanvasServer({ title: path.basename(session.file), sdkSrc: '/sdk.js' }); - return sendHtml(res, 200, html, { csp: false }); + return sendHtml(res, 200, html, { csp: 'artifact' }); } const sdkTag = ''; const injected = content.includes('') ? content.replace('', `${sdkTag}\n`) : `${content}\n${sdkTag}`; - return sendHtml(res, 200, injected, { csp: false }); + return sendHtml(res, 200, injected, { csp: 'artifact' }); } // Sibling assets resolve relative to the artifact's directory and must - // stay confined to it. + // stay confined to it. The prefix check alone is insufficient: a symlink + // inside the directory can point outside it, so the check is repeated + // against the real paths and fails closed when they cannot be resolved. const baseDir = path.dirname(session.file); const resolved = path.resolve(baseDir, assetPath); if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) { return sendJson(res, 403, { error: 'asset path escapes artifact directory' }); } + let realTarget; let data; try { - data = fs.readFileSync(resolved); - } catch { + ({ data, realTarget } = readSiblingAsset(baseDir, resolved)); + } catch (error) { + if (error.code === 'EARTIFACT_TOO_LARGE') return sendJson(res, 413, { error: 'asset too large' }); + if (error.code === 'EARTIFACT_UNSAFE') return sendJson(res, 403, { error: 'asset changed or unsafe' }); return sendJson(res, 404, { error: 'asset not found' }); } - const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream'; - res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' }); + // MIME comes from the link/request name first so a symlinked asset keeps + // the type the page asked for; the target extension is the fallback. + const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] + || CONTENT_TYPES[path.extname(realTarget).toLowerCase()] + || 'application/octet-stream'; + const headers = { 'content-type': type, 'cache-control': 'no-store' }; + if (type.startsWith('text/html') || type === 'image/svg+xml') { + headers['content-security-policy'] = ARTIFACT_CSP; + } + res.writeHead(200, headers); return res.end(data); } diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js index 5d1e8745f..972c16ee5 100644 --- a/tests/scripts/plan-canvas.test.js +++ b/tests/scripts/plan-canvas.test.js @@ -5,6 +5,7 @@ * browser chrome (fetch + SSE) and the agent CLI (long-poll) do. * * Run with: node tests/scripts/plan-canvas.test.js + * Offline artifact checks: add --artifact-security-only (no listener). */ const assert = require('assert'); @@ -12,19 +13,650 @@ const fs = require('fs'); const http = require('http'); const os = require('os'); const path = require('path'); +const { compileFunction } = require('node:vm'); +const { createRequire } = require('node:module'); const { createSessionStore } = require('../../scripts/lib/plan-canvas/sessions'); const { createPlanCanvasServer } = require('../../scripts/lib/plan-canvas/server'); -async function test(name, fn) { +class SkippedTest extends Error {} + +function createTestRunner(log = console.log) { + let results = { passed: 0, failed: 0, skipped: 0 }; + return { + get results() { return results; }, + async test(name, fn) { + try { + await fn(); + results = { ...results, passed: results.passed + 1 }; + log(` PASS ${name}`); + } catch (error) { + if (error instanceof SkippedTest) { + results = { ...results, skipped: results.skipped + 1 }; + log(` SKIP ${name}: ${error.message}`); + } else { + results = { ...results, failed: results.failed + 1 }; + log(` FAIL ${name}\n Error: ${error.stack || error.message}`); + } + } + } + }; +} + +function createTestSymlink(target, link, type = 'file', { symlink = fs.symlinkSync, platform = process.platform } = {}) { try { - await fn(); - console.log(` ✓ ${name}`); - return true; - } catch (err) { - console.log(` ✗ ${name}`); - console.log(` Error: ${err.stack || err.message}`); - return false; + symlink(target, link, type); + } catch (error) { + if (error.code === 'ENOSYS' || error.code === 'ENOTSUP' + || (platform === 'win32' && error.code === 'EPERM')) { + throw new SkippedTest(`symlink creation unavailable (${platform}, ${error.code})`); + } + throw error; + } +} + +function printResults(results) { + console.log(`Passed: ${results.passed}`); + console.log(`Failed: ${results.failed}`); + console.log(`Skipped: ${results.skipped}`); + process.exitCode = results.failed > 0 ? 1 : 0; +} + +// Compile the exact trusted module privately; this is not a security sandbox. +// Relative dependencies retain normal resolution, without rewriting source or +// changing module loaders, shared exports, or require.cache. +const artifactServerFilename = require.resolve('../../scripts/lib/plan-canvas/server'); +const artifactServerSource = fs.readFileSync(artifactServerFilename, 'utf8'); +const artifactRequire = createRequire(artifactServerFilename); + +function loadArtifactServer(filesystem, localHttp) { + const localRequire = name => { + if (name === 'fs' || name === 'node:fs') return filesystem; + if (name === 'http' || name === 'node:http') return localHttp; + return artifactRequire(name); + }; + const localModule = { exports: {} }; + const evaluate = compileFunction(artifactServerSource, + ['require', 'module', 'exports', '__filename', '__dirname'], { filename: artifactServerFilename }); + evaluate.call(localModule.exports, localRequire, localModule, localModule.exports, + artifactServerFilename, path.dirname(artifactServerFilename)); + return localModule.exports; +} + +// Preserve arbitrary primary thrown values, including falsy values. Secondary +// cleanup diagnostics are intentionally discarded when a primary exists. +async function withFixtureCleanup(callback, cleanups) { + let didThrow = false; + let primary; + let result; + try { result = await callback(); } catch (error) { didThrow = true; primary = error; } + let cleanupThrew = false; + let firstCleanup; + for (const cleanup of cleanups()) { + try { await cleanup(); } catch (error) { + if (!cleanupThrew) { cleanupThrew = true; firstCleanup = error; } + } + } + if (didThrow) throw primary; + if (cleanupThrew) throw firstCleanup; + return result; +} + +// Capture fresh real dispatchers without binding sockets. Each request captures +// its own filesystem facade; the fixture owns those canvases until teardown. +// Artifact bodies are inert response bytes, never executed in a browser. +async function withArtifactHandler(callback, { + closeCanvas = canvas => canvas.close(), + removeRoot = root => fs.rmSync(root, { recursive: true, force: true }) +} = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-artifact-')); + const canvases = []; + return withFixtureCleanup(async () => { + const base = path.join(root, 'artifacts'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(base); + fs.mkdirSync(outside); + const session = { key: '0123456789ab', file: path.join(base, 'main.md') }; + fs.writeFileSync(session.file, '# Inert fixture\n'); + fs.writeFileSync(path.join(outside, 'secret.txt'), 'private-fixture-secret'); + const get = (asset, filesystem = fs) => new Promise(resolve => { + let handler; + const localHttp = Object.freeze({ ...http, createServer(requestHandler) { + handler = requestHandler; + return { + listen() { throw new Error('Artifact tests must not open a listener'); }, + close(done) { done(); } + }; + } }); + const localServer = loadArtifactServer(filesystem, localHttp); + const canvas = localServer.createPlanCanvasServer({ + store: { get: key => key === session.key ? session : null }, idleTimeoutMs: 0 + }); + canvases.push(canvas); + const response = { headersSent: false }; + response.writeHead = (statusCode, headers) => { + response.statusCode = statusCode; + response.headers = headers; + response.headersSent = true; + }; + response.end = data => resolve({ statusCode: response.statusCode, headers: response.headers, body: String(data || '') }); + handler({ method: 'GET', headers: { host: '127.0.0.1' }, url: `/artifact/${session.key}/${asset}` }, response); + }); + return callback({ base, outside, session, get }); + }, () => [...canvases.map(canvas => () => closeCanvas(canvas)), () => removeRoot(root)]); +} + +async function artifactSecurityTests(test) { + const sandbox = 'sandbox allow-scripts allow-forms allow-popups'; + const svg = ''; + await test('SVG sibling documents keep their MIME and use the artifact sandbox', () => withArtifactHandler(async ({ base, get }) => { + fs.writeFileSync(path.join(base, 'shape.svg'), svg); + const response = await get('shape.svg'); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.headers['content-type'], 'image/svg+xml'); + assert.strictEqual(response.headers['content-security-policy'], sandbox); + assert.strictEqual(response.body, svg); + })); + await test('SVG symlink request and target MIME fallback both receive sandbox CSP', () => withArtifactHandler(async ({ base, get }) => { + fs.writeFileSync(path.join(base, 'extensionless'), svg); + fs.writeFileSync(path.join(base, 'shape.svg'), svg); + createTestSymlink(path.join(base, 'extensionless'), path.join(base, 'by-name.svg')); + createTestSymlink(path.join(base, 'shape.svg'), path.join(base, 'by-target')); + for (const alias of ['by-name.svg', 'by-target']) { + const response = await get(alias); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.headers['content-type'], 'image/svg+xml'); + assert.strictEqual(response.headers['content-security-policy'], sandbox); + assert.strictEqual(response.body, svg); + } + })); + await test('HTML and Markdown artifact policies and ordinary CSS MIME are preserved', () => withArtifactHandler(async ({ base, session, get }) => { + fs.writeFileSync(path.join(base, 'note.html'), 'inert HTML'); + fs.writeFileSync(path.join(base, 'style.css'), 'body { color: red }'); + for (const asset of ['', 'note.html']) { + const response = await get(asset); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.headers['content-type'], 'text/html; charset=utf-8'); + assert.strictEqual(response.headers['content-security-policy'], sandbox); + } + session.file = path.join(base, 'note.html'); + const html = await get(''); + assert.strictEqual(html.headers['content-security-policy'], sandbox); + assert.ok(html.body.includes('')); + const css = await get('style.css'); + assert.strictEqual(css.statusCode, 200); + assert.strictEqual(css.headers['content-type'], 'text/css; charset=utf-8'); + assert.strictEqual(css.headers['content-security-policy'], undefined); + assert.strictEqual(css.body, 'body { color: red }'); + })); + await test('outside file and directory symlinks are refused without exposing their bytes', () => withArtifactHandler(async ({ base, outside, get }) => { + createTestSymlink(path.join(outside, 'secret.txt'), path.join(base, 'outside.txt')); + createTestSymlink(outside, path.join(base, 'outside-dir'), 'dir'); + for (const asset of ['outside.txt', 'outside-dir/secret.txt']) { + const response = await get(asset); + assert.strictEqual(response.statusCode, 403); + assert.ok(!response.body.includes('private-fixture-secret')); + } + })); + await test('internal CSS symlink MIME uses the requested extension', () => withArtifactHandler(async ({ base, get }) => { + fs.writeFileSync(path.join(base, 'raw-style'), 'body { color: blue }'); + createTestSymlink(path.join(base, 'raw-style'), path.join(base, 'theme.css')); + const response = await get('theme.css'); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.headers['content-type'], 'text/css; charset=utf-8'); + assert.strictEqual(response.body, 'body { color: blue }'); + })); + await test('broken sibling links return 404', () => withArtifactHandler(async ({ base, get }) => { + createTestSymlink(path.join(base, 'missing.txt'), path.join(base, 'broken.txt')); + assert.strictEqual((await get('broken.txt')).statusCode, 404); + })); + await test('encoded lexical traversal stays forbidden', () => withArtifactHandler(async ({ get }) => { + const response = await get('..%2Foutside%2Fsecret.txt'); + assert.strictEqual(response.statusCode, 403); + assert.ok(!response.body.includes('private-fixture-secret')); + })); + await test('missing paths escape angle brackets and quotes under restrictive CSP', () => withArtifactHandler(async ({ base, session, get }) => { + // This is a missing-path string, not a platform-dependent filename. + session.file = path.join(base, '.md'); + const response = await get(''); + assert.strictEqual(response.statusCode, 404); + assert.ok(response.body.includes('<svg "quoted" & 'single'>.md')); + assert.ok(!response.body.includes(' { + const suite = createTestRunner(() => {}); + await suite.test('synthetic Windows privilege boundary', () => createTestSymlink('target', 'link', 'file', { + platform: 'win32', symlink() { throw Object.assign(new Error('privilege unavailable'), { code: 'EPERM' }); } + })); + assert.deepStrictEqual(suite.results, { passed: 0, failed: 0, skipped: 1 }); + }); + await test('unexpected symlink and ordinary fixture errors count as failures', async () => { + const suite = createTestRunner(() => {}); + await suite.test('unexpected existing link', () => createTestSymlink('target', 'link', 'file', { + platform: 'win32', symlink() { throw Object.assign(new Error('already exists'), { code: 'EEXIST' }); } + })); + await suite.test('ordinary write error', () => { throw Object.assign(new Error('fixture write failed'), { code: 'EIO' }); }); + await suite.test('non-Windows permission error', () => createTestSymlink('target', 'link', 'file', { + platform: 'darwin', symlink() { throw Object.assign(new Error('permission denied'), { code: 'EPERM' }); } + })); + assert.deepStrictEqual(suite.results, { passed: 0, failed: 3, skipped: 0 }); + }); +} + +// Deterministic filesystem boundaries, using private regular files only. Native +// descriptors are owned here even when a spy returns a different private file. +async function withAssetIo(asset, overrides, callback, { cleanupClose = fs.closeSync } = {}) { + const target = fs.realpathSync(asset); + const methods = ['openSync', 'fstatSync', 'lstatSync', 'readSync', 'closeSync']; + const original = Object.fromEntries(methods.map(name => [name, fs[name]])); + const live = new Set(); + const calls = { opens: 0, reads: 0, closes: 0, fstats: 0, requested: [], returned: 0, flags: [] }; + const filesystem = Object.freeze({ ...fs, openSync(candidate, flags, ...rest) { + if (typeof candidate !== 'string' || path.resolve(candidate) !== target) return original.openSync(candidate, flags, ...rest); + calls.opens++; + calls.flags.push(flags); + const fd = overrides.open + ? overrides.open({ candidate, flags, original, calls }) + : original.openSync(candidate, flags, ...rest); + live.add(fd); + return fd; + }, + fstatSync(fd, ...rest) { + const stats = original.fstatSync(fd, ...rest); + if (!live.has(fd)) return stats; + calls.fstats++; + return overrides.fstat ? overrides.fstat(stats, calls) : stats; + }, + lstatSync(candidate, ...rest) { + const stats = original.lstatSync(candidate, ...rest); + return overrides.lstat ? overrides.lstat(path.resolve(candidate), stats, calls) : stats; + }, + readSync(fd, buffer, offset, length, position) { + if (!live.has(fd)) return original.readSync(fd, buffer, offset, length, position); + calls.reads++; + calls.requested.push({ length, position, capacity: buffer.length }); + const count = overrides.read + ? overrides.read({ fd, buffer, offset, length, position, original, calls }) + : original.readSync(fd, buffer, offset, length, position); + calls.returned += count; + return count; + }, + closeSync(fd) { + if (!live.has(fd)) return original.closeSync(fd); + calls.closes++; + // Close the actual fixture descriptor before optionally simulating a close + // error. The test never leaks an fd to imitate an ambiguous OS error. + live.delete(fd); + original.closeSync(fd); + if (overrides.close) overrides.close(calls); + } }); + return withFixtureCleanup(async () => { + await callback(calls, filesystem); + // Assert BEFORE fallback cleanup: closing a leaked fd cannot make it pass. + assert.strictEqual(live.size, 0, 'All returned descriptors must close'); + }, () => [() => closeOwnedDescriptors(live, cleanupClose)]); +} + +function closeOwnedDescriptors(owned, close) { + let didThrow = false; + let first; + for (const fd of owned) { + owned.delete(fd); // An ambiguous close result must never cause a retry. + try { close(fd); } catch (error) { + if (!didThrow) { didThrow = true; first = error; } + } + } + if (didThrow) throw first; +} + +function changedStats(stats, changes) { + return Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, changes); +} + +function assertAssetRefusal(response, status, base, outside) { + assert.strictEqual(response.statusCode, status); + assert.ok(!response.body.includes('private-fixture-secret')); + assert.ok(!response.body.includes(base)); + assert.ok(!response.body.includes(outside)); +} + +async function artifactRaceTests(test) { + const withFile = callback => withArtifactHandler(async value => { + const base = fs.realpathSync(value.base); + const outside = fs.realpathSync(value.outside); + const parent = path.join(base, 'nested'); + fs.mkdirSync(parent); + const asset = path.join(parent, 'asset.txt'); + fs.writeFileSync(asset, 'inert'); + fs.writeFileSync(path.join(outside, 'asset.txt'), 'private-fixture-secret'); + await callback({ ...value, base, outside, parent, asset, fetch: filesystem => value.get('nested/asset.txt', filesystem) }); + }); + await test('sibling reads use one guarded descriptor and at most size plus one bytes', () => withFile(async ({ asset, fetch }) => { + await withAssetIo(asset, {}, async (calls, filesystem) => { + const response = await fetch(filesystem); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, 'inert'); + assert.deepStrictEqual([calls.opens, calls.closes], [1, 1]); + assert.ok(calls.fstats >= 2); + assert.strictEqual(typeof calls.flags[0], 'number'); + for (const flag of [fs.constants.O_NOFOLLOW || 0, fs.constants.O_NONBLOCK || 0]) { + assert.strictEqual(calls.flags[0] & flag, flag); + } + assert.ok(calls.requested.length > 0); + assert.ok(calls.requested.every(call => call.capacity === 6 && call.length <= 6 && Number.isInteger(call.position))); + assert.strictEqual(calls.returned, 5); + }); + })); + await test('a leaf symlink replacement before native open never reads outside bytes', () => withFile(async ({ asset, base, outside, fetch }) => { + const probe = path.join(base, 'probe'); + createTestSymlink(path.join(outside, 'asset.txt'), probe); + fs.unlinkSync(probe); + await withAssetIo(asset, { open({ candidate, flags, original }) { + fs.unlinkSync(asset); + fs.symlinkSync(path.join(outside, 'asset.txt'), asset, 'file'); + return original.openSync(candidate, flags); + } }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.strictEqual(calls.reads, 0); + assert.ok(calls.closes === 0 || calls.closes === 1); + }); + })); + await test('a substituted descriptor is rejected even with an unchanged pathname', () => withFile(async ({ asset, base, outside, fetch }) => { + await withAssetIo(asset, { open({ original }) { + return original.openSync(path.join(outside, 'asset.txt'), fs.constants.O_RDONLY); + } }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.deepStrictEqual([calls.reads, calls.closes], [0, 1]); + }); + })); + await test('an intermediate directory symlink swap before open never reads outside bytes', () => withFile(async ({ asset, parent, base, outside, fetch }) => { + const probe = path.join(base, 'probe'); + createTestSymlink(outside, probe, 'dir'); + fs.unlinkSync(probe); + await withAssetIo(asset, { open({ candidate, flags, original }) { + fs.renameSync(parent, `${parent}.saved`); + fs.symlinkSync(outside, parent, 'dir'); + return original.openSync(candidate, flags); + } }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.deepStrictEqual([calls.reads, calls.closes], [0, 1]); + }); + })); + await test('parent replacement is refused even with the same leaf inode and simulated stable leaf metadata', () => withFile(async ({ asset, parent, base, outside, fetch }) => { + const before = fs.statSync(asset, { bigint: true }); + const stable = stats => changedStats(stats, { mtimeNs: before.mtimeNs, ctimeNs: before.ctimeNs }); + await withAssetIo(asset, { + open({ candidate, flags, original }) { + fs.renameSync(parent, `${parent}.saved`); + fs.mkdirSync(parent); + fs.renameSync(path.join(`${parent}.saved`, 'asset.txt'), asset); + const current = fs.statSync(asset, { bigint: true }); + assert.deepStrictEqual([current.dev, current.ino], [before.dev, before.ino]); + return original.openSync(candidate, flags); + }, + fstat: stable, + lstat(candidate, stats) { return candidate === asset ? stable(stats) : stats; } + }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.deepStrictEqual([calls.reads, calls.closes], [0, 1]); + }); + })); + await test('simulated ancestor replacement above the artifact base is refused before read', () => withFile(async ({ asset, base, outside, fetch }) => { + await withAssetIo(asset, { lstat(candidate, stats, calls) { + return calls.opens > 0 && candidate === path.dirname(base) + ? changedStats(stats, { ino: stats.ino + 1n }) : stats; + } }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.deepStrictEqual([calls.reads, calls.closes], [0, 1]); + }); + })); + for (const change of ['descriptor metadata', 'ancestor identity']) { + await test(`simulated ${change} change after reading discards the buffered body`, () => withFile(async ({ asset, parent, base, outside, fetch }) => { + await withAssetIo(asset, { + fstat(stats, calls) { return change === 'descriptor metadata' && calls.reads > 0 + ? changedStats(stats, { mtimeNs: stats.mtimeNs + 1n }) : stats; }, + lstat(candidate, stats, calls) { return change === 'ancestor identity' && calls.reads > 0 && candidate === parent + ? changedStats(stats, { ino: stats.ino + 1n }) : stats; } + }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.ok(calls.reads > 0); + assert.strictEqual(calls.closes, 1); + }); + })); + } + await test('retargeting an in-root alias after read discards buffered bytes', () => withFile(async ({ asset, base, outside, get }) => { + const alias = path.join(base, 'alias.txt'); + const other = path.join(base, 'other.txt'); + fs.writeFileSync(other, 'other'); + createTestSymlink(asset, alias); + await withAssetIo(asset, { read({ fd, buffer, offset, length, position, original, calls }) { + const count = original.readSync(fd, buffer, offset, length, position); + if (calls.reads === 1) { fs.unlinkSync(alias); fs.symlinkSync(other, alias, 'file'); } + return count; + } }, async (calls, filesystem) => { + assertAssetRefusal(await get('alias.txt', filesystem), 403, base, outside); + assert.strictEqual(calls.closes, 1); + }); + })); + for (const size of [67108865n, -1n, 1.5, Infinity]) { + await test(`invalid or over-limit sampled size ${size} is refused before open`, () => withFile(async ({ asset, base, outside, fetch }) => { + await withAssetIo(asset, { lstat(candidate, stats) { return candidate === asset ? changedStats(stats, { size }) : stats; } }, async (calls, filesystem) => { + const status = size === 67108865n ? 413 : 403; + const response = await fetch(filesystem); + assertAssetRefusal(response, status, base, outside); + if (status === 413) assert.deepStrictEqual(JSON.parse(response.body), { error: 'asset too large' }); + assert.deepStrictEqual([calls.opens, calls.reads, calls.closes], [0, 0, 0]); + }); + })); + } + await test('non-regular opened descriptors are refused without reading', () => withFile(async ({ asset, base, outside, fetch }) => { + await withAssetIo(asset, { fstat(stats) { return changedStats(stats, { isFile: () => false }); } }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.deepStrictEqual([calls.reads, calls.closes], [0, 1]); + }); + })); + for (const kind of ['growth sentinel', 'early EOF']) { + await test(`bounded read refuses ${kind}`, () => withFile(async ({ asset, base, outside, fetch }) => { + await withAssetIo(asset, { read({ buffer, offset, length }) { + if (kind === 'early EOF') return 0; + buffer.fill(97, offset, offset + length); + return length; + } }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.ok(calls.returned <= 6); + assert.ok(calls.requested.every(call => call.length <= 6 && call.capacity === 6)); + assert.strictEqual(calls.closes, 1); + }); + })); + } + for (const boundary of ['open', 'fstat', 'read', 'close', 'read and close']) { + await test(`${boundary} failure closes only acquired descriptors once and returns no body`, () => withFile(async ({ asset, base, outside, fetch }) => { + const failure = code => Object.assign(new Error(`private failure at ${asset}`), { code }); + const overrides = {}; + if (boundary === 'open') overrides.open = () => { throw failure('EACCES'); }; + if (boundary === 'fstat') overrides.fstat = () => { throw failure('EIO'); }; + if (boundary.includes('read')) overrides.read = () => { throw failure('EIO'); }; + if (boundary.includes('close')) overrides.close = () => { throw failure(boundary === 'read and close' ? 'ELOOP' : 'EIO'); }; + await withAssetIo(asset, overrides, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 404, base, outside); + assert.strictEqual(calls.closes, boundary === 'open' ? 0 : 1); + }); + })); + } + await test('primary descriptor validation refusal survives a secondary close error', () => withFile(async ({ asset, base, outside, fetch }) => { + await withAssetIo(asset, { + open({ original }) { return original.openSync(path.join(outside, 'asset.txt'), fs.constants.O_RDONLY); }, + close() { throw Object.assign(new Error('secondary close error'), { code: 'EIO' }); } + }, async (calls, filesystem) => { + assertAssetRefusal(await fetch(filesystem), 403, base, outside); + assert.deepStrictEqual([calls.reads, calls.closes], [0, 1]); + }); + })); + await test('empty sibling files and static in-root directory aliases remain supported', () => withFile(async ({ asset, parent, base, get }) => { + fs.writeFileSync(asset, ''); + createTestSymlink(parent, path.join(base, 'inside'), 'dir'); + await withAssetIo(asset, {}, async (calls, filesystem) => { + const response = await get('inside/asset.txt', filesystem); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, ''); + assert.deepStrictEqual([calls.opens, calls.closes, calls.returned], [1, 1, 0]); + assert.ok(calls.requested.every(call => call.capacity === 1)); + }); + })); +} + +// Fixture-only regressions: real private files, direct dispatch, no listener. +async function fixtureIsolationTests(test) { + const methods = ['openSync', 'fstatSync', 'lstatSync', 'readSync', 'closeSync']; + const native = Object.fromEntries(methods.map(name => [name, fs[name]])); + const createServer = http.createServer; + const normalModule = require('../../scripts/lib/plan-canvas/server'); + function assertSharedIdentity() { + for (const name of methods) assert.strictEqual(fs[name], native[name], `shared fs.${name} changed`); + assert.strictEqual(http.createServer, createServer, 'shared HTTP factory changed'); + assert.strictEqual(require('../../scripts/lib/plan-canvas/server'), normalModule); + } + async function capture(callback) { + try { await callback(); return { didThrow: false }; } + catch (error) { return { didThrow: true, error }; } + } + const withAsset = callback => withArtifactHandler(async value => { + const asset = path.join(fs.realpathSync(value.base), 'isolation.txt'); + fs.writeFileSync(asset, 'isolated'); + await callback({ ...value, asset }); + }); + + await test('fixture overrides never replace shared modules, even during an awaited callback', () => withAsset(async ({ asset, get }) => { + await withAssetIo(asset, {}, async (calls, filesystem = fs) => { + assertSharedIdentity(); + assert.ok(Object.isFrozen(filesystem), 'case filesystem facade must be frozen'); + assert.strictEqual((await get('isolation.txt', filesystem)).body, 'isolated'); + assert.strictEqual(calls.opens, 1); + await Promise.resolve(); + assertSharedIdentity(); + }); + assertSharedIdentity(); + const primary = Object.freeze(new Error('frozen callback failure')); + const caught = await capture(() => withAssetIo(asset, {}, async (_calls, filesystem = fs) => { + await get('isolation.txt', filesystem); + assertSharedIdentity(); + throw primary; + })); + assert.strictEqual(caught.didThrow, true); + assert.strictEqual(caught.error, primary); + assertSharedIdentity(); + })); + + await test('interleaved private handlers consume only their own filesystem facades', () => withAsset(async ({ asset, get }) => { + await withAssetIo(asset, {}, async (left, leftFs = fs) => { + await withAssetIo(asset, {}, async (right, rightFs = fs) => { + assert.notStrictEqual(leftFs, rightFs, 'simultaneously active fixtures need distinct facades'); + assert.strictEqual((await get('isolation.txt', leftFs)).body, 'isolated'); + assert.deepStrictEqual([left.opens, right.opens], [1, 0]); + assert.strictEqual((await get('isolation.txt', rightFs)).body, 'isolated'); + assert.deepStrictEqual([left.opens, right.opens], [1, 1]); + assert.strictEqual((await get('isolation.txt', leftFs)).body, 'isolated'); + assert.deepStrictEqual([left.opens, right.opens, left.closes, right.closes], [2, 1, 2, 1]); + assertSharedIdentity(); + }); + }); + })); + + await test('private CommonJS loader uses exact source and leaves the normal module identity intact', () => { + const filename = require.resolve('../../scripts/lib/plan-canvas/server'); + const before = fs.readFileSync(filename, 'utf8'); + const localHttp = Object.freeze({ ...http, createServer() { assertSharedIdentity(); throw new Error('local factory'); } }); + const isolated = loadArtifactServer(fs, localHttp); + assert.notStrictEqual(isolated, normalModule); + assert.notStrictEqual(isolated.createPlanCanvasServer, normalModule.createPlanCanvasServer); + assert.strictEqual(artifactServerSource, before, 'compile the unchanged on-disk source'); + assert.throws(() => isolated.createPlanCanvasServer({ store: { get() {} }, idleTimeoutMs: 0 }), /local factory/); + assert.strictEqual(fs.readFileSync(filename, 'utf8'), before); + assertSharedIdentity(); + }); + + for (const primary of [Object.freeze(new Error('primary fixture failure')), 0, false, null, undefined]) { + await test(`descriptor fallback preserves exact ${String(primary)} and attempts all owned fds`, () => withAsset(async ({ asset }) => { + const fds = []; + const attempts = []; + const secondary = new Error('secondary cleanup'); + const caught = await capture(() => withAssetIo(asset, {}, (_calls, filesystem = fs) => { + fds.push(filesystem.openSync(asset, 'r'), filesystem.openSync(asset, 'r')); + throw primary; + }, { cleanupClose(fd) { + attempts.push(fd); + native.closeSync(fd); + if (attempts.length === 1) throw secondary; + } })); + assert.strictEqual(caught.didThrow, true); + assert.ok(Object.is(caught.error, primary), 'cleanup must preserve the exact arbitrary thrown value'); + assert.deepStrictEqual(attempts, fds, 'each remaining descriptor gets one cleanup attempt'); + for (const fd of fds) assert.throws(() => native.fstatSync(fd), error => error.code === 'EBADF'); + assertSharedIdentity(); + })); + } + + await test('fallback cleanup cannot turn a leaked-descriptor assertion into a pass', () => withAsset(async ({ asset }) => { + const fds = []; + const attempts = []; + const secondary = new Error('cleanup after leak assertion'); + const caught = await capture(() => withAssetIo(asset, {}, (_calls, filesystem = fs) => { + fds.push(filesystem.openSync(asset, 'r'), filesystem.openSync(asset, 'r')); + }, { cleanupClose(fd) { attempts.push(fd); native.closeSync(fd); throw secondary; } })); + assert.strictEqual(caught.didThrow, true); + assert.match(caught.error.message, /All returned descriptors must close/); + assert.notStrictEqual(caught.error, secondary); + assert.deepStrictEqual(attempts, fds); + for (const fd of fds) assert.throws(() => native.fstatSync(fd), error => error.code === 'EBADF'); + })); + + await test('descriptor cleanup alone removes ownership before each single attempt and reports its first failure', () => withAsset(async ({ asset }) => { + const fds = [native.openSync(asset, 'r'), native.openSync(asset, 'r')]; + const owned = new Set(fds); + const attempts = []; + const first = new Error('first cleanup failure'); + try { + const caught = await capture(() => closeOwnedDescriptors(owned, fd => { + assert.ok(!owned.has(fd), 'ownership must be removed before ambiguous close'); + attempts.push(fd); + native.closeSync(fd); + throw attempts.length === 1 ? first : new Error('later cleanup failure'); + })); + assert.strictEqual(caught.didThrow, true); + assert.strictEqual(caught.error, first); + assert.deepStrictEqual(attempts, fds); + assert.strictEqual(owned.size, 0); + } finally { + // Safety cleanup only for an unimplemented/broken helper in RED. Entries + // already attempted must have been removed and are never retried. + for (const fd of owned) { owned.delete(fd); native.closeSync(fd); } + } + for (const fd of fds) assert.throws(() => native.fstatSync(fd), error => error.code === 'EBADF'); + })); + + for (const state of ['frozen primary', 'falsy primary', 'cleanup only']) { + await test(`canvas and root cleanup preserve ${state} and attempt every stage`, async () => { + const primary = state === 'frozen primary' ? Object.freeze(new Error('primary canvas callback')) : 0; + const closeFailure = new Error('canvas cleanup failure'); + const rootFailure = new Error('root cleanup failure'); + const stages = []; + let fixtureRoot; + const caught = await capture(() => withArtifactHandler(async ({ base, get }) => { + fixtureRoot = path.dirname(base); + await get(''); + await get(''); + if (state !== 'cleanup only') throw primary; + }, { + async closeCanvas(canvas) { stages.push('close'); await canvas.close(); throw closeFailure; }, + removeRoot(root) { stages.push('root'); fs.rmSync(root, { recursive: true, force: true }); throw rootFailure; } + })); + assert.strictEqual(caught.didThrow, true); + assert.ok(Object.is(caught.error, state === 'cleanup only' ? closeFailure : primary)); + assert.deepStrictEqual(stages, ['close', 'close', 'root']); + assert.strictEqual(fs.existsSync(fixtureRoot), false); + assertSharedIdentity(); + }); } } @@ -109,8 +741,15 @@ function waitFor(predicate, { timeoutMs = 3000, intervalMs = 20 } = {}) { async function main() { console.log('\n=== Testing plan-canvas server ===\n'); - let passed = 0; - let failed = 0; + const suite = createTestRunner(); + const { test } = suite; + await artifactSecurityTests(test); + await artifactRaceTests(test); + await fixtureIsolationTests(test); + if (process.argv.includes('--artifact-security-only')) { + printResults(suite.results); + return; + } const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-server-')); const artifact = path.join(tmp, 'demo.plan.md'); @@ -137,36 +776,36 @@ async function main() { let key = null; let htmlKey = null; - if (await test('GET /health identifies the app and version', async () => { + await test('GET /health identifies the app and version', async () => { const res = await request(port, 'GET', '/health'); assert.deepStrictEqual(jsonBody(res), { ok: true, app: 'ecc-plan-canvas', version: '9.9.9-test' }); - })) passed++; else failed++; + }); - if (await test('requests with a non-loopback Host header are rejected', async () => { + await test('requests with a non-loopback Host header are rejected', async () => { const res = await request(port, 'GET', '/health', { headers: { host: 'evil.example.com' } }); assert.strictEqual(res.statusCode, 403); - })) passed++; else failed++; + }); - if (await test('requests with a cross-site Origin are rejected', async () => { + await test('requests with a cross-site Origin are rejected', async () => { const res = await request(port, 'POST', '/shutdown', { headers: { origin: 'https://evil.example.com' } }); assert.strictEqual(res.statusCode, 403); - })) passed++; else failed++; + }); - if (await test('POST /api/sessions opens a session for an existing artifact', async () => { + await test('POST /api/sessions opens a session for an existing artifact', async () => { const res = await request(port, 'POST', '/api/sessions', { body: { file: artifact } }); assert.strictEqual(res.statusCode, 200); const body = jsonBody(res); assert.strictEqual(body.status, 'open'); assert.match(body.key, /^[a-f0-9]{12}$/); key = body.key; - })) passed++; else failed++; + }); - if (await test('POST /api/sessions 404s for a missing artifact', async () => { + await test('POST /api/sessions 404s for a missing artifact', async () => { const res = await request(port, 'POST', '/api/sessions', { body: { file: path.join(tmp, 'nope.md') } }); assert.strictEqual(res.statusCode, 404); - })) passed++; else failed++; + }); - if (await test('GET /canvas/:key serves the ECC chrome with CSP', async () => { + await test('GET /canvas/:key serves the ECC chrome with CSP', async () => { const res = await request(port, 'GET', `/canvas/${key}`); assert.strictEqual(res.statusCode, 200); assert.ok(res.headers['content-security-policy'].includes("default-src 'self'")); @@ -174,20 +813,20 @@ async function main() { assert.ok(res.body.includes('pc-session')); assert.ok(res.body.includes('Approve plan')); assert.ok(res.body.includes('sandbox="allow-scripts allow-forms allow-popups"')); - })) passed++; else failed++; + }); - if (await test('markdown artifacts render in the ECC plan template with the SDK', async () => { + await test('markdown artifacts render in the ECC plan template with the SDK', async () => { const res = await request(port, 'GET', `/artifact/${key}/`); assert.strictEqual(res.statusCode, 200); assert.ok(res.body.includes('

')); assert.ok(res.body.includes('')); assert.ok(res.body.includes('\n')); - })) passed++; else failed++; + }); - if (await test('sibling assets are served, traversal is blocked', async () => { + 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); assert.ok(ok.body.includes('color: red')); const escape = await request(port, 'GET', `/artifact/${key}/..%2F${path.basename(outsideDir)}%2Fsecret.txt`); assert.strictEqual(escape.statusCode, 403); - })) passed++; else failed++; + }); - if (await test('static chrome assets are served', async () => { + await test('artifact responses carry a sandbox CSP (direct-navigation hardening)', async () => { + const md = await request(port, 'GET', `/artifact/${key}/`); + assert.strictEqual(md.statusCode, 200); + assert.strictEqual(md.headers['content-security-policy'], 'sandbox allow-scripts allow-forms allow-popups'); + const html = await request(port, 'GET', `/artifact/${htmlKey}/`); + assert.strictEqual(html.statusCode, 200); + assert.strictEqual(html.headers['content-security-policy'], 'sandbox allow-scripts allow-forms allow-popups'); + }); + + await test('missing-artifact 404 escapes the file path', async () => { + // Quotes and ampersands are escapable on every platform (Windows + // rejects < > in filenames, so angle brackets stay out of fixtures). + const evilFile = path.join(tmp, `evil'b&xss.plan.md`); + fs.writeFileSync(evilFile, '# Evil\n'); + const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: evilFile } })); + fs.rmSync(evilFile); + const res = await request(port, 'GET', `/artifact/${opened.key}/`); + assert.strictEqual(res.statusCode, 404); + assert.ok(!res.body.includes(`evil'b&xss`), 'raw filename must not appear in the 404 page'); + assert.ok(res.body.includes('evil'b&xss'), 'filename must be HTML-escaped in the 404 page'); + }); + + await test('symlinked sibling assets escaping the artifact dir are blocked', async () => { + createTestSymlink(path.join(outsideDir, 'secret.txt'), path.join(tmp, 'evil-link.txt')); + createTestSymlink(path.join(tmp, 'style.css'), path.join(tmp, 'ok-link.css')); + const blocked = await request(port, 'GET', `/artifact/${key}/evil-link.txt`); + assert.strictEqual(blocked.statusCode, 403); + const allowed = await request(port, 'GET', `/artifact/${key}/ok-link.css`); + assert.strictEqual(allowed.statusCode, 200); + assert.ok(allowed.body.includes('color: red')); + }); + + await test('served HTML siblings carry the sandbox CSP', async () => { + fs.writeFileSync(path.join(tmp, 'note.html'), '

hi

'); + const res = await request(port, 'GET', `/artifact/${key}/note.html`); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers['content-security-policy'], 'sandbox allow-scripts allow-forms allow-popups'); + }); + + await test('symlinked assets take their MIME from the link name', async () => { + fs.writeFileSync(path.join(tmp, 'realfile'), 'body { color: blue }'); + createTestSymlink(path.join(tmp, 'realfile'), path.join(tmp, 'theme.css')); + const res = await request(port, 'GET', `/artifact/${key}/theme.css`); + assert.strictEqual(res.statusCode, 200); + assert.ok(String(res.headers['content-type']).startsWith('text/css')); + }); + + await test('static chrome assets are served', async () => { for (const asset of ['/canvas.css', '/client.js', '/sdk.js']) { const res = await request(port, 'GET', asset); assert.strictEqual(res.statusCode, 200, `${asset} should be 200`); } - })) passed++; else failed++; + }); - if (await test('await with timeoutMs returns waiting when idle', async () => { + await test('await with timeoutMs returns waiting when idle', async () => { const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=50`); assert.strictEqual(jsonBody(res).status, 'waiting'); - })) passed++; else failed++; + }); - if (await test('await returns missing for files without a session', async () => { + await test('await returns missing for files without a session', async () => { const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(path.join(tmp, 'other.md'))}`); assert.strictEqual(jsonBody(res).status, 'missing'); - })) passed++; else failed++; + }); - if (await test('browser feedback wakes a blocking await; presence transitions', async () => { + await test('browser feedback wakes a blocking await; presence transitions', async () => { const sse = openSse(port, key); await sse.ready; const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`); @@ -256,11 +942,11 @@ async function main() { await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking')); await waitFor(() => sse.received.some(e => e.event === 'chat-sync' && e.data.chat.length === 2)); sse.close(); - })) passed++; else failed++; + }); // Regression: feedback sent with nobody parked on `await` used to leave the // pill claiming "agent working" while the message sat undelivered forever. - if (await test('feedback with no listener reports queued, not working', async () => { + await test('feedback with no listener reports queued, not working', async () => { const queuedArtifact = path.join(tmp, 'queued.plan.md'); fs.writeFileSync(queuedArtifact, '# Plan: Queued\n'); const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: queuedArtifact } })); @@ -280,9 +966,9 @@ async function main() { assert.strictEqual(drained.status, 'feedback'); assert.strictEqual(canvas.presenceFor(opened.key), 'thinking'); sse.close(); - })) passed++; else failed++; + }); - if (await test('typing endpoint drives the indicator and reply clears it', async () => { + await test('typing endpoint drives the indicator and reply clears it', async () => { const typingArtifact = path.join(tmp, 'typing.plan.md'); fs.writeFileSync(typingArtifact, '# Plan: Typing\n'); const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: typingArtifact } })); @@ -304,9 +990,9 @@ async function main() { assert.strictEqual(canvas.presenceFor(opened.key), 'waiting'); await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting')); sse.close(); - })) passed++; else failed++; + }); - if (await test('thinking and typing states expire instead of sticking', async () => { + await test('thinking and typing states expire instead of sticking', async () => { const staleArtifact = path.join(tmp, 'stale.plan.md'); fs.writeFileSync(staleArtifact, '# Plan: Stale\n'); const staleStore = createSessionStore({ stateDir: path.join(tmp, 'stale-state') }); @@ -336,11 +1022,11 @@ async function main() { await new Promise(resolve => setTimeout(resolve, 60)); assert.strictEqual(staleCanvas.presenceFor(opened.key), 'queued'); await staleCanvas.close(); - })) passed++; else failed++; + }); // The stuck pill only self-heals if the decay is pushed to an idle browser // that is not making any requests of its own. - if (await test('presence sweep pushes the decayed state to an idle browser', async () => { + await test('presence sweep pushes the decayed state to an idle browser', async () => { const sweepArtifact = path.join(tmp, 'sweep.plan.md'); fs.writeFileSync(sweepArtifact, '# Plan: Sweep\n'); const sweepStore = createSessionStore({ stateDir: path.join(tmp, 'sweep-state') }); @@ -365,9 +1051,9 @@ async function main() { ); sse.close(); await sweepCanvas.close(); - })) passed++; else failed++; + }); - if (await test('long-poll heartbeat whitespace arrives before the payload', async () => { + await test('long-poll heartbeat whitespace arrives before the payload', async () => { const chunks = []; const done = new Promise((resolve, reject) => { const req = http.get( @@ -386,9 +1072,9 @@ async function main() { await done; const full = chunks.join(''); assert.strictEqual(JSON.parse(full.trim()).status, 'feedback'); - })) passed++; else failed++; + }); - if (await test('agent reply lands in the chat via SSE chat-sync', async () => { + await test('agent reply lands in the chat via SSE chat-sync', async () => { const sse = openSse(port, key); await sse.ready; const res = await request(port, 'POST', `/api/session/${key}/reply`, { body: { text: 'reworked, please re-check' } }); @@ -399,17 +1085,17 @@ async function main() { ) ); sse.close(); - })) passed++; else failed++; + }); - if (await test('live reload: editing the artifact emits an SSE reload event', async () => { + await test('live reload: editing the artifact emits an SSE reload event', async () => { const sse = openSse(port, key); await sse.ready; fs.appendFileSync(artifact, '\n## Addendum\n'); await waitFor(() => sse.received.some(e => e.event === 'reload'), { timeoutMs: 4000 }); sse.close(); - })) passed++; else failed++; + }); - if (await test('send-and-end delivers the final batch and ends the session', async () => { + await test('send-and-end delivers the final batch and ends the session', async () => { const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`); await waitFor(() => canvas.presenceFor(key) === 'listening'); await request(port, 'POST', `/api/session/${key}/feedback`, { @@ -421,44 +1107,44 @@ async function main() { assert.strictEqual(result.endedBy, 'user'); const after = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=0`); assert.strictEqual(jsonBody(after).status, 'ended'); - })) passed++; else failed++; + }); - if (await test('user-ended sessions return 409 on plain reopen, open with reopen:true', async () => { + await test('user-ended sessions return 409 on plain reopen, open with reopen:true', async () => { const refused = await request(port, 'POST', '/api/sessions', { body: { file: artifact } }); assert.strictEqual(refused.statusCode, 409); assert.strictEqual(jsonBody(refused).status, 'user-ended'); const forced = await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } }); assert.strictEqual(forced.statusCode, 200); - })) passed++; else failed++; + }); - if (await test('agent end via POST /api/end allows plain reopen', async () => { + await test('agent end via POST /api/end allows plain reopen', async () => { const res = await request(port, 'POST', '/api/end', { body: { file: artifact } }); assert.strictEqual(jsonBody(res).endedBy, 'agent'); const reopened = await request(port, 'POST', '/api/sessions', { body: { file: artifact } }); assert.strictEqual(reopened.statusCode, 200); - })) passed++; else failed++; + }); - if (await test('feedback on an ended session is refused with 409', async () => { + await test('feedback on an ended session is refused with 409', async () => { await request(port, 'POST', `/api/end`, { body: { file: htmlArtifact } }); const res = await request(port, 'POST', `/api/session/${htmlKey}/feedback`, { body: { items: [{ kind: 'chat', text: 'too late' }] } }); assert.strictEqual(res.statusCode, 409); - })) passed++; else failed++; + }); - if (await test('GET / lists sessions in the ECC shell', async () => { + await test('GET / lists sessions in the ECC shell', async () => { const res = await request(port, 'GET', '/'); assert.ok(res.body.includes('Plan Canvas sessions')); assert.ok(res.body.includes('demo.plan.md')); - })) passed++; else failed++; + }); - if (await test('POST /shutdown triggers the shutdown callback', async () => { + await test('POST /shutdown triggers the shutdown callback', async () => { const res = await request(port, 'POST', '/shutdown'); assert.strictEqual(jsonBody(res).status, 'stopping'); await waitFor(() => idleFired); - })) passed++; else failed++; + }); - if (await test('close() settles a held long-poll instead of hanging', async () => { + await test('close() settles a held long-poll instead of hanging', async () => { await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } }); const held = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`); await waitFor(() => canvas.presenceFor(store.findByFile(artifact).key) === 'listening'); @@ -466,17 +1152,14 @@ async function main() { const result = jsonBody(await held); assert.strictEqual(result.status, 'waiting'); assert.ok(result.note.includes('shutting down')); - })) passed++; else failed++; + }); fs.rmSync(tmp, { recursive: true, force: true }); fs.rmSync(outsideDir, { recursive: true, force: true }); console.log('\n' + '='.repeat(40)); - console.log(`Passed: ${passed}`); - console.log(`Failed: ${failed}`); + printResults(suite.results); console.log('='.repeat(40)); - - process.exit(failed > 0 ? 1 : 0); } main().catch(err => {