Skip to content

fix(native): Fast Preview is authoritative over the sandbox interceptor - #6064

Open
tlgimenes wants to merge 8 commits into
mainfrom
claude/fastpreview-upstream-authority
Open

fix(native): Fast Preview is authoritative over the sandbox interceptor#6064
tlgimenes wants to merge 8 commits into
mainfrom
claude/fastpreview-upstream-authority

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Stacked on #6054.

The bug

In the desktop app, a Fast Preview project's /git/status was answered from a local sandbox clone, not from the GitHub API. Symptoms seen while testing #6054:

  • the toolbar stuck on Loading… forever after a branch switch, with SandboxGitError: repository not initialized and fetchStatus: "paused" — the query had failed and stopped retrying
  • git/status reporting head 6abff372 while /decofile (which the editor writes through) reported 64cf81af — two different repositories for the same branch
  • working-tree files (.deco/generate.digests.json) in a payload the GitHub-backed shim can never produce, since it hardcodes modified: []

Why

intercept/sandbox_ops.rs's is_handled matches ["git", _] unconditionally, and try_intercept is the upstream proxy's first decision — so the request never reaches the cloud, and sandbox-proxy.ts:213's Fast Preview branch is never evaluated in the native app.

grep -rn "fastPreview\|fast_preview" apps/native/crates/ returns zero hits. The desktop app has no concept of Fast Preview.

sandbox_ops already declines when no local worktree exists, and its comment names sandbox-less Fast Preview as a case that falls through there. It doesn't, because the worktree handle is derived from the repository: a sandbox left over from vibecoding on the same repo claims the route for a branch it has never checked out, and routes/git.rs:329 then answers not_ready("repository not initialized").

Inference can't work here. The flag has to be declared.

The fix

The webview sets x-deco-fast-preview on its sandbox git calls; try_intercept declines every /sandbox/* path carrying it, before any interceptor runs.

A routing hint, not a trust boundary — it only decides whether to answer locally, and the API re-derives the flag from the vMCP's own metadata before serving anything. A wrong or forged value can only route the request to the authority, never around it.

Declining at try_intercept rather than per-interceptor is what makes it authoritative: Fast Preview is sandbox-less by definition, so nothing under /sandbox/* is ours to serve.

apps/api needed no change

resolveVmClaim returns next() before resolveSandboxProvider is ever called (sandbox-proxy.ts:229), so a Fast Preview project never resolves a runner and daemon-backed routes 503 via requireRunner. It was already authoritative — it was being bypassed.

Testing

  • New unit test for the header predicate, documenting the repository-derived-handle regression.
  • cargo test -p local-api --lib routes::intercept69 pass.
  • cargo check, cargo clippy, cargo fmt --check clean.
  • Web: bun test 214 pass, bun run lint 0 errors, bun run fmt clean.

Follow-up not in scope

Only the two calls the CMS toolbar makes (fetchGitStatus, rebaseGitBranch) send the header today. PublishDialog's calls still go through the old path — they run on explicit user action and fail visibly rather than silently spinning, but they should carry it too.

🤖 Generated with Claude Code


Summary by cubic

Make Fast Preview authoritative for desktop sandbox git routes to prevent local interception. Previously the native app served /api/:org/sandbox/*/git/* from a local sandbox, causing mismatched HEADs and “repository not initialized”; now Fast Preview-marked calls always go to the cloud API.

  • Native (apps/native): define x-deco-fast-preview and predicate; try_intercept now receives headers and returns None for any /sandbox/* when the header is "1" or "true"; upstream proxy passes headers; adds a unit test for the predicate/regression.
  • Web (apps/web): send x-deco-fast-preview in fetchGitStatus and rebaseGitBranch; add FAST_PREVIEW_HEADER and SandboxGitCallOptions; plumb via sandboxFetch when { fastPreview: true } is set.
  • Routing: header is a hint only; the API re-derives Fast Preview from vMCP metadata. A wrong/forged header can only route to the authority, never around it.
  • Rollout: no migrations. The header is a no-op on older desktop builds; the bug persists until the native app is updated.

Written for commit edbbfb6. Summary will update on new commits.

Review in cubic

@github-actions github-actions Bot added the claude PR authored by a coding agent label Aug 13, 2026
tlgimenes and others added 6 commits August 13, 2026 16:28
Fast Preview is sandbox-less and content-only: no daemon, no coding agent,
no chat. It nonetheless rendered the vibecoding header cascade, where 5 of
21 states dispatch chat prompts into a composer Fast Preview disables (dead
clicks) and 7 more can never match (lifecycle is pinned to running,
workingTreeDirty and unpushed are hardcoded).

Give it its own 7-state machine and one split button, in the editor's
vocabulary rather than git's.

- packages/ui: new SplitButton (on the existing unused ButtonGroup) and a
  `warning` Button variant using the existing --warning tokens. `disabled`
  disables only the primary half, so "Up to date" stays an inert pill whose
  menu still offers "Get latest".
- cms-panel-state.ts: pure selector, 40 unit tests. Check state is encoded
  as colour (outcome) plus motion (progress): brand normally, warning when
  a check is not passing, spinner where the editor must wait, pulse where
  they can still act.
- cms-header-actions.tsx: renderer reusing PublishDialog unchanged.
- The branch lives at the mount point, so Fast Preview never mounts
  useSandboxEvents, useSandboxLifecycle or usePublishGate.

usePublishGate is dropped from this path rather than disabled:
canPublishDirectly defers to isDecoOnlyDiff under every policy, and a Fast
Preview diff is deco-only by construction, so it always allowed. That also
removes its 10s GitHub poll.

The vibecoding cascade is untouched; its 39 tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nerated-only churn

Three defects found driving the real CMS in the native app.

1. The Draft state only looked at `aheadOfBase`, and the BranchMeta mapping
   pinned `workingTreeDirty: false`. That held for the GitHub-backed
   `/git/status` shim, which commits on every save — but the same contract is
   served by a real clone whenever the project isn't claimed as Fast Preview
   server-side, and there the editor's saves sit uncommitted. The button
   claimed "Up to date" over unpublished edits. Unpublished work is now
   `aheadOfBase > 0 || workingTreeDirty`, and `isLevelWithMergedPr` yields to
   a dirty tree so post-merge edits aren't swallowed either.

2. No saving state. A publish fired mid-write would ship whichever half of the
   edit had landed. Reads the same `decofileWriteMutationKey` the preview's
   autosave indicator uses, so edits and deletes are both covered. The menu is
   empty while saving: a branch-wins merge racing an in-flight write is how an
   edit goes missing.

3. `.deco/generate.digests.json` and `.deco/meta.gen.json` are rewritten on
   every save and never reverted by an undo, so a dirty-tree check counted them
   as work the publish diff couldn't find — "0 changes to publish" under a live
   button. `hasPublishableLocalWork` ignores generated artifacts.

Known gap, deliberately left: on the commit-per-save backend, edit-then-undo
leaves N commits with a net-zero diff, and `aheadOfBase > 0` still reads as
Draft. Closing it needs a changed-file count from the `compare` call
`githubGitStatus` already makes — an apps/api change, tracked separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Request approval" was invented copy for an action vibecoding already names.
Reuse `thread.headerActions.submitForReview` instead — one fewer string, an
existing pt-br translation, and the same action stops having two names
depending on which toolbar you are in. The state it leads to follows the verb:
"Waiting for approval" → "Waiting for review", so you no longer submit for
review and then wait for approval.

Icons on the primary and every menu entry: a rocket for publish, the repo's
own GitHub mark for both GitHub links, and RefreshCw01 for Get latest — the
glyph the Sync button already uses for the same operation. Chosen by `action`
in the renderer so the state machine stays free of JSX.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nning

Switching branches could leave the button on "Loading…" permanently. The
selector collapsed "still fetching" and "failed permanently" into one state
via `branch.kind !== "ready"`, so a status read that errored — the native
API answers `not_ready` for a branch whose local repo isn't set up yet
(local-api/src/routes/git.rs:329) — presented as a spinner that never
resolves. TanStack had already stopped retrying; nothing was in flight.

A failed read now renders "Retry" with the reason as its tooltip. Branch
metadata that did arrive still wins, so a stale error can't mask real state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… view

Switching branches rendered "Review & Publish" brand-green and then recoloured
it to warning a beat later. Not a race — a waterfall. `checks` and `reviews`
are gated on the PR number, so they cannot start until `usePrByBranch`
returns, while `loading` reflected only that PR query. The button therefore
committed to a confident, fully actionable state from data still missing the
one signal that can contradict it.

A UI that corrects itself in front of the user reads as broken even when it is
converging. `isCmsStateSettling` holds one window until every query the state
depends on has landed, so the button goes from Loading to its final answer and
stays there. No extra requests — the same waterfall, just not narrated.

Scoped to an open PR, since that is the only case where the dependent queries
run at all: a Draft branch has no checks to wait for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…imation

"Get latest" ran a server-side merge with no feedback at all — the branch was
being rewritten under the editor while the button looked idle and clickable.
It now holds the control with "Getting latest…" and a spinner, and Retry spins
while its refetch is in flight. Publishing still outranks both.

Also drops the pulse from "Review & Publish". It was meant to say "checks are
running, you may still act", but animating a button the editor is free to click
reads as something being wrong with it. Only a genuine wait animates now; the
tooltip already carried the progress, so nothing is lost. `pulse` leaves the
CMS descriptor entirely — SplitButton keeps the prop for other callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes
tlgimenes force-pushed the claude/wise-watching-fog branch from 2154006 to 773dad2 Compare August 13, 2026 19:32
@tlgimenes
tlgimenes force-pushed the claude/fastpreview-upstream-authority branch from 9d873a1 to 5aacfd3 Compare August 13, 2026 19:33
tlgimenes and others added 2 commits August 13, 2026 16:41
…tale state

Editing an up-to-date branch went "Saving…" → "Up to date" → "Review &
Publish". The save hooks fired their status invalidation with `void`, so the
mutation resolved before the re-read landed. Every observer of "is a save in
flight" — the header button, the preview's autosave indicator — was released
onto the PREVIOUS status and rendered it as current: a clean, confident "Up to
date" over an edit that already existed.

Awaiting the invalidation makes the mutation mean what its observers read it
to mean. Preferred over holding the button on `statusQuery.isFetching`, which
is also true for a window-focus refetch and would have flashed "Saving…" when
nothing was being saved — the same class of bug in a new place.

Audit of the neighbouring transitions found one more: with no task branch the
status query is disabled, so its data never arrives and the button sat on
"Loading…" permanently. There is nothing to publish without a branch, so it
renders nothing instead.

The other paths are already covered: TanStack v5 awaits onSuccess before
settling a mutation, so `getLatest` and the post-publish branch switch both
span their own invalidations; a branch switch drops the status data, so the
Loading state — not a stale one — is what shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desktop app answered `/api/:org/sandbox/*/git/*` from a local sandbox
unconditionally (intercept/sandbox_ops.rs `is_handled` matches `["git", _]`),
and `try_intercept` runs before any upstream forwarding — so the cloud's Fast
Preview branch (api sandbox-proxy.ts:213) was never reached in the native app.
There are zero references to fastPreview anywhere in apps/native/crates.

sandbox_ops already declines when no local worktree exists, and its comment
names Fast Preview as a case that falls through. It doesn't, because the
worktree handle is derived from the REPOSITORY: a sandbox left over from
vibecoding on the same repo claims the route for a branch it never checked
out, then answers `repository not initialized` (routes/git.rs:329).

So the flag has to be declared, not inferred. The webview sets
`x-deco-fast-preview` on its sandbox git calls and try_intercept declines every
`/sandbox/*` path carrying it. A routing hint, not a trust boundary: the API
re-derives the flag from the vMCP's own metadata, so a wrong value can only
route toward the authority, never around it.

apps/api needed no change — resolveVmClaim returns before
resolveSandboxProvider is ever called, so a Fast Preview project never resolves
a runner and daemon-backed routes 503 via requireRunner. It was already
authoritative; it was being bypassed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes
tlgimenes force-pushed the claude/fastpreview-upstream-authority branch from 5aacfd3 to edbbfb6 Compare August 13, 2026 19:45
Base automatically changed from claude/wise-watching-fog to main August 14, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude PR authored by a coding agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant