fix(cli,studio): close the four R3 blocking gaps

P1 — SPA route bypassed the DNS-rebinding guard. Guarding only
/api/telemetry-identity left the catch-all as an open side door: a
rebound origin could fetch `/` and read __HF_CLI_DISTINCT_ID and
__HF_CLI_BUCKET_SEED straight out of the returned HTML. The SPA response
now applies the same isLoopbackHost() check; an untrusted Host still gets
a working Studio, just with no identity, seed or decisions injected.
Route-level regression added.

P1 — a CLI cohort roll could override Studio's own opt-out.
decideStudioCanary() adopted the injected decision before checking
isOptedOut(), so CLI-telemetry-on plus Studio-opted-out still enrolled
Studio. A bare boolean could not express the difference between a
deliberate override and an ordinary cohort roll, so the injected map now
carries provenance ({ enabled, forced }). Forced wins outright — it is
the documented escalation channel and must behave the same on both
surfaces — while a percentage roll now loses to this profile's opt-out.
Full interaction matrix tested.

P1 — the legacy studio:* path sat outside both contracts.
utils/studioTelemetry.ts shipped its own opt-out key and its own send
loop, so the documented hyperframes-studio:telemetryDisabled did not
silence it and its events carried no cohort assignment. It now honours
both keys (the legacy one stays, so nobody already opted out is quietly
re-enabled) and mixes in canaryEventProperties(), making "every
telemetry event carries the assignment" actually true.

P2 — partial salvage could drop a tripped breaker.
salvageInstallState() discarded the whole record when markerAt and
bucketSeed were both unusable, taking deParallelRouterTrialFired with it
and re-enrolling a machine whose router already failed. All three fields
are now independently salvageable.

Docs: canary-rollouts.mdx said "disabling telemetry disables the
reporting, not the enrolment" — exactly backwards since the opt-out gate
landed. Corrected; checked for other copies, none.

Tests: 13 new (4 opt-out precedence, 4 legacy-path opt-out and canary
props, 3 route-level host guard, 2 breaker salvage). Fault injection:
each of the four fixes reverted independently fails its own tests
(2 CLI + 1 Studio + 2 Studio).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-30 18:35:22 -07:00
co-authored by Claude Opus 5
parent 54534d53c2
commit f81ab0162e
12 changed files with 313 additions and 47 deletions
+11 -1
View File
@@ -814,7 +814,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// Inject before the studio bundle runs. Identity script first (see
// buildStudioHeadScripts) so the CLI distinct id is on `window` by the time
// telemetry init reads it.
const headScript = buildStudioHeadScripts(buildRuntimeEnvScript());
//
// Host-guarded for the same reason /api/telemetry-identity is, and it has
// to be checked HERE too: guarding only the endpoint leaves this route as
// an open side door, since a rebound origin can simply fetch `/` and read
// the same distinct id and seed out of the returned HTML. Untrusted Host
// still gets a working Studio — it just gets the env script alone, with no
// identity, no seed, and no canary decisions.
const trustedHost = isLoopbackHost(c.req.header("host"));
const headScript = trustedHost
? buildStudioHeadScripts(buildRuntimeEnvScript())
: buildRuntimeEnvScript();
if (headScript) {
html = html.replace("<head>", `<head>${headScript}`);
}