Skip to content

fix(producer): serve symlinked render assets#486

Merged
miguel-heygen merged 1 commit intomainfrom
fix/render-symlinked-assets
Apr 25, 2026
Merged

fix(producer): serve symlinked render assets#486
miguel-heygen merged 1 commit intomainfrom
fix/render-symlinked-assets

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented Apr 25, 2026

Problem

The AIS ads repro exposed a render-mode parity bug around shared project assets.

Those compositions can keep shared/ inside each ad folder as a symlink to a sibling ../shared/ directory. hyperframes preview follows that symlink, so shared/brand.css loads and the authored glass cards, reveal wrappers, and layout primitives render correctly.

hyperframes render was different. The producer FileServer resolved the requested file through realpath before checking containment under the project root. For shared -> ../shared, that turned a project-local request like /shared/brand.css into a real path outside the ad folder, so the FileServer returned 404 Not Found.

Without brand.css, the visual result is not a small styling drift: the AIS card chrome disappears, centered reveal layout breaks, and dark callouts can render as blunt overlays on top of the source video.

What this fixes

  • keeps producer FileServer traversal protection for requested paths under compiledDir and projectDir
  • changes the render serving check back to lexical containment before opening the file, so valid project-root symlinked asset directories behave like preview mode
  • still rejects .. requests that normalize outside the intended root
  • adds a FileServer unit regression for the AIS-style shared -> ../shared asset layout
  • adds a producer regression fixture with a tiny 5s, 320x180 MP4 baseline that renders through the symlinked stylesheet
  • teaches the regression harness to copy fixture-level support files beside temp src/, so fixtures can model sibling shared asset folders without copying those assets into src/

Root cause

The previous hardening mixed two separate concerns:

  1. reject URL path traversal before serving files
  2. reject symlink targets whose real path is outside the project directory

The second behavior is stricter than preview mode and broke a real composition-authoring pattern. The request path itself was still inside the project root (shared/brand.css), but the real path pointed at the sibling shared folder, so render rejected an asset the browser preview could load.

This PR keeps the URL traversal guard at the request-path layer and lets the filesystem follow the symlink when reading the file.

The regression harness needed one small companion change because it previously copied only each fixture's src/ directory into the render temp dir. That loses the target for a fixture like src/shared -> ../shared. Copying fixture-level support files preserves the same sibling-folder shape during the render test.

Verification

Local checks

  • bun test packages/producer/src/services/fileServer.test.ts
  • bun run build:hyperframes-runtime:modular
  • bun run --filter @hyperframes/producer typecheck
  • bunx oxlint packages/producer/src/services/fileServer.ts packages/producer/src/services/fileServer.test.ts packages/producer/src/regression-harness.ts
  • bunx oxfmt --check packages/producer/src/services/fileServer.ts packages/producer/src/services/fileServer.test.ts packages/producer/src/regression-harness.ts packages/producer/tests/render-symlinked-assets/meta.json packages/producer/tests/render-symlinked-assets/src/index.html packages/producer/tests/render-symlinked-assets/shared/brand.css packages/producer/tests/render-symlinked-assets/output/compiled.html
  • pre-commit hook: lint, format, typecheck

CI-matching regression checks

  • docker buildx build --platform linux/amd64 -f Dockerfile.test -t hyperframes-producer:test-amd64 --load .
  • docker run --rm --platform linux/amd64 --security-opt seccomp=unconfined --shm-size=4g -v "$PWD/packages/producer/tests:/app/packages/producer/tests" hyperframes-producer:test-amd64 --update --sequential render-symlinked-assets
  • docker run --rm --platform linux/amd64 --security-opt seccomp=unconfined --shm-size=4g -v "$PWD/packages/producer/tests:/app/packages/producer/tests" hyperframes-producer:test-amd64 --sequential render-symlinked-assets
  • docker run --rm --platform linux/amd64 --security-opt seccomp=unconfined --shm-size=4g -v "$PWD/packages/producer/tests:/app/packages/producer/tests" hyperframes-producer:test-amd64 --sequential --exclude-tags slow,render-compat,hdr
    • result: Total: 9 | Passed: 9 | Failed: 0

Repro verification

Verified against the cloned private repro aidan785/ais-ads-hyperframes-bug-repro with annual-upsell-2/shared -> ../shared:

  • before the fix, producer FileServer returned 404 for /shared/brand.css
  • after the fix, the same request returns 200 and includes the expected .aisplus-glass stylesheet content

Render / browser verification

  • rendered a 1s symlinked-CSS smoke composition through the producer render path: /Users/miguel07code/.codex/artifacts/ais-ads-render-fix/symlink-render-check.mp4
  • extracted a first-frame proof showing the symlinked CSS applied: /Users/miguel07code/.codex/artifacts/ais-ads-render-fix/symlink-render-frame.png
  • used agent-browser against a local render-mode FileServer page and confirmed computed styles from the symlinked stylesheet (backgroundColor: rgb(33, 197, 93), text SYMLINK CSS)
  • saved agent-browser screenshot and recording:
    • /Users/miguel07code/.codex/artifacts/ais-ads-render-fix/agent-browser-symlink-css.png
    • /Users/miguel07code/.codex/artifacts/ais-ads-render-fix/agent-browser-symlink-css.webm

Notes

  • this PR intentionally does not copy the AIS repro assets into the HyperFrames repo
  • the local render/browser artifacts are verification-only and are not part of this PR
  • the committed regression MP4 is deliberately tiny (packages/producer/tests/render-symlinked-assets/output/output.mp4, about 10 KB)
  • the regression MP4 baseline was regenerated in a linux/amd64 Docker image to match the GitHub Actions regression runner
  • the scope is limited to FileServer serving parity for valid project-root symlinked assets; it does not broaden into general outside-root asset allowlisting

@miguel-heygen miguel-heygen force-pushed the fix/render-symlinked-assets branch from f920d79 to 9677c6f Compare April 25, 2026 04:15
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symlinked render-asset fix verified end-to-end. Cherry-picked the new createFileServer symlink test onto pre-PR main and confirmed it produces the exact [FileServer] 404 Not Found: /shared/brand.css Nate hit; on this branch it returns 200 with the expected stylesheet content.

The fix drops { resolveSymlinks: true } from two isPathInside calls but keeps path.resolve() normalization, so URL-layer .. traversal is still blocked (the existing isPathInside traversal tests cover this). This brings producer's render FileServer into parity with engine's preview FileServer, which has no containment check at all and was already following symlinks — exactly the asymmetry that produced the bug.

The regression-harness copyFixtureSupportFiles change is well-scoped: walks the suite dir excluding {src, output, meta.json, failures} and copies the rest to tempRoot before the existing src copy. Existing fixtures (which only contain those excluded entries) are no-op; the new fixture's shared/ directory lands at tempRoot/shared/ so src/shared -> ../shared resolves at render time.

CI for the latest commit:

  • Lint / Format / Typecheck / Build / Test / Test: runtime contract / Smoke / Tests on windows / Render on windows / CodeQL / Semantic PR title — all ✅
  • regression-shards (render-compat) ✅; style-prod shards still finishing but unrelated to FileServer/harness scope

Security trade-off acknowledged correctly in the PR body: in-project symlink to /etc/shadow is no longer rejected by realpath check, but (a) preview already allowed this, (b) the threat model is the project author themselves on localhost, (c) URL-layer traversal is still blocked.

Non-blocking suggestion (worth a quick follow-up, not gating): a createFileServer integration test asserting the URL-level traversal block still works (fetch(/shared/../../etc/passwd) → 404) would make the security boundary explicit at the integration level. The lower-level isPathInside tests cover it but it'd be nice to assert it on the actual server.

— Rames Jusso

@miguel-heygen miguel-heygen merged commit 2f60dd3 into main Apr 25, 2026
36 of 45 checks passed
@miguel-heygen miguel-heygen deleted the fix/render-symlinked-assets branch April 25, 2026 04:36
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.

2 participants