Skip to content

fix(studio): open projects named with a colon and keep preview assets for names with # - #4829

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/studio-colon-project-id
Oct 1, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
fix/studio-colon-project-id

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

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) threw Invalid 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 named Take #2 got the base /api/projects/Take #2/preview/. The browser cut that at #, and every relative asset loaded from /api/projects/Take%20 and 404'd. A " in a name ended the attribute early.

Why

A colon is a legal folder character on macOS and Linux, and encodeProjectId already 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), which resolve() and isPathWithin do 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

  • isValidProjectId refuses a leading drive letter (WINDOWS_DRIVE_PREFIX, /^[a-z]:/i) instead of any :. /, \, control characters, . and .. stay refused.
  • The dev server's resolveProject (vite.adapter.ts) also refuses any id with a colon when process.platform is win32.
  • preview.ts builds the root preview's and every sub-composition's <base href> through one previewBaseHref, which encodes the name with encodeURIComponent once (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.
  • Known limit: a macOS folder whose name starts with one letter and a colon (Finder shows A/B test as A:B test on disk) still reads as a drive and stays refused.

Test plan

  • Unit tests added/updated:
    • projectRouting.test.ts adds "opens a folder whose name has a colon, encoded once both ways" (encode, API path, hash round trip). The existing rejection cases, including C: and C:demo, are unchanged.
    • vite.adapter.projects.test.ts adds "opens a folder named with a colon, which Windows would read as a file stream and refuses": it resolves on Linux, and returns null with process.platform set to win32. Removing the Windows guard fails it.
    • Both files: 42 of 42 pass on Linux.
    • studio-server/src/routes/preview.test.ts adds "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 for Take #2, checks that logo.png resolves under /api/projects/Take%20%232/preview/, and checks that a " arrives as %22. Against origin/main's preview.ts it fails (/api/projects/Take #2/preview/).
    • It also adds "keeps the encoded when the bundler fails and the page is read from disk"; removing the base from the fallback fails it. The file: 82 of 82 pass.
  • Manual testing performed: hyperframes preview from source on Linux, serving a fixture project in a folder named Customer story: Northwind, opened at #project/Customer%20story%3A%20Northwind in Chrome at 1440x900. With origin/main's projectRouting.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/ffmpeg 404s, the same in both runs.
  • The new test fails against origin/main's projectRouting.ts and passes here (23 of 23 in the file).
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments"

Before

Before: Studio stays on its loading screen for a project named with a colon

After

After: Studio opens the project with its preview and timeline

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Edit accuracy: 494 passing here, 494 on the base branch

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Unstable (1)

  • move-tween-center-r0-root-z50: error / tracking 0.07, pressJump 0, drop 0, reload 0.07, render 0.02, undo true / tracking 0.07, pressJump 0, drop 0, reload 0.07, render 0.02, undo true

@miguel-heygen
miguel-heygen force-pushed the fix/studio-colon-project-id branch from 6c3ab1b to d5c1ed1 Compare October 1, 2026 04:31
@miguel-heygen
miguel-heygen force-pushed the fix/studio-colon-project-id branch 2 times, most recently from e028c7e to 150e0e0 Compare October 1, 2026 05:00
@miguel-heygen miguel-heygen changed the title fix(studio): open projects whose folder name has a colon fix(studio): open projects named with a colon and keep preview assets for names with # Oct 1, 2026
@miguel-heygen
miguel-heygen force-pushed the fix/studio-colon-project-id branch from 150e0e0 to 51c1ec2 Compare October 1, 2026 05:12
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 1, 2026 05:16

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  1. withPreviewBase only 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), but injectTagsAtHeadStart is already imported in this file and handles attributed <head> tags.
  2. Media-metadata cache key collides now that ids may contain : (useColorGradingController.ts:287). The key is ${projectId}:${selectedAssetPath}, so foo:bar + video.mp4 and foo + bar:video.mp4 share one entry. That can show the wrong HDR warning after switching projects. Low impact; a tuple or nested map key fixes it.
  3. The demo::$INDEX_ALLOCATION assertion passes vacuously on Linux, because the directory doesn't exist there. It is real coverage on windows-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.ts passes 82/82, and projectRouting.test.ts + vite.adapter.projects.test.ts pass 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.
  • <base href> injection: encodeURIComponent encodes " < > & $ # ? % and spaces, so a crafted name can't break out of the attribute or use String.replace $-patterns. That incidentally fixes a latent $& issue in the old raw interpolation. The client's encodeProjectId uses the same encoding, so the client markers (useColorGradingController, useRenderClipContent) now match the served base.
  • Other resolvers: the CLI's resolveProject is 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.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-colon-project-id branch from b7a8f3d to e279acc Compare October 1, 2026 05:58

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@miguel-heygen
miguel-heygen merged commit a369708 into main Oct 1, 2026
67 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-colon-project-id branch October 1, 2026 06:21
vanceingalls added a commit that referenced this pull request Oct 1, 2026
…-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants