fix(studio): open projects named with a colon and keep preview assets for names with # - #4829
Conversation
Edit accuracy: 494 passing here, 494 on the base branchThe gate passes. Unstable (1)
|
6c3ab1b to
d5c1ed1
Compare
e028c7e to
150e0e0
Compare
150e0e0 to
51c1ec2
Compare
somanshreddy
left a comment
There was a problem hiding this comment.
Reviewed at 51c1ec21. This was two independent passes, reconciled against the source: an unbiased Codex pass on the raw PR, and my own pass. Both found the same blocker on their own.
Blocking
1. Session aliases skip the new Windows colon guard (packages/studio/vite.adapter.ts:248)
resolveProject applies isWindowsStreamId to the incoming id only. The session-alias branch checks session.projectId with isValidProjectId alone. That function now accepts any colon that isn't a leading drive letter. So on Windows, sessions/alias.json → {"projectId":"demo::$INDEX_ALLOCATION"} reaches resolve(), isPathWithin and existsSync. That is exactly the stream form the PR description says those calls can't see, and NTFS resolves it to demo/. On origin/main the same input was refused, because isValidProjectId rejected every :. So this is a regression of the class the PR sets out to guard.
I reproduced it by adding a probe to vite.adapter.projects.test.ts with process.platform stubbed to win32. resolveProject("Customer story: Northwind") returns null, but resolveProject("alias"), with a session pointing at that id, returns { id: "Customer story: Northwind", … }.
Session files are local data, not writable through the API, so I don't see a remote path. Codex also points out the functional knock-on: the preview <base> is then built from the stream id, and the outer guard rejects every asset request under it.
Suggested fix: make the rule a property, not a per-call-site check. Use one adapter-side validator, e.g. isServableProjectId = (id) => isValidProjectId(id) && !isWindowsStreamId(id). Use it for id, session.projectId and the directory listing. Add a test for the session branch.
Should-fix / nits
withPreviewBaseonly matches the literal<head>(preview.ts:350). A page with<head data-theme="dark">gets no<base>in the new bundler-failure fallback, so its relative assets 404. Codex probed this directly. It's no worse than main (the bundled path had the same regex, and the fallback had no base at all), butinjectTagsAtHeadStartis already imported in this file and handles attributed<head>tags.- Media-metadata cache key collides now that ids may contain
:(useColorGradingController.ts:287). The key is${projectId}:${selectedAssetPath}, sofoo:bar+video.mp4andfoo+bar:video.mp4share one entry. That can show the wrong HDR warning after switching projects. Low impact; a tuple or nested map key fixes it. - The
demo::$INDEX_ALLOCATIONassertion passes vacuously on Linux, because the directory doesn't exist there. It is real coverage onwindows-latest, and that run passed at this head, so this is fine. Just noting that Linux CI alone doesn't exercise it.
What I verified
- Tests at head:
preview.test.tspasses 82/82, andprojectRouting.test.ts+vite.adapter.projects.test.tspass 42/42, all run locally. - Mutation checks: each of these breaks a test.
- raw id in the root
<base>: 2 fail; - no base on the fallback: 1 fails;
- raw sub-composition base: 1 fails;
- dropping the win32 guard: 1 fails;
- reverting to "refuse every
:": 2 fail; - dropping the drive-prefix check: 4 existing rejection cases fail.
So "tests fail without each fix" holds.
- raw id in the root
<base href>injection:encodeURIComponentencodes" < > & $ # ? %and spaces, so a crafted name can't break out of the attribute or useString.replace$-patterns. That incidentally fixes a latent$&issue in the old raw interpolation. The client'sencodeProjectIduses the same encoding, so the client markers (useColorGradingController,useRenderClipContent) now match the served base.- Other resolvers: the CLI's
resolveProjectis static equality (id === projectId), so the loosened validator only reaches the Vite dev adapter. UNC,\\?\and mixed separators are still refused by the/and\checks. - CI: the "Windows render verification" run passed at this head, including studio-1/2, which run this adapter test on real NTFS. A second, duplicate Windows run and the edit-accuracy shards were still pending when I posted.
Not verified: real-Windows behavior of the session-alias case. I only simulated it by stubbing the platform. I also didn't repeat the manual Chrome run.
— Somu
One adapter rule, isServableProjectId, now guards the requested id, a session's projectId and the folder listing. The bundler-failure preview places its <base> inside an attributed <head>, and the media metadata cache keys on the encoded URL so ids with ':' can't collide.
b7a8f3d to
e279acc
Compare
somanshreddy
left a comment
There was a problem hiding this comment.
Re-review at e279accc (delta from 51c1ec21). B1 is fixed; no blockers.
B1 (session-alias stream id), fixed. isServableProjectId (isValidProjectId plus the win32 colon refusal) now guards all three places the Vite adapter turns a name into a folder: the requested id (resolveProject), session.projectId on the alias branch, and the folder listing. Every studio-server route reaches a folder through adapter.resolveProject. The CLI host's resolver is static (id === projectId), so nothing else gets around the guard. On win32 the rule refuses on the property itself: drive prefixes, /, \ (so UNC, \\?\ and mixed separators) and control characters are already refused by isValidProjectId, and any : is refused by the new clause. Route params are decoded before the check, so encoded forms are covered too. The new test now includes my repro (session stream.json pointing at demo::$INDEX_ALLOCATION, platform stubbed to win32 → null).
Should-fix 2 (<head> with attributes), fixed. withPreviewBase now goes through injectTagsAtHeadStart. The fallback test uses <head data-theme="dark">.
Should-fix 3 (cache-key collision), fixed. The cache key is now the encoded mediaMetadataUrl. The test covers p:a + b.mp4 vs p + a:b.mp4.
Verified locally (NODE_ENV=test): the touched files pass (studio 64/64, studio-server preview 82/82). For each fix, reverting it fails its test: the session guard, dropping the win32 clause, the <head> regex, and the cache key. Required checks are green at this head. The edit-accuracy shards were still running when I checked.
Nit: reverting the listing guard to isValidProjectId keeps every test green. That's harmless in practice, because NTFS can't hold a folder name with :, so readdirSync can't return one on Windows.
I didn't run a Codex pass on this delta, and I didn't run it on real Windows.
My CHANGES_REQUESTED at 51c1ec21 no longer applies. I'm leaving the approval to a human reviewer: my approve attempt was blocked by my session's permission gate.
— Somu
…-path read Review on #4843 (Somu, two independent passes including Codex) found the original design would most likely report a WORKING fix as "rarely recovers" — both findings verified against source before this fix: B1: `onAbsentRead` → `refreshFileTree` starts no read (confirmed in useFileTree.ts — it only updates the fetched file/composition lists), and the open effect deliberately excludes `fileTree` from its deps. So the fallback's actual target scenario (an agent deletes the file, the SSE is missed) never produces a same-path read success: the tree just stops listing the path and CompositionMissingBanner tells the user to pick another composition. The old `recovered` stage would have scored that as unrecovered. On the master view it's worse — a refresh rotates `masterCompPath` to the next composition (App.tsx), orphaning the original trigger's key entirely. B2: DesignPanelPromoteProvider opens a second, callback-less `useSdkSession` targeting the same path as the primary session whenever nothing is selected (confirmed at its call site) — `triggered` fired from both, with no field to tell PostHog's two identical events apart. Both fixed, and extracted into `useAbsentReadRecoveryTelemetry` (also keeps useSdkSession.ts under the 600-line filesize gate): - `triggerOnce` now only does the once-per-path bookkeeping and emits `triggered` when `onAbsentRead` is actually provided — the callback-less secondary session no longer fires at all. - The success half is now `stage: "tree_corrected"`, fired from an effect over `fileTree`/`fileTreeLoaded` that checks every still-pending path against the CURRENT tree, independent of which path `useSdkSession` happens to be reading right now. That's the one thing the fallback actually controls. - `elapsed_ms` now comes from `performance.now()` (monotonic), not `Date.now()` (wall-clock, skews under tab suspension or clock changes). - Pending keys are path-only, not `${projectId}:${path}` — the composite key could stringify two different (projectId, path) pairs identically once either contains `:` (a real near-term risk: #4829 opens colon- containing project names). Safe without the prefix because the map is fully cleared on every `projectId` change, so only one project's entries are ever live. That clear now covers both the dedup set and the pending map — only the former was cleared before, so a pending entry could survive a project switch and either inflate its own `elapsed_ms` with idle time spent on the other project, or collide with an unrelated path reused there. 9 new/rewritten tests, 2 manually mutation-checked against this head (removing the `onAbsentRead` guard, removing the delete-after-emit) — both caught by the existing assertions before being reverted. 56/56 across the three touched test files. oxlint/oxfmt/tsc clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
What
Studio opens a project whose folder name contains a colon, such as
Customer story: Northwind. Before this, Studio refused the id, and any view that builds the project's API path (the composition stack among them) threwInvalid project ID.The preview also keeps a project's own assets when its name has a URL character. The injected
<base href>used the raw name, so a project namedTake #2got the base/api/projects/Take #2/preview/. The browser cut that at#, and every relative asset loaded from/api/projects/Take%20and 404'd. A"in a name ended the attribute early.Why
A colon is a legal folder character on macOS and Linux, and
encodeProjectIdalready encodes it (%3A), so the API path and the hash route stay one path segment. On Windows a colon can never be part of a folder name; there it means a drive (C:demo) or a file stream (demo::$INDEX_ALLOCATION), whichresolve()andisPathWithindo not see. So a leading drive letter stays refused everywhere, and the dev server refuses any colon id when it runs on Windows.Related work
None.
How
isValidProjectIdrefuses a leading drive letter (WINDOWS_DRIVE_PREFIX,/^[a-z]:/i) instead of any:./,\, control characters,.and..stay refused.resolveProject(vite.adapter.ts) also refuses any id with a colon whenprocess.platformiswin32.preview.tsbuilds the root preview's and every sub-composition's<base href>through onepreviewBaseHref, which encodes the name withencodeURIComponentonce (the route hands over the decoded name). The fallback page served when the bundler fails now gets the same base; before, it had none, so its relative assets 404'd for every project.A/B testasA:B teston disk) still reads as a drive and stays refused.Test plan
projectRouting.test.tsadds "opens a folder whose name has a colon, encoded once both ways" (encode, API path, hash round trip). The existing rejection cases, includingC:andC:demo, are unchanged.vite.adapter.projects.test.tsadds "opens a folder named with a colon, which Windows would read as a file stream and refuses": it resolves on Linux, and returns null withprocess.platformset towin32. Removing the Windows guard fails it.studio-server/src/routes/preview.test.tsadds "encodes the project name in , so a '#' or '"' in it keeps assets in its own folder". It covers the root preview and a sub-composition forTake #2, checks thatlogo.pngresolves under/api/projects/Take%20%232/preview/, and checks that a"arrives as%22. Againstorigin/main'spreview.tsit fails (/api/projects/Take #2/preview/).hyperframes previewfrom source on Linux, serving a fixture project in a folder namedCustomer story: Northwind, opened at#project/Customer%20story%3A%20Northwindin Chrome at 1440x900. Withorigin/main'sprojectRouting.ts, Studio never leaves its loading screen ("Waiting for preview server…") although the server is up. With this change, it opens the project with its preview, compositions and timeline. The only console errors were two/api/environment/ffmpeg404s, the same in both runs.origin/main'sprojectRouting.tsand passes here (23 of 23 in the file).Before
After