fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session (#1981)

* fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session

Root-causes the setTiming element_not_found resolver-shadow divergence class:
timeline edits carry hf-ids read from the live preview DOM, but the preview
minted ids AFTER rewriting attributes (and never persisted them for sub-comps),
while the SDK session mints from the raw file — content-keyed minting then
yields different ids for the same element. Template-based comps were worse:
the SDK excluded the whole <template> subtree, so the session had zero
elements and every edit diverged.

- parsers: ensureHfIds now descends into <template> subtrees (linkedom's
  querySelectorAll does not), minting and pinning inner ids
- sdk: buildRoots/buildElement treat <template> as a transparent container,
  and resolution (resolveScoped, animation-id map) searches template subtrees
  via querySelectorAllDeep — template comps now model, resolve, and edit
- studio-server: the sub-comp preview route persists hf-ids to the raw file
  BEFORE the rewrite pipeline (mirrors the main route), pinning one id space
  across served DOM, disk, and SDK session
- studio: resolver-shadow skips structurally-empty sessions (no event, no
  attempt) and tags fail-open emissions with sourceReadFailed so read errors
  are distinguishable from unwired readers in telemetry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(parsers,sdk,studio-server,studio): scope template descent, guard persist route

Addresses the 10 verified findings from the PR #1981 review:

- Restrict template transparency to COMPOSITION templates
  (<template data-composition-id>) everywhere — ensureHfIds, SDK
  buildChildren, querySelectorAllDeep. A plain <template> (runtime
  clone-source) keeps its old fully-excluded behavior: stamping its
  interior would duplicate one persisted id across every runtime clone,
  and modeling it would show phantom timeline clips.
- Guard the sub-comp persist: only .html files (the wildcard route can
  serve any project path — stamping an SVG corrupted it on disk),
  try/catch the read (file-removed race becomes 404, not 500), salt the
  etag (v2) so pre-fix cached clients don't 304 past the id pin, and
  thread the stamped content into buildSubCompositionHtml so served ids
  match the mint even when the disk write is skipped.
- Rewrite querySelectorAllDeep as a document-order DOM walk — appending
  template matches after top-level matches made duplicate-id tiebreaks
  disagree with the preview's unwrapped DOM (wrong-element edits).
- Recurse sourceMutation.querySelectorAllWithTemplates so server-side
  ops resolve ids at any template depth, matching SDK resolution.
- Replace the empty-session silent skip with ONE tagged session_empty
  event per session — silence would blind the tripwire to exactly the
  modeling-gap class that exposed the template bug. Attempts stay
  uncounted (an unmodelable comp can't cut over).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio-server): close TOCTOU in sub-comp hf-id persist (CodeQL js/file-system-race)

Replace the route-level stat/read/persist sequence with stampFileHfIds:
validation (fstat), read, mint, and write-back all go through ONE open
file descriptor (O_NOFOLLOW where supported), so the path cannot be
swapped between validation and write. Falls back to read-only stamping
when the file isn't writable — content-keyed minting means the SDK
derives the same ids from the same bytes even without the disk write.

Addresses miguel-heygen's blocking review on PR #1981.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio-server): linear-time template-attr match (CodeQL js/polynomial-redos)

promoteTemplateCompositionId's single-pattern regex backtracked
polynomially on crafted input. Two-step match: grab each <template>
open tag linearly, then find data-composition-id within that short
tag text. Same semantics (first template carrying the attr wins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-06 00:16:07 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent e9076324e7
commit 3a717fa719
14 changed files with 628 additions and 43 deletions
+39 -5
View File
@@ -41,6 +41,42 @@ export function escapeHfId(id: string): string {
return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* querySelectorAll that also descends into COMPOSITION `<template>` subtrees
* (`data-composition-id` — the pattern the studio preview unwraps) — linkedom's
* querySelectorAll does not, so template-based sub-comp content would be
* unreachable for resolution/dispatch even though buildRoots models it.
*
* Implemented as a document-order DOM walk (not qsa + append) so duplicate-id
* tiebreaks resolve in TRUE document order — appending template matches after
* all top-level matches would make resolveScoped pick a different duplicate
* than the preview's unwrapped DOM does. Plain templates (runtime clone
* sources) are skipped, matching buildChildren and ensureHfIds.
*
* Throws like querySelectorAll on an invalid selector (Element.matches).
*/
export function querySelectorAllDeep(root: Document | Element, selector: string): Element[] {
const out: Element[] = [];
const start: Element | null =
"body" in root ? ((root as Document).body ?? null) : (root as Element);
const walk = (parent: Element): void => {
for (const child of Array.from(parent.children)) {
if (child.tagName.toLowerCase() === "template") {
if (child.getAttribute("data-composition-id") !== null) walk(child);
continue;
}
if (child.matches(selector)) out.push(child);
walk(child);
}
};
if (start) {
// When rooted at an Element (scoped-path step), the root itself is the
// context, not a candidate — only descendants match, like querySelectorAll.
walk(start);
}
return out;
}
/**
* True when an element lives at the top-level (canonical) scope — i.e. its
* scopedId equals its bare id because no ancestor opens a sub-composition
@@ -75,7 +111,7 @@ export function resolveScoped(document: Document, id: string): Element | null {
// resolution agrees with getElement (scopedId === id wins over document order).
if (parts.length === 1) {
const escaped = escapeHfId(id);
const matches = Array.from(document.querySelectorAll(`[data-hf-id="${escaped}"]`));
const matches = querySelectorAllDeep(document, `[data-hf-id="${escaped}"]`);
if (matches.length > 0) {
return matches.find((el) => isCanonicalScope(el)) ?? matches[0] ?? null;
}
@@ -84,16 +120,14 @@ export function resolveScoped(document: Document, id: string): Element | null {
// (the id studio passes when targeting the sub-comp root). data-hf-id takes
// precedence above; only when no hf-id matches do we treat the bare id as a
// composition id, making comp-ids first-class resolvable addresses.
return document.querySelector(`[data-composition-id="${escaped}"]`);
return querySelectorAllDeep(document, `[data-composition-id="${escaped}"]`)[0] ?? null;
}
let context: Element | Document = document;
for (const part of parts) {
const escaped = escapeHfId(part);
const found: Element | null =
context === document
? (context as Document).querySelector(`[data-hf-id="${escaped}"]`)
: (context as Element).querySelector(`[data-hf-id="${escaped}"]`);
querySelectorAllDeep(context, `[data-hf-id="${escaped}"]`)[0] ?? null;
if (!found) return null;
context = found;
}