diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08b648a..b2c9265 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,22 @@ jobs: cache: npm - run: npm ci - run: npm run typecheck + # the package's distribution shape is part of the contract: tsc must + # actually EMIT, the bin entry must exist and resolve, and the tarball + # must stay lean (assets are allowlisted in package.json "files") + - run: npm run build + - name: verify bin entry resolves + run: | + test -f dist/cli/index.js + node dist/cli/index.js --help > /dev/null + - run: npm pack --dry-run # whole unit suite (no explicit file list, so new test files aren't # silently skipped); the e2e files run in the browser-e2e job below, # which has chromium + ffmpeg installed. - run: npx vitest run --exclude '**/*.e2e.test.ts' + # deliberately STRICT (no --omit=dev): dev-tree advisories must stay + # visible. This is green because the lockfile is clean, not because the + # check was weakened — keep it that way. - run: npm audit --audit-level=moderate browser-e2e: @@ -41,4 +53,7 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium - run: sudo apt-get update && sudo apt-get install -y ffmpeg - - run: npm test -- --run test/record.e2e.test.ts test/generate.e2e.test.ts + # filter by the *.e2e.test.ts naming convention (not an explicit file + # list) so a new e2e file cannot be silently skipped here — the same + # rule the unit job above already follows in the other direction + - run: npm test -- --run e2e.test diff --git a/README.md b/README.md index 9e88fdb..b9a1bc0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- supercut — real app footage → cinematic launch video + supercut — real app footage → cinematic launch video

@@ -17,7 +17,7 @@

- supercut filming a live app: it opens the console, fills in a record, and frames the resulting audit + supercut filming a live app: it opens the console, fills in a record, and frames the resulting audit
Generated by supercut from a live web app — zero manual editing. The cursor, the camera, the cuts: all automatic.

@@ -26,7 +26,7 @@ **You built something great. Now you need a launch video — and all you've got is a screen recording, iMovie, and a deadline.** -`supercut` is a launch video generator for the command line — a Screen Studio alternative that makes a product demo video straight from a URL. Point its AI director at your *running* app: it reads your source, crawls the live UI, decides the 2–4 moments that actually sell the product, drives a real browser to perform them on camera, then renders the whole thing cinematically — spring zoom-to-cursor, motion blur, music, a wallpaper stage, and a clean 1080p60 export. +`supercut` is an AI director for launch videos. Screen recorders hand you a canvas and a timeline and leave the direction to you — supercut decides what to film by itself. Point it at your *running* app: it reads your source, crawls the live UI, picks the 2–4 moments that actually sell the product, drives a real browser to perform them on camera, then renders the result cinematically — spring zoom-to-cursor, motion blur, music, a wallpaper stage, and a clean 1080p60 export. > Not a screen recording. Not a fake UI mockup. **Your real product**, shot like a launch film — automatically. @@ -53,17 +53,29 @@ Each stage hands off a plain-JSON artifact, so you can stop at any point, hand-e ## 🚀 Quick start +```bash +# your app running locally? one command: +npx supercut generate --url http://localhost:3000 --yes +``` + +`generate` needs an LLM key in a `.env` (see [provider setup](#-llm-provider-setup)), +plus Chromium and ffmpeg: `npx playwright install chromium`, an `ffmpeg` on your PATH, +and `npx supercut doctor` checks both. + +> Any command accepts `--help` to print its own usage (e.g. `npx supercut generate --help`). + +### From source (contributors — and the no-API-key demo) + ```bash git clone https://github.com/Co-Messi/supercut cd supercut npm install npm run build -# point it at your running app — that's it node dist/cli/index.js generate --url http://127.0.0.1:3000 --yes ``` -`generate` needs an LLM key (see [provider setup](#-llm-provider-setup)). No key? The non-AI path works standalone against the bundled demo app: +No key? The non-AI path works standalone against the bundled demo app: ```bash # 1. serve the bundled demo app on port 4173 (or: npx serve -l 4173 examples/demo-app) @@ -76,10 +88,6 @@ node dist/cli/index.js render --take out/take --out out/final.mp4 node dist/cli/index.js doctor # check Chromium + ffmpeg are installed ``` -> Browser + video need Chromium and ffmpeg: `npx playwright install chromium` and an `ffmpeg` on your PATH. - -> Any command accepts `--help` to print its own usage (e.g. `node dist/cli/index.js generate --help`). - ### 🤖 Or: let your coding agent set it up Already living in **Claude Code, Codex, opencode, Cursor, or Cline**? Don't run the @@ -130,9 +138,13 @@ node dist/cli/index.js generate --url https://untrusted.example --block-private- ``` (`--allow-private-network` is a deprecated no-op kept for back-compat. With the guard on, -the crawler also resolves-and-pins the target host's DNS so a rebinding hostname can't -swap in a private IP mid-run; the `record` stage's browser does not pin yet, so its -navigations are validated as URLs only.) +both the crawler and the `record` stage resolve-and-pin their target hosts' DNS so a +rebinding hostname can't swap in a private IP mid-run, and every in-flight browser +request — navigations from clicked links and submits, `fetch`/XHR, images, scripts, +and WebSocket connections — is checked against the policy before it leaves the +browser. WebSocket gating relies on Playwright's `routeWebSocket`; if you run supercut +against a Playwright older than 1.48 it prints a warning and WebSocket connections are +**not** policy-checked.) > ⚠️ **supercut drives and may MUTATE the target app** — it performs real clicks and > typing on whatever you point it at. Destructive controls (Delete, Remove, Pay, …) @@ -140,6 +152,10 @@ navigations are validated as URLs only.) > English-only**: it matches visible labels and cannot catch icon-only buttons or > other wording. Film against a disposable/staging environment, never production > data or URLs/recipes you do not trust. Pass `--allow-destructive` to opt back in. +> +> `generate` prints the recipe's full action list — every selector and every typed +> string — before filming starts, and `--dry-run` stops right there: review +> `recipe.json`, then film it with `supercut record`. ## 🔌 LLM provider setup @@ -253,7 +269,18 @@ take directory ──▶ render ──▶ final.mp4 Schemas reject unsupported URL schemes, malformed events, non-monotonic timelines, oversized logs, and impossible camera boxes. -Event timestamps share the frame `t_source` clock: identical runs now produce structurally/geometrically identical events.json with timestamps agreeing within ~150ms (not byte-identical), and renders fail when events lead the footage by >250ms unless `SUPERCUT_ALLOW_SKEW=1` (legacy sparse takes only warn). +Event timestamps share the frame `t_source` clock, declared by `t_source_unified: true` +in `events.json` (the built-in recorder always writes it). Identical runs produce +structurally/geometrically identical events.json with timestamps agreeing within ~150ms +(not byte-identical). Two render-time gates protect the output: + +- **Skew**: on a unified-clock take, events leading the footage by >250ms fail the render + (`SUPERCUT_ALLOW_SKEW=1` forces). Logs without the marker are treated as legacy + recorders whose clocks were never unified, and only warn. +- **Capture health**: a take whose frame count falls far below its duration × fps is + refused — that footage renders as stills with a camera gliding over them. Average + source fps is printed on every `record`/`generate`/`render` run. To render a genuinely + sparse take (e.g. from an old change-driven recorder) set `SUPERCUT_ALLOW_SPARSE=1`. ## Project principles diff --git a/assets/music/CREDITS.md b/assets/music/CREDITS.md index ea44668..b310ad0 100644 --- a/assets/music/CREDITS.md +++ b/assets/music/CREDITS.md @@ -24,7 +24,7 @@ Use them in your videos — commercial or not — with no attribution required. | track | vibe | length | bpm | | -------------- | ----------------------------- | ------ | ---- | -| `pulse.mp3` | minimal tech-house, sleek | 95s | ~104 | -| `daybreak.mp3` | bright melodic house, upbeat | 95s | ~110 | -| `midnight.mp3` | dark synthwave/techno, premium| 100s | ~100 | -| `momentum.mp3` | driving minimal techno | 95s | ~122 | +| `pulse.mp3` | minimal tech-house, sleek | 92s | ~104 | +| `daybreak.mp3` | bright melodic house, upbeat | 92s | ~110 | +| `midnight.mp3` | dark synthwave/techno, premium| 92s | ~100 | +| `momentum.mp3` | driving minimal techno | 91s | ~122 | diff --git a/package-lock.json b/package-lock.json index 2d49317..762a27d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1377,9 +1377,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1467,9 +1467,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -1487,7 +1487,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index 93aac07..464f68f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,11 @@ { "name": "supercut", - "version": "0.0.1", - "description": "AI launch-video generator: films your real web app with a cinematic camera, motion blur, and music — a CLI alternative to Screen Studio for product demos", + "version": "0.1.0", + "description": "An AI director that films your real web app by itself: it decides the money moments, drives a browser to perform them, and renders a cinematic 60s launch video — camera, motion blur, music, done", "keywords": [ "launch-video", + "ai-director", "product-demo", - "screen-recording", - "screen-studio-alternative", "demo-video", "marketing-video", "ai-video", @@ -15,9 +14,20 @@ "cinematic", "cli", "video-generator", + "screen-recording", + "screen-studio-alternative", "product-hunt" ], "license": "MIT", + "author": "Brayden Siew", + "repository": { + "type": "git", + "url": "git+https://github.com/Co-Messi/supercut.git" + }, + "homepage": "https://supercut-five.vercel.app", + "bugs": { + "url": "https://github.com/Co-Messi/supercut/issues" + }, "type": "module", "engines": { "node": ">=20" @@ -27,7 +37,8 @@ }, "files": [ "dist", - "assets", + "assets/backgrounds", + "assets/music", "examples/demo-app", "examples/demo.recipe.json" ], @@ -39,7 +50,7 @@ "typecheck": "tsc --noEmit", "dev": "tsx src/cli/index.ts", "test:fast": "vitest run test/cursor.test.ts test/director.test.ts test/director-validation.test.ts test/schema.test.ts test/config.test.ts test/url-policy.test.ts test/redaction.test.ts test/plan.test.ts", - "test:e2e": "vitest run test/record.e2e.test.ts test/generate.e2e.test.ts" + "test:e2e": "vitest run e2e.test" }, "dependencies": { "playwright": "^1.53.0", diff --git a/src/capture/executor.ts b/src/capture/executor.ts index dc951cf..83dce89 100644 --- a/src/capture/executor.ts +++ b/src/capture/executor.ts @@ -29,7 +29,7 @@ import { join } from "node:path"; import { chromium, type CDPSession, type Page } from "playwright"; import type { EventLog, KnownEvent, Recipe, Scene, Action } from "../schema/index.js"; import { cursorPath, makeRng, type CursorPoint } from "./cursor.js"; -import { assertSafeNavigationUrl } from "../security/url-policy.js"; +import { assertSafeNavigationUrl, createRequestGate, gateWebSockets, resolveAndPinHost } from "../security/url-policy.js"; const VIEWPORT = { width: 1920, height: 1080 }; const DPR = 2; @@ -159,13 +159,23 @@ export interface RecordOptions { seed?: number; /** Skip screencast (faster scheduling-only tests). */ captureFrames?: boolean; - /** allow localhost/RFC1918/cloud-metadata navigation; off by default for safety */ + /** Allow localhost/RFC1918/link-local navigation. Defaults to FALSE: the + * library fails closed and callers opt in. Every caller in this repo + * (generate(), the CLI) passes the value explicitly — the CLI allows by + * default and --block-private-network opts the guard in — so the default + * exists only for external embedders, and for them the safe direction is + * closed (matching crawlApp()'s default). With the guard on, the recipe's + * URLs are policy-checked, the target hosts are DNS resolve-and-pinned, + * and every in-flight request is gated. */ allowPrivateNetwork?: boolean; } export interface RecordResult { eventLog: EventLog; frameCount: number; + /** frames captured per second of take time (frame + event span). ~60 on a + * healthy beacon-era capture; near zero when the screencast starved. */ + avgSourceFps: number; failedScenes: string[]; aborted: boolean; outDir: string; @@ -219,9 +229,38 @@ export async function record(opts: RecordOptions): Promise { mkdirSync(join(outDir, "frames"), { recursive: true }); + // guard ON: resolve-and-pin every recipe host so the browser connects to the + // exact IPs the policy vetted — a DNS re-resolve mid-run can't swap in a + // private one (same defense the crawler applies). + // Note: this re-resolves hosts that assertRecipeNavigationPolicy above + // already resolved — a second lookup and a small TOCTOU window between the + // two. Deliberate: the assert is a pure yes/no policy check, the pin is the + // one whose answer the browser actually connects to, and collapsing them + // would couple the policy module to Chromium launch-arg formatting. + const launchArgs: string[] = []; + if (!allowPrivateNetwork) { + const rules: string[] = []; + const seenHosts = new Set(); + const recipeUrls: string[] = []; + for (const scene of recipe.scenes) { + recipeUrls.push(scene.entry.url); + for (const action of [...scene.entry.prelude, ...scene.actions]) { + if (action.kind === "goto" && action.url) recipeUrls.push(action.url); + } + } + for (const u of recipeUrls) { + const host = new URL(u).hostname; + if (seenHosts.has(host)) continue; + seenHosts.add(host); + const pinned = await resolveAndPinHost(u, { allowPrivateNetwork }); + if (pinned) rules.push(pinned.hostResolverRule); + } + if (rules.length > 0) launchArgs.push(`--host-resolver-rules=${rules.join(",")}`); + } + // launch is the only setup outside try/finally; everything else (newPage, // CDP session) lives inside so a setup failure can't leak the browser - const browser = await chromium.launch({ headless: true }); + const browser = await chromium.launch({ headless: true, args: launchArgs }); const events: KnownEvent[] = []; const pathPoints: [number, number, number][] = []; // [t, x, y] global cursor track @@ -487,6 +526,25 @@ export async function record(opts: RecordOptions): Promise { try { page = await browser.newPage({ viewport: VIEWPORT, deviceScaleFactor: DPR }); + // guard ON: gate EVERY in-flight request (clicked links, Enter submits, + // redirects, subresources) — assertSafeNavigationUrl only covers entry/goto + // URLs known from the recipe, but a click on an a[href] or a submit + // navigates with no pre-check. Installed ONLY when the guard is engaged: + // route interception funnels every request through Node, and the default + // local-app path must not pay that tax during a 60fps capture. + if (!allowPrivateNetwork) { + const gate = createRequestGate({ allowPrivateNetwork }); + await page.context().route("**/*", async (route) => { + if (!(await gate.allows(route.request().url()))) return route.abort(); + return route.continue(); + }); + // WebSocket upgrades bypass ctx.route — gate them separately + if (!(await gateWebSockets(page.context(), gate))) { + console.error( + "warning: this Playwright build lacks routeWebSocket — WebSocket connections are NOT policy-checked", + ); + } + } if (captureFrames) await page.addInitScript(REPAINT_BEACON_SCRIPT); await page.addInitScript(MUTATION_OBSERVER_SCRIPT); cdp = await page.context().newCDPSession(page); @@ -653,6 +711,10 @@ export async function record(opts: RecordOptions): Promise { const eventLog: EventLog = { version: 0, + // clock declaration (schema): event `t` shares the frame t_source timeline. + // The render stage keys its skew/health gates off this marker — never off + // the capture's frame rate — so a starved take can't pass as "legacy". + t_source_unified: true, viewport: { width: VIEWPORT.width, height: VIEWPORT.height, dpr: DPR }, fps: FPS, events, @@ -665,5 +727,15 @@ export async function record(opts: RecordOptions): Promise { frameIndex.sort((a, b) => a.t_source - b.t_source); writeFileSync(join(outDir, "frames-index.json"), JSON.stringify(frameIndex)); - return { eventLog, frameCount: frameIndex.length, failedScenes, aborted, outDir }; + // capture-health telemetry: frames per second of take time. The span uses + // BOTH clocks (last frame t_source and last event t) so a capture that + // stalled early — few frames, but a long event timeline — reads as sparse + // instead of hiding behind its own short frame span. + let maxEventT = 0; + for (const e of events) maxEventT = Math.max(maxEventT, e.t); + const lastFrameT = frameIndex.length ? frameIndex[frameIndex.length - 1]!.t_source : 0; + const spanMs = Math.max(lastFrameT, maxEventT); + const avgSourceFps = spanMs > 0 ? (frameIndex.length / spanMs) * 1000 : 0; + + return { eventLog, frameCount: frameIndex.length, avgSourceFps, failedScenes, aborted, outDir }; } diff --git a/src/cli/index.ts b/src/cli/index.ts index cf2707e..8e5ea48 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,7 +11,7 @@ import { doctor } from "./doctor.js"; * supercut doctor check deps */ -const HELP = `supercut — institutional-grade 60s launch videos from your real app +const HELP = `supercut — an AI director that films your real app into a cinematic 60s launch video Usage: supercut generate --url [--repo ] [--music ] @@ -21,6 +21,17 @@ Usage: Run any command with --help for details.`; +/** parseArgs throws a raw Node error on a bare positional (`supercut generate + * https://app`) unless allowPositionals is set — accept them in the parse, + * then reject with the usage line and a hint instead of a stack trace. */ +function rejectPositionals(positionals: string[], usage: string): boolean { + if (positionals.length === 0) return false; + const p0 = positionals[0]!; + const hint = /^https?:\/\//.test(p0) ? ` (did you mean --url ${p0}?)` : ""; + console.error(`unexpected argument "${p0}"${hint}\n${usage}`); + return true; +} + async function main(): Promise { const [command, ...rest] = process.argv.slice(2); @@ -36,8 +47,9 @@ async function main(): Promise { "usage: supercut record --recipe [--out ] [--seed ] [--block-private-network]"; // help is a real parsed boolean, not a substring scan — so a --help that // is actually the VALUE of another flag can't hijack the command. - const { values } = parseArgs({ + const { values, positionals } = parseArgs({ args: rest, + allowPositionals: true, options: { recipe: { type: "string" }, out: { type: "string" }, @@ -51,6 +63,7 @@ async function main(): Promise { console.log(recordUsage); return 0; } + if (rejectPositionals(positionals, recordUsage)) return 1; if (!values.recipe) { console.error(recordUsage); return 1; @@ -77,7 +90,8 @@ async function main(): Promise { } const res = await record({ recipe, outDir, seed, allowPrivateNetwork: !values["block-private-network"] }); console.log( - `done in ${((Date.now() - t0) / 1000).toFixed(1)}s — ${res.frameCount} frames, ` + + `done in ${((Date.now() - t0) / 1000).toFixed(1)}s — ${res.frameCount} frames ` + + `(avg ${res.avgSourceFps.toFixed(1)} fps source), ` + `${res.eventLog.events.length} events` + (res.failedScenes.length ? `, FAILED scenes: ${res.failedScenes.join(", ")}` : ""), ); @@ -89,8 +103,9 @@ async function main(): Promise { "[--bg cobalt|glacier|sunrise|daydream|magenta|coral|lavender|aurora|midnight|dusk|paper|] " + "[--music ]"; // help is a real parsed boolean (see record) — no substring scan - const { values } = parseArgs({ + const { values, positionals } = parseArgs({ args: rest, + allowPositionals: true, options: { take: { type: "string" }, out: { type: "string" }, @@ -103,6 +118,7 @@ async function main(): Promise { console.log(renderUsage); return 0; } + if (rejectPositionals(positionals, renderUsage)) return 1; if (!values.take) { console.error(renderUsage); return 1; @@ -128,11 +144,12 @@ async function main(): Promise { const generateUsage = "usage: supercut generate --url [--repo ] [--app ] [--out ] " + "[--bg ] [--music ] [--seed ] [--model ] " + - "[--env-file ] [--max-tokens ] " + + "[--env-file ] [--max-tokens ] [--dry-run] [--skip-preflight] " + "[--block-private-network] [--allow-destructive] [--no-vision] [--yes]"; // help is a real parsed boolean (see record) — no substring scan - const { values } = parseArgs({ + const { values, positionals } = parseArgs({ args: rest, + allowPositionals: true, options: { url: { type: "string" }, repo: { type: "string" }, @@ -147,6 +164,12 @@ async function main(): Promise { // hard LLM spend ceiling for the whole run (SUPERCUT_MAX_TOKENS env); // 0 or "off" disables, default 300000 "max-tokens": { type: "string" }, + // preview: analyze + script only; print every action (incl. typed + // text), write recipe.json, and stop before capture touches the app + "dry-run": { type: "boolean" }, + // skip the HTTP reachability probe (bare fetch, no browser UA) — + // for apps it misjudges; the ffmpeg + URL policy checks still run + "skip-preflight": { type: "boolean" }, help: { type: "boolean", short: "h" }, // private/localhost is ALLOWED BY DEFAULT — filming your own local // dev app is the #1 use case. --block-private-network opts into the @@ -165,6 +188,7 @@ async function main(): Promise { console.log(generateUsage); return 0; } + if (rejectPositionals(positionals, generateUsage)) return 1; if (!values.url) { console.error(generateUsage); return 1; @@ -177,7 +201,7 @@ async function main(): Promise { ); } const { loadDotEnv, resolveProvider } = await import("../director/config.js"); - const { generate } = await import("../director/generate.js"); + const { dryRunFollowUpCommand, generate } = await import("../director/generate.js"); const envLoad = loadDotEnv(values["env-file"] ?? ".env"); // L2: a missing .env is fine (reason "not found"), but a file that EXISTED // and failed to PARSE is a real error — surface it even without verbose so @@ -243,7 +267,19 @@ async function main(): Promise { // default OFF; --allow-destructive opts into filming destructive controls allowDestructive: !!values["allow-destructive"], ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(values["dry-run"] ? { dryRun: true } : {}), + ...(values["skip-preflight"] ? { skipPreflight: true } : {}), }); + if (values["dry-run"]) { + // the suggested command must preserve the security posture of THIS + // run: record defaults to allowing private networks, so a dry run + // made under --block-private-network has to say so in the follow-up + const followUp = dryRunFollowUpCommand(values.out ?? "out/generate", { + blockPrivateNetwork: !!values["block-private-network"], + }); + console.log(`\nsupercut: dry run complete — review the recipe, then film it with:\n ${followUp}`); + return 0; + } console.log(`\nsupercut: ${res.outFile} (${res.recipe.scenes.length} scenes, ${res.retakes} re-take(s))`); return 0; } @@ -258,10 +294,15 @@ async function main(): Promise { } } +// process.exitCode, not process.exit(): an explicit exit() can truncate +// buffered stdout when the CLI's output is piped — set the code and let the +// process drain and exit on its own (all servers/browsers are closed by now) main().then( - (code) => process.exit(code), + (code) => { + process.exitCode = code; + }, (err) => { console.error(err instanceof Error ? err.message : err); - process.exit(1); + process.exitCode = 1; }, ); diff --git a/src/director/analyze.ts b/src/director/analyze.ts index 97d09d8..d9c79a8 100644 --- a/src/director/analyze.ts +++ b/src/director/analyze.ts @@ -6,7 +6,7 @@ * Invalid output bounces back with the validation error (max 3 attempts). */ import { z } from "zod"; -import { extractJson, type ChatPart, type LlmClient } from "./llm.js"; +import { extractJson, UNTRUSTED_RULES, wrapUntrusted, type ChatPart, type LlmClient } from "./llm.js"; import type { PageDigest } from "./inventory.js"; import { redactForPrompt } from "../security/redaction.js"; @@ -156,6 +156,8 @@ Write the story as a problem → solution → payoff arc: Prefer beats with visible payoff (something appears, changes, completes). The "title" field stays a short internal label; the "caption" is the on-screen copy and must be benefit-framed. +${UNTRUSTED_RULES} + Each inventory line is: \`\` [tag] "text". In "elements", copy ONLY the exact text INSIDE the backticks — never the [tag] or the "text" that follows it. Respond ONLY with a JSON object matching: { "product_summary": string, "product_name": string, "headline": string, "tagline": string, "music_track": "pulse"|"daybreak"|"midnight"|"momentum", "money_moments": [{ "title": string, "caption": string, "why": string, "page_url": string (one crawled URL), "elements": [selectors copied from between the backticks] }] }`; @@ -164,11 +166,15 @@ export async function analyzeApp( digests: PageDigest[], repoNotes?: string, ): Promise { + // page-derived text (and repo notes: README/source strings are just as + // attacker-writable) travels inside the untrusted markers the system prompt + // declares — delimited data, never instruction const textPart: ChatPart = { type: "text", - text: + text: wrapUntrusted( (repoNotes ? `REPO NOTES:\n${repoNotes.slice(0, 4000)}\n\n` : "") + - digests.map(digestText).join("\n\n"), + digests.map(digestText).join("\n\n"), + ), }; const imageParts: ChatPart[] = []; for (const d of digests) { diff --git a/src/director/config.ts b/src/director/config.ts index c68f29d..0a1ba31 100644 --- a/src/director/config.ts +++ b/src/director/config.ts @@ -31,6 +31,9 @@ export interface ResolvedProvider { model: string; baseUrl: string; vision: boolean; + /** which env var supplied the credential (e.g. "DEEPSEEK_API_KEY") — + * surfaced in the summary so a user always sees which key is being sent */ + keySource: string; summary: string; } @@ -82,10 +85,29 @@ export function resolveProvider( ); } - const apiKey = - provider === "deepseek" ? env.DEEPSEEK_API_KEY || env.SUPERCUT_API_KEY || "" : - provider === "openrouter" ? env.OPENROUTER_API_KEY || env.SUPERCUT_API_KEY || "" : - env.SUPERCUT_API_KEY || env.DEEPSEEK_API_KEY || env.OPENROUTER_API_KEY || ""; + let apiKey: string; + let keySource: string; + if (provider === "deepseek") { + apiKey = env.DEEPSEEK_API_KEY || env.SUPERCUT_API_KEY || ""; + keySource = env.DEEPSEEK_API_KEY ? "DEEPSEEK_API_KEY" : "SUPERCUT_API_KEY"; + } else if (provider === "openrouter") { + apiKey = env.OPENROUTER_API_KEY || env.SUPERCUT_API_KEY || ""; + keySource = env.OPENROUTER_API_KEY ? "OPENROUTER_API_KEY" : "SUPERCUT_API_KEY"; + } else { + // custom endpoints take SUPERCUT_API_KEY ONLY. A provider-scoped key must + // never fall through here: SUPERCUT_LLM_BASE_URL is an arbitrary + // user-supplied host, and a leftover DEEPSEEK_API_KEY in a .env or shell + // profile would be sent to it as a bearer token the user never intended + // to share. Refuse loudly instead of silently borrowing a credential. + apiKey = env.SUPERCUT_API_KEY || ""; + keySource = "SUPERCUT_API_KEY"; + if (!apiKey && (env.DEEPSEEK_API_KEY || env.OPENROUTER_API_KEY)) { + throw new Error( + "SUPERCUT_API_KEY is required when SUPERCUT_PROVIDER=custom — provider-scoped keys " + + "(DEEPSEEK_API_KEY / OPENROUTER_API_KEY) are never sent to a custom endpoint", + ); + } + } if (!apiKey) throw new Error(`no API key found for provider ${provider}`); const baseUrl = overrides.baseUrl ?? env.SUPERCUT_LLM_BASE_URL ?? ( @@ -119,7 +141,8 @@ export function resolveProvider( model, baseUrl, vision, - summary: `${client.label} @ ${baseUrl} · vision ${vision ? "on" : "off (DOM-only)"}`, + keySource, + summary: `${client.label} @ ${baseUrl} · key from ${keySource} · vision ${vision ? "on" : "off (DOM-only)"}`, }; } diff --git a/src/director/generate.ts b/src/director/generate.ts index 284fae3..b61ef57 100644 --- a/src/director/generate.ts +++ b/src/director/generate.ts @@ -15,12 +15,12 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { join } from "node:path"; import { promisify } from "node:util"; import { record, type RecordResult } from "../capture/index.js"; -import { renderTake, resolveMusicTrack } from "../render/index.js"; +import { assessCaptureHealth, renderTake, resolveMusicTrack } from "../render/index.js"; import type { Recipe } from "../schema/index.js"; import { analyzeApp, type AppAnalysis } from "./analyze.js"; import { crawlApp, type PageDigest } from "./inventory.js"; import { BudgetedLlmClient, type LlmClient } from "./llm.js"; -import { applyVerdicts, deterministicChecks, visionQc, type SceneVerdict } from "./qc.js"; +import { AllScenesCutError, applyVerdicts, deterministicChecks, visionQc, type SceneVerdict } from "./qc.js"; import { writeRecipe } from "./script.js"; import { assertSafeNavigationUrl, urlResolvesPrivate } from "../security/url-policy.js"; import { redactForPrompt } from "../security/redaction.js"; @@ -68,10 +68,20 @@ export interface GenerateOptions { /** hard cumulative token ceiling for the run's LLM calls (prompt+completion, * provider-reported). 0 disables. Default: 300000. */ maxTokens?: number; + /** preview mode: run analyze + script, print the FULL action list (every + * selector, every typed string), write recipe.json — and stop before the + * capture browser ever touches the app. The recipe can be reviewed and then + * filmed with `supercut record --recipe /recipe.json`. */ + dryRun?: boolean; + /** skip the preflight HTTP reachability probe. Escape hatch for apps the + * bare-fetch probe misjudges (aggressive UA gating, unusual status codes at + * `/`); the ffmpeg check and all URL policy checks still run. */ + skipPreflight?: boolean; log?: (msg: string) => void; } export interface GenerateResult { + /** empty string on a --dry-run (nothing was filmed or rendered) */ outFile: string; recipe: Recipe; analysis: AppAnalysis; @@ -79,7 +89,12 @@ export interface GenerateResult { verdictLog: SceneVerdict[][]; } -async function preflight(url: string, allowPrivateNetwork: boolean): Promise { +export async function preflight( + url: string, + allowPrivateNetwork: boolean, + opts: { skipReachability?: boolean; skipRenderDeps?: boolean; log?: (msg: string) => void } = {}, +): Promise { + const log = opts.log ?? ((m: string) => console.error(`[generate] ${m}`)); // app reachable — error in seconds, never after 10 minutes of work. // Follow redirects MANUALLY and validate EVERY hop BEFORE the request: a // default `fetch` follows 3xx automatically, so a public URL that 302s to @@ -87,34 +102,62 @@ async function preflight(url: string, allowPrivateNetwork: boolean): Promise ctrl.abort(), 5000); - try { - let current = url; - let status = 0; - for (let hop = 0; hop < 6; hop++) { - await assertSafeNavigationUrl(current, { allowPrivateNetwork }); - let res: Response; - try { - res = await fetch(current, { signal: ctrl.signal, redirect: "manual" }); - } catch (err) { + if (!opts.skipReachability) { + // even when the probe is skipped, the URL itself is still policy-checked + // by the caller and the crawl; here it gates the probe's own fetch + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 5000); + try { + let current = url; + let status = 0; + for (let hop = 0; hop < 6; hop++) { + await assertSafeNavigationUrl(current, { allowPrivateNetwork }); + let res: Response; + try { + res = await fetch(current, { signal: ctrl.signal, redirect: "manual" }); + } catch (err) { + throw new Error( + `preflight: cannot reach ${current} — is the app running? (${err instanceof Error ? err.message : err})`, + ); + } + status = res.status; + const loc = res.headers.get("location"); + if (status >= 300 && status < 400 && loc) { + current = new URL(loc, current).href; + continue; + } + break; + } + // 401/403 at the root is NORMAL for the "film your own dev app" case — + // basic auth, a dev proxy, an SSO shim, an API-first backend. And this + // probe is a bare Node fetch (no browser UA, no cookies) while the crawl + // is Chromium, so a UA-gating edge can 403 a URL Chromium loads fine. + // Warn and continue; if the wall is real the crawl shows it in seconds. + // Everything else >= 400 is as doomed as it looks (a 404/410/5xx start + // page films as an error screen) and used to surface only deep in the + // crawl — fail here instead. --skip-preflight overrides the whole probe. + if (status === 401 || status === 403) { + log( + `preflight warning: ${url} responded ${status} — continuing (auth walls at the root are ` + + `normal for private dev apps, and this probe carries no browser UA or cookies). ` + + `If the whole app is behind that wall, the crawl will come back empty.`, + ); + } else if (status >= 400) { throw new Error( - `preflight: cannot reach ${current} — is the app running? (${err instanceof Error ? err.message : err})`, + `app at ${url} responded ${status} — point --url at a page that loads, ` + + `or pass --skip-preflight if you know better`, ); } - status = res.status; - const loc = res.headers.get("location"); - if (status >= 300 && status < 400 && loc) { - current = new URL(loc, current).href; - continue; - } - break; + if (status >= 300 && status < 400) throw new Error(`preflight: ${url} kept redirecting (loop?)`); + } finally { + clearTimeout(timer); } - if (status >= 500) throw new Error(`app at ${url} responded ${status}`); - if (status >= 300 && status < 400) throw new Error(`preflight: ${url} kept redirecting (loop?)`); - } finally { - clearTimeout(timer); } + // (review) recipe preview must not need the render toolchain: --dry-run + // stops after analyze + script, so nothing is filmed or rendered and a + // machine without ffmpeg can still produce and review a recipe. The URL + // policy and reachability checks above still ran. + if (opts.skipRenderDeps) return; try { await exec("ffmpeg", ["-version"]); } catch { @@ -172,6 +215,48 @@ export function pickMusic( } } +/** + * Human-readable action list for a recipe — one line per action, including + * every `type` string and submit flag. Printed before capture on every run + * (and as the payload of --dry-run) so the operator can see exactly what the + * director is about to do to the live app; a prompt-injected `type` payload + * has to survive being shown to a human first. + */ +export function formatRecipePreview(recipe: Recipe): string[] { + const lines: string[] = []; + for (const [i, scene] of recipe.scenes.entries()) { + lines.push( + `scene ${i + 1} "${scene.name}" @ ${scene.entry.url}` + + (scene.depends_on.length ? ` (after ${scene.depends_on.join(", ")})` : ""), + ); + for (const a of [...scene.entry.prelude, ...scene.actions]) { + let desc = a.kind as string; + if (a.kind === "goto" && a.url) desc += ` ${a.url}`; + if (a.selector) desc += ` ${a.selector}`; + if (a.kind === "type") desc += ` "${a.text ?? ""}"${a.submit ? " then press Enter" : ""}`; + desc += ` (${a.duration_ms}ms${a.focus_selector ? `, focus ${a.focus_selector}` : ""})`; + lines.push(` · ${desc}`); + } + if (scene.hold_ms > 0) lines.push(` · hold ${scene.hold_ms}ms`); + } + return lines; +} + +/** + * The follow-up command a --dry-run tells the user to copy. Flags that set + * record's SECURITY posture must survive the copy-paste: `record` allows + * private networks by default, so a recipe generated under + * --block-private-network must carry the flag into the suggested line — the + * user who asked for the guard and then runs exactly what the tool printed + * must not silently lose it. + */ +export function dryRunFollowUpCommand(outDir: string, opts: { blockPrivateNetwork?: boolean } = {}): string { + return ( + `supercut record --recipe ${join(outDir, "recipe.json")}` + + (opts.blockPrivateNetwork ? " --block-private-network" : "") + ); +} + function repoNotes(repoPath: string): string | undefined { for (const f of ["README.md", "readme.md", "package.json"]) { const p = join(repoPath, f); @@ -193,12 +278,33 @@ export async function generate(opts: GenerateOptions): Promise { // every LLM call in the run goes through the budget guard (analyze, script, // and vision QC all receive this wrapper) — no stage can spend past the cap const llm = new BudgetedLlmClient(opts.llm, budget); + // spend summary. The budget is ENFORCED against meteredTokens (provider- + // reported where available, locally estimated where not), so that is the + // headline number; on a mixed-reporting provider a diverging provider total + // is shown alongside instead of silently replacing the enforced one — + // "unavailable" only when nothing was called at all. + const usageLine = (): string => { + const metered = llm.meteredTokens; + const reported = llm.tokensUsed; + if (metered <= 0) return reported !== undefined ? `~${reported} tokens (${llm.breakdown()})` : "unavailable"; + if (reported === undefined) { + return `~${metered} tokens (locally estimated — provider reported no usage; ${llm.breakdown()})`; + } + if (reported === metered) return `~${reported} tokens (${llm.breakdown()})`; + return `~${metered} tokens metered against the budget (provider reported ${reported}; ${llm.breakdown()})`; + }; mkdirSync(opts.outDir, { recursive: true }); log("preflight…"); // a bad --music must die here, not after the LLM crawl and capture spend resolveMusicTrack(opts.music); - await preflight(opts.url, opts.allowPrivateNetwork ?? true); + if (opts.skipPreflight) log(" note: --skip-preflight — not probing the app URL before the crawl"); + await preflight(opts.url, opts.allowPrivateNetwork ?? true, { + ...(opts.skipPreflight ? { skipReachability: true } : {}), + // dry runs never render — don't fail the preview on a missing ffmpeg + ...(opts.dryRun ? { skipRenderDeps: true } : {}), + log: (m) => log(` ${m}`), + }); if ((opts.allowPrivateNetwork ?? true) && !(await urlResolvesPrivate(opts.url))) { log("hint: target resolves to a public address — pass --block-private-network when filming untrusted targets"); } @@ -263,6 +369,24 @@ export async function generate(opts: GenerateOptions): Promise { llm.stage = "script"; const { recipe: firstRecipe, attempts } = await writeRecipe(llm, analysis, digests, opts.url); log(` recipe valid after ${attempts} attempt(s): ${firstRecipe.scenes.length} scenes`); + // full action preview BEFORE the capture browser touches the app — every + // selector and every typed string is on the record for the operator + for (const line of formatRecipePreview(firstRecipe)) log(` ${line}`); + + if (opts.dryRun) { + writeFileSync(join(opts.outDir, "recipe.json"), JSON.stringify(firstRecipe, null, 2)); + writeFileSync( + join(opts.outDir, "director-report.json"), + JSON.stringify( + { analysis, recipe: firstRecipe, retakes: 0, verdictLog: [], llm: opts.llm.label, dryRun: true }, + null, + 2, + ), + ); + log(`LLM usage: ${usageLine()}`); + log(`dry run: recipe written to ${join(opts.outDir, "recipe.json")} — nothing was filmed`); + return { outFile: "", recipe: firstRecipe, analysis, retakes: 0, verdictLog: [] }; + } let recipe = firstRecipe; let retakes = 0; @@ -275,11 +399,37 @@ export async function generate(opts: GenerateOptions): Promise { rmSync(takeDir, { recursive: true, force: true }); log(`③ record: take ${retakes} (${recipe.scenes.length} scenes)…`); result = await record({ recipe, outDir: takeDir, seed: opts.seed ?? 1, allowPrivateNetwork: opts.allowPrivateNetwork ?? true }); + log(` captured ${result.frameCount} frames (avg ${result.avgSourceFps.toFixed(1)} fps source)`); if (result.aborted) { throw new Error( `capture aborted: scenes failed [${result.failedScenes.join(", ")}] — app state may not match the recipe`, ); } + // capture-health gate, BEFORE any QC spend: a starved capture (repaint + // beacon dead, page never committing frames) renders as a slideshow no + // amount of QC patching can save — fail here, not after vision tokens. + { + const rawIndex = JSON.parse(readFileSync(join(takeDir, "frames-index.json"), "utf8")); + // shape guard mirrors renderTake's: a non-array would make `.length` + // undefined and the sparse comparison silently false — gate passed. + // record() just wrote this file, so today it can't happen; the guard is + // for whatever writes it tomorrow. + if (!Array.isArray(rawIndex)) throw new Error("generate: frames-index.json is not an array"); + const health = assessCaptureHealth(result.eventLog, rawIndex); + if (health.action === "fail") { + if (process.env.SUPERCUT_ALLOW_SPARSE === "1") { + // LOUD, matching render/index.ts: someone who exported the variable + // once to salvage an old take must not keep generating starved + // videos with no line saying the health gate is off + console.error(`[generate] WARNING: ${health.reason} (continuing: SUPERCUT_ALLOW_SPARSE=1)`); + } else { + throw new Error( + `generate: ${health.reason}. The app may suspend rendering when headless, or the repaint ` + + `beacon failed to attach — try re-running; SUPERCUT_ALLOW_SPARSE=1 forces a render anyway.`, + ); + } + } + } log("④ qc: deterministic checks…"); const verdicts = deterministicChecks(result); @@ -296,7 +446,26 @@ export async function generate(opts: GenerateOptions): Promise { } for (const v of notOk) log(` ${v.verdict.toUpperCase()} "${v.scene}": ${v.reason}`); - const applied = applyVerdicts(recipe, verdicts); + let applied: ReturnType; + try { + applied = applyVerdicts(recipe, verdicts); + } catch (err) { + if (!(err instanceof AllScenesCutError)) throw err; + // Refusing to render an empty video is right; discarding a recorded, + // renderable take after the full crawl + both LLM stages + a complete + // capture is not. Preserve every artifact, then fail with the way out. + writeFileSync(join(opts.outDir, "recipe.json"), JSON.stringify(recipe, null, 2)); + writeFileSync( + join(opts.outDir, "director-report.json"), + JSON.stringify({ analysis, recipe, retakes, verdictLog, llm: opts.llm.label }, null, 2), + ); + throw new Error( + `QC cut every scene (${err.cut.join(", ")}) — refusing to render an empty video. ` + + `The recorded take is preserved at ${takeDir} (recipe.json and director-report.json ` + + `sit beside it); inspect the verdicts, and render it anyway with: ` + + `supercut render --take ${takeDir}`, + ); + } if (!applied.changed || retakes >= MAX_RETAKES) { if (retakes >= MAX_RETAKES) { log(` re-take budget exhausted (${MAX_RETAKES}) — proceeding with the take as recorded`); @@ -315,6 +484,13 @@ export async function generate(opts: GenerateOptions): Promise { } writeFileSync(join(opts.outDir, "recipe.json"), JSON.stringify(recipe, null, 2)); + // report + usage BEFORE render: runs that die in stage 5 used to be exactly + // the runs with no report and no spend line — the ones that need them most + writeFileSync( + join(opts.outDir, "director-report.json"), + JSON.stringify({ analysis, recipe, retakes, verdictLog, llm: opts.llm.label }, null, 2), + ); + log(`LLM usage: ${usageLine()}`); log("⑤ render…"); const outFile = join(opts.outDir, "final.mp4"); @@ -332,14 +508,5 @@ export async function generate(opts: GenerateOptions): Promise { }); log(`done: ${outFile} (${renderRes.frames} frames, ${(renderRes.encodedBytes / 1048576).toFixed(1)}MB, music ${music.label})`); - writeFileSync( - join(opts.outDir, "director-report.json"), - JSON.stringify({ analysis, recipe, retakes, verdictLog, llm: opts.llm.label }, null, 2), - ); - - // best-effort cost telemetry (the hard cap lives in BudgetedLlmClient) - const tokens = llm.tokensUsed; - log(`LLM usage: ${tokens !== undefined ? `~${tokens} tokens (${llm.breakdown()})` : "unavailable"}`); - return { outFile, recipe, analysis, retakes, verdictLog }; } diff --git a/src/director/inventory.ts b/src/director/inventory.ts index f060abd..956db8f 100644 --- a/src/director/inventory.ts +++ b/src/director/inventory.ts @@ -5,7 +5,7 @@ * construction: it fails the whitelist check and bounces back for retry. */ import { chromium, type Browser, type Page } from "playwright"; -import { assertSafeNavigationUrl, navigationRequestAllowed, resolveAndPinHost } from "../security/url-policy.js"; +import { assertSafeNavigationUrl, createRequestGate, gateWebSockets, resolveAndPinHost } from "../security/url-policy.js"; import { redactForPrompt } from "../security/redaction.js"; /** @@ -65,6 +65,26 @@ export interface PageDigest { const cssEscape = (s: string) => s.replace(/["\\]/g, "\\$&"); +/** + * Escape a raw id for the CSS IDENT position (`#id`). cssEscape above is + * enough inside quoted attribute selectors, but an id used as `#id` is an + * identifier: a dot, colon, comma, brackets, or a leading digit produce a + * wrong or invalid selector, and the `.catch(() => 0)` count probe then + * swallows the failure — the element vanishes from the inventory silently. + * Minimal CSS.escape: leading digit as a code-point escape, backslash-escape + * everything outside [-_a-zA-Z0-9\u00A0-\uFFFF]. + */ +export const cssIdent = (s: string): string => { + let out = ""; + for (let i = 0; i < s.length; i++) { + const ch = s[i]!; + if (i === 0 && ch >= "0" && ch <= "9") out += `\\3${ch} `; + else if (/[-_a-zA-Z0-9\u00A0-\uFFFF]/.test(ch)) out += ch; + else out += `\\${ch}`; + } + return out; +}; + /** ceiling on distinct :nth-match entries per duplicated base selector — six * rows are plenty to tell a switch-between-items story */ const MAX_SIBLINGS_PER_BASE = 6; @@ -149,7 +169,7 @@ async function collectRegions(page: Page): Promise { const id = await el.getAttribute("id").catch(() => null); const role = await el.getAttribute("role").catch(() => null); let selector: string; - if (id) selector = `#${id}`; + if (id) selector = `#${cssIdent(id)}`; else if (tag === "main") selector = "main"; else if (role) selector = `[role="${cssEscape(role)}"]`; else continue; // no stable handle — skip @@ -315,7 +335,7 @@ async function digestPage(page: Page, withScreenshot: boolean, allowDestructive // verification) and gives same-testid siblings a shared base that the // :nth-match pass below splits into distinct per-row entries. let selector: string; - if (id) selector = `#${id}`; + if (id) selector = `#${cssIdent(id)}`; else if (testid) selector = `[data-testid="${cssEscape(testid)}"]`; else if (aria) selector = `[aria-label="${cssEscape(aria)}"]`; else if (placeholder) selector = `[placeholder="${cssEscape(placeholder)}"]`; @@ -419,26 +439,34 @@ export async function crawlApp( const digests: PageDigest[] = []; const visited = new Set(); - // block downloads outright so a stray file link can't hang/crash the crawl + // guard ON: EVERY request type — navigation, fetch/XHR, , +`; + export interface DemoApp { url: string; close: () => Promise; @@ -209,6 +231,7 @@ export async function startDemoApp(port = 0): Promise { : req.url?.startsWith("/panel") ? PANEL : req.url?.startsWith("/fleet") ? FLEET : req.url?.startsWith("/overlay") ? OVERLAY + : req.url?.startsWith("/probe") ? PROBE : LANDING; res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(body); diff --git a/test/generate.e2e.test.ts b/test/generate.e2e.test.ts index d989e41..7cec713 100644 --- a/test/generate.e2e.test.ts +++ b/test/generate.e2e.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -257,6 +257,105 @@ describe("generate E2E (stubbed brain, real pipeline)", () => { expect(await probeStreams(res.outFile)).toEqual(["video:h264"]); }, 300_000); + it("preserves the recorded take + artifacts when QC cuts every scene (M4)", async () => { + const outDir = mkdtempSync(join(tmpdir(), "supercut-allcut-")); + dirs.push(outDir); + const llm = new ScriptedLlm(() => [ + JSON.stringify({ + product_summary: "Lumon Metrics: a dashboard product with instant signup and live metrics.", + product_name: "Lumon", + headline: "Your team's numbers, live in seconds", + tagline: "Metrics without the setup", + music_track: "daybreak", + money_moments: [ + { title: "Zero-friction signup", caption: "Start in one click", why: "form appears instantly", page_url: `${app.url}/`, elements: ["#cta"] }, + { title: "Live dashboard", caption: "Watch the numbers move", why: "numbers count up live", page_url: `${app.url}/dash`, elements: ["#task-ship"] }, + ], + }), + JSON.stringify({ + version: 0, + app_url: app.url, + music_track: "daybreak", + scenes: [ + { name: "signup", priority: 1, entry: { url: `${app.url}/`, prelude: [] }, depends_on: [], + actions: [{ kind: "click", selector: "#cta", duration_ms: 900 }], hold_ms: 0 }, + { name: "dashboard", priority: 2, entry: { url: `${app.url}/dash`, prelude: [] }, depends_on: [], + actions: [{ kind: "hover", selector: "#task-ship", duration_ms: 900 }], hold_ms: 0 }, + ], + }), + // ④ vision QC condemns everything + JSON.stringify({ + verdicts: [ + { scene: "signup", verdict: "cut", reason: "blank frame" }, + { scene: "dashboard", verdict: "cut", reason: "error page" }, + ], + }), + ]); + + await expect( + generate({ llm, url: app.url, outDir, seed: 7, allowPrivateNetwork: true, log: () => {} }), + ).rejects.toThrow(/QC cut every scene.*preserved at/s); + + // the take survived, and the run left enough on disk to debug + render it + expect(existsSync(join(outDir, "take-0", "events.json"))).toBe(true); + expect(existsSync(join(outDir, "take-0", "frames-index.json"))).toBe(true); + expect(existsSync(join(outDir, "recipe.json"))).toBe(true); + const report = JSON.parse(readFileSync(join(outDir, "director-report.json"), "utf8")); + expect(report.verdictLog.flat().filter((v: { verdict: string }) => v.verdict === "cut")).toHaveLength(2); + expect(existsSync(join(outDir, "final.mp4"))).toBe(false); + }, 300_000); + + it("--dry-run writes the recipe + preview and never films (H6)", async () => { + const outDir = mkdtempSync(join(tmpdir(), "supercut-dry-")); + dirs.push(outDir); + const llm = new ScriptedLlm(() => [ + JSON.stringify({ + product_summary: "Lumon Metrics: a dashboard product with instant signup and live metrics.", + product_name: "Lumon", + headline: "Your team's numbers, live in seconds", + tagline: "Metrics without the setup", + music_track: "daybreak", + money_moments: [ + { title: "Zero-friction signup", caption: "Start in one click", why: "form appears instantly", page_url: `${app.url}/`, elements: ["#cta", "#email"] }, + { title: "Live dashboard", caption: "Watch the numbers move", why: "numbers count up live", page_url: `${app.url}/dash`, elements: ["#task-ship"] }, + ], + }), + JSON.stringify({ + version: 0, + app_url: app.url, + music_track: "daybreak", + scenes: [ + { name: "signup", priority: 1, entry: { url: `${app.url}/`, prelude: [] }, depends_on: [], + actions: [ + { kind: "click", selector: "#cta", duration_ms: 1500 }, + { kind: "type", selector: "#email", text: "ada@lumon.dev", submit: true, duration_ms: 1800 }, + ], hold_ms: 400 }, + { name: "dashboard", priority: 2, entry: { url: `${app.url}/dash`, prelude: [] }, depends_on: [], + actions: [{ kind: "hover", selector: "#task-ship", duration_ms: 1400 }], hold_ms: 400 }, + ], + }), + ]); + + const logs: string[] = []; + const res = await generate({ + llm, url: app.url, outDir, seed: 7, dryRun: true, + allowPrivateNetwork: true, log: (m) => logs.push(m), + }); + + // nothing filmed, nothing rendered — but the recipe artifact exists + expect(res.outFile).toBe(""); + expect(llm.calls).toBe(2); // analyze + script only, no QC + expect(existsSync(join(outDir, "recipe.json"))).toBe(true); + expect(existsSync(join(outDir, "take-0"))).toBe(false); + expect(existsSync(join(outDir, "final.mp4"))).toBe(false); + const report = JSON.parse(readFileSync(join(outDir, "director-report.json"), "utf8")); + expect(report.dryRun).toBe(true); + // the preview surfaces every action, including the full typed text + Enter + const preview = logs.join("\n"); + expect(preview).toContain('type #email "ada@lumon.dev" then press Enter'); + expect(preview).toContain("click #cta"); + }, 120_000); + it("fails fast on an unreachable app URL (before any LLM call)", async () => { const llm = new ScriptedLlm(() => []); await expect( @@ -265,3 +364,66 @@ describe("generate E2E (stubbed brain, real pipeline)", () => { expect(llm.calls).toBe(0); }, 30_000); }); + +describe("preflight status handling", () => { + /** serves /s/ with that status (and a tiny html body) */ + let statusServer: { url: string; close: () => Promise }; + + beforeAll(async () => { + const { createServer } = await import("node:http"); + const srv = createServer((req, res) => { + const code = Number(/^\/s\/(\d{3})/.exec(req.url ?? "")?.[1] ?? 200); + res.writeHead(code, { "content-type": "text/html; charset=utf-8" }); + res.end("

status page

"); + }); + await new Promise((r) => srv.listen(0, "127.0.0.1", r)); + const { port } = srv.address() as { port: number }; + statusServer = { + url: `http://127.0.0.1:${port}`, + close: () => new Promise((r) => srv.close(() => r())), + }; + }); + + afterAll(async () => { + await statusServer.close(); + }); + + function tryGenerate(url: string, extra: { skipPreflight?: boolean } = {}) { + const logs: string[] = []; + const llm = new ScriptedLlm(() => []); + const outDir = mkdtempSync(join(tmpdir(), "supercut-preflight-")); + dirs.push(outDir); + const run = generate({ + llm, url, outDir, allowPrivateNetwork: true, vision: false, + log: (m) => logs.push(m), ...extra, + }); + return { run, logs, llm }; + } + + it("404, 410, and 5xx roots are fatal before any LLM call", async () => { + for (const code of [404, 410, 500, 503]) { + const { run, llm } = tryGenerate(`${statusServer.url}/s/${code}`); + await expect(run).rejects.toThrow(new RegExp(`responded ${code}`)); + expect(llm.calls).toBe(0); + } + }, 60_000); + + it("401/403 warn and CONTINUE — an auth wall at the root must not block filming your own app", async () => { + for (const code of [401, 403]) { + const { run, logs } = tryGenerate(`${statusServer.url}/s/${code}`); + // getting PAST preflight means the run dies later, in analyze, when the + // deliberately-empty scripted LLM runs out — not on a preflight error + await expect(run).rejects.toThrow(/scripted LLM exhausted/); + const all = logs.join("\n"); + expect(all).toMatch(new RegExp(`preflight warning: .*responded ${code}`)); + } + }, 120_000); + + it("--skip-preflight bypasses the reachability probe entirely (escape hatch)", async () => { + // a 500 root would be fatal — with the override the run proceeds to the + // crawl and dies in analyze instead, proving the probe never gated it + const { run, logs } = tryGenerate(`${statusServer.url}/s/500`, { skipPreflight: true }); + await expect(run).rejects.toThrow(/scripted LLM exhausted/); + expect(logs.join("\n")).toContain("--skip-preflight"); + }, 120_000); +}); diff --git a/test/health-gate.e2e.test.ts b/test/health-gate.e2e.test.ts new file mode 100644 index 0000000..0004ed9 --- /dev/null +++ b/test/health-gate.e2e.test.ts @@ -0,0 +1,154 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { generate } from "../src/director/generate.js"; +import { record } from "../src/capture/index.js"; +import type { ChatOptions, LlmClient } from "../src/director/llm.js"; +import type { EventLog } from "../src/schema/index.js"; +import { startDemoApp, type DemoApp } from "./fixtures/demo-app/server.js"; + +/** + * WIRING coverage for the capture-health gate at its generate call site. + * assessCaptureHealth is well covered as a unit and through renderTake; this + * file proves the call BETWEEN record and QC actually fires — by mocking + * record() to hand back a starved take (the one thing a healthy fixture can + * never produce) and running the real generate pipeline into it. + */ + +vi.mock("../src/capture/index.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, record: vi.fn(actual.record) }; +}); + +let app: DemoApp; +const dirs: string[] = []; + +class ScriptedLlm implements LlmClient { + readonly label = "scripted"; + calls = 0; + constructor(private makeResponses: () => string[]) {} + private responses: string[] | null = null; + async chat(_opts: ChatOptions): Promise { + this.responses ??= this.makeResponses(); + this.calls++; + const next = this.responses.shift(); + if (next === undefined) throw new Error("scripted LLM exhausted"); + return next; + } +} + +beforeAll(async () => { + app = await startDemoApp(); +}, 30_000); + +afterAll(async () => { + await app.close(); + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +afterEach(() => { + delete process.env.SUPERCUT_ALLOW_SPARSE; + vi.restoreAllMocks(); +}); + +/** analyze + script responses against the real crawled fixture inventory */ +function scriptedBrain(): ScriptedLlm { + return new ScriptedLlm(() => [ + JSON.stringify({ + product_summary: "Lumon Metrics: a dashboard product with instant signup and live metrics.", + product_name: "Lumon", + headline: "Your team's numbers, live in seconds", + tagline: "Metrics without the setup", + music_track: "daybreak", + money_moments: [ + { title: "Zero-friction signup", caption: "Start in one click", why: "form appears instantly", page_url: `${app.url}/`, elements: ["#cta"] }, + { title: "Live dashboard", caption: "Watch the numbers move", why: "numbers count up live", page_url: `${app.url}/dash`, elements: ["#task-ship"] }, + ], + }), + JSON.stringify({ + version: 0, + app_url: app.url, + music_track: "daybreak", + scenes: [ + { name: "signup", priority: 1, entry: { url: `${app.url}/`, prelude: [] }, depends_on: [], + actions: [{ kind: "click", selector: "#cta", duration_ms: 900 }], hold_ms: 0 }, + { name: "dashboard", priority: 2, entry: { url: `${app.url}/dash`, prelude: [] }, depends_on: [], + actions: [{ kind: "hover", selector: "#task-ship", duration_ms: 900 }], hold_ms: 0 }, + ], + }), + ]); +} + +/** swap record() for a stub that writes a STARVED take: 3 frames across a + * 40-second event timeline — the shape a dead repaint beacon produces */ +function stubSparseRecord(failedScenes: string[]): void { + vi.mocked(record).mockImplementation(async (opts) => { + mkdirSync(join(opts.outDir, "frames"), { recursive: true }); + const frameIndex = [ + { file: "frames/000000.png", t_source: 0 }, + { file: "frames/000001.png", t_source: 100 }, + { file: "frames/000002.png", t_source: 200 }, + ]; + const eventLog: EventLog = { + version: 0, + t_source_unified: true, + viewport: { width: 1920, height: 1080, dpr: 2 }, + fps: 60, + events: [ + { t: 0, type: "scene", name: "signup", priority: 1 }, + { t: 20_000, type: "scene", name: "dashboard", priority: 2 }, + { t: 40_000, type: "click", bbox: [10, 10, 50, 20], selector: "#x", point: [20, 20] }, + ], + }; + writeFileSync(join(opts.outDir, "events.json"), JSON.stringify(eventLog, null, 2)); + writeFileSync(join(opts.outDir, "frames-index.json"), JSON.stringify(frameIndex)); + return { + eventLog, + frameCount: frameIndex.length, + avgSourceFps: (frameIndex.length / 40_000) * 1000, + failedScenes, + aborted: false, + outDir: opts.outDir, + }; + }); +} + +describe("generate-path capture-health gate wiring (H1)", () => { + it("refuses a starved take right after record, before any QC", async () => { + const outDir = mkdtempSync(join(tmpdir(), "supercut-health-fail-")); + dirs.push(outDir); + stubSparseRecord([]); + + const llm = scriptedBrain(); + await expect( + generate({ llm, url: app.url, outDir, vision: false, allowPrivateNetwork: true, log: () => {} }), + ).rejects.toThrow(/generate: capture is sparse.*SUPERCUT_ALLOW_SPARSE=1/s); + + expect(vi.mocked(record)).toHaveBeenCalledTimes(1); + // analyze + script only — the run died at the gate, before QC or render + expect(llm.calls).toBe(2); + expect(existsSync(join(outDir, "final.mp4"))).toBe(false); + }, 120_000); + + it("SUPERCUT_ALLOW_SPARSE=1 bypasses the gate LOUDLY and the run continues into QC", async () => { + const outDir = mkdtempSync(join(tmpdir(), "supercut-health-bypass-")); + dirs.push(outDir); + // every scene failed at capture, so the (bypassed) gate is followed by a + // deterministic all-cut — a cheap, hermetic proof the pipeline got PAST + // the health check rather than dying on it + stubSparseRecord(["signup", "dashboard"]); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.env.SUPERCUT_ALLOW_SPARSE = "1"; + + const llm = scriptedBrain(); + await expect( + generate({ llm, url: app.url, outDir, vision: false, allowPrivateNetwork: true, log: () => {} }), + ).rejects.toThrow(/QC cut every scene/); // NOT the sparse error + + // the bypass printed the same WARNING shape the render path prints — + // a silently disabled gate is H1's failure mode back through the opt-out + const errOutput = errSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(errOutput).toMatch(/\[generate\] WARNING: capture is sparse.*\(continuing: SUPERCUT_ALLOW_SPARSE=1\)/s); + }, 120_000); +}); diff --git a/test/plan.test.ts b/test/plan.test.ts index a4ac714..51409d7 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -5,8 +5,8 @@ import type { EventLog } from "../src/schema/index.js"; const viewport = { width: 1920, height: 1080, dpr: 2 }; -function makeLog(events: EventLog["events"]): EventLog { - return { version: 0, viewport, fps: 60, events }; +function makeLog(events: EventLog["events"], extra: Partial = {}): EventLog { + return { version: 0, viewport, fps: 60, events, ...extra }; } const frameIndex = Array.from({ length: 100 }, (_, i) => ({ @@ -34,7 +34,7 @@ describe("buildRenderPlan", () => { expect(JSON.stringify(a)).toBe(JSON.stringify(b)); }); - it("maps output frames to source frames by nearest-hold", () => { + it("maps output frames to source frames by floor-hold (previous frame held)", () => { const plan = buildRenderPlan(clickLog, frameIndex); // output frame at t=0 → source 0; t=100ms (frame 6) → source with t_source ≤ 100 → idx 3 (99ms) expect(plan.sourceByFrame[0]).toBe(0); @@ -360,6 +360,15 @@ describe("plan input bounds (PR #1 review)", () => { expect(() => buildRenderPlan(clickLog, bad)).toThrow(/malformed/); }); + it("constrains frame files to the frames/ namespace", () => { + // the host page fetches /take/ — a hand-edited index must not be + // able to point the fetch at other take artifacts + for (const file of ["render-plan.json", "../events.json", "frames/../events.json", "frames/a/b.png"]) { + expect(() => buildRenderPlan(clickLog, [{ file, t_source: 0 }])).toThrow(/frames\//); + } + expect(() => buildRenderPlan(clickLog, [{ file: "frames/000000.png", t_source: 0 }])).not.toThrow(); + }); + it("accepts a clamped index (capture clamps jittered stamps to t_source 0)", () => { // CDP delivery jitter can stamp a frame before the first-processed one; // the executor clamps those to 0, so duplicates at 0 are a legal index @@ -371,21 +380,34 @@ describe("plan input bounds (PR #1 review)", () => { expect(() => buildRenderPlan(clickLog, clamped)).not.toThrow(); }); - it("skew gate: dense (beacon-era) takes fail hard past 250ms", () => { - // 60fps source — clearly a unified-clock take + it("skew gate: unified-clock takes (declared in the log) fail hard past 250ms", () => { const dense = Array.from({ length: 300 }, (_, i) => ({ file: `frames/${String(i).padStart(6, "0")}.png`, t_source: Math.round(i * 16.7), })); const lastFrameT = dense[dense.length - 1]!.t_source; - const ok = makeLog([{ t: lastFrameT + 200, type: "scene", name: "s", priority: 1 }]); + const ok = makeLog([{ t: lastFrameT + 200, type: "scene", name: "s", priority: 1 }], { t_source_unified: true }); expect(assessSkew(ok, dense).action).toBe("ok"); - const broken = makeLog([{ t: lastFrameT + 400, type: "scene", name: "s", priority: 1 }]); + const broken = makeLog([{ t: lastFrameT + 400, type: "scene", name: "s", priority: 1 }], { t_source_unified: true }); expect(assessSkew(broken, dense).action).toBe("fail"); }); - it("skew gate: legacy sparse takes (pre-unified clock) only warn — back-compat", () => { - // ~5fps change-driven capture: events routinely outrun the footage + it("skew gate: a sparse take that DECLARES the unified clock still fails — a starved capture can't reclassify itself as legacy", () => { + // this was the H1 hole: fps was inferred, so 12 frames over 40s read as + // "legacy" and the fail downgraded to a warning. Legacy now comes from the + // schema declaration only. + const starved = Array.from({ length: 12 }, (_, i) => ({ + file: `frames/${String(i).padStart(6, "0")}.png`, + t_source: i * 100, + })); + const lastFrameT = starved[starved.length - 1]!.t_source; + const skewed = makeLog([{ t: lastFrameT + 3000, type: "scene", name: "s", priority: 1 }], { t_source_unified: true }); + expect(assessSkew(skewed, starved).action).toBe("fail"); + }); + + it("skew gate: legacy takes (no clock declaration) only warn — back-compat", () => { + // pre-unified-clock recorders stamped events on a separate wall + // accumulator: events routinely outrun the footage, warn is correct const sparse = Array.from({ length: 25 }, (_, i) => ({ file: `frames/${String(i).padStart(6, "0")}.png`, t_source: i * 200, diff --git a/test/record.e2e.test.ts b/test/record.e2e.test.ts index 17f8e3d..459142d 100644 --- a/test/record.e2e.test.ts +++ b/test/record.e2e.test.ts @@ -123,6 +123,102 @@ afterAll(async () => { }); describe("record E2E on fixture app", () => { + it("render fails fast on an in-page FATAL (missing frames) instead of waiting out the timeout (M9)", async () => { + // a take whose index points at frame files that don't exist: the host page + // dies on the first fetch, and the crash/console hooks must surface it in + // seconds — the raw temp stream must also be cleaned up on that path + const takeDir = mkdtempSync(join(tmpdir(), "supercut-fatal-")); + dirs.push(takeDir); + writeFileSync( + join(takeDir, "events.json"), + JSON.stringify({ + version: 0, + t_source_unified: true, + viewport: { width: 1920, height: 1080, dpr: 2 }, + fps: 60, + events: [ + { t: 0, type: "scene", name: "s1", priority: 1 }, + { t: 300, type: "click", bbox: [10, 10, 50, 20], selector: "#x", point: [20, 20] }, + ], + }), + ); + writeFileSync( + join(takeDir, "frames-index.json"), + JSON.stringify([ + { file: "frames/000000.png", t_source: 0 }, + { file: "frames/000001.png", t_source: 500 }, + ]), + ); + const t0 = Date.now(); + await expect( + renderTake({ takeDir, outFile: join(takeDir, "final.mp4") }), + ).rejects.toThrow(/FATAL/); + expect(Date.now() - t0).toBeLessThan(60_000); // fail-fast, not timeout-wait + }, 90_000); + + it("render surfaces an ffmpeg mux failure (undecodable music) as a real error (M5)", async () => { + // a tiny but VALID take: two real 1x1 PNGs, short timeline (below the + // health gate's judgeable floor). The encode succeeds; the mux is handed a + // "music" file that exists but is not audio, so ffmpeg exits non-zero at + // the very last step — that must reject, not hang or succeed silently. + const takeDir = mkdtempSync(join(tmpdir(), "supercut-badmux-")); + dirs.push(takeDir); + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ); + const framesDir = join(takeDir, "frames"); + const { mkdirSync: mkdir } = await import("node:fs"); + mkdir(framesDir, { recursive: true }); + writeFileSync(join(framesDir, "000000.png"), png); + writeFileSync(join(framesDir, "000001.png"), png); + writeFileSync( + join(takeDir, "events.json"), + JSON.stringify({ + version: 0, + t_source_unified: true, + viewport: { width: 1920, height: 1080, dpr: 2 }, + fps: 60, + events: [ + { t: 0, type: "scene", name: "s1", priority: 1 }, + { t: 300, type: "click", bbox: [10, 10, 50, 20], selector: "#x", point: [20, 20] }, + ], + }), + ); + writeFileSync( + join(takeDir, "frames-index.json"), + JSON.stringify([ + { file: "frames/000000.png", t_source: 0 }, + { file: "frames/000001.png", t_source: 500 }, + ]), + ); + const notAudio = join(takeDir, "not-audio.mp3"); + writeFileSync(notAudio, "this is not an audio file"); + + await expect( + renderTake({ takeDir, outFile: join(takeDir, "final.mp4"), music: notAudio }), + ).rejects.toThrow(/ffmpeg/i); + }, 120_000); + + it("refuses a private-network recipe when the guard is engaged (H5)", async () => { + const out = mkdtempSync(join(tmpdir(), "supercut-guard-")); + dirs.push(out); + await expect( + record({ recipe: demoRecipe(app.url), outDir: out, seed: 1, captureFrames: false, allowPrivateNetwork: false }), + ).rejects.toThrow(/private network/i); + }, 30_000); + + it("record() fails closed when allowPrivateNetwork is omitted (library default)", async () => { + // the CLI and generate() both pass the value explicitly; the default only + // ever reaches external embedders, and for them it must be the guard ON — + // matching crawlApp(), which has always defaulted closed + const out = mkdtempSync(join(tmpdir(), "supercut-default-")); + dirs.push(out); + await expect( + record({ recipe: demoRecipe(app.url), outDir: out, seed: 1, captureFrames: false }), + ).rejects.toThrow(/private network/i); + }, 30_000); + it("produces valid events.json + frames, twice, with identical scheduled timelines", async () => { const recipe = demoRecipe(app.url); @@ -144,8 +240,11 @@ describe("record E2E on fixture app", () => { expect(idx[i]!.t_source).toBeGreaterThanOrEqual(idx[i - 1]!.t_source); } - // schema-valid event log with the expected interaction events + // schema-valid event log with the expected interaction events, declaring + // the unified clock (the render gates key off this marker, not off fps) const log = parseEventLog(JSON.parse(readFileSync(join(out1, "events.json"), "utf8"))); + expect(log.t_source_unified).toBe(true); + expect(r1.avgSourceFps).toBeGreaterThanOrEqual(30); // healthy takes report their fps const types = log.events.map((e) => e.type); expect(types.filter((t) => t === "scene")).toHaveLength(2); expect(types).toContain("click"); diff --git a/test/request-gate.e2e.test.ts b/test/request-gate.e2e.test.ts new file mode 100644 index 0000000..093427a --- /dev/null +++ b/test/request-gate.e2e.test.ts @@ -0,0 +1,162 @@ +import { createServer, type Server } from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { record } from "../src/capture/index.js"; +import { createRequestGate, resolveAndPinHost } from "../src/security/url-policy.js"; +import { parseRecipe, type Recipe } from "../src/schema/index.js"; +import { startDemoApp, type DemoApp } from "./fixtures/demo-app/server.js"; + +/** + * WIRING coverage for the H4/H5 request gate, through record() itself — not + * createRequestGate as a pure function (test/url-policy.test.ts owns that), + * and not assertRecipeNavigationPolicy (which fires first and rejects any + * private recipe URL long before the gate exists, so no unmocked localhost + * run can ever reach the gate). + * + * Why the host classifier is injected: a hermetic guard-ON run needs an entry + * host the policy calls public that still lands on the local fixture. Real + * DNS cannot deliver that — and the reviewer-suggested route (a fake hostname + * pinned to loopback via --host-resolver-rules) is not portable either: + * on a machine whose resolver hijacks unknown names (VPN/TUN fake-IP DNS, + * e.g. Clash's 198.18/15) the pin is bypassed entirely and the navigation + * never reaches loopback (verified here: even `MAP example.com 127.0.0.1` + * never produced a TCP connection to a local server). So this file mocks the + * pre-flight assert/pin seams and swaps ONLY the gate's DNS classifier: + * "localhost" plays the vetted public app; 127.0.0.1 (the probe server) is + * private. Everything downstream is real — record()'s launch, its route + * handler install, route.abort(), the WebSocket gate, the verdict cache — + * which is exactly the wiring the unit tests could not see. + */ + +vi.mock("../src/security/url-policy.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, // gateWebSockets et al stay REAL + assertSafeNavigationUrl: vi.fn(async () => {}), + resolveAndPinHost: vi.fn(async () => undefined), + createRequestGate: vi.fn((opts: { allowPrivateNetwork: boolean }) => + actual.createRequestGate({ + ...opts, + isPrivateHost: async (h) => h !== "localhost", + }), + ), + }; +}); + +let app: DemoApp; +/** the "internal service" the probe page attacks: records every plain request + * and every WebSocket upgrade that actually LEAVES the browser */ +let probe: { port: number; requests: string[]; upgrades: string[]; close: () => Promise }; +const dirs: string[] = []; + +beforeAll(async () => { + app = await startDemoApp(); + const requests: string[] = []; + const upgrades: string[] = []; + const srv: Server = createServer((req, res) => { + requests.push(req.url ?? ""); + res.writeHead(200, { "content-type": "text/plain" }); + res.end("hit"); + }); + srv.on("upgrade", (req, socket) => { + upgrades.push(req.url ?? ""); + socket.destroy(); // the attempt is what we count; no handshake needed + }); + await new Promise((r) => srv.listen(0, "127.0.0.1", r)); + const { port } = srv.address() as { port: number }; + probe = { port, requests, upgrades, close: () => new Promise((r) => srv.close(() => r())) }; +}, 30_000); + +afterAll(async () => { + await app.close(); + await probe.close(); + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +function probeRecipe(entryOrigin: string): Recipe { + const fetchTarget = `http://127.0.0.1:${probe.port}/hit`; + const wsTarget = `ws://127.0.0.1:${probe.port}/ws`; + const entry = + `${entryOrigin}/probe?fetch=${encodeURIComponent(fetchTarget)}&ws=${encodeURIComponent(wsTarget)}`; + return parseRecipe({ + version: 0, + app_url: entryOrigin, + music_track: "institutional-01", + scenes: [ + { + name: "probe", + priority: 1, + entry: { url: entry, prelude: [] }, + depends_on: [], + // the page needs only time: its inline script fires the fetch and the + // WebSocket at the "internal" probe server on load + actions: [{ kind: "wait", duration_ms: 2600 }], + hold_ms: 0, + }, + ], + }); +} + +describe("request gate wiring through record() (H4/H5)", () => { + it("guard ON: the entry loads, but the page's fetch() and WebSocket to a private host never leave the browser", async () => { + vi.clearAllMocks(); + const appPort = new URL(app.url).port; + const out = mkdtempSync(join(tmpdir(), "supercut-gate-on-")); + dirs.push(out); + + const res = await record({ + recipe: probeRecipe(`http://localhost:${appPort}`), + outDir: out, + seed: 1, + captureFrames: false, + allowPrivateNetwork: false, + }); + + // the entry navigated and the scene ran to completion — the gate allowed + // the vetted app host through (a gate that blocked everything would have + // aborted the entry itself and failed the scene) + expect(res.aborted).toBe(false); + expect(res.failedScenes).toEqual([]); + + // the in-flight attacks were stopped BEFORE the wire: zero requests, zero + // upgrade attempts observed by the private server. The positive control + // below proves the same page genuinely fires both. + expect(probe.requests).toEqual([]); + expect(probe.upgrades).toEqual([]); + + // and the guard-on plumbing ran: the gate was constructed with the guard + // engaged, and the pin loop visited the entry host before launch + expect(vi.mocked(createRequestGate)).toHaveBeenCalledWith( + expect.objectContaining({ allowPrivateNetwork: false }), + ); + expect(vi.mocked(resolveAndPinHost)).toHaveBeenCalledWith( + expect.stringContaining(`http://localhost:${appPort}/probe`), + expect.objectContaining({ allowPrivateNetwork: false }), + ); + }, 60_000); + + it("guard OFF: no gate is even installed, and the same page's probes reach the server — the blocked run measured a real gate, not a broken page", async () => { + vi.clearAllMocks(); + const out = mkdtempSync(join(tmpdir(), "supercut-gate-off-")); + dirs.push(out); + + const res = await record({ + recipe: probeRecipe(app.url), + outDir: out, + seed: 1, + captureFrames: false, + allowPrivateNetwork: true, + }); + + expect(res.aborted).toBe(false); + expect(probe.requests.some((u) => u.startsWith("/hit"))).toBe(true); + expect(probe.upgrades.some((u) => u.startsWith("/ws"))).toBe(true); + + // the default local path pays no interception tax: neither the gate nor + // the pinning path is touched when the guard is off + expect(vi.mocked(createRequestGate)).not.toHaveBeenCalled(); + expect(vi.mocked(resolveAndPinHost)).not.toHaveBeenCalled(); + }, 60_000); +}); diff --git a/test/source-routes.test.ts b/test/source-routes.test.ts index dd913c7..7717da7 100644 --- a/test/source-routes.test.ts +++ b/test/source-routes.test.ts @@ -84,3 +84,37 @@ describe("extractAppRoutes", () => { rmSync(empty, { recursive: true, force: true }); }); }); + +describe("walk budget (M11)", () => { + it("stops enumerating at maxFiles instead of walking a monorepo unbounded", () => { + // the fixture tree holds well over 3 files; a budget of 3 must bound the + // enumeration (and therefore the routes derived from it) + const routes = extractAppRoutes(root, { maxFiles: 3 }); + expect(routes.length).toBeLessThanOrEqual(3); + // and the default budget still finds everything the other tests rely on + const full = extractAppRoutes(root); + expect(full.length).toBeGreaterThan(routes.length); + }); + + it("--app scoping is applied before the budget is spent, not after", () => { + // monorepo where a sibling app holds 3x the file budget and sorts BEFORE + // the requested app (traversal is sorted, so it is enumerated first). + // Budget spent repo-wide used to exhaust on the junk app and return no + // routes for --app web — while the truncation warning recommended --app + // as the remedy. Scoped-in-walk, junk files cost nothing. + const mono = mkdtempSync(join(tmpdir(), "supercut-mono-")); + const junk = join(mono, "apps", "aaa-junk"); + mkdirSync(junk, { recursive: true }); + for (let i = 0; i < 30; i++) writeFileSync(join(junk, `f${String(i).padStart(2, "0")}.ts`), "// junk"); + const webApp = join(mono, "apps", "web", "app"); + mkdirSync(webApp, { recursive: true }); + writeFileSync(join(webApp, "page.tsx"), `export default () =>

Web home

;`); + try { + const routes = extractAppRoutes(mono, { appName: "web", maxFiles: 10 }); + expect(routes.map((r) => r.route)).toEqual(["/"]); + expect(routes[0]!.file).toContain(join("apps", "web")); + } finally { + rmSync(mono, { recursive: true, force: true }); + } + }); +}); diff --git a/test/take-validation.test.ts b/test/take-validation.test.ts new file mode 100644 index 0000000..5fef770 --- /dev/null +++ b/test/take-validation.test.ts @@ -0,0 +1,89 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { renderTake } from "../src/render/index.js"; +import type { EventLog } from "../src/schema/index.js"; + +/** + * Malformed take directories must be refused by renderTake BEFORE any + * browser/encode work — these are the inputs a third-party recorder (or a + * hand-edit) is most likely to get wrong. All of these throw during the + * fail-fast validation phase, so no browser is needed. + */ + +const viewport = { width: 1920, height: 1080, dpr: 2 }; + +const goodLog: EventLog = { + version: 0, + t_source_unified: true, + viewport, + fps: 60, + events: [ + { t: 0, type: "scene", name: "s1", priority: 1 }, + { t: 300, type: "click", bbox: [10, 10, 50, 20], selector: "#x", point: [20, 20] }, + ], +}; + +const goodIndex = [ + { file: "frames/000000.png", t_source: 0 }, + { file: "frames/000001.png", t_source: 500 }, +]; + +const dirs: string[] = []; +afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +function makeTake(opts: { events?: unknown; index?: unknown; skipEvents?: boolean; skipIndex?: boolean }): string { + const dir = mkdtempSync(join(tmpdir(), "supercut-badtake-")); + dirs.push(dir); + mkdirSync(join(dir, "frames"), { recursive: true }); + if (!opts.skipEvents) writeFileSync(join(dir, "events.json"), JSON.stringify(opts.events ?? goodLog)); + if (!opts.skipIndex) writeFileSync(join(dir, "frames-index.json"), JSON.stringify(opts.index ?? goodIndex)); + return dir; +} + +const render = (takeDir: string) => renderTake({ takeDir, outFile: join(takeDir, "final.mp4") }); + +describe("renderTake refuses malformed take directories", () => { + it("missing events.json", async () => { + await expect(render(makeTake({ skipEvents: true }))).rejects.toThrow(/events\.json/); + }); + + it("missing frames-index.json", async () => { + await expect(render(makeTake({ skipIndex: true }))).rejects.toThrow(/frames-index\.json/); + }); + + it("frames-index that is not an array", async () => { + await expect(render(makeTake({ index: { frames: [] } }))).rejects.toThrow(/not an array/); + }); + + it("frames-index entry with an empty file", async () => { + await expect(render(makeTake({ index: [{ file: "", t_source: 0 }] }))).rejects.toThrow(/malformed/); + }); + + it("frames-index entry outside the frames/ namespace", async () => { + await expect( + render(makeTake({ index: [{ file: "render-plan.json", t_source: 0 }] })), + ).rejects.toThrow(/frames\//); + }); + + it("non-monotonic t_source", async () => { + const index = [ + { file: "frames/000000.png", t_source: 0 }, + { file: "frames/000001.png", t_source: 500 }, + { file: "frames/000002.png", t_source: 100 }, + ]; + await expect(render(makeTake({ index }))).rejects.toThrow(/monotonic/); + }); + + it("a corrupt huge timestamp is refused (health gate or take cap, never an allocation)", async () => { + const log: EventLog = { + ...goodLog, + events: [{ t: 0, type: "scene", name: "s1", priority: 1 }], + }; + const index = [{ file: "frames/000000.png", t_source: 500_000 }]; + await expect(render(makeTake({ events: log, index }))).rejects.toThrow(/sparse|cap/); + }); +}); diff --git a/test/url-policy-dns-failure.test.ts b/test/url-policy-dns-failure.test.ts new file mode 100644 index 0000000..21f5b15 --- /dev/null +++ b/test/url-policy-dns-failure.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; + +/** + * DNS-failure behavior of the DEFAULT resolvers (no injected isPrivateHost): + * with --block-private-network engaged, a lookup that fails or NXDOMAINs must + * DENY, not read as "not private". The old advisory resolver swallowed the + * error into an allow, and the request gate then cached that allow for the + * whole run — a rebinding window, because Chromium re-resolves on its own + * once the hostname starts pointing somewhere private. Lives in its own file + * because vi.mock of node:dns/promises is module-wide; the main url-policy + * suite does real lookups. + */ + +vi.mock("node:dns/promises", () => ({ + lookup: vi.fn(async (hostname: string) => { + if (hostname === "nxdomain.example") { + throw Object.assign(new Error("getaddrinfo ENOTFOUND nxdomain.example"), { code: "ENOTFOUND" }); + } + if (hostname === "public.example") return [{ address: "93.184.216.34", family: 4 }]; + if (hostname === "private.example") return [{ address: "127.0.0.1", family: 4 }]; + throw Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { code: "ENOTFOUND" }); + }), +})); + +import { + assertSafeNavigationUrl, + createRequestGate, + navigationRequestAllowed, + urlResolvesPrivate, +} from "../src/security/url-policy.js"; + +describe("default resolvers under DNS failure (guard engaged = fail closed)", () => { + it("request gate denies an unresolvable host with its DEFAULT resolver", async () => { + const gate = createRequestGate({ allowPrivateNetwork: false }); + expect(await gate.allows("http://nxdomain.example/latest/meta-data/")).toBe(false); + }); + + it("request gate still verifies resolvable hosts with its DEFAULT resolver", async () => { + const gate = createRequestGate({ allowPrivateNetwork: false }); + expect(await gate.allows("http://public.example/app.js")).toBe(true); + expect(await gate.allows("http://private.example/internal")).toBe(false); + }); + + it("assertSafeNavigationUrl refuses an unverifiable URL while the guard is on", async () => { + await expect(assertSafeNavigationUrl("http://nxdomain.example/")).rejects.toThrow(/cannot verify|DNS lookup failed/i); + }); + + it("navigationRequestAllowed blocks the same unverifiable URL", async () => { + expect(await navigationRequestAllowed("http://nxdomain.example/")).toBe(false); + }); + + it("with the guard OFF no lookup happens at all — an unresolvable host is not an error", async () => { + await expect( + assertSafeNavigationUrl("http://nxdomain.example/", { allowPrivateNetwork: true }), + ).resolves.toBeUndefined(); + }); + + it("the advisory urlResolvesPrivate stays lenient (hints must never throw or deny)", async () => { + await expect(urlResolvesPrivate("http://nxdomain.example/")).resolves.toBe(false); + await expect(urlResolvesPrivate("http://private.example/")).resolves.toBe(true); + }); +}); diff --git a/test/url-policy.test.ts b/test/url-policy.test.ts index f0c7afe..1f48a39 100644 --- a/test/url-policy.test.ts +++ b/test/url-policy.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { assertSafeNavigationUrl, + createRequestGate, + gateWebSockets, hostResolverRule, navigationRequestAllowed, resolveAndPinHost, @@ -103,3 +105,150 @@ describe("urlResolvesPrivate (advisory hint)", () => { await expect(urlResolvesPrivate("not a url")).resolves.toBe(false); }); }); + +describe("request gate — every request type, not just navigations (H4)", () => { + it("blocks a subresource request to a private host while the guard is on", async () => { + const gate = createRequestGate({ allowPrivateNetwork: false }); + // the SSRF classic: a crawled page fetch()es cloud metadata / loopback + expect(await gate.allows("http://169.254.169.254/latest/meta-data/iam/")).toBe(false); + expect(await gate.allows("http://127.0.0.1:8080/internal.js")).toBe(false); + expect(await gate.allows("http://[::1]/img.png")).toBe(false); + expect(await gate.allows("http://0x7f000001/x")).toBe(false); + }); + + it("allows everything when the guard is off, resolving nothing", async () => { + let resolves = 0; + const gate = createRequestGate({ + allowPrivateNetwork: true, + isPrivateHost: async () => { resolves++; return true; }, + }); + expect(await gate.allows("http://127.0.0.1:3000/app.js")).toBe(true); + expect(resolves).toBe(0); + }); + + it("fails closed on non-http(s) and malformed URLs while the guard is on", async () => { + const gate = createRequestGate({ allowPrivateNetwork: false }); + expect(await gate.allows("file:///etc/passwd")).toBe(false); + expect(await gate.allows("not a url")).toBe(false); + }); + + it("caches the DNS verdict per host so subresources don't become a DNS storm", async () => { + const lookups: string[] = []; + const gate = createRequestGate({ + allowPrivateNetwork: false, + isPrivateHost: async (h) => { lookups.push(h); return h === "internal.corp"; }, + }); + expect(await gate.allows("http://internal.corp/a.png")).toBe(false); + expect(await gate.allows("http://internal.corp/b.png")).toBe(false); + expect(await gate.allows("http://internal.corp/api/steal")).toBe(false); + expect(await gate.allows("https://cdn.example/lib.js")).toBe(true); + expect(await gate.allows("https://cdn.example/style.css")).toBe(true); + expect(lookups).toEqual(["internal.corp", "cdn.example"]); + }); + + it("fails closed when the resolver itself throws", async () => { + const gate = createRequestGate({ + allowPrivateNetwork: false, + isPrivateHost: async () => { throw new Error("resolver down"); }, + }); + expect(await gate.allows("http://flaky.example/x.js")).toBe(false); + }); + + it("never caches a verdict derived from a failed lookup — the next request re-resolves", async () => { + // rebinding shape: NXDOMAIN at first check, then the name starts resolving + // to a private address. The failure must deny AND be forgotten, so the + // fresh lookup sees the private address instead of a frozen verdict. + let calls = 0; + const gate = createRequestGate({ + allowPrivateNetwork: false, + isPrivateHost: async () => { + calls++; + if (calls === 1) throw new Error("getaddrinfo ENOTFOUND rebinder.example"); + return true; // now resolves — and it is private + }, + }); + expect(await gate.allows("http://rebinder.example/steal")).toBe(false); // unverifiable → deny + expect(await gate.allows("http://rebinder.example/steal")).toBe(false); // re-resolved → private → deny + expect(calls).toBe(2); // the failed lookup was not cached + }); + + it("a re-resolve after a transient failure can still allow a genuinely public host", async () => { + let calls = 0; + const gate = createRequestGate({ + allowPrivateNetwork: false, + isPrivateHost: async () => { + calls++; + if (calls === 1) throw new Error("resolver down"); + return false; + }, + }); + expect(await gate.allows("http://cdn.example/a.js")).toBe(false); // outage → deny this one + expect(await gate.allows("http://cdn.example/b.js")).toBe(true); // recovered → verified public + expect(calls).toBe(2); + }); +}); + +describe("WebSocket gate — upgrades bypass route interception", () => { + /** minimal fake of Playwright's routeWebSocket surface: capture the handler, + * then feed it fake WebSocketRoute objects and observe connect vs close */ + function fakeWsTarget() { + let handler: ((ws: { + url(): string; + connectToServer(): unknown; + close(o?: { code?: number; reason?: string }): Promise; + }) => unknown) | undefined; + const target = { + routeWebSocket: async (_url: RegExp, h: typeof handler) => { handler = h; }, + }; + const drive = async (url: string) => { + const calls: { connected: boolean; closed?: { code?: number; reason?: string } } = { connected: false }; + await handler!({ + url: () => url, + connectToServer: () => { calls.connected = true; return {}; }, + close: async (o?: { code?: number; reason?: string }) => { calls.closed = o ?? {}; }, + }); + return calls; + }; + return { target, drive }; + } + + it("blocks ws:// to a private host (never connected, closed with 1008) and passes a public one through", async () => { + const asked: string[] = []; + const gate = createRequestGate({ + allowPrivateNetwork: false, + isPrivateHost: async (h) => { asked.push(h); return h === "127.0.0.1"; }, + }); + const { target, drive } = fakeWsTarget(); + expect(await gateWebSockets(target, gate)).toBe(true); + + const blocked = await drive("ws://127.0.0.1:8080/socket"); + expect(blocked.connected).toBe(false); + expect(blocked.closed?.code).toBe(1008); + + const allowed = await drive("wss://api.example/live"); + expect(allowed.connected).toBe(true); + expect(allowed.closed).toBeUndefined(); + // the ws/wss scheme was mapped to http(s) before the gate saw it: the gate + // rejects non-http(s) outright, so reaching isPrivateHost proves the map + expect(asked).toEqual(["127.0.0.1", "api.example"]); + }); + + it("shares the gate's per-host DNS cache with plain requests", async () => { + const lookups: string[] = []; + const gate = createRequestGate({ + allowPrivateNetwork: false, + isPrivateHost: async (h) => { lookups.push(h); return false; }, + }); + const { target, drive } = fakeWsTarget(); + await gateWebSockets(target, gate); + await gate.allows("https://api.example/prefetch.js"); + const ws = await drive("wss://api.example/live"); + expect(ws.connected).toBe(true); + expect(lookups).toEqual(["api.example"]); // one lookup covered both + }); + + it("reports (not throws) when routeWebSocket is unavailable, so callers can warn", async () => { + const gate = createRequestGate({ allowPrivateNetwork: false }); + expect(await gateWebSockets({}, gate)).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a467848 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +/** Without an explicit `include`, vitest walks the repo root and collects + * `.worktrees//test/*.ts` alongside the real suite — a local run + * then reports ~305 tests from 22 files, half of them stale copies from + * another branch, and "tests pass locally" stops meaning anything. + * Scope collection to this checkout's `test/` directory only. */ +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**", ".worktrees/**"], + }, +});