fix(cli,studio): close the five R4 blocking gaps

P1 — the required Test lane was red, and it was my test. The
hostile-Host SPA case asserted a 200, which only holds when
packages/studio/dist is built: true on a dev box, false in CI, so it
passed locally and failed there. The Host split moved into a pure
buildStudioHeadScriptsForHost() and is asserted directly; the route test
no longer depends on build state. Verified by running the CLI suite with
the bundle moved aside — 2330 pass.

P1 — studio:* still bypassed most privacy controls. It honoured two
localStorage keys but not navigator.doNotTrack,
VITE_HYPERFRAMES_NO_TELEMETRY, Vite dev mode or API-key eligibility, and
canary enrolment honoured a different single control. New
telemetry/policy.ts is the one answer to "may this profile be measured",
consumed by both transports and by enrolment. It imports only ./config,
so no cycle with the modules that import it. Each control is asserted
individually.

P1 — LAN/remote preview lost the authoritative decisions. Withholding
the whole head script for any non-loopback Host also dropped the safe
{enabled, forced} map, sending a supported HYPERFRAMES_PREVIEW_HOST=
0.0.0.0 Studio back to re-deriving. Identity injection is now gated
separately from decision injection: identity is loopback-only, decisions
always publish.

P1 — the breaker latch was neither authoritative nor truthfully
persisted. syncInstallState swallowed its own failures so
writeConfigWithResult always reported ok, and reads took the flag only
from config.json. The latch is now merged into every effective read,
which makes install-state authoritative and closes both the failed-mirror
and stale-concurrent-writer paths; the write additionally reports
mirrored: false rather than swallowing.

P2 — public contracts. canary-rollouts.mdx said a config wipe loses the
breaker (it does not) and documented the superseded {name: boolean} map
with unconditional CLI precedence; both corrected, with the precedence
ladder written out and the override exception stated explicitly. PR body
rewritten — it still named ~/.local/state, claimed state survives
deleting ~/.hyperframes, and carried stale counts.

Tests: 2330 CLI (bundle absent), 3151 Studio, 24 core. Fault injection:
reverting each fix alone fails 5 CLI / 5 Studio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-30 23:03:20 -07:00
co-authored by Claude Opus 5
parent f81ab0162e
commit 5e2a9432f1
14 changed files with 448 additions and 121 deletions
@@ -6,6 +6,14 @@ import { evaluateCanary } from "@hyperframes/core/canary";
// Pin the registry: real entries move as rollouts ramp, and these tests are
// about the BINDING (does the browser supply the right three inputs?), not
// about whichever canaries happen to be live today.
// The policy reads import.meta.env.DEV, which vitest sets true — without
// this every case would resolve to telemetry_opt_out. Controlled explicitly
// so each test states the privacy posture it is exercising.
const policyState = { allowed: true };
vi.mock("./policy", () => ({
browserTelemetryAllowed: () => policyState.allowed,
}));
vi.mock("@hyperframes/core/canary-registry", async () => {
const actual = await vi.importActual<typeof import("@hyperframes/core/canary-registry")>(
"@hyperframes/core/canary-registry",
@@ -43,6 +51,7 @@ function setSearch(search: string): void {
}
beforeEach(() => {
policyState.allowed = true;
localStorage.clear();
sessionStorage.clear();
setSearch("");
@@ -198,6 +207,7 @@ describe("telemetry opt-out is canary opt-out", () => {
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
it("does not enrol an opted-out browser profile", () => {
policyState.allowed = false;
localStorage.setItem(OPT_OUT_KEY, "1");
// on-everywhere is at 100% — it would be on for everyone otherwise.
expect(resolveCanary("on-everywhere")).toEqual({
@@ -207,17 +217,20 @@ describe("telemetry opt-out is canary opt-out", () => {
});
it("never buckets an opted-out profile — no cohort is assigned at all", () => {
policyState.allowed = false;
localStorage.setItem(OPT_OUT_KEY, "1");
expect(resolveCanary("on-everywhere").bucket).toBeUndefined();
});
it("still honours an explicit URL override", () => {
policyState.allowed = false;
localStorage.setItem(OPT_OUT_KEY, "1");
setSearch("?hf_canary_off_everywhere=on");
expect(resolveCanary("off-everywhere")).toEqual({ enabled: true, reason: "forced_on" });
});
it("reports every canary as false when opted out", () => {
policyState.allowed = false;
localStorage.setItem(OPT_OUT_KEY, "1");
expect(canaryEventProperties()).toEqual({
"$feature/canary-on-everywhere": "false",
@@ -281,6 +294,7 @@ describe("CLI-launched Studio adopts the CLI's decisions", () => {
// opt-outs, and CLI telemetry being on says nothing about this profile.
describe("precedence against Studio's own opt-out", () => {
beforeEach(() => {
policyState.allowed = false;
localStorage.setItem(OPT_OUT_KEY, "1");
});
+2 -2
View File
@@ -41,7 +41,7 @@ import {
} from "@hyperframes/core/canary";
import { CANARIES, findCanary } from "@hyperframes/core/canary-registry";
import { resolveStudioDistinctId } from "./distinctId";
import { isOptedOut } from "./config";
import { browserTelemetryAllowed } from "./policy";
import { safeSessionStorage } from "../utils/safeStorage";
/** `my-feature` → `hf_canary_my_feature`, the query param and storage key. */
@@ -212,7 +212,7 @@ function decideStudioCanary(name: string): CanaryDecision {
const override = readOverride(definition.name);
if (override === undefined) {
if (isOptedOut()) return { enabled: false, reason: "telemetry_opt_out" };
if (!browserTelemetryAllowed()) return { enabled: false, reason: "telemetry_opt_out" };
if (fromCli !== undefined) return cohortOutcome(fromCli.enabled);
}
+5 -39
View File
@@ -4,7 +4,8 @@
// All calls are fire-and-forget; telemetry must never break the studio UI.
// ---------------------------------------------------------------------------
import { getAnonymousId, hasShownNotice, isOptedOut, markNoticeShown } from "./config";
import { getAnonymousId, hasShownNotice, markNoticeShown } from "./config";
import { browserTelemetryAllowed } from "./policy";
import { getBrowserSystemMeta } from "./system";
import { canaryEventProperties } from "./canary";
@@ -25,46 +26,11 @@ let eventQueue: QueuedEvent[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let telemetryEnabled: boolean | null = null;
function isDoNotTrackOn(): boolean {
return typeof navigator !== "undefined" && navigator.doNotTrack === "1";
}
function isApiKeyConfigured(): boolean {
return POSTHOG_API_KEY.startsWith("phc_");
}
// VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
// opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
// bundle the same way. Vite injects it at build time. Match the CLI's
// affirmative privacy-control spellings.
// `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack).
function isBuildTimeOptOut(): boolean {
try {
const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
} catch {
return false;
}
}
// `import.meta.env.DEV` is true under `vite dev` / `vite preview`. Auto-suppress
// so developers running `hyperframes preview` don't pollute production telemetry.
function isViteDevMode(): boolean {
try {
return import.meta.env.DEV === true;
} catch {
return false;
}
}
export function shouldTrack(): boolean {
if (telemetryEnabled !== null) return telemetryEnabled;
telemetryEnabled =
isApiKeyConfigured() &&
!isBuildTimeOptOut() &&
!isViteDevMode() &&
!isOptedOut() &&
!isDoNotTrackOn();
// Delegated to telemetry/policy.ts so this transport, the older `studio:*`
// transport, and canary enrolment cannot drift apart again.
telemetryEnabled = browserTelemetryAllowed();
return telemetryEnabled;
}
@@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Each control the browser telemetry policy enforces, asserted individually.
// This is the SSOT that `telemetry/client.ts`, `utils/studioTelemetry.ts` and
// canary enrolment all consult, so a gap here is a gap in all three — which is
// how `navigator.doNotTrack` and Vite dev mode came to suppress one transport
// but not the other, nor enrolment.
const DOCUMENTED_OPT_OUT = "hyperframes-studio:telemetryDisabled";
const LEGACY_OPT_OUT = "hf-studio-telemetry-opt-out";
describe("browserTelemetryAllowed", () => {
let browserTelemetryAllowed: typeof import("./policy").browserTelemetryAllowed;
beforeEach(async () => {
localStorage.clear();
vi.resetModules();
// vitest sets import.meta.env.DEV; the policy suppresses under it, so the
// baseline has to be an explicitly production-like env.
vi.stubEnv("DEV", false);
vi.stubEnv("VITE_HYPERFRAMES_NO_TELEMETRY", "");
Object.defineProperty(navigator, "doNotTrack", { value: null, configurable: true });
({ browserTelemetryAllowed } = await import("./policy"));
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("allows telemetry with no control set", () => {
expect(browserTelemetryAllowed()).toBe(true);
});
it("refuses when the documented localStorage key is set", () => {
localStorage.setItem(DOCUMENTED_OPT_OUT, "1");
expect(browserTelemetryAllowed()).toBe(false);
});
// Anyone already opted out this way must never be quietly re-enabled by the
// move to the documented key.
it("refuses when the legacy localStorage key is set", () => {
localStorage.setItem(LEGACY_OPT_OUT, "1");
expect(browserTelemetryAllowed()).toBe(false);
});
it("refuses when navigator.doNotTrack is on", () => {
Object.defineProperty(navigator, "doNotTrack", { value: "1", configurable: true });
expect(browserTelemetryAllowed()).toBe(false);
});
it("refuses under Vite dev mode", async () => {
vi.stubEnv("DEV", true);
vi.resetModules();
({ browserTelemetryAllowed } = await import("./policy"));
expect(browserTelemetryAllowed()).toBe(false);
});
it.each(["1", "true", "yes", "on", " ON "])(
"refuses when VITE_HYPERFRAMES_NO_TELEMETRY=%s",
async (value) => {
vi.stubEnv("VITE_HYPERFRAMES_NO_TELEMETRY", value);
vi.resetModules();
({ browserTelemetryAllowed } = await import("./policy"));
expect(browserTelemetryAllowed()).toBe(false);
},
);
it("ignores an unset or unrelated VITE_HYPERFRAMES_NO_TELEMETRY value", async () => {
vi.stubEnv("VITE_HYPERFRAMES_NO_TELEMETRY", "0");
vi.resetModules();
({ browserTelemetryAllowed } = await import("./policy"));
expect(browserTelemetryAllowed()).toBe(true);
});
it("is not memoized — a mid-session opt-out takes effect immediately", () => {
expect(browserTelemetryAllowed()).toBe(true);
localStorage.setItem(DOCUMENTED_OPT_OUT, "1");
expect(browserTelemetryAllowed()).toBe(false);
});
});
+93
View File
@@ -0,0 +1,93 @@
// ---------------------------------------------------------------------------
// Browser telemetry policy — the single answer to "may this profile be
// measured?", shared by every transport and by canary enrolment.
//
// This exists because the answer was previously duplicated and the copies had
// drifted: `telemetry/client.ts` enforced five controls, the older
// `utils/studioTelemetry.ts` transport enforced one (its own localStorage
// key), and canary evaluation enforced a different one. So a profile with
// `navigator.doNotTrack` set, or a Vite dev build, still emitted `studio:*`
// events AND could be bucketed into a rollout — under controls the public
// docs say disable both.
//
// Deliberately imports only `./config` (localStorage helpers). Nothing here
// may import a transport or the canary module: both of those import this, and
// the whole point is one definition with no cycle.
// ---------------------------------------------------------------------------
import { isOptedOut } from "./config";
// Write-only PostHog project key, safe to embed in client code. Duplicated
// from client.ts intentionally — the eligibility check must not drag the
// transport (and its queue/timer state) into modules that only need the
// policy.
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
/** Legacy opt-out key predating `telemetry/config.ts`. Still honoured so
* anyone already opted out is never quietly re-enabled. */
const LEGACY_OPT_OUT_KEY = "hf-studio-telemetry-opt-out";
function isLegacyOptedOut(): boolean {
try {
return localStorage.getItem(LEGACY_OPT_OUT_KEY) === "1";
} catch {
return false;
}
}
function isDoNotTrackOn(): boolean {
return typeof navigator !== "undefined" && navigator.doNotTrack === "1";
}
function isApiKeyConfigured(): boolean {
return POSTHOG_API_KEY.startsWith("phc_");
}
// VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
// opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
// bundle the same way. Vite injects it at build time. Match the CLI's
// affirmative privacy-control spellings.
// `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack).
function isBuildTimeOptOut(): boolean {
try {
const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
} catch {
return false;
}
}
// `import.meta.env.DEV` is true under `vite dev` / `vite preview`. Auto-suppress
// so developers running `hyperframes preview` don't pollute production telemetry.
function isViteDevMode(): boolean {
try {
return import.meta.env.DEV === true;
} catch {
return false;
}
}
/**
* May this browser profile be measured at all?
*
* Governs BOTH sending events and canary enrolment. Enrolment is part of
* measurement, not separate from it: a profile that reports nothing cannot be
* compared against anyone, so bucketing it changes that user's code path for
* no signal. The one documented exception is an explicit `HF_CANARY_*` /
* `?hf_canary_*=` override, which callers apply before consulting this.
*
* Not memoized — `isOptedOut()` reads localStorage, which a user can flip in
* DevTools mid-session, and the per-call cost is a couple of property reads.
* Callers that must stay stable within a session memoize their own result
* (canary decisions do; the transports intentionally do not).
*/
export function browserTelemetryAllowed(): boolean {
return (
isApiKeyConfigured() &&
!isBuildTimeOptOut() &&
!isViteDevMode() &&
!isOptedOut() &&
!isLegacyOptedOut() &&
!isDoNotTrackOn()
);
}