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>
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
/**
|
|
* Phase 8 P1 — scene templates lifecycle end-to-end via stdio MCP.
|
|
*
|
|
* Spawns a dedicated stdio MCP child (isolated PASCAL_DATA_DIR), instantiates
|
|
* every seed template, saves/validates/inspects them, exercises `measure`,
|
|
* deletes them, and asserts error-path behaviour for unknown template ids
|
|
* and missing scene ids.
|
|
*
|
|
* Run: bun packages/mcp/test-reports/phase8/p1-templates.ts
|
|
*/
|
|
import { rmSync, writeFileSync } from 'node:fs'
|
|
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'
|
|
import { McpError } from '@modelcontextprotocol/sdk/types.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, 'p1-templates.md')
|
|
const DATA_DIR = '/tmp/pascal-phase8-p1'
|
|
|
|
type StepStatus = 'PASS' | 'FAIL'
|
|
type Step = { id: string; title: string; status: StepStatus; detail: string }
|
|
const steps: Step[] = []
|
|
|
|
function record(id: string, title: string, status: StepStatus, detail: string): void {
|
|
steps.push({ id, title, status, detail })
|
|
const icon = status === 'PASS' ? '[PASS]' : '[FAIL]'
|
|
console.log(`${icon} ${id} ${title} — ${detail}`)
|
|
}
|
|
|
|
type TextContent = Array<{ type?: string; text?: string }>
|
|
function parseText(content: unknown): any {
|
|
const arr = content as TextContent
|
|
const first = Array.isArray(arr) ? arr[0] : undefined
|
|
if (!first || typeof first.text !== 'string') return null
|
|
try {
|
|
return JSON.parse(first.text)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
type TemplateSummary = { id: string; name: string; description: string; nodeCount: number }
|
|
|
|
const TEMPLATE_IDS = ['empty-studio', 'two-bedroom', 'garden-house'] as const
|
|
|
|
// Per-template snapshot recorded in step 2c.
|
|
type SceneSnapshot = {
|
|
templateId: string
|
|
sceneId: string
|
|
sceneName: string
|
|
nodeCount: number
|
|
zoneCount: number
|
|
wallCount: number
|
|
doorCount: number
|
|
windowCount: number
|
|
}
|
|
const snapshots: SceneSnapshot[] = []
|
|
|
|
async function main(): Promise<void> {
|
|
// Idempotent cleanup.
|
|
try {
|
|
rmSync(DATA_DIR, { recursive: true, force: true })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const t0 = Date.now()
|
|
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: 'p1-templates', version: '0.0.0' })
|
|
await client.connect(transport)
|
|
|
|
// ========================================================================
|
|
// 1. list_templates — assert 3 templates with required fields.
|
|
// ========================================================================
|
|
{
|
|
const r = await client.callTool({ name: 'list_templates', arguments: {} })
|
|
const payload = parseText(r.content) ?? (r.structuredContent as any)
|
|
const list = payload?.templates as TemplateSummary[] | undefined
|
|
const ids = (list ?? []).map((t) => t.id).sort()
|
|
const expected = [...TEMPLATE_IDS].sort()
|
|
const idsOk = JSON.stringify(ids) === JSON.stringify(expected)
|
|
const fieldsOk =
|
|
!!list &&
|
|
list.every(
|
|
(t) =>
|
|
typeof t.id === 'string' &&
|
|
typeof t.name === 'string' &&
|
|
typeof t.description === 'string' &&
|
|
typeof t.nodeCount === 'number' &&
|
|
t.nodeCount > 0,
|
|
)
|
|
const status: StepStatus = idsOk && fieldsOk ? 'PASS' : 'FAIL'
|
|
const summary = list
|
|
? list.map((t) => `${t.id}(${t.nodeCount})`).join(', ')
|
|
: 'missing templates array'
|
|
record('1', 'list_templates', status, `ids=[${ids.join(',')}], ${summary}`)
|
|
}
|
|
|
|
// ========================================================================
|
|
// 2. For each template: create_from_template → save_scene → validate → get_scene.
|
|
// ========================================================================
|
|
for (const id of TEMPLATE_IDS) {
|
|
const sceneName = `p1-${id}`
|
|
// 2a. create_from_template + save_scene
|
|
let sceneId: string | null = null
|
|
{
|
|
const cr = await client.callTool({
|
|
name: 'create_from_template',
|
|
arguments: { id, name: sceneName },
|
|
})
|
|
const cpayload = parseText(cr.content) ?? (cr.structuredContent as any)
|
|
const createOk = !cr.isError && cpayload?.templateId === id && (cpayload?.nodeCount ?? 0) > 0
|
|
const sr = await client.callTool({
|
|
name: 'save_scene',
|
|
arguments: { name: sceneName },
|
|
})
|
|
const spayload = parseText(sr.content) ?? (sr.structuredContent as any)
|
|
sceneId = spayload?.id ?? null
|
|
const saveOk = !sr.isError && !!sceneId
|
|
const status: StepStatus = createOk && saveOk ? 'PASS' : 'FAIL'
|
|
record(
|
|
`2a/${id}`,
|
|
'create_from_template + save_scene',
|
|
status,
|
|
`templateId=${cpayload?.templateId}, createdNodes=${cpayload?.nodeCount}, sceneId=${sceneId}, version=${spayload?.version}`,
|
|
)
|
|
}
|
|
|
|
// 2b. validate_scene
|
|
{
|
|
const vr = await client.callTool({ name: 'validate_scene', arguments: {} })
|
|
const vp = parseText(vr.content) ?? (vr.structuredContent as any)
|
|
const ok =
|
|
!vr.isError && vp?.valid === true && Array.isArray(vp?.errors) && vp.errors.length === 0
|
|
record(
|
|
`2b/${id}`,
|
|
'validate_scene',
|
|
ok ? 'PASS' : 'FAIL',
|
|
`valid=${vp?.valid}, errors=${vp?.errors?.length}`,
|
|
)
|
|
}
|
|
|
|
// 2c. get_scene — record counts per type.
|
|
{
|
|
const gr = await client.callTool({ name: 'get_scene', arguments: {} })
|
|
const gp = parseText(gr.content) ?? (gr.structuredContent as any)
|
|
const nodes = (gp?.nodes as Record<string, { type?: string }> | undefined) ?? {}
|
|
const counts = { zone: 0, wall: 0, door: 0, window: 0 }
|
|
for (const n of Object.values(nodes)) {
|
|
const t = n?.type
|
|
if (t === 'zone' || t === 'wall' || t === 'door' || t === 'window') {
|
|
counts[t] += 1
|
|
}
|
|
}
|
|
const nodeCount = Object.keys(nodes).length
|
|
const ok = !gr.isError && nodeCount > 0
|
|
snapshots.push({
|
|
templateId: id,
|
|
sceneId: sceneId ?? '(missing)',
|
|
sceneName,
|
|
nodeCount,
|
|
zoneCount: counts.zone,
|
|
wallCount: counts.wall,
|
|
doorCount: counts.door,
|
|
windowCount: counts.window,
|
|
})
|
|
record(
|
|
`2c/${id}`,
|
|
'get_scene counts',
|
|
ok ? 'PASS' : 'FAIL',
|
|
`nodes=${nodeCount}, zones=${counts.zone}, walls=${counts.wall}, doors=${counts.door}, windows=${counts.window}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// 3. list_scenes — expect 3 scenes with our names.
|
|
// ========================================================================
|
|
{
|
|
const lr = await client.callTool({ name: 'list_scenes', arguments: {} })
|
|
const lp = parseText(lr.content) ?? (lr.structuredContent as any)
|
|
const names = (lp?.scenes ?? []).map((s: any) => s.name).sort()
|
|
const expected = TEMPLATE_IDS.map((id) => `p1-${id}`).sort()
|
|
const ok =
|
|
!lr.isError && names.length === 3 && JSON.stringify(names) === JSON.stringify(expected)
|
|
record(
|
|
'3',
|
|
'list_scenes',
|
|
ok ? 'PASS' : 'FAIL',
|
|
`scenes=${lp?.scenes?.length}, names=[${names.join(',')}]`,
|
|
)
|
|
}
|
|
|
|
// ========================================================================
|
|
// 4. Load two-bedroom; measure between two zones; distance > 0.
|
|
// ========================================================================
|
|
{
|
|
const twoBedroom = snapshots.find((s) => s.templateId === 'two-bedroom')
|
|
if (!twoBedroom || twoBedroom.sceneId === '(missing)') {
|
|
record('4', 'measure between zones', 'FAIL', 'no two-bedroom scene recorded')
|
|
} else {
|
|
const load = await client.callTool({
|
|
name: 'load_scene',
|
|
arguments: { id: twoBedroom.sceneId },
|
|
})
|
|
const loadOk = !load.isError
|
|
const gs = await client.callTool({ name: 'get_scene', arguments: {} })
|
|
const gsp = parseText(gs.content) ?? (gs.structuredContent as any)
|
|
const nodes = (gsp?.nodes as Record<string, { id?: string; type?: string }> | undefined) ?? {}
|
|
const zones = Object.values(nodes).filter((n) => n.type === 'zone')
|
|
if (!loadOk || zones.length < 2) {
|
|
record('4', 'measure between zones', 'FAIL', `loadOk=${loadOk}, zones=${zones.length}`)
|
|
} else {
|
|
const from = zones[0]!.id as string
|
|
const to = zones[1]!.id as string
|
|
const mr = await client.callTool({
|
|
name: 'measure',
|
|
arguments: { fromId: from, toId: to },
|
|
})
|
|
const mp = parseText(mr.content) ?? (mr.structuredContent as any)
|
|
const dist = mp?.distanceMeters as number | undefined
|
|
const ok = !mr.isError && typeof dist === 'number' && dist > 0
|
|
record(
|
|
'4',
|
|
'measure between zones',
|
|
ok ? 'PASS' : 'FAIL',
|
|
`from=${from}, to=${to}, distance=${dist?.toFixed?.(3)}m`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// 5. delete each scene, then list_scenes → 0.
|
|
// ========================================================================
|
|
for (const snap of snapshots) {
|
|
if (snap.sceneId === '(missing)') continue
|
|
const dr = await client.callTool({
|
|
name: 'delete_scene',
|
|
arguments: { id: snap.sceneId },
|
|
})
|
|
const dp = parseText(dr.content) ?? (dr.structuredContent as any)
|
|
const ok = !dr.isError && dp?.deleted === true
|
|
record(
|
|
`5/${snap.templateId}`,
|
|
'delete_scene',
|
|
ok ? 'PASS' : 'FAIL',
|
|
`id=${snap.sceneId}, deleted=${dp?.deleted}`,
|
|
)
|
|
}
|
|
{
|
|
const lr = await client.callTool({ name: 'list_scenes', arguments: {} })
|
|
const lp = parseText(lr.content) ?? (lr.structuredContent as any)
|
|
const count = lp?.scenes?.length ?? -1
|
|
const ok = !lr.isError && count === 0
|
|
record('5/final', 'list_scenes empty', ok ? 'PASS' : 'FAIL', `remaining scenes=${count}`)
|
|
}
|
|
|
|
// ========================================================================
|
|
// 6a. Error: create_from_template with unknown id → McpError(InvalidParams).
|
|
// ========================================================================
|
|
{
|
|
let status: StepStatus = 'FAIL'
|
|
let detail = ''
|
|
try {
|
|
const r = await client.callTool({
|
|
name: 'create_from_template',
|
|
arguments: { id: 'nonexistent' },
|
|
})
|
|
if (r.isError) {
|
|
const textArr = r.content as TextContent
|
|
const text = textArr?.[0]?.text ?? ''
|
|
const looksInvalid = /unknown_template|InvalidParams|nonexistent/i.test(text)
|
|
status = looksInvalid ? 'PASS' : 'FAIL'
|
|
detail = `tool_error text="${String(text).slice(0, 160)}"`
|
|
} else {
|
|
detail = 'unexpected success'
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof McpError) {
|
|
// ErrorCode.InvalidParams = -32602
|
|
const ok = err.code === -32602
|
|
status = ok ? 'PASS' : 'FAIL'
|
|
detail = `McpError code=${err.code} msg="${err.message}"`
|
|
} else {
|
|
detail = `threw non-McpError: ${err instanceof Error ? err.message : String(err)}`
|
|
}
|
|
}
|
|
record('6a', 'create_from_template unknown id', status, detail)
|
|
}
|
|
|
|
// ========================================================================
|
|
// 6b. Error: load_scene missing id → expect error.
|
|
// ========================================================================
|
|
{
|
|
let status: StepStatus = 'FAIL'
|
|
let detail = ''
|
|
try {
|
|
const r = await client.callTool({
|
|
name: 'load_scene',
|
|
arguments: { id: 'missing-id-xyz' },
|
|
})
|
|
if (r.isError) {
|
|
const textArr = r.content as TextContent
|
|
const text = textArr?.[0]?.text ?? ''
|
|
const looksMissing = /scene_not_found|missing|not found/i.test(text)
|
|
status = looksMissing ? 'PASS' : 'FAIL'
|
|
detail = `tool_error text="${String(text).slice(0, 160)}"`
|
|
} else {
|
|
detail = 'unexpected success'
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof McpError) {
|
|
const ok = err.code === -32602 || /scene_not_found|not found/i.test(err.message)
|
|
status = ok ? 'PASS' : 'FAIL'
|
|
detail = `McpError code=${err.code} msg="${err.message}"`
|
|
} else {
|
|
detail = `threw non-McpError: ${err instanceof Error ? err.message : String(err)}`
|
|
}
|
|
}
|
|
record('6b', 'load_scene missing id', status, detail)
|
|
}
|
|
|
|
const elapsed = Date.now() - t0
|
|
await client.close()
|
|
|
|
// --- Write markdown report ---
|
|
const passed = steps.filter((s) => s.status === 'PASS').length
|
|
const failed = steps.filter((s) => s.status === 'FAIL').length
|
|
const total = steps.length
|
|
|
|
const lines: string[] = []
|
|
lines.push('# Phase 8 P1 — templates lifecycle (stdio MCP)')
|
|
lines.push('')
|
|
lines.push(`Generated: ${new Date().toISOString()}`)
|
|
lines.push(
|
|
`Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`), data dir \`${DATA_DIR}\`.`,
|
|
)
|
|
lines.push('')
|
|
lines.push(`**Summary:** ${passed}/${total} PASS, ${failed} FAIL, ${elapsed} ms.`)
|
|
lines.push('')
|
|
lines.push('## Steps')
|
|
lines.push('')
|
|
lines.push('| # | Step | Status | Detail |')
|
|
lines.push('|---|------|--------|--------|')
|
|
for (const s of steps) {
|
|
const safe = s.detail.replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
|
lines.push(`| ${s.id} | ${s.title} | ${s.status} | ${safe} |`)
|
|
}
|
|
lines.push('')
|
|
lines.push('## Per-template snapshot (step 2c)')
|
|
lines.push('')
|
|
lines.push('| Template | Scene name | nodes | zones | walls | doors | windows |')
|
|
lines.push('|----------|------------|-------|-------|-------|-------|---------|')
|
|
for (const snap of snapshots) {
|
|
lines.push(
|
|
`| ${snap.templateId} | ${snap.sceneName} | ${snap.nodeCount} | ${snap.zoneCount} | ${snap.wallCount} | ${snap.doorCount} | ${snap.windowCount} |`,
|
|
)
|
|
}
|
|
lines.push('')
|
|
|
|
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
|
|
console.log(`\n[p1] report: ${REPORT_PATH}`)
|
|
console.log(`[p1] ${passed}/${total} PASS, ${failed} FAIL in ${elapsed}ms`)
|
|
|
|
if (failed > 0) process.exitCode = 1
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('[p1] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
|
|
process.exit(2)
|
|
})
|