fix(dev-tunnel): warn when the dev server can't be embedded by the host - #198
Conversation
`civitai app dev-tunnel` reported "Ready" with no errors while the app never loaded: the iframe stayed data-block-ready="false" and the host showed "This app didn't load in time". The existing preflight is a bare TCP dial and the two other probes read status codes only, so none of the actual causes were observable from the terminal (#196). The host iframes the tunneled dev server sandboxed (allow-scripts allow-forms, deliberately no allow-same-origin), so it runs at an opaque "null" origin. Add an embeddability preflight that runs before the mint and renders immediately before the "open this URL" block: - CheckEmbeddable (evidence): GETs /@vite/client with `Origin: null` through the same DialLocalDevServer the proxy uses, and reports a missing wildcard ACAO, a frame-ancestors CSP or X-Frame-Options that excludes civitai.com, and a 403 on the tunneled *.civit.ai Host. - CheckParentOrigins (heuristic): mirrors Vite's dev env resolution to report a missing VITE_BLOCK_ALLOWED_PARENT_ORIGINS, which cannot be observed over HTTP. Gated to dirs holding a manifest AND a package.json depending on @civitai/app-sdk. Both WARN and never block — one HTTP response cannot rule out a proxy or an exotic-but-working setup, and a check that cannot observe returns no findings rather than manufacturing advice. Measured on real Vite dev servers before writing the predicate, on 6.4.3 and 8.2.0 alike: a stock config answers a null-origin module fetch 200 with NO Access-Control-Allow-Origin and 403s a dev-*.civit.ai Host; with the page-money headers applied, ACAO is * and the tunneled Host is 200. That measurement also surfaced a third failure mode the issue did not name — the allowedHosts 403 — which is now checked. The CLI predicate and the page-money template are one contract in two languages, so internal/scaffold/dev_embed_contract_test.go renders the real template, extracts the values it emits, and requires the CLI's own check to accept them; drift on either side now fails loudly. Every guard reports a control pair and was mutation-tested (18/18 killed, each by the test that owns it). Also hardens TestDialLocalDevServerSpecificHost: it bound [::1]:0 and assumed the same port was free on IPv4, which nothing guarantees. The new loopback listeners made that collide once; it now picks a port that is genuinely IPv4-free. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01DC9uE5774YN7oquYrzE4Yq
Audit of #198 found four ways the preflight warned at a correctly configured or simply-authenticated dev server. Advisory output that cries wolf is worse than none — it teaches authors to ignore it — so each is fixed and pinned. - frame-ancestors OBSOLETES X-Frame-Options (CSP L3): when both are present browsers enforce frame-ancestors and ignore XFO. XFO is now only consulted when no frame-ancestors directive is present. The old test asserted the opposite, so the suite could not have caught this. - Only a 2xx baseline is interpretable. A 401/403/302/5xx response came from something other than the app, so its headers say nothing: reading CORS off one reported "your modules are CORS-blocked" at servers whose CORS was fine, and reading the follow-up 403 blamed `allowedHosts` for an auth proxy that refuses every request identically. This also covers the hostname `--local-host` case, which 403s the baseline. - Every Content-Security-Policy header is evaluated (Header.Values, not Get) since policies combine restrictively, and `'none'` is decisive only as the sole source. Default ports (:443/:80) now match, and ACAO is compared case-sensitively because CORS is a byte comparison — `NULL` does not match the `null` origin. - The dotenv mirror is now verified DIFFERENTIALLY against Vite's own loadEnv over 26 fixtures, all matching, with a negative control proving the harness can detect a difference. Backtick quoting, `#` anywhere in an unquoted value, and ${VAR} expansion are supported; an unresolved reference expands to empty, not to its own text. Two corrections to the audit itself, caught by that differential: Vite does NOT accept `KEY: value` (it resolves to nothing, so honouring a colon separator would find a value the app never sees and stay silent on a broken project), and an unresolved `${NOPE}` expands to "". The first harness reported the opposite because dotenv-expand writes into process.env, so each case leaked its answer into the next — the tell was an impossible constant result. Rebuilt with one child process per case. Also pins the user-visible evidence strings, which no test covered: the audit's independent sweep found four surviving mutants there, all now killed. Full battery re-run after the fixes: 30/30 killed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01DC9uE5774YN7oquYrzE4Yq
Audit round 1 — fixed in 2dc2715An adversarial audit found four ways this warned at a correctly configured or simply authenticated dev server. That's the worst failure mode for advisory output, so all four are fixed. No deploy-blocking findings.
1 — 2 & 3 — only a 2xx baseline is interpretable. A 401/403/302/5xx response came from something other than the app (auth proxy, deny gate, login redirect), so its headers say nothing about how the app would be served. Reading CORS off one reported "your modules are CORS-blocked" at servers whose CORS was fine; reading the follow-up 403 blamed 4 — the dotenv mirror is now verified differentially, not from assumptions: 26 fixtures run through both this parser and Vite's own Two corrections to the auditThe differential contradicted the audit on two points, and the audit was wrong:
Both original measurements came from a harness that ran every case in one process. Other fixesEvery The audit's independent sweep also found 4 surviving mutants, all in unpinned user-visible evidence strings (the probed path, the Verification
The honest limit is unchanged: the full browser path (real tunnel → host → 🤖 Generated with Claude Code |
Delta re-audit of the previous fix round found that the 2xx baseline gate
over-corrected: combined with refusing to follow redirects, it turned a
genuinely un-embeddable server into SILENCE. A Vite project with a `base`
path 404s /@vite/client and 302s /, so the gate saw a 302 and returned
nothing — measured: 2 findings before the gate, 0 after.
- Same-host redirects are now followed (bounded) and the FINAL response is
judged, which is what the browser does. A cross-host Location is never
followed: the transport always dials the local dev server, so chasing an
external redirect would send someone else's Host to it and prove nothing.
Vite detection is retried under a discovered base path, so a
`base: '/app/'` project still gets the vite.config.ts remediation.
- Env files are MERGED before expanding once. Expanding per file resolved
a cross-file `${PARENT}` to nothing and warned at a project Vite
resolves correctly. A reference now resolves against the process env
before the file values, `${X:-default}` is supported, a self-reference
terminates via the visited set (not a pass counter, whose stated
rationale was wrong and which no test pinned), and a quoted value ends
at its closing quote so a trailing comment is not swallowed.
- Duplicate Access-Control-Allow-Origin headers are reported: more than
one is a CORS failure in the browser whatever the values say, and
reading only the first called a blocked server clean.
- Default ports are stripped only for the source's own scheme, so
`https://civitai.com:80` no longer collapses onto the bare origin.
The dotenv mirror is re-verified differentially against Vite's own
loadEnv — now 38 fixtures including the cross-file, default-value,
escaped-sigil, empty-brace and self-reference cases — all matching, with
a negative control proving the harness detects an injected difference.
Mutation battery re-run in full (a fix round resets the gate): 34/34
killed. Six needed the harness fixed first — two mutants left a variable
unused and did not compile, and one CRASHED the test binary with a stack
overflow, which prints no `--- FAIL` line and so read as a survivor when
it was the strongest kill in the set.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01DC9uE5774YN7oquYrzE4Yq
Audit round 2 (delta) — fixed in 68cb8b7The delta re-audit of 🔴 The 2xx gate over-corrected into silenceRound 1 added "only a 2xx baseline is interpretable" to stop false warnings at auth proxies. Combined with the pre-existing "don't follow redirects", it silenced a genuinely broken server: a Vite project with a Measured across the fix commit, same server: Fixed: same-host redirects are followed (bounded) and the final response is judged — what the browser does. A cross-host 🟡 The env mirror still warned at healthy projectsThe same class round 1 claimed to close. Fixed and re-verified:
Differential re-run: 38 fixtures, all matching Vite's Also fixedDuplicate Mutation testing — and two harness defects34/34 killed. Six needed the harness fixed before they meant anything:
Worth recording: the kill count was the number I'd have quoted, and it was wrong in both directions until the harness was checked against what it actually observes. Verification
Remaining known gaps, not fixed
🤖 Generated with Claude Code |
Round-3 delta audit of 2dc2715..68cb8b7. No deploy-blocking findings; the redirect fix and the env-resolver rewrite both hold up. Folding in the 🟡 it did find, plus the residual false-warning cases. Following redirects made a 200 reached VIA a redirect indistinguishable from a 200 at the requested path. Any server that bounces unknown paths to an index — an auth gate, any SPA dev server — was classified Vite and handed vite.config.ts advice, and the evidence line asserted that /@vite/client returned 200 when it had returned 302. isVite now re-checks finalPath, so such a server gets the generic remediation and evidence that names the path actually answered. Also from the audit: - net/http DROPS a custom Request.Host across an ABSOLUTE redirect (it keeps it only for relative ones), silently turning the tunnel-Host probe into an ordinary loopback request. It is re-applied on each same-host hop. - finalPath/basePrefix returned the DECODED URL.Path and it was concatenated back into a URL string, so a redirect to `/a%23b/` probed `/a` while the evidence claimed otherwise. EscapedPath round-trips. - bufio.Scanner's default 64 KiB line cap discards the ENTIRE .env file, so one long unrelated line beside a correct origins value produced a false warning. Pre-existing, same class, fixed here. - The visited set stops cycles but not SHARING: `A=${B}${B}` down a chain doubles per level, so a tiny .env could expand to gigabytes and hang dev-tunnel before it printed anything. Expansion now has a byte budget. - `${X:-default}` where X is exported but EMPTY must take the default (`:-` means unset-or-empty), and a default may itself contain `${...}` — both warned at projects Vite resolves correctly. Three guards the audit found unpinned are now pinned, and three of my own tests turned out not to observe what they claimed: the cross-host redirect case asserted on a Host that the new Host-preservation code forces back anyway, the absolute-redirect case never traversed a redirect at all, and the expansion-budget case used a chain too short to exceed its own threshold. All three rewritten until their mutants died. One mutant survived as genuinely EQUIVALENT — a nesting-aware scan for the `:-` separator cannot differ from a plain index, because a reference name can never contain `${`. Removed rather than left as untestable complexity. Differential against Vite's own loadEnv: 43 fixtures, all matching, with a negative control. Full mutant battery re-run: 42/42 killed, 0 survived, 0 invalid. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01DC9uE5774YN7oquYrzE4Yq
Audit round 3 (delta) — fixed in ce43629Delta re-audit of 🟡 A redirected 200 was credited to the path we asked forFollowing redirects (the round-2 fix) made a 200 reached via a redirect indistinguishable from a 200 at the requested path. Consequences: any server that bounces unknown paths to an index — an auth gate, any SPA dev server — was classified Vite and handed
Also fixed
Three of my own tests didn't observe what they claimedThe mutation sweep caught this, not review:
All three rewritten until their mutants died. Worth stating plainly: each had been passing and would have read as coverage. One mutant survived as genuinely equivalentA nesting-aware scan for the VerificationDifferential against Vite's own Known and deliberately not fixed
🤖 Generated with Claude Code |
Closes #196.
The problem
civitai app dev-tunnelreported "Ready" with no errors while the app never loaded — iframe stuck atdata-block-ready="false", host showing "This app didn't load in time". Nothing in the terminal said why.The existing
probeLocalDevServeris a bare TCP dial; the two other probes target the public host and read status codes only. So none of the actual causes were observable.What this adds
An embeddability preflight that runs before the mint (so a broken setup never burns a rate-limited session) and renders immediately before the "open this URL" block — placement is the point, since a warning printed before the readiness wait would scroll away and recreate the same silent failure.
CheckEmbeddable— evidence. GETs/@vite/clientwithOrigin: null(the origin a sandboxed iframe actually sends) through the sameDialLocalDevServerthe tunnel proxy uses, so what the probe measured is by construction what the tunnel will serve. Reports:Access-Control-Allow-Origin→ every ES module fetch blocked, no JS runs at allframe-ancestorsCSP or anyX-Frame-Optionsexcludingcivitai.com*.civit.aiHost — Vite's DNS-rebinding check, a third failure mode the issue didn't name (found by measurement, see below)/@vite/clientdoubles as Vite detection: a 200 means the remediation can namevite.config.ts; otherwise it falls back to/with generic advice.CheckParentOrigins— heuristic.VITE_BLOCK_ALLOWED_PARENT_ORIGINSis inlined at transform time and cannot be observed over HTTP, so this mirrors Vite's own dev env resolution (.env→.env.local→.env.development→.env.development.local, with a real process-env var beating every file). Gated to dirs holding a manifest and apackage.jsondepending on@civitai/app-sdk, becausedev-tunneltakes an explicit blockId and runs from anywhere.Both warn and never block. One HTTP response can't rule out a proxy or a deliberately exotic setup, and hard-failing would regress flows that work today. A check that cannot observe returns no findings rather than manufacturing advice.
Real output
Against a live stock Vite 8.2.0 dev server:
Findings usually share one remediation, so each distinct fix prints once after all the evidence — before the dedupe the same eight-line snippet repeated three times and read as three unrelated problems.
Verification
The premise was measured before the predicate was written, on both Vite 6.4.3 (what
page-vitepins) and 8.2.0 (the reporter's version) — identical on both:/@vite/client,Origin: null*+ CSPHost: dev-abc123.civit.aiThe first attempt at that measurement was invalid and said so: a pre-existing Vite server already held the port, so all four runs came back byte-identical with stock and headers indistinguishable. The rerun added a
/proc/<pid>/cwdownership check before trusting any verdict. TheHost: …civit.ai→ 403 row is what added the third check.CheckEmbeddablerun against real Vite servers, stock (must flag) and configured (must be clean), on both versions — all four as expected.internal/scaffold/dev_embed_contract_test.gorenders the real template, extracts the values it emits, and requires the CLI's own check to accept them — plus a negative control, so a preflight that degenerated to "always nil" fails there instead of reading as a clean bill of health.make cigreen; 1442 tests run, 1440 pass, 0 fail, 2 skip (counted, not inferred from an exit code);gofmt -s -lempty;golangci-lint run ./...→ 0 issues.Two bugs the tests caught during development, both of which would have shipped:
Host, which a real Vite server 403s — it would have manufactured findings for a perfectly healthy server.HTTPS://CSP source a spurious warning.Honest limit: the full browser path — real tunnel, real host, iframe reaching
data-block-ready="true"— could not be verified here. The tunnel endpoint isn't exposed and the feature sits behind an author invite plus an off kill-switch. What is verified is that the CLI correctly detects the conditions the issue identifies; that those conditions were the actual cause rests on the reporter's browser diagnosis.Scope notes
page-money(the only SDK template) emits both fixes correctly today — the reporter's app is an older one scaffolded before that landed, which is exactly why the durable fix has to be CLI-side detection that reaches apps already in the wild.page-vitedeliberately untouched. It has no@civitai/app-sdk, uses rawwindow.parent.postMessage(…, '*'), and so appears unable to complete the host's ready handshake at all — adding headers would fix its CORS half while it still never signals ready. Worth its own issue rather than a half-fix here.TestDialLocalDevServerSpecificHostbound[::1]:0and assumed the same port number was free on IPv4, which nothing guarantees. The new loopback listeners made it collide once in ~30 runs; it now picks a port that is genuinely IPv4-free, and was mutation-checked to confirm it still catches the regression it exists for.🤖 Generated with Claude Code
https://claude.ai/code/session_01DC9uE5774YN7oquYrzE4Yq