Files
editor/packages/mcp/test-reports/phase8/p8-concurrency.ts
T
Adrian PerezandClaude Opus 4.7 0b84e7b7b1 fix(mcp,editor): close URL-validation bypasses surfaced by Phase 8 P4
Phase 8 parallel validation flagged two boundaries where malicious URLs
(javascript:, file:, external http:, data:text/html, ...) could be
persisted despite the AssetUrl allowlist added in Phase 7 A7:

1. `save_scene({ includeCurrentScene: false, graph })` — the graph arg
   was treated as opaque (`z.record(z.string(), z.unknown())`) and
   written to the store without re-running AnyNode.safeParse.

2. `POST /api/scenes { graph }` in the editor API — same issue; the
   Zod `graphSchema` accepted anything object-shaped.

Fixes:
- `save-scene.ts`: when `includeCurrentScene === false`, iterate every
  node and run `AnyNode.safeParse`; collect issues and throw
  `McpError(InvalidParams, 'graph_invalid', { errors })` on any
  failure.
- `app/api/scenes/route.ts`: replace `graphSchema` with a structured
  `z.object({ nodes, rootNodeIds, collections? })` + `superRefine`
  that runs `AnyNode.safeParse` on every node. Invalid → 400 with
  detailed issue paths.

Tests:
- Added `save_scene` regression test for the P4 attack
  (item.asset.src = 'javascript:alert(1)') — expected error.
- Fixed the existing `includeCurrentScene=false` test to use a
  schema-compliant site node id (the prior `id: 'root'` now fails
  the AnyNode parse, which is the desired strict behaviour).
- Full suite: 294 pass / 0 fail.

Also adds Phase 8 test-reports/phase8/** (10 agents, ~15 scripts +
markdown reports) documenting the validation run, plus minor biome
cleanups to the Phase 5/7 test artefacts (removed stale
`// biome-ignore` suppression comments that now resolve to the
already-off `noConsole` rule).

Phase 8 result summary (10 parallel agents, stdio MCP transport with
isolated data dirs):
- P1 templates: 18/18 PASS
- P2 variants: 6/7 mutations + determinism + save + combined + error
- P3 locking: 12/12 PASS (MCP + editor HTTP If-Match)
- P4 URL hardening: fixed 2 bypasses (see above)
- P5 photo-to-scene: 6/6 PASS
- P6 Casa del Sol via save_scene: 13/13 PASS
- P7 editor HTTP API: 18/18 PASS
- P8 concurrency: 4/5 PASS, flagged 2 real filesystem-store races
  (expectedVersion CAS gap + .index.json drift under parallel writes)
- P9 edge cases: 13/13 PASS (size cap, slug safety, bad inputs)
- P10 full sweep: 37/37 PASS (30 tools + 4 resources + 3 prompts)

Known follow-ups:
- FilesystemSceneStore needs a proper lockfile / atomic CAS to fix
  the P8 concurrency bugs (low priority: single-writer MCP is the
  typical case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:26:26 +02:00

512 lines
21 KiB
TypeScript

/**
* Phase 8 P8 — concurrency stress test.
*
* Hammer the MCP stdio server with parallel save/delete/rename calls and
* verify the FilesystemSceneStore keeps its invariants:
* - parallel saves of distinct ids → all succeed, listing matches
* - parallel saves to the same id with optimistic concurrency → exactly one
* winner, N-1 version_conflict losers
* - parallel delete + rename of the same id → only one winner, the other
* reports a clean structured error
* - parallel saves of many distinct ids → no corruption / no drift between
* the on-disk `<id>.json` files and the `.index.json` sidecar
*
* Run (from worktree root):
* PASCAL_DATA_DIR=/tmp/pascal-phase8-p8 \
* bun run packages/mcp/test-reports/phase8/p8-concurrency.ts
*/
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import * as fs from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p8-concurrency.md')
const DATA_DIR = process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p8'
const SCENES_DIR = `${DATA_DIR}/scenes`
const INDEX_PATH = `${SCENES_DIR}/.index.json`
type SceneMeta = {
id: string
name: string
projectId: string | null
thumbnailUrl: string | null
version: number
createdAt: string
updatedAt: string
ownerId: string | null
sizeBytes: number
nodeCount: number
}
type CallOutcome =
| { ok: true; text: string; json: unknown }
| { ok: false; error: string; json?: unknown }
type Scenario = {
name: string
status: 'pass' | 'fail'
summary: string
details: string[]
}
const scenarios: Scenario[] = []
function pickText(result: { content?: unknown; isError?: boolean }): string {
const content = result.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content) || content.length === 0) return ''
const first = content[0]
if (first && typeof first === 'object' && typeof first.text === 'string') return first.text
return ''
}
async function callTool(
client: Client,
name: string,
args: Record<string, unknown>,
): Promise<CallOutcome> {
try {
const result = (await client.callTool({ name, arguments: args })) as {
content?: unknown
structuredContent?: unknown
isError?: boolean
}
const text = pickText(result)
let parsed: unknown = null
if (text) {
try {
parsed = JSON.parse(text)
} catch {
// keep parsed = null
}
}
if (result.isError) {
return { ok: false, error: text || 'isError', json: parsed ?? result.structuredContent }
}
return { ok: true, text, json: parsed ?? result.structuredContent }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { ok: false, error: msg }
}
}
function cleanDataDir(): void {
if (existsSync(DATA_DIR)) {
rmSync(DATA_DIR, { recursive: true, force: true })
}
mkdirSync(SCENES_DIR, { recursive: true })
}
function record(scenario: Scenario): void {
scenarios.push(scenario)
const sym = scenario.status === 'pass' ? 'PASS' : 'FAIL'
console.log(`[${sym}] ${scenario.name}${scenario.summary}`)
for (const d of scenario.details) console.log(` ${d}`)
}
async function main(): Promise<void> {
cleanDataDir()
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
stderr: 'inherit',
})
const client = new Client({ name: 'pascal-mcp-p8', version: '0.0.0' })
const t0 = Date.now()
await client.connect(transport)
console.log(`[p8] connected to MCP stdio — data dir: ${DATA_DIR}`)
// Bootstrap a small scene once. For the rest of the test we will save with
// `includeCurrentScene: true` (the bridge's current scene graph).
const getScene = await callTool(client, 'get_scene', {})
if (!getScene.ok) {
throw new Error(`bootstrap get_scene failed: ${getScene.error}`)
}
// ---- Scenario 1: parallel saves of 10 different ids -------------------
{
const ids = Array.from({ length: 10 }, (_, i) => `parallel-${i.toString().padStart(2, '0')}`)
const results = await Promise.all(
ids.map((id) =>
callTool(client, 'save_scene', { id, name: `Parallel ${id}`, includeCurrentScene: true }),
),
)
const successes = results.filter((r) => r.ok)
const failures = results.filter((r) => !r.ok)
// list_scenes should at least show all 10 ids. (Other scenarios below
// may add more later, but right now these should be the only scenes.)
const list = await callTool(client, 'list_scenes', { limit: 1000 })
const listedIds = list.ok
? ((list.json as { scenes?: Array<{ id: string }> })?.scenes ?? []).map((s) => s.id).sort()
: []
const allPresent = ids.every((id) => listedIds.includes(id))
const details: string[] = [
`saves succeeded: ${successes.length}/10`,
`saves failed: ${failures.length}`,
`list_scenes returned ${listedIds.length} ids: ${listedIds.join(', ')}`,
]
if (failures.length > 0) {
details.push(...failures.map((f) => `fail: ${!f.ok ? f.error : ''}`))
}
record({
name: 'Parallel saves of 10 different ids',
status: successes.length === 10 && allPresent ? 'pass' : 'fail',
summary: `${successes.length}/10 succeeded, list_scenes shows ${listedIds.length} scenes (all 10 present: ${allPresent})`,
details,
})
}
// ---- Scenario 2: parallel save to the SAME id — race ------------------
{
const initial = await callTool(client, 'save_scene', {
id: 'race',
name: 'Race initial',
includeCurrentScene: true,
})
const baselineVersion = initial.ok
? ((initial.json as { version?: number })?.version ?? -1)
: -1
const NUM_RACERS = 5
const racers = await Promise.all(
Array.from({ length: NUM_RACERS }, (_, i) =>
callTool(client, 'save_scene', {
id: 'race',
name: `Race #${i}`,
includeCurrentScene: true,
expectedVersion: baselineVersion,
}),
),
)
const winners = racers.filter((r) => r.ok)
const losers = racers.filter((r) => !r.ok)
const conflicts = losers.filter((r) => !r.ok && r.error.includes('version_conflict'))
// After the race, the scene should be at version baseline+1 (exactly one bump).
const postLoad = await callTool(client, 'load_scene', { id: 'race' })
const finalVersion = postLoad.ok ? ((postLoad.json as { version?: number })?.version ?? -1) : -1
const details: string[] = [
`baseline version after initial save: ${baselineVersion}`,
`race winners (ok=true): ${winners.length}`,
`race losers (ok=false): ${losers.length}`,
`losers reporting version_conflict: ${conflicts.length}`,
`final version on disk: ${finalVersion}`,
...losers.map((l, i) => `loser[${i}]: ${!l.ok ? l.error.slice(0, 180) : ''}`),
]
const passed =
winners.length === 1 &&
losers.length === NUM_RACERS - 1 &&
conflicts.length === NUM_RACERS - 1 &&
finalVersion === baselineVersion + 1
record({
name: 'Parallel saves to SAME id (version race)',
status: passed ? 'pass' : 'fail',
summary: `${winners.length} winner, ${losers.length} loser(s), ${conflicts.length} version_conflict — finalVersion=${finalVersion} (expected ${baselineVersion + 1})`,
details,
})
}
// ---- Scenario 3: parallel delete + rename of the same id ---------------
{
const saved = await callTool(client, 'save_scene', {
id: 'mix',
name: 'Mix baseline',
includeCurrentScene: true,
})
if (!saved.ok) {
record({
name: 'Parallel delete + rename of same id',
status: 'fail',
summary: `baseline save_scene failed: ${saved.error}`,
details: [],
})
} else {
const [delResult, renResult] = await Promise.all([
callTool(client, 'delete_scene', { id: 'mix' }),
callTool(client, 'rename_scene', { id: 'mix', newName: 'mix2' }),
])
const delOk = delResult.ok
const renOk = renResult.ok
// What actually survives on disk? load_scene should be deterministic.
const postLoad = await callTool(client, 'load_scene', { id: 'mix' })
const stillExists = postLoad.ok && (postLoad.json as { id?: string } | null)?.id === 'mix'
const postLoadName =
postLoad.ok && postLoad.json ? ((postLoad.json as { name?: string }).name ?? null) : null
// Acceptable outcomes: (delOk=true && renOk=false) OR (delOk=false && renOk=true).
// The *loser* must report a structured error string, never a process crash.
const exactlyOneWinner = Number(delOk) + Number(renOk) === 1
const loserErr = !delOk ? delResult.error : !renOk ? renResult.error : ''
const loserCleanError =
loserErr.includes('scene_not_found') ||
loserErr.includes('version_conflict') ||
loserErr.includes('not found') ||
loserErr.includes('version mismatch')
const details: string[] = [
`delete_scene ok=${delOk} err=${!delOk ? delResult.error.slice(0, 160) : ''}`,
`rename_scene ok=${renOk} err=${!renOk ? renResult.error.slice(0, 160) : ''}`,
`post-race load_scene({id:'mix'}).id=${stillExists ? 'mix' : 'null'} name=${postLoadName}`,
]
record({
name: 'Parallel delete + rename of same id',
status: exactlyOneWinner && loserCleanError ? 'pass' : 'fail',
summary: `winners=${Number(delOk) + Number(renOk)} (delete=${delOk}, rename=${renOk}); loser reports structured error: ${loserCleanError}`,
details,
})
}
}
// ---- Scenario 4: 20 parallel saves, then load each ---------------------
{
const ids = Array.from({ length: 20 }, (_, i) => `bulk-${i.toString().padStart(2, '0')}`)
const saveResults = await Promise.all(
ids.map((id) =>
callTool(client, 'save_scene', { id, name: `Bulk ${id}`, includeCurrentScene: true }),
),
)
const saveSuccesses = saveResults.filter((r) => r.ok)
// Ensure every scene file is readable and has the same id it was saved with.
// `load_scene` returns only the meta envelope (no `graph` field); the graph
// is loaded into the bridge as a side-effect. We verify id + version ≥ 1.
const loadResults = await Promise.all(ids.map((id) => callTool(client, 'load_scene', { id })))
const loadedOk: string[] = []
const loadedBad: string[] = []
for (let i = 0; i < ids.length; i++) {
const id = ids[i]!
const r = loadResults[i]!
if (r.ok) {
const payload = r.json as { id?: string; version?: number } | null
if (payload?.id === id && typeof payload.version === 'number' && payload.version >= 1) {
loadedOk.push(id)
} else {
loadedBad.push(`${id}: payload mismatch (got ${JSON.stringify(payload)})`)
}
} else {
loadedBad.push(`${id}: ${r.error.slice(0, 120)}`)
}
}
// Direct on-disk read — look for bad JSON (the atomic write path should
// never leave half-written files behind).
const entries = await fs.readdir(SCENES_DIR)
const jsonFiles = entries.filter((e) => e.endsWith('.json') && e !== '.index.json')
const tmpFiles = entries.filter((e) => e.endsWith('.tmp'))
const corruptFiles: string[] = []
for (const f of jsonFiles) {
const full = `${SCENES_DIR}/${f}`
try {
const raw = await fs.readFile(full, 'utf8')
JSON.parse(raw)
} catch (err) {
corruptFiles.push(`${f}: ${err instanceof Error ? err.message : String(err)}`)
}
}
const pass =
saveSuccesses.length === ids.length &&
loadedOk.length === ids.length &&
loadedBad.length === 0 &&
corruptFiles.length === 0 &&
tmpFiles.length === 0
record({
name: '20 parallel distinct saves + per-id load',
status: pass ? 'pass' : 'fail',
summary: `saves=${saveSuccesses.length}/20, loads=${loadedOk.length}/20, corrupt=${corruptFiles.length}, stray .tmp=${tmpFiles.length}`,
details: [
...loadedBad.slice(0, 5).map((b) => `load issue: ${b}`),
...corruptFiles.slice(0, 5).map((b) => `corrupt: ${b}`),
...tmpFiles.slice(0, 5).map((b) => `stray tmp: ${b}`),
],
})
}
// ---- Scenario 5: index sidecar <-> file drift --------------------------
{
let indexRaw = ''
let indexParsed: SceneMeta[] | null = null
try {
indexRaw = readFileSync(INDEX_PATH, 'utf8')
const parsed = JSON.parse(indexRaw)
if (Array.isArray(parsed)) indexParsed = parsed as SceneMeta[]
} catch (err) {
record({
name: 'Index sidecar consistency',
status: 'fail',
summary: `could not read .index.json: ${err instanceof Error ? err.message : String(err)}`,
details: [],
})
}
if (indexParsed) {
const entries = await fs.readdir(SCENES_DIR)
const fileIds = entries
.filter((e) => e.endsWith('.json') && e !== '.index.json')
.map((e) => e.slice(0, -'.json'.length))
.sort()
const indexIds = indexParsed.map((m) => m.id).sort()
const missingFromDisk = indexIds.filter((id) => !fileIds.includes(id))
const missingFromIndex = fileIds.filter((id) => !indexIds.includes(id))
// Also verify each indexed entry's `version` matches what's in the file
// — a weaker but useful check against "index points to stale version".
const versionMismatches: string[] = []
for (const m of indexParsed.slice(0, 30)) {
const filePath = `${SCENES_DIR}/${m.id}.json`
try {
const raw = await fs.readFile(filePath, 'utf8')
const fileMeta = (JSON.parse(raw) as { meta?: { version?: number } }).meta
if (fileMeta?.version !== m.version) {
versionMismatches.push(`${m.id}: index=${m.version} file=${fileMeta?.version}`)
}
} catch (err) {
versionMismatches.push(
`${m.id}: could not verify (${err instanceof Error ? err.message : String(err)})`,
)
}
}
const pass =
missingFromDisk.length === 0 &&
missingFromIndex.length === 0 &&
versionMismatches.length === 0
record({
name: 'Index sidecar consistency',
status: pass ? 'pass' : 'fail',
summary: `index=${indexIds.length} ids, disk=${fileIds.length} ids, missingFromDisk=${missingFromDisk.length}, missingFromIndex=${missingFromIndex.length}, versionMismatches=${versionMismatches.length}`,
details: [
...missingFromDisk.slice(0, 10).map((id) => `missing-from-disk: ${id}`),
...missingFromIndex.slice(0, 10).map((id) => `missing-from-index: ${id}`),
...versionMismatches.slice(0, 10).map((m) => `version-mismatch: ${m}`),
],
})
}
}
const elapsedMs = Date.now() - t0
await client.close()
// ---- Write the report --------------------------------------------------
const passCount = scenarios.filter((s) => s.status === 'pass').length
const failCount = scenarios.length - passCount
const lines: string[] = []
lines.push('# Phase 8 P8 — concurrency stress report')
lines.push('')
lines.push(`- Generated: ${new Date().toISOString()}`)
lines.push(`- Transport: stdio (\`bun ${BIN_PATH} --stdio\`)`)
lines.push(`- Data dir: \`${DATA_DIR}\``)
lines.push(`- Elapsed: ${elapsedMs} ms`)
lines.push(`- Scenarios: **${passCount}/${scenarios.length} pass**, ${failCount} fail`)
lines.push('')
lines.push('## Matrix')
lines.push('')
lines.push('| # | Scenario | Status | Summary |')
lines.push('|---|----------|--------|---------|')
scenarios.forEach((s, i) => {
const safeSummary = s.summary.replace(/\|/g, '\\|')
const sym = s.status === 'pass' ? 'PASS' : 'FAIL'
lines.push(`| ${i + 1} | ${s.name} | ${sym} | ${safeSummary} |`)
})
lines.push('')
lines.push('## Detail')
lines.push('')
scenarios.forEach((s, i) => {
lines.push(`### ${i + 1}. ${s.name}${s.status.toUpperCase()}`)
lines.push('')
lines.push(s.summary)
if (s.details.length > 0) {
lines.push('')
lines.push('```')
for (const d of s.details) lines.push(d)
lines.push('```')
}
lines.push('')
})
lines.push('## Flakiness note')
lines.push('')
lines.push(
'Scenarios 1 and 5 are both symptoms of the same index-drift bug. Which one surfaces (or both, or neither) depends on timing — on repeated runs I observed: run A had `3/5 pass` with scenarios 2 and 5 failing; run B had `3/5 pass` with scenarios 1 and 2 failing. Scenario 2 is deterministic and always fails. Scenario 3 is deterministic and always passes. Scenario 4 (file bytes) is deterministic and always passes.',
)
lines.push('')
lines.push('## Findings / bugs')
lines.push('')
lines.push('### BUG 1 — `expectedVersion` check is racy (scenario 2)')
lines.push('')
lines.push(
'Five parallel `save_scene({ id: "race", expectedVersion: 1 })` calls ALL returned `ok:true`. Only one of them actually produced a durable bump (final on-disk version is 2, not 6), so we do not see corruption — but the server silently accepts writes that should be rejected with `version_conflict`.',
)
lines.push('')
lines.push(
'Root cause is in `FilesystemSceneStore.save()`: the check reads `existing.meta.version` at the top of the function and writes much later. Because `fs.readFile` and `fs.writeFile` each `await`, interleaved invocations all observe the same pre-race version, all pass the check, all claim `version = existing+1`, and the last `fs.rename` wins. There is no mutex / lock-file / compare-and-swap at the filesystem level. Expected behavior: exactly 1 success + 4 `version_conflict` errors.',
)
lines.push('')
lines.push('### BUG 2 — `.index.json` sidecar drifts under load (scenario 5)')
lines.push('')
lines.push(
'After 20 concurrent distinct saves (all files present on disk), `.index.json` was missing 3 of the scenes that DID make it to disk. `list_scenes` calls `readIndex()` first and only falls back to `collectAllMeta()` if the index file is absent — so those 3 scenes would also be hidden from `list_scenes` callers. The filter inside `readIndex` (drop entries whose file vanished) cannot paper this over because the problem is the opposite direction: files exist, index entry is missing.',
)
lines.push('')
lines.push(
"Root cause: `save()` calls `writeIndex(await collectAllMeta())` at the end. When two `save()` calls race, call A may snapshot the directory while call B has not yet renamed its file into place; call A then writes an index that omits B. Call B then writes its own index that DOES include both — but if A's write happens to lose the final `rename` race (or B's write lands first and A's lands second) the loser's index is the one that sticks. This is exactly `index=28, disk=31` in the run above. `delete_scene` and `rename_scene` repeat the same pattern.",
)
lines.push('')
lines.push('### Non-bugs observed')
lines.push('')
lines.push(
'- Parallel saves of distinct ids (scenarios 1 + 4): all 10 / 20 files land on disk, no corruption, no stray `.tmp` files (atomic-rename does its job). The problem is not the file bytes — it is the `.index.json` denormalisation.',
)
lines.push(
'- Parallel `delete_scene` + `rename_scene` of the same id (scenario 3): delete wins, rename loses cleanly with a structured `version_conflict` error (rename uses `expectedVersion = current`, and delete removed the record, so the compare yields `0 !== 1`). No process crash, no half-state.',
)
lines.push('')
lines.push('## Observations on the implementation')
lines.push('')
lines.push(
'- `FilesystemSceneStore.save` serializes through a tmp+rename atomic write, then rewrites `.index.json` from a fresh directory listing. That is correct for single-writer, wrong for multi-writer.',
)
lines.push(
'- Optimistic concurrency relies on re-reading the existing record inside `save()` without any lock, so the check-then-write window is always a race.',
)
lines.push(
'- Suggested fix surface: serialize mutating operations per-id via an in-process queue (`Promise` chain keyed by id), or move the expectedVersion check to the final rename (`fs.rename` with a sentinel). The supabase backend is not affected because Postgres does the compare-and-swap server-side.',
)
lines.push('')
const { writeFileSync } = await import('node:fs')
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
console.log(`\n[p8] report written: ${REPORT_PATH}`)
console.log(`[p8] ${passCount}/${scenarios.length} scenarios pass, elapsed ${elapsedMs} ms`)
if (failCount > 0) process.exitCode = 1
}
main().catch((err) => {
console.error('[p8] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})