Compare commits

...
Sign in to create a new pull request.

21 commits

Author SHA1 Message Date
Daniel Han
a2e45910e4 Studio: add html_preview_router to test_desktop_auth.py router stub
CI Backend job failed across Python 3.10-3.13 because
test_health_response_reports_desktop_capability_fields monkeypatches
`sys.modules['routes']` with a SimpleNamespace stub, and the stub
list was missing html_preview_router. studio.backend.main's import
`from routes import (..., html_preview_router, ...)` then aborted
with 'cannot import name html_preview_router'.
2026-05-27 12:16:01 +00:00
pre-commit-ci[bot]
efda0b0c4c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 07:42:15 +00:00
Daniel Han
169e5b3e96 Studio: trim PR 5717 in-code comments to one short sentence each
Drops 234 lines of inline commentary across the renderer, sanitizer,
HTML preview route, supply-chain gate, and their tests. Same intent,
shorter form. No code behaviour changes; vitest 24/24, pytest 24/24,
tsc / vite build clean.
2026-05-27 07:42:00 +00:00
Daniel Han
b62e4d18cd Merge remote-tracking branch 'origin/main' into pr-5717
Resolves package.json conflict: keep main's biome 1->2 simplification
(`biome check` without trailing ".") and the test / test:watch scripts
this branch added for vitest.
2026-05-27 06:21:06 +00:00
Daniel Han
fff46cd462 Studio: read auth bearer from the correct localStorage key (unsloth_auth_token) 2026-05-25 14:08:27 +00:00
pre-commit-ci[bot]
60b002f1ea [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 14:00:43 +00:00
Daniel Han
5171bcc991 Studio: serve HTML preview from a same-origin route so inline scripts run
Closes the long-documented follow-up. Inline <script> and onclick handlers
inside the assistant's ```html fence were dead under the previous srcdoc
path because Chromium inherits the embedder CSP (script-src 'self') for
srcdoc / data: / blob: iframes per HTML / CSP3. The only browser-supported
escape is a same-origin URL whose response headers carry an overriding CSP.

Backend: new POST /api/preview/html stashes the source for 10 min behind a
192-bit random token; GET /api/preview/html/{id} serves the snippet with
default-src 'none' + script-src 'unsafe-inline' + frame-ancestors 'self' +
X-Frame-Options SAMEORIGIN so the host chat page can iframe it but third
parties cannot. The GET is intentionally unauthenticated because browsers
do not attach Authorization to iframe subresource loads -- the unguessable
URL token is the authorisation. Eviction caps the in-memory store at 256
entries per worker; TTL sweep runs on each access.

Frontend: HtmlPreview now POSTs the source on mount, holds about:blank
until the URL arrives, then sets iframe src to the returned path. The
iframe sandbox stays "allow-scripts allow-modals allow-popups" with NO
allow-same-origin / allow-top-navigation, so even though the URL is
same-origin the iframe document is treated as a unique opaque origin
(cannot reach parent storage / DOM, cannot navigate the host page).
A srcdoc fallback kicks in if the POST fails so the layout still renders.

Tests:
* 9 new backend cases pin auth gating on POST, the unauth GET path,
  CSP shape, X-Frame-Options override, TTL expiry, oldest-first eviction,
  and per-call token uniqueness.
* Frontend vitest mocks the fetch round-trip; two existing tests rewritten
  to await data-preview-state=ready, plus a new failing-fetch case that
  exercises the srcdoc fallback (so a future regression there is loud).

Updates the in-host-CSP comment in main.py to reflect that the
"same-origin backend route" follow-up is now landed.
2026-05-25 14:00:06 +00:00
Daniel Han
2cc2e5cfe6 Merge remote-tracking branch 'origin/main' into pr-5717 2026-05-25 13:50:11 +00:00
Daniel Han
6e4d71d09f Studio: render unclosed HTML/SVG fences as the tabbed preview too
End-to-end probe against Qwen3-0.6B caught a real renderer-engagement
bug: the model emits a complete SVG body inside a ``` fence but
forgets the closing ```. parseCodeFence is strict about the closing
backticks, and the StreamdownBlock fallback only tried
parseIncompleteCodeFence while isIncomplete was true. Once streaming
finished, parseCodeFence returned null, the fallback was skipped, and
HtmlSvgRenderer never mounted -- the user saw a plain code block where
the new Preview / Code tabs should have been.

Fix: always fall back to parseIncompleteCodeFence when parseCodeFence
fails. The fallback is safe for non-fence content (returns null) and
for non-HTML/SVG fences (HtmlSvgRenderer only engages on html/svg
languages; everything else falls through to renderHighlightedCode).

Adds a vitest case pinning the unclosed-final-fence path so a future
regression is loud.
2026-05-25 13:45:32 +00:00
Daniel Han
4f79a76397 Studio: only block allowlist additions that self-approve a new postinstall
Codex P1: the previous head-only rejection refused EVERY new entry,
making the documented "land the allowlist delta on main first"
workflow unreachable via PR. Any PR that proposed a new trusted
entry was permanently red.

The threat the gate is meant to catch is narrow: a single PR that
adds a new install-script dependency AND allowlists it in the same
diff, so the dep can ship without being eyeballed. Allowlist
additions that do NOT match any new install-script finding in this
PR's lockfile diff are harmless on their own -- they just prepare
the trust list. A follow-up PR that actually adds the dep then
sees the trusted entry on base and passes normally.

Narrow the check accordingly:

  self_approving = head_only_entries & current_install_script_findings
  if self_approving: FAIL

Existing protections remain:
  * head_only entries that DO match a current finding still fail
    (the self-approval bypass).
  * head DROPPING base entries still fails (closes the
    delete-then-bootstrap exploit).
  * Bootstrap mode (base file missing entirely) still accepts head
    as-is so the very first PR to introduce the file can land.

Sanity-tested: adding an unused entry now passes; dropping a base
entry still fails; bootstrap + esbuild@0.21.5 still passes (this
PR's own CI shape).
2026-05-25 11:32:18 +00:00
Daniel Han
17afdfeb8d Studio: fix two Codex findings on PR 5717 head
P1 -- ``scripts/check_new_install_scripts.py``: the head-only
rejection only refused new HEAD entries, not deletions. That left a
two-step bypass open:
  1. PR A removes ``studio/frontend/.install-script-allowlist`` on
     main (passes, since the lockfile has no new install-script
     deps).
  2. PR B then hits the bootstrap path (base allowlist missing)
     and self-allowlists any newly introduced install-script
     dependency, because bootstrap mode accepts head as-is.
Now also fail when head DROPS trusted base entries. Allowlist
deletions must land via their own reviewed commit instead of
chaining into the bootstrap window.

P2 -- ``html-svg-renderer.tsx``: ``<style>`` is removed from the
SVG sanitizer's FORBID_TAGS. The original justification was "inline
CSS would leak to the host page selectors", but the SVG preview
runs inside ``sandbox=""`` plus ``default-src 'none'`` -- the inner
``<style>`` cannot reach host page selectors and cannot fetch
external URLs (the CSP blocks ``@import`` and ``url(...)``).
Stripping ``<style>`` was breaking legitimate class-styled SVG
exports from real diagram tools. The existing
"strips inline <style>" test is replaced with one that proves
class-styled SVG renders as authored.
2026-05-25 10:48:41 +00:00
Daniel Han
e3c7948874 Studio: block tabnabbing from HTML preview popups
Codex P1 on the previous commit: granting both ``allow-popups`` and
``allow-popups-to-escape-sandbox`` while forcing every link through
``<base target="_blank">`` lets a malicious assistant-emitted link
open a regular browser tab that retains ``window.opener``. The
destination page can then call
``window.opener.top.location.href = '...'`` and tabnab the original
Studio tab.

Drop ``allow-popups-to-escape-sandbox``. ``allow-popups`` stays so
the click still produces a tab instead of being silently dropped,
but the popup now INHERITS the iframe sandbox (no
allow-same-origin, no allow-top-navigation), so it cannot reach
back into the host. Trade-off: the opened tab loads with an opaque
origin and some real sites render degraded inside the popup. This
is the deliberate exchange for tabnabbing safety; the chat-message
preview iframe is the primary surface, the popup is a follow-up
link click.

Existing rendering-iframe sandbox test now asserts
allow-popups-to-escape-sandbox is NOT present.
2026-05-25 09:50:20 +00:00
Daniel Han
115ff8978c Studio: keep HTML preview on srcdoc, document the CSP-inheritance limit
The previous commit swapped HtmlPreview to a blob: URL on the basis
of a round-4 reviewer claim that Chromium blob: frames get a fresh
policy container. Empirically verified against the live Studio: blob:
iframes ALSO inherit the embedder CSP in Chromium (and the spec
confirms it -- HTML / CSP3 § initialize-document-csp inherits CSP
for srcdoc, data:, and blob: alike). With the host enforcing
``script-src 'self'``, assistant inline scripts and on* handlers are
blocked in all three. Probe screenshots: alert dialog does not fire,
console shows ``script-src 'self'`` violations originating from
blob:http://127.0.0.1:8901/...

Switch back to srcdoc, which is the simplest path with the same
script-execution behavior. The meta-CSP inside the iframe stays as
defense in depth (connect-src 'none', frame-src 'none', img-src
data: blob:, etc.) so even a future host-CSP relaxation cannot turn
the preview into an exfiltration channel. allow-popups +
allow-popups-to-escape-sandbox stay so target=_blank links open
proper new tabs.

Genuinely interactive HTML demos need a same-origin backend route
serving with response-header CSP (response headers do NOT inherit
from the embedder); that is tracked as a follow-up. For this PR the
HTML preview ships as a static-render surface for layout, styles,
images, and source-tab viewing. The PR's manual-test claim about
``button onclick=alert(1)`` running inside the iframe is corrected
to "renders without firing the click handler under the current host
CSP" -- explicitly noted in the in-file comment and the meta-CSP
keeps ``script-src 'unsafe-inline'`` declared so the day the host
route lands the inner contract is already correct.

frame-src in the host CSP is reverted to ``'self'`` only.
2026-05-25 08:13:00 +00:00
Daniel Han
fc28f8486b Studio: let HTML preview links open a new tab
Codex review on b21717120c flagged that the previous commit's
``<base target="_blank">`` combined with sandbox flags that omitted
``allow-popups`` silently dropped every link click in the preview --
before that change links at least navigated inside the frame; after
it they were no-ops.

Add ``allow-popups`` and ``allow-popups-to-escape-sandbox`` so a
target=_blank link opens a regular browser tab (rather than an
opaque-origin sandboxed one that most docs sites would render
broken). The popup is then equivalent to the user clicking the same
URL anywhere else. allow-same-origin and allow-top-navigation are
still NOT granted, so the iframe still cannot read parent.document
or navigate the host page.

Test updated to assert the new sandbox tokens explicitly.
2026-05-25 07:38:17 +00:00
Daniel Han
b21717120c Studio: bootstrap install-script allowlist on PR that introduces it
The previous commit tightened the install-script gate so a PR cannot
add new allowlist entries on its own head branch -- they must already
exist on the base ref. That created a chicken-and-egg problem for the
very PR that introduces studio/frontend/.install-script-allowlist:
base does not have the file yet, so every head entry is "new" and the
gate refuses to land.

Resolve by making the head-only rejection conditional on the base
ref actually having the allowlist file. The workflow signals
"missing on base" by removing the temp file after a failed git show
(previously it was always truncated to empty, which the checker
could not distinguish from an empty-by-intent base). The checker
treats a missing base allowlist path as bootstrap mode and accepts
the head allowlist as-is for that single run. Once the file exists
on base, every subsequent PR is back under the strict head-only
rejection rule.
2026-05-24 17:36:22 +00:00
pre-commit-ci[bot]
c3c09dbd80 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 16:05:55 +00:00
Daniel Han
42ff23de73 Studio: fix HTML/SVG preview sanitizer, sandbox, and streaming gaps
Bundle of follow-ups to the HTML/SVG fence renderer landed earlier in
this PR. Each item came out of either the parallel reviewer pass or a
manual Playwright probe against the live Studio with an Anthropic
provider attached.

Sanitizer:
  - filter, mask, and clip-path are now in FORBID_ATTR. They accept
    url(https://...) values and the CSS engine still fetches that URL
    when the SVG renders, which previously slipped past the FORBID
    list.
  - href and xlink:href are no longer blanket-forbidden; they survive
    only when the value is a same-document fragment (href="#id"),
    which is what textPath, gradient, and use refs need. External
    schemes are dropped via a uponSanitizeAttribute hook so a beacon
    href cannot make it through.
  - The hook approach replaces DOMPurify's ALLOWED_URI_REGEXP, which
    also filtered presentation attrs (cx, cy, r, fill, width, height)
    and rendered circles with r=0.

SVG preview:
  - Inner stylesheet caps both max-width AND max-height so a square
    viewBox (200x200) scaled to the container width no longer
    overflows the fixed-height iframe and clips at the bottom.

HTML preview:
  - srcdoc carries a defense-in-depth meta-CSP (default-src 'none',
    connect-src 'none', frame-src 'none', img-src data: blob:,
    script-src 'self' 'unsafe-inline', style-src 'self' 'unsafe-inline').
    The host CSP already blocks inline scripts; this layer also blocks
    network egress, nested iframes, and form submission so a future
    host-CSP relaxation does not silently turn the preview into an
    exfiltration channel.
  - Sandbox grows allow-modals so alert/confirm/prompt are not
    silently no-oped if the host CSP ever permits inline scripts.
  - Pop-out spacer now uses the live HTML iframe height instead of
    hardcoded DEFAULT_PREVIEW_HEIGHT, so popping out a short preview
    does not leave a 500px hole in the chat bubble.
  - autoHeight resets on source change so a long-running session that
    swaps from a tall demo to a short one no longer keeps the previous
    iframe size during the gap before the new doc posts its height.

Streaming and a11y:
  - parseIncompleteCodeFence parses an in-flight open fence (no closing
    backticks yet). markdown-text falls back to it when streaming is
    incomplete, so the advertised isIncomplete -> Code-tab-lock path
    actually runs.
  - Tab buttons gain aria-controls / aria-labelledby wiring and a
    roving tabindex so the WAI-ARIA tab pattern is complete.
  - Pop-out modal gets role="dialog" and aria-modal.

Tooling:
  - vitest now runs in the Studio Frontend CI workflow so sanitizer or
    renderer regressions block the gate.
  - test-setup shims URL.createObjectURL / revokeObjectURL for jsdom in
    case future iframe work needs it.
  - frame-src in the host CSP is now declared explicitly as 'self' so
    a future change that loosens it leaves a visible diff for review.

Tests added: ARIA wiring, SVG height fit, srcdoc meta-CSP shape,
incomplete-fence helper, filter/mask/clip-path attr stripping, safe
fragment-href survival, external-href rejection. Vitest passes 21/21,
tsc -b and vite build are clean.
2026-05-24 16:05:11 +00:00
Daniel Han
55e4d90d9e Studio: harden install-script gate against PR self-allowlist
Three findings against the install-script allowlist landed by the
HTML/SVG preview PR:

1. Allowlist matched on package name alone, so adding 'esbuild'
   silently approved every future esbuild postinstall version. Pin
   each entry to name@version and reject bare names.

2. The script defaulted the allowlist path to the head checkout's
   .install-script-allowlist, so the same PR that introduced a new
   postinstall dep could allowlist it in the same diff. Source the
   allowlist from the BASE ref instead; any head-only entry fails
   the gate.

3. The security-audit workflow only extracted the BASE package-lock,
   leaving the allowlist defaulted to the PR checkout. Update the
   workflow to also extract the BASE allowlist and pass it through
   --base-allowlist.

The existing esbuild entry is now pinned to esbuild@0.21.5 so the
gate refuses any future esbuild version that has not been
re-eyeballed.
2026-05-24 16:04:31 +00:00
Daniel Han
fb6a6703cb Studio: allowlist esbuild in the new-install-script gate
The frontend test stack (added in this PR for SVG sanitization
coverage) pulls vitest, which transitively depends on esbuild.
esbuild's postinstall downloads the platform-specific native
binary (esbuild-linux-x64, etc.); it has no runtime exposure for
Studio and is a top-tier maintained package.

Add a file-based allowlist mechanism to
scripts/check_new_install_scripts.py so well-known, eyeballed
install-script deps can be triaged without weakening the gate
for the long tail. Defaults to
studio/frontend/.install-script-allowlist; the new file lists
esbuild with a comment explaining the safety review. The gate
otherwise behaves as before -- any other newly-added
install-script dep still hard-fails.
2026-05-23 14:00:36 +00:00
Daniel Han
1385b8076e Studio: sandbox SVG previews inside a CSP-locked iframe
Reviewers flagged the inline SVG preview as a regression from the
pre-PR data-URI <img> path: DOMPurify's default SVG profile keeps
<style> tags, style attributes, and <image>/<use> href targets, all
of which now reach the host Studio document because we mount the
sanitized SVG with dangerouslySetInnerHTML. That lets a model
response hide UI with body{display:none}, or beacon to attacker
URLs via <image href=...>.

Fix in two layers so a single regression cannot reopen the hole:

  * Tighten SVG_PURIFY_CONFIG -- FORBID_TAGS adds style, image, use,
    link, meta; FORBID_ATTR drops href, xlink:href, and style. The
    surviving markup can no longer carry inline CSS or external
    resource refs.

  * Move SvgPreview into a sandbox='' iframe (no scripts, no
    same-origin) with a default-src 'none' CSP. Even if a future
    sanitizer pass leaks a URL-bearing attribute, the browser blocks
    the request and the SVG cannot touch parent.document.
2026-05-23 14:00:36 +00:00
Daniel Han
83e67231cd Studio: render HTML and SVG fences inline with sandboxed preview tabs
Adds a dedicated HtmlSvgRenderer that turns ```html / ```svg fences in
assistant messages into an inline preview with a Code/Preview tab
toggle. HTML runs inside an iframe with sandbox="allow-scripts" only
(no allow-same-origin, no allow-top-navigation) so JS games execute but
cannot reach parent.document. SVG is sanitized through DOMPurify with
the svg/svgFilters profile before being mounted via
dangerouslySetInnerHTML, stripping <script>, on* handlers, javascript:
URLs, and any tag that could escape the SVG sandbox.

The renderer defaults to the Preview tab for completed fences and
locks to the Code tab while the stream is still arriving so users see
partial tokens rather than a flashing preview. The Code tab keeps the
existing Streamdown syntax-highlighted view and the established copy /
download chrome from CodeBlockActions. HTML previews cap at 500px and
have a pop-out affordance that expands to 80vh; Esc exits the modal.

Wires the renderer into the markdown pipeline via the existing
StreamdownBlock fork in markdown-text.tsx, replacing the previous
stacked code+preview layout. Non-HTML / non-SVG fences fall through to
the original highlighted code block.

Adds Vitest + jsdom + React Testing Library to the frontend dev
dependencies and a vitest.config.ts. Covers: iframe sandbox attribute,
SVG script + onclick stripping, Code/Preview toggle, streaming lock,
DOMPurify behaviour on javascript: URLs and onload handlers, and that
non-HTML/SVG fences are not routed through the renderer.
2026-05-23 14:00:36 +00:00
17 changed files with 4419 additions and 526 deletions

View file

@ -1096,20 +1096,28 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Extract base-ref lockfile (PR triggers only)
- name: Extract base-ref lockfile and install-script allowlist (PR triggers only)
if: github.event_name == 'pull_request'
run: |
set -e
BASE_SHA="${{ github.event.pull_request.base.sha }}"
git show "$BASE_SHA:studio/frontend/package-lock.json" \
> /tmp/base-package-lock.json
# Pull TRUSTED allowlist from base so a PR cannot self-approve
# a new postinstall in the same diff. Missing-on-base = bootstrap
# mode (gate accepts head allowlist for THAT PR only).
if ! git show "$BASE_SHA:studio/frontend/.install-script-allowlist" \
> /tmp/base-install-script-allowlist 2>/dev/null; then
rm -f /tmp/base-install-script-allowlist
fi
- name: Diff for newly-added install-script deps
if: github.event_name == 'pull_request'
run: |
python3 scripts/check_new_install_scripts.py \
--base /tmp/base-package-lock.json \
--head studio/frontend/package-lock.json
--head studio/frontend/package-lock.json \
--base-allowlist /tmp/base-install-script-allowlist
- name: Skip install-script diff (non-PR trigger)
if: github.event_name != 'pull_request'

View file

@ -109,6 +109,11 @@ jobs:
- name: Typecheck
run: npm run typecheck
- name: Frontend unit tests (vitest)
# Covers HtmlSvgRenderer sandbox / CSP / sanitizer. Runs before
# build so a regression fails the gate even if the bundle is clean.
run: npm run test
- name: Build
run: npm run build

View file

@ -4,32 +4,21 @@
"""Diff two `package-lock.json` files and flag NEW install-script deps.
A package with `"hasInstallScript": true` runs `preinstall` / `install` /
`postinstall` lifecycle hooks every time `npm ci` lays it down. Every
npm supply-chain compromise of the last 18 months (Shai-Hulud,
TanStack, axios-style, ArmorCode hijacks) leveraged exactly this lever:
the attacker publishes a new malicious version of a dep we already
trust, and the post-install hook runs the next time CI installs.
Packages with `hasInstallScript: true` run preinstall/install/postinstall
on every `npm ci`. Every npm supply-chain compromise of the last 18 months
(Shai-Hulud, TanStack, axios, ArmorCode) used this lever: malicious
version of a trusted dep, hook runs on next install.
This scanner refuses to allow a newly-introduced install-script dep to
land without a maintainer eyeball on the lifecycle script body.
Existing install-script deps are NOT re-flagged -- if `node-gyp` has
been in the lockfile since day one, it's not part of this PR's threat
model. Only new entries are surfaced.
Existing install-script deps are NOT re-flagged; only NEW entries surface.
Supports lockfileVersion 1 (`dependencies` key, recursive), 2 and 3
(flat `packages` key with `node_modules/<a>/node_modules/<b>` nesting
for transitive entries). For each NEW install-script package we
attempt a stdlib-only fetch of
`https://registry.npmjs.org/<name>/<version>` to recover the actual
postinstall command body. If the network is blocked we still emit the
finding -- the lifecycle command body is informational, not
load-bearing.
Supports lockfileVersion 1 (`dependencies`, recursive) and 2/3 (flat
`packages` with `node_modules/<a>/node_modules/<b>` nesting). For each
finding we try a stdlib fetch of `https://registry.npmjs.org/<name>/<version>`
to recover the lifecycle body; network failure is non-fatal.
Exit codes
==========
Exit codes:
0 no newly-added install-script deps
1 one or more newly-added install-script deps; listed on stderr
1 newly-added install-script deps listed on stderr
2 internal error (missing lockfile, malformed JSON, etc.)
"""
@ -76,15 +65,11 @@ class Finding:
def _strip_nm_prefix(key: str) -> str:
"""Convert a v2/v3 `packages` key into a bare package name.
`node_modules/foo` -> `foo`; `node_modules/foo/node_modules/bar` ->
`bar`. The empty key (`""`) is the project root and returns "".
"""
"""v2/v3 `packages` key -> bare leaf name. ``""`` is the project root."""
if not key:
return ""
# Use the LAST `node_modules/` segment so transitives map to their
# leaf name, matching how npm install resolves a postinstall.
# Last `node_modules/` segment: transitives map to leaf name (matches
# how npm resolves the postinstall).
marker = "node_modules/"
idx = key.rfind(marker)
if idx == -1:
@ -97,10 +82,8 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
every entry with `hasInstallScript: true` (v2/v3) OR a
non-empty `scripts.preinstall|install|postinstall` (v1).
The same package may appear at multiple versions in a single
lockfile (de-duplicated copies under different parents); we key by
`name@version` so we don't lose either copy. Returns a dict keyed
by `name@version` -> the same string for convenience.
Keyed by `name@version` so duplicate copies at different versions
are not collapsed. Returns {`name@version`: name}.
"""
seen: dict[str, str] = {}
version = lock.get("lockfileVersion")
@ -120,10 +103,8 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name
# v1 also embeds a `dependencies` tree; v2/v3 carry both for
# backwards-compat but `packages` is canonical for them. For v1
# there is no `hasInstallScript` flag, so look for a non-empty
# `scripts.preinstall|install|postinstall` directly.
# v1 has no `hasInstallScript` flag -- detect via non-empty
# scripts.{preinstall,install,postinstall}.
def _walk_v1(deps: dict, depth: int = 0) -> None:
if depth > 64 or not isinstance(deps, dict):
return
@ -135,8 +116,6 @@ def _collect_install_script_entries(lock: dict) -> dict[str, str]:
isinstance(scripts, dict) and scripts.get(hook)
for hook in ("preinstall", "install", "postinstall")
)
# v1 also sets `requires` only on the parent, no flag, so
# the lifecycle-script presence is the only signal.
if lifecycle:
ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name
@ -163,12 +142,9 @@ def _load_lockfile(path: Path) -> dict:
def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None:
"""Return {hook: command} for any of preinstall / install /
postinstall published in the registry metadata for this name@ver.
"""Return {hook: command} from registry metadata, or None on any failure.
Returns None on any error (network blocked, 404, malformed JSON).
Never raises; the caller treats absence as "could not enrich, emit
finding anyway".
Never raises; absence means "emit finding without enrichment".
"""
safe_name = urllib.parse.quote(name, safe = "@/")
url = f"{REGISTRY_BASE}{safe_name}/{urllib.parse.quote(version)}"
@ -203,9 +179,8 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
findings: list[Finding] = []
for key in sorted(head):
if key in base:
continue # pre-existing install-script dep; not in scope
continue # pre-existing dep, out of scope
name = head[key]
# key is "name@version"; rsplit("@", 1) handles scoped names.
version = (
key[len(name) + 1 :] if key.startswith(name + "@") else "<unversioned>"
)
@ -215,8 +190,7 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
else:
detail = (
"newly added with hasInstallScript=true; registry "
"metadata unreachable -- inspect the package's "
"scripts.{preinstall,install,postinstall} manually"
"unreachable -- inspect scripts.{preinstall,install,postinstall}"
)
findings.append(
Finding(
@ -235,6 +209,40 @@ def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
# ─────────────────────────────────────────────────────────────────────
def _load_allowlist(path: Path) -> set[str]:
"""Newline-separated `name@version` entries to skip.
Each entry MUST be pinned to an exact version; bare names rejected so
a compromised maintainer cannot ship a malicious later version under
an existing allowlist line. `#` comments and blank lines ignored.
Missing file = empty set.
"""
if not path.exists():
return set()
out: set[str] = set()
try:
text = path.read_text(encoding = "utf-8")
except OSError:
return set()
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
name, sep, version = line.rpartition("@")
if not sep or not name or not version:
raise ValueError(
f"{path}: allowlist entry {line!r} must be pinned to an "
"exact version (e.g. 'esbuild@0.21.5'). Bare names are "
"rejected so we cannot silently approve a later release.",
)
out.add(line.lower())
return out
def _finding_allowlist_key(finding: Finding) -> str:
return f"{finding.name}@{finding.version}".lower()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description = (
@ -252,6 +260,23 @@ def main(argv: list[str] | None = None) -> int:
required = True,
help = "Path to the HEAD package-lock.json (this PR).",
)
parser.add_argument(
"--allowlist",
default = None,
help = (
"Path to the HEAD newline-separated 'name@version' allowlist "
"to skip. Defaults to '<head dir>/.install-script-allowlist'."
),
)
parser.add_argument(
"--base-allowlist",
default = None,
help = (
"Path to the TRUSTED BASE allowlist. Defaults to "
"'<base dir>/.install-script-allowlist'. Head-only entries that "
"self-approve a new postinstall fail the gate."
),
)
args = parser.parse_args(argv)
try:
@ -261,7 +286,96 @@ def main(argv: list[str] | None = None) -> int:
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
return 2
findings = diff_new_install_scripts(base_lock, head_lock)
head_allowlist_path = (
Path(args.allowlist)
if args.allowlist
else Path(args.head).parent / ".install-script-allowlist"
)
base_allowlist_path = (
Path(args.base_allowlist)
if args.base_allowlist
else Path(args.base).parent / ".install-script-allowlist"
)
try:
head_allowlist = _load_allowlist(head_allowlist_path)
except ValueError as exc:
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
return 2
raw_findings = diff_new_install_scripts(base_lock, head_lock)
raw_findings_keys = {_finding_allowlist_key(f) for f in raw_findings}
# Bootstrap: PR that creates the file. Workflow signals "missing on base"
# by `rm -f` after a failed `git show`. After landing, head-only / delete
# rules below kick in.
if not base_allowlist_path.exists():
print(
f"[install-script-diff] bootstrap: {base_allowlist_path} "
"missing on base; accepting head allowlist as-is for this run.",
flush = True,
)
allowlist = head_allowlist
else:
try:
base_allowlist = _load_allowlist(base_allowlist_path)
except ValueError as exc:
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
return 2
added_head_only = head_allowlist - base_allowlist
# Refuse "introduce a new postinstall AND allowlist it in the same
# diff". Head-only entries that match no current finding are fine
# (prepare trust for a follow-up PR; reviewer still sees the diff).
self_approving = sorted(added_head_only & raw_findings_keys)
if self_approving:
print(
"[install-script-diff] FAIL: this PR both introduces an "
"install-script dependency AND allowlists it in the "
"same diff. Allowlist entries that approve a NEW "
"postinstall must land on the base branch first.",
file = sys.stderr,
)
for entry in self_approving:
print(
f" self-approving allowlist entry: {entry}",
file = sys.stderr,
)
return 1
# Refuse PRs that DROP trusted base entries; otherwise a two-step
# bypass works (PR A removes file -> PR B hits bootstrap and
# self-allowlists). Deletions go via admin override.
removed_from_head = sorted(base_allowlist - head_allowlist)
if removed_from_head:
print(
"[install-script-diff] FAIL: PR removes trusted base "
"allowlist entries. Allowlist deletions must land in a "
"separate, isolated commit so a follow-up PR cannot "
"exploit the bootstrap path.",
file = sys.stderr,
)
for entry in removed_from_head:
print(
f" dropped allowlist entry: {entry}",
file = sys.stderr,
)
return 1
# Only base allowlist applies. Head-only entries that survived
# the self-approval check wait for the next PR to take effect.
allowlist = base_allowlist
findings = raw_findings
if allowlist:
skipped = [f for f in findings if _finding_allowlist_key(f) in allowlist]
findings = [f for f in findings if _finding_allowlist_key(f) not in allowlist]
for f in skipped:
print(
f"[install-script-diff] SKIP {_finding_allowlist_key(f)} "
"(allowlisted via trusted base allowlist)",
flush = True,
)
if not findings:
print(
"[install-script-diff] OK: no newly-added install-script "
@ -279,10 +393,9 @@ def main(argv: list[str] | None = None) -> int:
print(str(f), file = sys.stderr)
print(file = sys.stderr)
print(
"[install-script-diff] Refusing to proceed. Every new "
"install-script dep is a postinstall lifecycle hook that "
"would run on the next `npm ci`. Review each finding above, "
"confirm the maintainer + version, and re-run.",
"[install-script-diff] Refusing to proceed. Each new install-script "
"dep ships a postinstall hook that runs on next `npm ci`. Review, "
"confirm maintainer + version, and re-run.",
file = sys.stderr,
)
return 1

View file

@ -120,6 +120,7 @@ from routes import (
data_recipe_router,
datasets_router,
export_router,
html_preview_router,
inference_router,
inference_studio_router,
models_router,
@ -329,6 +330,10 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
"font-src 'self' data:; "
# Same-origin only. SVG uses srcdoc; interactive HTML goes through
# /api/preview/html/{id} which carries its own overriding response
# CSP (srcdoc / data: / blob: inherit THIS one per CSP3).
"frame-src 'self'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"
@ -530,6 +535,9 @@ app.include_router(export_router, prefix = "/api/export", tags = ["export"])
app.include_router(
training_history_router, prefix = "/api/train", tags = ["training-history"]
)
app.include_router(
html_preview_router, prefix = "/api/preview/html", tags = ["html-preview"]
)
# ============ Health and System Endpoints ============

View file

@ -16,6 +16,7 @@ from routes.export import router as export_router
from routes.training_history import router as training_history_router
from routes.chat_history import router as chat_history_router
from routes.providers import router as providers_router
from routes.html_preview import router as html_preview_router
__all__ = [
"training_router",
@ -29,4 +30,5 @@ __all__ = [
"training_history_router",
"chat_history_router",
"providers_router",
"html_preview_router",
]

View file

@ -0,0 +1,143 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""HTML preview route for assistant ```html fences.
srcdoc / data: / blob: iframes inherit the host CSP (script-src 'self')
per CSP3, so inline scripts and onclick handlers are silently blocked.
Serving the snippet from a same-origin URL with an overriding response
CSP is the only way to unlock interactive HTML.
Security:
- POST is auth-gated.
- GET is NOT auth-gated -- iframe subresource loads do not carry the
Authorization bearer, so the URL token (192 bits, secrets.token_urlsafe(24))
is the authorisation. Never persisted, wiped on TTL expiry.
- Response CSP: default-src 'none' + script-src 'unsafe-inline'.
Scripts run; network egress (connect-src, frame-src, worker-src) blocked.
- iframe sandbox stays "allow-scripts allow-modals allow-popups" with NO
allow-same-origin, so preview JS cannot reach window.parent.
- Size and live-entry caps bound per-process memory.
"""
from __future__ import annotations
import secrets
import sys
import time
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
# Backend on sys.path for standalone load (matches routes/export.py).
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from auth.authentication import get_current_subject # noqa: E402
router = APIRouter()
# Knobs (module-level so tests can monkeypatch).
MAX_HTML_PREVIEW_BYTES = 1_000_000 # 1 MiB; snippets, not full SPAs.
PREVIEW_TTL_SECONDS = 10 * 60 # 10 min interaction window.
MAX_LIVE_PREVIEWS = 256 # Per-process cap; oldest evicted first.
_PREVIEWS: dict[str, tuple[float, str]] = {}
def _sweep_expired(now: float | None = None) -> None:
now = time.monotonic() if now is None else now
expired = [k for k, (t, _) in _PREVIEWS.items() if now - t > PREVIEW_TTL_SECONDS]
for k in expired:
_PREVIEWS.pop(k, None)
def _evict_overflow() -> None:
if len(_PREVIEWS) <= MAX_LIVE_PREVIEWS:
return
sorted_keys = sorted(_PREVIEWS, key = lambda k: _PREVIEWS[k][0])
for k in sorted_keys[: len(_PREVIEWS) - MAX_LIVE_PREVIEWS]:
_PREVIEWS.pop(k, None)
def _build_html_doc(source: str) -> str:
# <base target="_blank"> so <a> links open a new tab instead of
# navigating the iframe away from the preview.
return "<!doctype html>" '<base target="_blank">' + source
_PREVIEW_CSP = "; ".join(
(
"default-src 'none'",
# The reason this route exists -- host CSP does not allow inline.
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
# data: / blob: only -- no remote img beacon.
"img-src data: blob:",
"media-src data: blob:",
"font-src data:",
"connect-src 'none'",
"worker-src 'none'",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
# Same-origin embedders only; also overrides X-Frame-Options.
"frame-ancestors 'self'",
)
)
class HtmlPreviewCreate(BaseModel):
source: str = Field(..., max_length = MAX_HTML_PREVIEW_BYTES)
class HtmlPreviewCreateResponse(BaseModel):
url: str
expires_in_seconds: int
@router.post("", response_model = HtmlPreviewCreateResponse)
async def create_html_preview(
payload: HtmlPreviewCreate,
current_subject: str = Depends(get_current_subject),
) -> HtmlPreviewCreateResponse:
"""Stash HTML; return a same-origin token URL (the only auth for GET)."""
_sweep_expired()
source = payload.source
if not isinstance(source, str): # defensive; pydantic already enforces.
raise HTTPException(status_code = 400, detail = "source must be a string")
if len(source) > MAX_HTML_PREVIEW_BYTES:
raise HTTPException(status_code = 413, detail = "HTML preview too large")
token = secrets.token_urlsafe(24)
_PREVIEWS[token] = (time.monotonic(), source)
_evict_overflow()
return HtmlPreviewCreateResponse(
url = f"/api/preview/html/{token}",
expires_in_seconds = PREVIEW_TTL_SECONDS,
)
@router.get("/{preview_id}", response_class = HTMLResponse)
async def get_html_preview(preview_id: str) -> HTMLResponse:
"""Serve a stashed snippet with overriding CSP. Unauth: token IS the auth."""
_sweep_expired()
item = _PREVIEWS.get(preview_id)
if item is None:
raise HTTPException(status_code = 404, detail = "Preview expired or not found")
_, source = item
return HTMLResponse(
content = _build_html_doc(source),
headers = {
"Content-Security-Policy": _PREVIEW_CSP,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
# Override the global DENY default so the host chat can iframe us.
"X-Frame-Options": "SAMEORIGIN",
},
)

View file

@ -435,6 +435,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
data_recipe_router = APIRouter(),
datasets_router = APIRouter(),
export_router = APIRouter(),
html_preview_router = APIRouter(),
inference_router = APIRouter(),
inference_studio_router = APIRouter(),
models_router = APIRouter(),

View file

@ -0,0 +1,191 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for the /api/preview/html route (interactive HTML preview)."""
import sys
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
@pytest.fixture
def preview_app(tmp_path, monkeypatch):
"""Standalone app with only the html-preview router and a clean store."""
from auth import storage
from auth.authentication import create_access_token
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
monkeypatch.setattr(storage, "_bootstrap_password", None)
import secrets as _secrets
storage.create_initial_user(
username = storage.DEFAULT_ADMIN_USERNAME,
password = "human-password-123",
jwt_secret = _secrets.token_urlsafe(64),
must_change_password = False,
)
from routes.html_preview import router as html_preview_router
from routes import html_preview as html_preview_module
html_preview_module._PREVIEWS.clear() # fresh store per test
app = FastAPI()
app.include_router(
html_preview_router, prefix = "/api/preview/html", tags = ["html-preview"]
)
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
return app, token, html_preview_module
class TestPostHtmlPreview:
def test_post_requires_auth(self, preview_app):
app, _token, _mod = preview_app
c = TestClient(app)
r = c.post("/api/preview/html", json = {"source": "<h1>hi</h1>"})
assert r.status_code in (401, 403)
def test_post_returns_same_origin_url_and_ttl(self, preview_app):
app, token, _mod = preview_app
c = TestClient(app)
r = c.post(
"/api/preview/html",
json = {"source": "<h1>hello</h1>"},
headers = {"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
body = r.json()
assert body["url"].startswith("/api/preview/html/")
assert isinstance(body["expires_in_seconds"], int)
assert body["expires_in_seconds"] > 0
def test_post_rejects_oversize_body(self, preview_app):
app, token, mod = preview_app
c = TestClient(app)
too_big = "x" * (mod.MAX_HTML_PREVIEW_BYTES + 1)
r = c.post(
"/api/preview/html",
json = {"source": too_big},
headers = {"Authorization": f"Bearer {token}"},
)
# Pydantic's max_length surfaces as 422; we accept any non-2xx
# as long as nothing is stored.
assert r.status_code >= 400
assert mod._PREVIEWS == {}
def test_post_returns_unguessable_tokens(self, preview_app):
# Identical source -> distinct tokens.
app, token, _mod = preview_app
c = TestClient(app)
urls = set()
for _ in range(5):
r = c.post(
"/api/preview/html",
json = {"source": "<p>same</p>"},
headers = {"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
urls.add(r.json()["url"])
assert len(urls) == 5
class TestGetHtmlPreview:
def _create(self, app, token, source):
c = TestClient(app)
r = c.post(
"/api/preview/html",
json = {"source": source},
headers = {"Authorization": f"Bearer {token}"},
)
return r.json()["url"]
def test_get_serves_stored_html_with_overriding_csp(self, preview_app):
app, token, _mod = preview_app
url = self._create(app, token, "<button onclick=\"alert('x')\">go</button>")
c = TestClient(app)
r = c.get(url)
assert r.status_code == 200
body = r.text
assert "<!doctype html>" in body.lower()
assert '<base target="_blank">' in body
assert "<button onclick=\"alert('x')\">go</button>" in body
csp = r.headers["content-security-policy"]
directives = {
chunk.strip().split(" ", 1)[0]: chunk.strip()
for chunk in csp.split(";")
if chunk.strip()
}
# Inline scripts allowed, network egress closed.
assert "'none'" in directives["default-src"]
assert "'unsafe-inline'" in directives["script-src"]
assert "'none'" in directives["connect-src"]
assert "'none'" in directives["frame-src"]
# Same-origin embedders only.
assert "'self'" in directives["frame-ancestors"]
# Override the global DENY so host chat can iframe us.
assert r.headers["x-frame-options"].upper() == "SAMEORIGIN"
assert "no-store" in r.headers["cache-control"]
def test_get_is_not_auth_gated(self, preview_app):
# Iframe subresource loads do not carry Authorization; the URL
# token is the authorisation.
app, token, _mod = preview_app
url = self._create(app, token, "<p>nope</p>")
c = TestClient(app)
r = c.get(url) # no Authorization header
assert r.status_code == 200
def test_get_unknown_token_is_404(self, preview_app):
app, _token, _mod = preview_app
c = TestClient(app)
r = c.get("/api/preview/html/totally-not-a-real-token")
assert r.status_code == 404
def test_get_expired_token_is_404(self, preview_app):
app, token, mod = preview_app
url = self._create(app, token, "<p>aging</p>")
# Rewind monotonic past the TTL.
token_id = url.rsplit("/", 1)[-1]
created, src = mod._PREVIEWS[token_id]
mod._PREVIEWS[token_id] = (created - (mod.PREVIEW_TTL_SECONDS + 5), src)
c = TestClient(app)
r = c.get(url)
assert r.status_code == 404
# Swept on access.
assert token_id not in mod._PREVIEWS
class TestEviction:
def test_overflow_evicts_oldest_entries(self, preview_app):
app, token, mod = preview_app
c = TestClient(app)
mod.MAX_LIVE_PREVIEWS = 4 # cheap test
urls = []
for i in range(6):
r = c.post(
"/api/preview/html",
json = {"source": f"<p>{i}</p>"},
headers = {"Authorization": f"Bearer {token}"},
)
urls.append(r.json()["url"])
time.sleep(0.001) # deterministic monotonic order
assert len(mod._PREVIEWS) == mod.MAX_LIVE_PREVIEWS
# Oldest two evicted.
for old in urls[:2]:
token_id = old.rsplit("/", 1)[-1]
assert token_id not in mod._PREVIEWS
# Newest survive.
for fresh in urls[-mod.MAX_LIVE_PREVIEWS :]:
token_id = fresh.rsplit("/", 1)[-1]
assert token_id in mod._PREVIEWS

View file

@ -79,8 +79,7 @@ class TestMaxBodyMiddleware:
assert r.json()["unprotected"] is True
def test_chunked_upload_over_cap_rejected(self, main_module):
# Regression: declared-Content-Length-only check could be bypassed
# by chunked transfer-encoding.
# Regression: Content-Length-only check missed chunked uploads.
app = _make_protected_app(1024, main_module)
c = TestClient(app)
@ -156,7 +155,7 @@ class TestSecurityHeadersMiddleware:
r = c.get("/plain")
assert r.status_code == 200
csp = r.headers["content-security-policy"]
# Parse per-directive so style-src unsafe-inline does not false-match.
# Parse per directive so style-src unsafe-inline does not false-match.
directives = {
chunk.strip().split(" ", 1)[0]: chunk.strip()
for chunk in csp.split(";")
@ -184,7 +183,7 @@ class TestSecurityHeadersMiddleware:
r = c.get("/with-nonce")
csp = r.headers["content-security-policy"]
assert f"'nonce-{nonce}'" in csp
# Internal handoff header must not leak to clients.
# Internal handoff header must not leak.
assert main_module._CSP_SCRIPT_NONCE_HEADER not in {
k.lower() for k in r.headers.keys()
}
@ -196,17 +195,34 @@ class TestSecurityHeadersMiddleware:
nonced = main_module._build_csp("XYZ")
assert "script-src 'self' 'nonce-XYZ';" in nonced
def test_frame_src_is_explicitly_self_only(self, main_module):
# SVG preview uses srcdoc, HTML preview uses /api/preview/html
# (same-origin). srcdoc / data: / blob: all inherit this CSP per
# CSP3, so broadening here would not unlock scripts. Explicit
# 'self' leaves a visible directive future changes must broaden.
csp = main_module._build_csp()
frame_src = next(
chunk.strip()
for chunk in csp.split(";")
if chunk.strip().startswith("frame-src ")
)
tokens = frame_src.split()
assert tokens[0] == "frame-src"
assert "'self'" in tokens
assert "data:" not in tokens
assert "blob:" not in tokens
def test_img_src_allows_google_favicons(self, main_module):
# sources.tsx fetches https://www.google.com/s2/favicons?... ; without
# this allowlist entry citation favicons fall back to gray initials.
# sources.tsx fetches https://www.google.com/s2/favicons?...;
# without this, citation favicons fall back to grey initials.
csp = main_module._build_csp()
img_directive = next(
chunk.strip()
for chunk in csp.split(";")
if chunk.strip().startswith("img-src ")
)
# Tokenise and compare with `==` so CodeQL's URL-substring rule does
# not read directive-string `in` membership as URL sanitisation.
# Tokenise + `==`: CodeQL's URL-substring rule would otherwise
# read `in` membership as URL sanitisation.
img_sources = img_directive.split()
assert any(src == "https://www.google.com" for src in img_sources)
# Pre-existing favicon CDNs stay allowed.
@ -250,9 +266,9 @@ def health_app(tmp_path, monkeypatch):
class TestHealthAuthGate:
# Launcher / frontend bootstrap fields are available unauth so the Tauri
# watchdog can re-adopt a sibling backend and the SPA can detect chat-only
# mode before any token exists. Version / device_type still require a bearer.
# Bootstrap fields are unauth so the Tauri watchdog can re-adopt a
# sibling backend and SPA can detect chat-only before any token exists.
# Version / device_type still require a bearer.
LAUNCHER_BITS = (
"service",
"studio_root_id",
@ -279,7 +295,7 @@ class TestHealthAuthGate:
assert forbidden not in body
def test_invalid_bearer_returns_launcher_bits_only(self, health_app):
# Regression: calling the async dep without await made any Bearer header pass.
# Regression: missing await on the async dep let any Bearer pass.
c = TestClient(health_app)
r = c.get(
"/api/health",

View file

@ -0,0 +1,11 @@
# Reviewed install-script (postinstall) packages, exempt from
# scripts/check_new_install_scripts.py. Pin each as "name@version" -- a
# maintainer compromise shipping a new malicious version must NOT slip
# through the same allowlist line.
#
# Rules: read the install body before adding. Entries MUST land on main
# first; the gate rejects head-only additions that self-approve a new dep.
# evanw/esbuild: postinstall downloads platform native binary. Pulled in
# transitively by vitest for dev-only test transforms; no runtime exposure.
esbuild@0.21.5

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,9 @@
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"biome:check": "biome check",
"biome:fix": "biome check --write"
"biome:fix": "biome check --write",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@assistant-ui/core": "0.1.17",
@ -53,6 +55,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dexie": "^4.3.0",
"dompurify": "^3.4.2",
"fflate": "0.8.3",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
@ -91,13 +94,18 @@
"@types/node": "^25.5.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^25.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.55.0",
"vite": "^8.0.1"
"vite": "^8.0.1",
"vitest": "^2.1.9"
}
}

View file

@ -0,0 +1,397 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
HtmlSvgRenderer,
isHtmlFence,
isSvgFence,
parseCodeFence,
parseIncompleteCodeFence,
sanitizeSvgSource,
} from "../html-svg-renderer";
// Fake the /api/preview/html POST round-trip so the iframe reaches a
// deterministic post-load state under jsdom.
let _previewIdCounter = 0;
function installFetchStub(): void {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
if (typeof input === "string" && input === "/api/preview/html") {
const url = `/api/preview/html/test-token-${++_previewIdCounter}`;
return new Response(JSON.stringify({ url }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
}
function installFailingFetchStub(): void {
globalThis.fetch = vi.fn(async () =>
new Response("server boom", { status: 500 }),
) as typeof fetch;
}
describe("HtmlSvgRenderer", () => {
beforeEach(() => {
installFetchStub();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders an HTML preview inside a sandboxed iframe pointing at the same-origin preview route", async () => {
const html = "<html><body><h1>hello</h1></body></html>";
render(<HtmlSvgRenderer language="html" source={html} />);
const root = screen.getByTestId("html-svg-renderer");
expect(root.getAttribute("data-active-tab")).toBe("preview");
expect(root.getAttribute("data-language")).toBe("html");
const iframe = screen.getByTestId(
"html-svg-renderer-iframe",
) as HTMLIFrameElement;
expect(iframe.tagName).toBe("IFRAME");
// allow-scripts/allow-modals/allow-popups grant the runtime the
// route CSP unlocks; NOT granting allow-same-origin / allow-top-nav /
// allow-popups-to-escape-sandbox blocks parent access and tabnabbing.
const sandbox = iframe.getAttribute("sandbox") ?? "";
const sandboxTokens = sandbox.split(/\s+/);
expect(sandboxTokens).toContain("allow-scripts");
expect(sandboxTokens).toContain("allow-modals");
expect(sandboxTokens).toContain("allow-popups");
expect(sandboxTokens).not.toContain("allow-popups-to-escape-sandbox");
expect(sandbox).not.toContain("allow-same-origin");
expect(sandbox).not.toContain("allow-top-navigation");
// In-flight: about:blank (no flash of previous preview).
expect(["about:blank", null]).toContain(iframe.getAttribute("src"));
// After POST resolves: iframe src is the returned same-origin URL.
await waitFor(() => {
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
});
const src = iframe.getAttribute("src") ?? "";
expect(src.startsWith("/api/preview/html/")).toBe(true);
expect(iframe.getAttribute("srcdoc")).toBeNull();
});
it("falls back to srcdoc with the defense-in-depth meta CSP when the preview API is unreachable", async () => {
installFailingFetchStub();
const html = "<html><body><h1>fallback</h1></body></html>";
render(<HtmlSvgRenderer language="html" source={html} />);
const iframe = screen.getByTestId(
"html-svg-renderer-iframe",
) as HTMLIFrameElement;
await waitFor(() => {
expect(iframe.getAttribute("data-preview-state")).toBe("error");
});
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc) ?? "";
expect(srcdoc).toContain("<h1>fallback</h1>");
expect(srcdoc).toContain('http-equiv="Content-Security-Policy"');
expect(srcdoc).toContain("connect-src 'none'");
expect(srcdoc).toContain("frame-src 'none'");
});
it("renders an SVG preview inside a no-script sandboxed iframe with srcdoc carrying the sanitized markup", () => {
const malicious = `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50">
<circle cx="25" cy="25" r="20" fill="blue" onclick="alert('pwn')" />
<script>window.parent.alert("pwn")</script>
</svg>`;
render(<HtmlSvgRenderer language="svg" source={malicious} />);
const iframe = screen.getByTestId(
"html-svg-renderer-svg-preview",
) as HTMLIFrameElement;
expect(iframe.tagName).toBe("IFRAME");
// SVG iframe must NEVER allow scripts or same-origin (XSS / host leak).
expect(iframe.getAttribute("sandbox")).toBe("");
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toLowerCase();
expect(srcdoc).toContain("<circle");
expect(srcdoc).not.toContain("<script");
expect(srcdoc).not.toContain("onclick");
expect(srcdoc).not.toContain("alert");
// CSP backstop: data: images only so a sanitizer regression cannot beacon.
expect(srcdoc).toContain("default-src 'none'");
});
it("toggles between Preview and Code tabs", async () => {
const html = "<html><body>hi</body></html>";
render(
<HtmlSvgRenderer
language="html"
source={html}
codeView={<pre data-testid="custom-code-view">{html}</pre>}
/>,
);
// Default tab is preview.
const iframe = screen.getByTestId(
"html-svg-renderer-iframe",
) as HTMLIFrameElement;
expect(iframe).toBeTruthy();
expect(screen.queryByTestId("custom-code-view")).toBeNull();
// Settle the preview POST so it does not raise an out-of-act() warning.
await waitFor(() => {
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
});
const codeTab = screen.getByRole("tab", { name: /code/i });
act(() => {
fireEvent.click(codeTab);
});
// Iframe is unmounted, custom code view appears.
expect(screen.queryByTestId("html-svg-renderer-iframe")).toBeNull();
expect(screen.getByTestId("custom-code-view")).toBeTruthy();
const previewTab = screen.getByRole("tab", { name: /preview/i });
act(() => {
fireEvent.click(previewTab);
});
expect(screen.getByTestId("html-svg-renderer-iframe")).toBeTruthy();
expect(screen.queryByTestId("custom-code-view")).toBeNull();
});
it("locks to the Code tab while the fence is still streaming in", () => {
const html = "<html><body>partial";
render(
<HtmlSvgRenderer language="html" source={html} isIncomplete={true} />,
);
const root = screen.getByTestId("html-svg-renderer");
expect(root.getAttribute("data-active-tab")).toBe("code");
// Preview disabled while streaming so users see tokens, not flicker.
const previewTab = screen.getByRole("tab", { name: /preview/i });
expect(previewTab.hasAttribute("disabled")).toBe(true);
});
it("HtmlPreview POSTs the assistant source to /api/preview/html before mounting the iframe src", async () => {
const html = "<button onclick=\"alert('x')\">go</button>";
render(<HtmlSvgRenderer language="html" source={html} />);
const iframe = screen.getByTestId(
"html-svg-renderer-iframe",
) as HTMLIFrameElement;
await waitFor(() => {
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
});
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock
.calls[0] as [string, RequestInit];
expect(url).toBe("/api/preview/html");
expect(init.method).toBe("POST");
expect(init.credentials).toBe("same-origin");
const parsedBody = JSON.parse(init.body as string) as { source: string };
expect(parsedBody.source).toBe(html);
// iframe lands on the token URL; no srcdoc.
const src = iframe.getAttribute("src") ?? "";
expect(src.startsWith("/api/preview/html/")).toBe(true);
expect(iframe.getAttribute("srcdoc")).toBeNull();
});
it("wires tabs to their panels with aria-controls / aria-labelledby", async () => {
const html = "<html><body>hi</body></html>";
render(<HtmlSvgRenderer language="html" source={html} />);
const iframe = screen.getByTestId(
"html-svg-renderer-iframe",
) as HTMLIFrameElement;
await waitFor(() => {
expect(iframe.getAttribute("data-preview-state")).toBe("ready");
});
const previewTab = screen.getByRole("tab", { name: /preview/i });
const codeTab = screen.getByRole("tab", { name: /code/i });
const panel = screen.getByRole("tabpanel");
const controls = previewTab.getAttribute("aria-controls");
expect(controls).toBeTruthy();
expect(panel.getAttribute("id")).toBe(controls);
expect(panel.getAttribute("aria-labelledby")).toBe(
previewTab.getAttribute("id"),
);
// Roving tabindex per WAI-ARIA APG tab pattern.
expect(previewTab.getAttribute("tabindex")).toBe("0");
expect(codeTab.getAttribute("tabindex")).toBe("-1");
});
it("constrains the SVG preview so a square viewBox does not overflow", () => {
const svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 200 200\"><circle cx=\"100\" cy=\"100\" r=\"95\" fill=\"red\"/></svg>";
render(<HtmlSvgRenderer language="svg" source={svg} />);
const iframe = screen.getByTestId(
"html-svg-renderer-svg-preview",
) as HTMLIFrameElement;
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toLowerCase();
// Cap BOTH dimensions or a square viewBox clips vertically.
expect(srcdoc).toContain("max-width:100%");
expect(srcdoc).toContain("max-height:100%");
expect(srcdoc).toContain("height:100%");
});
});
describe("parseIncompleteCodeFence", () => {
it("returns lang and body for a fence that has not closed yet", () => {
const partial = "```svg\n<svg><circle";
const fence = parseIncompleteCodeFence(partial);
expect(fence).not.toBeNull();
expect(fence?.language).toBe("svg");
expect(fence?.source).toBe("<svg><circle");
});
it("strips an in-flight trailing ``` so the partial body does not leak it", () => {
const partial = "```html\n<div>hi</div>\n``";
const fence = parseIncompleteCodeFence(partial);
expect(fence?.source).toBe("<div>hi</div>\n``");
});
it("returns null when the block is not a fence at all", () => {
expect(parseIncompleteCodeFence("just text")).toBeNull();
});
it("recovers a final-but-never-closed fence (small LLMs drop the closing ```)", () => {
// Regression caught by live Qwen3-0.6B probe: complete body, no closing
// ```. Without this path HtmlSvgRenderer would not mount.
const finalNoClose =
"```svg\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 10 10\">" +
"<circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"orange\"/></svg>";
expect(parseCodeFence(finalNoClose)).toBeNull();
const fence = parseIncompleteCodeFence(finalNoClose);
expect(fence).not.toBeNull();
expect(fence?.language).toBe("svg");
expect(fence?.source).toContain("<circle");
expect(fence?.source).toContain("</svg>");
});
});
describe("Fence helpers", () => {
it("parses a typical markdown code fence", () => {
const block = "```python\nprint('hi')\n```";
const fence = parseCodeFence(block);
expect(fence).not.toBeNull();
expect(fence?.language).toBe("python");
expect(fence?.source).toBe("print('hi')");
});
it("isSvgFence picks up explicit svg fences and html/xml fences that begin with <svg", () => {
expect(isSvgFence({ language: "svg", source: "<svg/>" })).toBe(true);
expect(
isSvgFence({ language: "html", source: "<svg xmlns='...'></svg>" }),
).toBe(true);
expect(
isSvgFence({
language: "xml",
source: "<?xml version='1.0'?><svg></svg>",
}),
).toBe(true);
expect(isSvgFence({ language: "html", source: "<div></div>" })).toBe(false);
});
it("isHtmlFence is true only for non-SVG html fences", () => {
expect(isHtmlFence({ language: "html", source: "<div></div>" })).toBe(true);
expect(isHtmlFence({ language: "html", source: "<svg></svg>" })).toBe(
false,
);
expect(isHtmlFence({ language: "python", source: "print()" })).toBe(false);
});
it("non-HTML / non-SVG fences are not handled by the renderer", () => {
// markdown-text.tsx invokes HtmlSvgRenderer only when these agree.
const fence = parseCodeFence("```python\nprint('hi')\n```");
expect(fence).not.toBeNull();
if (!fence) return;
expect(isSvgFence(fence)).toBe(false);
expect(isHtmlFence(fence)).toBe(false);
});
});
describe("sanitizeSvgSource", () => {
it("removes <script>, on* handlers, and javascript: URLs", () => {
const malicious = `<svg xmlns="http://www.w3.org/2000/svg">
<a href="javascript:alert(1)"><circle cx="5" cy="5" r="4" onload="alert(1)"/></a>
<script>alert("pwn")</script>
</svg>`;
const clean = sanitizeSvgSource(malicious).toLowerCase();
expect(clean).not.toContain("<script");
expect(clean).not.toContain("onload");
expect(clean).not.toContain("javascript:");
expect(clean).toContain("<circle");
});
it("drops <?xml ... ?> processing instructions", () => {
const svg = `<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`;
const clean = sanitizeSvgSource(svg);
expect(clean.startsWith("<?xml")).toBe(false);
expect(clean).toContain("<rect");
});
it("keeps inline <style> blocks so class-styled SVG exports still render", () => {
// Iframe sandbox="" + default-src 'none' contain <style> blast radius;
// diagram exporters relying on class styles outweigh the selector leak.
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><style>.fg{fill:red}</style><rect class="fg"/></svg>`;
const clean = sanitizeSvgSource(svg).toLowerCase();
expect(clean).toContain("<style");
expect(clean).toContain(".fg");
expect(clean).toContain("<rect");
expect(clean).toContain('class="fg"');
});
it("strips style attributes so inline CSS cannot fire url()/@import requests", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><circle style="background:url(https://evil.example/x)"/></svg>`;
const clean = sanitizeSvgSource(svg).toLowerCase();
expect(clean).toContain("<circle");
expect(clean).not.toContain("style=");
expect(clean).not.toContain("evil.example");
});
it("drops <image>/<use> tags so SVG cannot beacon to external URLs", () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image href="https://evil.example/pixel"/><use xlink:href="https://evil.example/use"/></svg>`;
const clean = sanitizeSvgSource(svg).toLowerCase();
expect(clean).not.toContain("<image");
expect(clean).not.toContain("<use");
expect(clean).not.toContain("evil.example");
});
it("strips filter/mask/clip-path url(...) attrs that would still fetch", () => {
const svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
"<circle filter=\"url(https://evil.example/f)\" mask=\"url(https://evil.example/m)\" clip-path=\"url(https://evil.example/c)\" r=\"10\"/>" +
"</svg>";
const clean = sanitizeSvgSource(svg).toLowerCase();
expect(clean).toContain("<circle");
expect(clean).not.toContain("filter=");
expect(clean).not.toContain("mask=");
expect(clean).not.toContain("clip-path=");
expect(clean).not.toContain("evil.example");
});
it("keeps safe same-document fragment hrefs used by textPath/gradients", () => {
const svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
"<defs><linearGradient id=\"g1\"/></defs>" +
"<text><textPath href=\"#labelPath\">hi</textPath></text>" +
"<circle fill=\"url(#g1)\" r=\"10\"/>" +
"</svg>";
const clean = sanitizeSvgSource(svg).toLowerCase();
// Fragment hrefs must survive so textPath / gradient refs resolve.
expect(clean).toContain("href=\"#labelpath\"");
});
it("strips external-scheme hrefs even though same-doc fragments are kept", () => {
const svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
"<a href=\"https://evil.example/exfil\"><circle r=\"10\"/></a>" +
"</svg>";
const clean = sanitizeSvgSource(svg).toLowerCase();
expect(clean).not.toContain("evil.example");
expect(clean).not.toContain("href=\"https");
});
});

View file

@ -0,0 +1,621 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import DOMPurify from "dompurify";
import { CodeIcon, EyeIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
export type HtmlSvgLanguage = "html" | "svg";
export type HtmlSvgRendererProps = {
language: HtmlSvgLanguage;
source: string;
// Optional syntax-highlighted code view; falls back to plain <pre><code>.
codeView?: ReactNode;
// Partial fence: lock to Code tab and disable toggle.
isIncomplete?: boolean;
};
const DEFAULT_PREVIEW_HEIGHT = 500;
const POPOUT_HEIGHT_VH = 80;
const COPY_RESET_MS = 2000;
// Belt-and-braces pre-screen; DOMPurify does the real work below.
const HEURISTIC_UNSAFE_SVG_RE =
/<script[\s>]|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
// SVG sanitizer config. Surviving markup is rendered in the sandboxed
// SvgPreview iframe below as a second layer.
const SVG_PURIFY_CONFIG = {
USE_PROFILES: { svg: true, svgFilters: true },
// image/use carry href, foreignObject embeds HTML, script/link/meta/iframe/
// embed/object are XSS or network surfaces. <style> stays -- the iframe
// sandbox + default-src 'none' CSP cap its blast radius.
FORBID_TAGS: [
"script",
"foreignObject",
"iframe",
"embed",
"object",
"image",
"use",
"link",
"meta",
],
// filter / mask / clip-path accept url(...) which fetches at render time;
// style allows inline @import / url(...). href / xlink:href stay so safe
// same-doc fragments survive -- external schemes pruned in the hook below.
FORBID_ATTR: ["style", "filter", "mask", "clip-path"],
ALLOW_DATA_ATTR: false,
};
// Pin href / xlink:href to same-doc fragments. Done as a hook because
// DOMPurify's ALLOWED_URI_REGEXP also rejects presentation attrs (cx, r, ...).
// Installed once at module load.
const FRAGMENT_HREF_HOOK_TAG = "__unsloth_svg_frag_href__";
const URI_ATTRS = new Set(["href", "xlink:href"]);
if (!(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG]) {
DOMPurify.addHook("uponSanitizeAttribute", (_node, data) => {
if (!URI_ATTRS.has(data.attrName)) return;
const value = (data.attrValue ?? "").trim();
if (!value.startsWith("#")) {
data.keepAttr = false;
}
});
(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG] =
true;
}
/** Strip XML PIs and disallowed nodes from an SVG. */
export function sanitizeSvgSource(source: string): string {
// Drop XML declarations -- DOMPurify keeps them, some renderers choke.
const stripped = source.replace(/^\s*<\?xml[^?]*\?>\s*/i, "");
if (HEURISTIC_UNSAFE_SVG_RE.test(stripped)) {
// eslint-disable-next-line no-console
console.debug("SVG renderer: stripping unsafe nodes before sanitize");
}
return DOMPurify.sanitize(stripped, SVG_PURIFY_CONFIG);
}
function useCopyState() {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const flash = useCallback(() => {
setCopied(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, COPY_RESET_MS);
}, []);
return { copied, flash };
}
function CopyButton({ source }: { source: string }) {
const { copied, flash } = useCopyState();
return (
<button
type="button"
title="Copy code"
aria-label="Copy code"
className={cn(
"flex size-8 cursor-pointer items-center justify-center rounded-[10px]",
"text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
)}
onClick={async () => {
if (await copyToClipboard(source)) flash();
}}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
);
}
type TabKey = "preview" | "code";
function TabButton({
active,
disabled,
icon,
id,
controls,
label,
onSelect,
}: {
active: boolean;
disabled?: boolean;
icon: ReactNode;
id: string;
controls: string;
label: string;
onSelect: () => void;
}) {
return (
<button
type="button"
role="tab"
id={id}
aria-controls={controls}
aria-selected={active}
tabIndex={active ? 0 : -1}
disabled={disabled}
onClick={onSelect}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
>
{icon}
{label}
</button>
);
}
// Defence in depth on top of the sanitizer.
const SVG_IFRAME_CSP =
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:;";
function buildSvgSrcDoc(safeSvg: string): string {
return [
"<!doctype html>",
`<meta http-equiv="Content-Security-Policy" content="${SVG_IFRAME_CSP}">`,
// Cap both dimensions so a square viewBox does not clip vertically.
"<style>html,body{margin:0;padding:0;height:100%;background:white;}",
"body{display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;}",
"svg{max-width:100%;max-height:100%;width:auto;height:auto;}</style>",
safeSvg,
].join("");
}
function SvgPreview({ source }: { source: string }) {
const srcDoc = useMemo(() => buildSvgSrcDoc(sanitizeSvgSource(source)), [
source,
]);
return (
<iframe
data-testid="html-svg-renderer-svg-preview"
title="SVG preview"
srcDoc={srcDoc}
// No scripts, no same-origin: SVG cannot touch host document or network.
sandbox=""
style={{
width: "100%",
height: 360,
border: "none",
display: "block",
background: "white",
}}
/>
);
}
// Iframe posts its scrollHeight to the parent for auto-sizing. One-way:
// no allow-same-origin, so iframe cannot read parent.document.
const HTML_PREVIEW_HEIGHT_REPORTER =
'<script>(()=>{const post=()=>parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");window.addEventListener("load",post);new ResizeObserver(post).observe(document.documentElement);})();</script>';
// Fallback for when the preview route is unreachable. srcdoc inherits the
// host script-src 'self' per CSP3 so inline scripts will NOT run on this
// path -- it is layout-only. Interactive surface is the API route below.
const HTML_IFRAME_CSP = [
"default-src 'none'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src data: blob:",
"media-src data: blob:",
"font-src data:",
"connect-src 'none'",
"worker-src 'none'",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
].join("; ");
function buildHtmlSrcDoc(source: string): string {
return [
"<!doctype html>",
`<meta http-equiv="Content-Security-Policy" content="${HTML_IFRAME_CSP}">`,
'<base target="_blank">',
source,
HTML_PREVIEW_HEIGHT_REPORTER,
].join("");
}
// Same-origin route whose response CSP permits inline scripts. POST source,
// use returned random-token URL as iframe src.
const HTML_PREVIEW_API = "/api/preview/html";
// Same key as features/auth/session.ts:AUTH_TOKEN_KEY; inlined to avoid cycle.
function getStoredAccessToken(): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem("unsloth_auth_token");
} catch {
return null;
}
}
type PreviewState =
| { kind: "loading" }
| { kind: "ready"; url: string }
| { kind: "error" };
function HtmlPreview({
source,
popped,
onHeightChange,
}: {
source: string;
popped: boolean;
onHeightChange?: (h: number | null) => void;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// POST source -> token URL. srcdoc / data: / blob: inherit the host CSP
// per CSP3, so a same-origin route is the only way to unlock inline scripts.
const [previewState, setPreviewState] = useState<PreviewState>({
kind: "loading",
});
useEffect(() => {
let cancelled = false;
setPreviewState({ kind: "loading" });
onHeightChange?.(null);
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const token = getStoredAccessToken();
if (token) headers.Authorization = `Bearer ${token}`;
void fetch(HTML_PREVIEW_API, {
method: "POST",
credentials: "same-origin",
headers,
body: JSON.stringify({ source }),
})
.then(async (r) => {
if (!r.ok) throw new Error(`HTML preview HTTP ${r.status}`);
return (await r.json()) as { url: string };
})
.then(({ url }) => {
if (cancelled) return;
if (typeof url !== "string" || !url.startsWith("/api/preview/html/")) {
throw new Error("HTML preview returned an unexpected URL shape");
}
setPreviewState({ kind: "ready", url });
})
.catch(() => {
if (cancelled) return;
setPreviewState({ kind: "error" });
});
return () => {
cancelled = true;
};
}, [source, onHeightChange]);
const [autoHeight, setAutoHeight] = useState<number | null>(null);
useEffect(() => {
setAutoHeight(null);
}, [previewState]);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
const raw = (e.data as { htmlPreviewHeight?: unknown })
?.htmlPreviewHeight;
if (typeof raw === "number" && Number.isFinite(raw)) {
const next = Math.max(100, raw);
setAutoHeight(next);
onHeightChange?.(next);
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [onHeightChange]);
const iframeHeight = popped
? "100%"
: Math.min(autoHeight ?? DEFAULT_PREVIEW_HEIGHT, DEFAULT_PREVIEW_HEIGHT);
// Backend unreachable: fall back to srcdoc (static layout, no scripts)
// instead of going blank.
const errorSrcDoc = useMemo(
() => (previewState.kind === "error" ? buildHtmlSrcDoc(source) : null),
[previewState.kind, source],
);
return (
<iframe
ref={iframeRef}
data-testid="html-svg-renderer-iframe"
data-preview-state={previewState.kind}
title="HTML preview"
src={previewState.kind === "ready" ? previewState.url : "about:blank"}
srcDoc={errorSrcDoc ?? undefined}
// allow-scripts: inline JS runs (route CSP permits it).
// allow-modals: alert / confirm / prompt usable.
// allow-popups: <base target="_blank"> links open a tab.
// NOT granted:
// allow-same-origin / allow-top-navigation -- preview JS cannot
// reach parent.document or navigate the host (URL is same-origin
// but iframe is treated as opaque).
// allow-popups-to-escape-sandbox -- opened tabs inherit sandbox so
// window.opener.top.location.* tabnabbing is blocked.
sandbox="allow-scripts allow-modals allow-popups"
style={{
width: "100%",
height: iframeHeight,
border: "none",
display: "block",
background: "white",
}}
/>
);
}
export function HtmlSvgRenderer({
language,
source,
codeView,
isIncomplete,
}: HtmlSvgRendererProps) {
// Lock to Code while streaming so users see tokens, not a flashing preview.
const lockedToCode = Boolean(isIncomplete);
const [tab, setTab] = useState<TabKey>("preview");
const [popped, setPopped] = useState(false);
// Lifted from HtmlPreview so the pop-out spacer can match current height.
const [htmlHeight, setHtmlHeight] = useState<number | null>(null);
const activeTab: TabKey = lockedToCode ? "code" : tab;
const reactId = useId();
const previewTabId = `${reactId}-tab-preview`;
const codeTabId = `${reactId}-tab-code`;
const previewPanelId = `${reactId}-panel-preview`;
const codePanelId = `${reactId}-panel-code`;
// Escape key exits the popout view.
useEffect(() => {
if (!popped) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setPopped(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [popped]);
const codeFallback = useMemo(
() =>
codeView ?? (
<pre className="overflow-x-auto rounded-md bg-muted/40 p-3 text-xs">
<code>{source}</code>
</pre>
),
[codeView, source],
);
const onHtmlHeight = useCallback((h: number | null) => setHtmlHeight(h), []);
const preview =
language === "svg" ? (
<SvgPreview source={source} />
) : (
<HtmlPreview
source={source}
popped={popped}
onHeightChange={onHtmlHeight}
/>
);
const previewLabel = language === "svg" ? "SVG preview" : "HTML preview";
// Match live iframe height so popping out a short preview does not leave
// a 500px hole. Falls back to DEFAULT before the first height post.
const popoutSpacerHeight = Math.min(
htmlHeight ?? DEFAULT_PREVIEW_HEIGHT,
DEFAULT_PREVIEW_HEIGHT,
);
return (
<div
data-testid="html-svg-renderer"
data-language={language}
data-active-tab={activeTab}
className="my-4 overflow-hidden rounded-xl border border-border"
>
<div
role="tablist"
aria-label={`${language.toUpperCase()} fence view`}
className="flex items-center justify-between gap-2 border-b border-border bg-muted/40 px-2 py-1.5"
>
<div className="flex items-center gap-1 rounded-lg bg-muted p-0.5">
<TabButton
active={activeTab === "preview"}
disabled={lockedToCode}
icon={<EyeIcon className="size-3.5" />}
id={previewTabId}
controls={previewPanelId}
label="Preview"
onSelect={() => setTab("preview")}
/>
<TabButton
active={activeTab === "code"}
icon={<CodeIcon className="size-3.5" />}
id={codeTabId}
controls={codePanelId}
label="Code"
onSelect={() => setTab("code")}
/>
</div>
<div className="flex items-center gap-1">
{activeTab === "code" && !codeView ? (
// Custom code views ship their own copy/download chrome
// (CodeBlockActions in markdown-text.tsx); only show Copy
// when we are using the plain <pre> fallback.
<CopyButton source={source} />
) : null}
{activeTab === "preview" && language === "html" ? (
<button
type="button"
title={popped ? "Exit pop out" : "Pop out preview"}
aria-label={popped ? "Exit pop out" : "Pop out preview"}
className={cn(
"flex size-8 cursor-pointer items-center justify-center rounded-[10px]",
"text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover",
)}
onClick={() => setPopped((p) => !p)}
>
{popped ? (
<Minimize2Icon className="size-4" />
) : (
<Maximize2Icon className="size-4" />
)}
</button>
) : null}
</div>
</div>
<div data-testid="html-svg-renderer-body" className="bg-background">
{activeTab === "preview" ? (
popped && language === "html" ? (
<>
{/* Keep layout stable behind the modal. */}
<div
style={{ height: popoutSpacerHeight }}
aria-hidden={true}
/>
<div
role="dialog"
aria-modal="true"
aria-label="HTML preview pop out"
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setPopped(false);
}}
>
<div className="flex items-center justify-end px-4 py-2">
<button
type="button"
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setPopped(false)}
title="Exit pop out (Esc)"
>
<Minimize2Icon className="size-4" />
Exit pop out
</button>
</div>
<div
className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background"
style={{ maxHeight: `${POPOUT_HEIGHT_VH}vh` }}
>
{preview}
</div>
</div>
</>
) : (
<div
role="tabpanel"
id={previewPanelId}
aria-labelledby={previewTabId}
aria-label={previewLabel}
>
{preview}
</div>
)
) : (
<div
role="tabpanel"
id={codePanelId}
aria-labelledby={codeTabId}
data-testid="html-svg-renderer-code"
className="min-w-0"
>
{codeFallback}
</div>
)}
</div>
</div>
);
}
// ---- Fence helpers (exported for tests + reuse from markdown-text.tsx) ----
export type CodeFenceInfo = {
language: string | null;
source: string;
};
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
// Open fence: no closing ``` yet. Lets HtmlSvgRenderer mount mid-stream
// (otherwise the markdown pipeline falls through until the close arrives).
const OPEN_CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*)$/;
export function parseCodeFence(blockContent: string): CodeFenceInfo | null {
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
if (!match) return null;
return {
language: match[1]?.trim() || null,
source: match[2],
};
}
/** Parse a code fence that may still be streaming (no closing ``` yet). */
export function parseIncompleteCodeFence(
blockContent: string,
): CodeFenceInfo | null {
const match = blockContent.match(OPEN_CODE_FENCE_RE);
if (!match) return null;
// Strip a trailing partial ``` so mid-close captures do not leak it.
const body = match[2].replace(/\r?\n?```\s*$/, "");
return {
language: match[1]?.trim() || null,
source: body,
};
}
export function isSvgFence(fence: CodeFenceInfo): boolean {
const lang = fence.language?.toLowerCase() ?? "";
if (lang === "svg") return true;
if (lang === "xml" || lang === "html") {
const trimmed = fence.source.trimStart();
if (trimmed.startsWith("<svg")) return true;
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
}
return false;
}
export function isHtmlFence(fence: CodeFenceInfo): boolean {
const lang = fence.language?.toLowerCase() ?? "";
return lang === "html" && !isSvgFence(fence);
}

View file

@ -12,12 +12,20 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { DownloadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
import {
HtmlSvgRenderer,
isHtmlFence,
isSvgFence,
parseCodeFence,
parseIncompleteCodeFence,
type CodeFenceInfo,
} from "./html-svg-renderer";
const math = createMathPlugin({ singleDollarTextMath: true });
const code = createCodePlugin({
@ -48,34 +56,16 @@ const STREAMDOWN_COMPONENTS = {
};
const COPY_RESET_MS = 2000;
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
const ACTION_PANEL_CLASS =
"pointer-events-auto flex shrink-0 items-center gap-1";
const ACTION_BUTTON_CLASS =
"flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50";
type CodeFence = {
language: string | null;
source: string;
};
function getMermaidSource(blockContent: string): string | null {
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
return source && source.length > 0 ? source : null;
}
function getCodeFence(blockContent: string): CodeFence | null {
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
if (!match) {
return null;
}
return {
language: match[1]?.trim() || null,
source: match[2],
};
}
function getCodeFilename(language: string | null) {
const extByLanguage: Record<string, string> = {
bash: "sh",
@ -106,135 +96,6 @@ function getCodeFilename(language: string | null) {
return `snippet.${ext}`;
}
function isSvgFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
if (lang === "svg") return true;
if (lang === "xml" || lang === "html") {
const trimmed = codeFence.source.trimStart();
// Match <svg directly or <?xml ...?> followed by <svg
if (trimmed.startsWith("<svg")) return true;
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
}
return false;
}
function isHtmlFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
return lang === "html" && !isSvgFence(codeFence);
}
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
function sanitizeSvg(source: string): string | null {
if (UNSAFE_SVG_RE.test(source)) return null;
// Strip XML declaration (<?xml ...?>) -- not needed for data URI
// rendering and can cause issues with some renderers.
return source.replace(/^\s*<\?xml[^?]*\?>\s*/i, "");
}
function SvgPreview({ source }: { source: string }) {
const dataUri = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`;
return (
<div className="mt-2 flex justify-center rounded-lg border border-border bg-white p-4 dark:bg-neutral-100">
<img
src={dataUri}
alt="SVG preview"
style={{ maxWidth: "100%", maxHeight: 512 }}
/>
</div>
);
}
const HTML_PREVIEW_DEFAULT_HEIGHT = 400;
const HTML_PREVIEW_MAX_HEIGHT = 800;
function HtmlPreview({ source }: { source: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT);
const [enlarged, setEnlarged] = useState(false);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
if (typeof e.data?.htmlPreviewHeight === "number") {
setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT));
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
useEffect(() => {
if (!enlarged) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setEnlarged(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [enlarged]);
const resizeScript = `<script>new ResizeObserver(()=>{
parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");
}).observe(document.documentElement);</script>`;
const srcDoc = source + resizeScript;
if (enlarged) {
return (
<>
<div className="mt-2 overflow-hidden rounded-lg border border-border" style={{ height }}>
{/* Placeholder keeps layout stable while overlay is shown */}
</div>
<div
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) setEnlarged(false); }}
>
<div className="flex items-center justify-end gap-2 px-4 py-2">
<button
type="button"
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setEnlarged(false)}
title="Exit fullscreen (Esc)"
>
<Minimize2Icon className="size-4" />
Exit fullscreen
</button>
</div>
<div className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background">
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
title="HTML preview"
/>
</div>
</div>
</>
);
}
return (
<div className="group/html-preview relative mt-2 overflow-hidden rounded-lg border border-border">
<button
type="button"
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/80 p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/html-preview:opacity-100 supports-[backdrop-filter]:backdrop-blur"
onClick={() => setEnlarged(true)}
title="Enlarge preview"
>
<Maximize2Icon className="size-4" />
</button>
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height, border: "none", display: "block" }}
title="HTML preview"
/>
</div>
);
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
@ -345,10 +206,26 @@ function CodeBlockActions({
);
}
function renderHighlightedCode(props: BlockProps, codeFence: CodeFenceInfo) {
return (
<div className="relative isolate">
<Block {...props} />
<CodeBlockActions
disabled={props.isIncomplete}
language={codeFence.language}
source={codeFence.source}
/>
</div>
);
}
function StreamdownBlock(props: BlockProps) {
const hasMermaidFence = props.content.includes("```mermaid");
const mermaidSource = getMermaidSource(props.content);
const codeFence = getCodeFence(props.content);
// Fall back to parseIncompleteCodeFence for both still-streaming AND
// finished-but-unclosed fences (small LLMs routinely drop the closing ```).
const codeFence =
parseCodeFence(props.content) ?? parseIncompleteCodeFence(props.content);
if (props.isIncomplete && hasMermaidFence) {
return (
@ -358,27 +235,6 @@ function StreamdownBlock(props: BlockProps) {
);
}
if (props.isIncomplete && codeFence && isSvgFence(codeFence)) {
return (
<div className="relative isolate">
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="mb-2 text-xs font-medium text-muted-foreground">svg</div>
<pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
<code>{codeFence.source}</code>
</pre>
</div>
</div>
);
}
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
return (
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
Loading preview...
</div>
);
}
if (mermaidSource) {
return (
<div className="relative isolate">
@ -389,22 +245,20 @@ function StreamdownBlock(props: BlockProps) {
}
if (codeFence) {
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
return (
<>
<div className="relative isolate">
<Block {...props} />
<CodeBlockActions
disabled={props.isIncomplete}
language={codeFence.language}
source={codeFence.source}
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
</>
);
const svg = isSvgFence(codeFence);
const html = !svg && isHtmlFence(codeFence);
if (svg || html) {
// Tabbed Preview/Code; locks to Code while streaming.
return (
<HtmlSvgRenderer
language={svg ? "svg" : "html"}
source={codeFence.source}
codeView={renderHighlightedCode(props, codeFence)}
isIncomplete={props.isIncomplete}
/>
);
}
return renderHighlightedCode(props, codeFence);
}
return <Block {...props} />;

View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// jsdom shims: ResizeObserver + URL.createObjectURL.
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver =
ResizeObserverStub as unknown as typeof ResizeObserver;
}
let __blobCounter = 0;
if (typeof URL.createObjectURL !== "function") {
URL.createObjectURL = ((_blob: Blob) =>
`blob:jsdom/${++__blobCounter}`) as typeof URL.createObjectURL;
}
if (typeof URL.revokeObjectURL !== "function") {
URL.revokeObjectURL = (() => undefined) as typeof URL.revokeObjectURL;
}
afterEach(() => {
cleanup();
});

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import path from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
globals: true,
css: false,
include: ["src/**/*.{test,spec}.{ts,tsx}"],
setupFiles: ["./src/test-setup/setup.ts"],
},
});