Skip to content

fix: robustness pass — atomic writes, abort signals, prompt-injection envelope, schema validation#16

Merged
cuio merged 1 commit intomainfrom
fix/robustness-hardening
Apr 25, 2026
Merged

fix: robustness pass — atomic writes, abort signals, prompt-injection envelope, schema validation#16
cuio merged 1 commit intomainfrom
fix/robustness-hardening

Conversation

@cuio
Copy link
Copy Markdown
Owner

@cuio cuio commented Apr 25, 2026

Summary

A wide robustness pass over the studio API and UI — security hardening, async-race fixes, schema validation, and ~80 new tests. No behavior change for the happy path; everything that previously worked still works.

Verified: 1592 tests passing across 5 packages (622 core, 174 cli, 84 player, 221 studio, 491 engine + 3 skipped on hosts without git lfs). oxlint clean. oxfmt clean. tsc --noEmit clean for every package. Studio dev server reloads cleanly — ScriptTab + VoicesTab render with all 12 existing scenes + 88 voice cards, no console errors.

What changed

Security

  • Atomic writes (internal/atomicWrite.ts): temp + rename + chmod, parent-dir creation. Used by envKey, elevenlabs/settings, script/theme, and script/scenes so a crash mid-write can't leave a corrupted .env / hyperframes.json / script.json.
  • isSafePath resolves through symlinks (studio-api/helpers/safePath.ts): walks up to the deepest existing ancestor, realpathSync, and checks the realpath is under the project realpath. Defeats in-tree symlink escapes that the previous prefix check missed.
  • Whitelist sanitizeFilename (elevenlabs.ts): per-component [A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)? — no leading dots, no .., no double-extension, optional expectedExt enforcement so a caller can't sneak a .html through an audio path.
  • TTS text cap (elevenlabs.ts): 4500-char limit returning 413 instead of forwarding 10MB to the provider.
  • Prompt-injection envelope (script/planner.ts): <user_design_brief|art_direction|research|theme_description> wraps user-sourced markdown; closing tags inside content are defanged.
  • Theme id validation (script.ts): PUT /theme rejects ids not in the registry so a typo doesn't silently fall back to the default.
  • Per-project mutex on /elevenlabs/key, /elevenlabs/settings, /theme, and /scenes writes via withMutex so concurrent PATCH/PUTs serialize.

Robustness

  • AbortControllers everywhere: VariantsModal (load, handlePick), VoicesTab (fetchVoices, refreshKeyStatus, settings, key save/clear, handleUseVoice), ScriptTab (10 controllers across all loaders + plan + generate + key save), ProjectSwitcher (loadProjects, create). All aborted on unmount and on project switch; AbortError filtered from error UI.
  • ScriptTab aggregate loadError banner (R4): silent loader failures now surface a dismissable amber banner ("Couldn't reach the studio API for key status. Is the server running?") instead of leaving the UI empty.
  • ScriptTab Generate cancel + elapsed counter (V6): user can abort an in-flight synth without reloading.
  • VoicesTab audio teardown (R2): pause + removeAttribute('src') + load() releases the network/buffer; ref-tagged onended/onerror so a stale handler can't clobber newer state after a project switch.
  • ProjectSwitcher regex validation (V5): live error label, button disabled while invalid.
  • templateEngine guards (R5): MAX_RENDER_DEPTH=8, MAX_RENDER_ITERATIONS=50_000, MAX_RENDER_OUTPUT_BYTES=4MB, typed TemplateRenderError.
  • themes/loader array hardening (V2): asStringArray guard so preferences.atmospheres = "aurora" doesn't crash the planner.
  • themes/registry TTL cache (V8): 1s TTL + invalidateThemeRegistry() so the loader doesn't re-walk docs/design-systems on every request.
  • studio/vite.config watcher narrow (V7): .ts/.tsx/.mts/.cts only, excluding test files; unlink handler added.

Validation

  • Scene props schema validation (V1): lightweight runtime validator (script/themes/validateProps.ts) checks type / required / one-level-deep properties / array items. PUT /scenes/:id returns 400 with an issues list when props don't match the template's propsSchema. Unknown template ids also rejected.
  • Typed isFidelity guard (V4) replaces the as "verbatim" | …" cast in ScriptTab`.

Tests

  • ~80 new tests across atomicWrite, withMutex, safePath (symlink scenarios), envKey (precedence + atomicity), sanitizeFilename, wrapUserContent, templateEngine guards, validateAgainstSchema, theme registry caching + manifest hardening.
  • core/vitest.config: testTimeout: 15_000 so jsdom rAF / animation tests don't flake under parallel load on a busy host.
  • engine/ffprobe.test: skipIf the HDR PNG is a git-lfs pointer (host without git lfs install) instead of failing with a confusing parse error. CI Docker (which fetches LFS) still runs them.

Out of scope

The 4 upstream commits ahead of origin/main (heygen-com/hyperframes v0.4.25, v0.4.26, the staged-uploads cli publish fix in PR heygen-com#491, and the sub-composition audio path fix in PR heygen-com#489) — happy to do that as a follow-up rebase if you want.

Test plan

  • bun run --cwd packages/core test — 622 / 622
  • bun run --cwd packages/cli test — 174 / 174
  • bun run --cwd packages/player test — 84 / 84
  • bun run --cwd packages/studio test — 221 / 221
  • bun run --cwd packages/engine test — 491 / 491 + 3 skipped on hosts w/o git lfs
  • bunx oxlint . — 0 warnings, 0 errors
  • bunx oxfmt --check — clean
  • All package typecheck scripts — clean
  • Studio loads in browser — ScriptTab + VoicesTab + ProjectSwitcher render, no console errors

🤖 Generated with Claude Code

… envelope, schema validation

Security
- envKey/elevenlabs/script: atomic file writes (temp + rename) so a crash
  mid-write can't corrupt .env / hyperframes.json / script.json
- safePath: realpath-resolve the deepest existing ancestor so a symlink
  inside <project> can't escape via realpath the loader doesn't follow
- elevenlabs: strict whitelist sanitizeFilename (no leading dots, no '..',
  one-extension-per-component, optional expectedExt enforcement); 4500-char
  TTS text cap returning 413; per-project mutex on /key and /settings
- planner: <user_design_brief|art_direction|research|theme_description>
  envelopes wrap DESIGN.md / DESIGN-ART.md / RESEARCH.md / theme.json
  descriptions; closing tags inside content are defanged so a hostile
  source file can't escape the envelope
- script /theme PUT: validate the requested theme id against the registry
  so a typo or stale UI can't silently fall back to the default

Robustness
- VariantsModal/VoicesTab/ScriptTab/ProjectSwitcher: AbortController on
  every fetch; controllers aborted on unmount and on project switch;
  AbortError filtered from error UI
- ScriptTab: aggregate loadError banner instead of silently-swallowed
  loader failures; cancel button + elapsed-seconds counter on Generate
- VoicesTab: audio teardown via pause + src-clear so stale audio can't
  outlive a project switch; ref-tagged onended/onerror won't clobber
  newer state
- ProjectSwitcher: client-side regex on project ids with live error
- templateEngine: depth, iteration, and output-size guards (typed
  TemplateRenderError) so a malicious sidecar can't blow stack/memory
- themes/loader: array-coerce hardening on preferences.atmospheres /
  transitions / icons (string-typed garbage no longer crashes the planner)
- themes/registry: 1s TTL cache + invalidateThemeRegistry() so the loader
  doesn't re-walk docs/design-systems on every request
- studio/vite.config: narrow file watcher to .ts/.tsx/.mts/.cts excluding
  test files; add unlink handler

Validation
- script /scenes/:id PUT: validate incoming.template against the project's
  template registry; validate incoming.props against the template's
  propsSchema (lightweight runtime validator, returns issues list)
- ScriptTab: typed isFidelity guard replacing 'as' cast on the select

Tests
- new: atomicWrite, withMutex, safePath (symlink scenarios), envKey
  (precedence + atomicity), sanitizeFilename, wrapUserContent,
  templateEngine guards, validateAgainstSchema, registry caching +
  manifest hardening (~80 new tests, 622 core total)
- core/vitest.config: testTimeout 15s for jsdom flake under parallel load
- engine/ffprobe.test: skipIf the HDR fixture is a git-lfs pointer so
  hosts without 'git lfs' don't fail; CI Docker still runs them

Verified: 1592 tests passing across 5 packages, oxlint clean, oxfmt clean,
typecheck clean, Studio reloads cleanly with 12 scenes + 88 voice cards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@cuio cuio merged commit 01d137f into main Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant