fix(cli,core,studio): close 15 review findings + 2 R5 blockers

R5 blockers
- Negative install-state latch was cached for the process lifetime, but
  only `true` is monotonic across processes. A long-lived preview server
  held a stale `false` and could re-enrol after another process tripped
  the breaker. Only the positive is cached now; `false` re-reads.
- The real breaker writer used writeConfig(), which collapses
  {ok:true, mirrored:false} to success, so a run that mirrored nothing
  reported done with the latch only on the erasable store. It consumes
  writeConfigWithResult and retries until both stores carry it.

Bucketing integrity
- Storage-restricted Studio profiles all bucketed on the literal
  "anonymous": computed against the shipped hash, 100% of them were
  enrolled in calibration-50 rather than 50%, and they merged into one
  PostHog person. Per-session random id instead — persists nothing.
- bucketSeed had read/write authority backwards: install-state is
  write-once authoritative, but readConfig took config.json's blindly, so
  the stores could hold different seeds until a re-mint flipped every
  cohort. Merged on read, like the latch.
- An unwritable ~/.hyperframes with no config.json re-minted per call,
  re-rolling the seed on every command, and the "cohorts will not be
  stable" warning was unreachable on that path.
- A corrupt PRE-MOVE state file was never deleted, so a machine reset
  with `rm -rf ~/.hyperframes` reported predecessorFound/stateFileCorrupt
  forever — poisoning the exact metric this work exists to produce.

Opt-out honoring
- CLI canary decisions memoized per process, so `hyperframes telemetry
  disable` during a running preview server was ignored for hours while
  the server kept serving pre-opt-out decisions. The memo is keyed on the
  telemetry posture.
- shouldTrack() memoized, contradicting policy.ts's documented "not
  memoized" contract that policy.test.ts asserts.
- The Studio override path resolved the bucket unit eagerly as an
  argument, minting and PERSISTING a tracking id for an opted-out profile
  — a value evaluateCanary discards unread.
- Storage reads could throw out of telemetry into a post-commit catch
  block, reporting an already-committed edit as failed.
- readConfig printed an unsilenceable stderr warning on every invocation
  for installs that opted out of telemetry entirely.

Host split
- isLoopbackHost rejected 0.0.0.0, so the documented
  HYPERFRAMES_PREVIEW_HOST LAN mode silently lost CLI→Studio identity
  stitching and split one user across two PostHog persons. Identity is
  now allowed when the operator explicitly opted into LAN binding.
- Corrected the comment claiming the guard refuses spoofed Hosts: a
  non-browser client sets Host freely. It is a browser DNS-rebinding
  mitigation, not access control, and now says so.

Semantics and test hygiene
- percentage:100 did not mean everyone — exclude and no_unit_id sat above
  the fast path, so the registry's "delete the entry at 100" step was an
  unstaged flip for CI and seedless installs.
- CLI cohort adoption returned before evaluateCanary, dropping Studio's
  own webdriver exclusion.
- overdueCanaries() was asserted against wall-clock time, so the whole
  core suite would go red on 2026-09-15 for every unrelated PR; and `>`
  against midnight made a canary overdue ON its sunset date.
- Statistical assertions ran on unseeded randomUUID() populations tight
  enough to fail ~1 run in 200. Seeded.

Also: broke a config -> policy -> transport -> config import cycle by
moving POSTHOG_API_KEY to a leaf module.

Tests: 2347 CLI (bundle absent), 3153 Studio, 1450 core. Fault injection
covers the latch, seed authority, LAN identity, webdriver exclusion and
the anonymous-bucket fix. Two pre-existing tests asserted behaviour these
findings identify as wrong (shouldTrack memoization, 100%-excludes-CI)
and were rewritten with the reasoning stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-31 01:27:37 -07:00
co-authored by Claude Opus 5
parent 5e2a9432f1
commit 3f69a2c635
24 changed files with 648 additions and 109 deletions
+37 -12
View File
@@ -44,6 +44,17 @@ vi.mock("@hyperframes/core/canary-registry", async () => {
owner: "t",
sunsetAfter: "2099-01-01",
},
{
// A PARTIAL percentage. `exclude` and the unit-id check only apply
// below 100 — at 100 the guard is about to be deleted, so everyone
// must already be on it — so exclusion behaviour cannot be expressed
// against test-alpha.
name: "test-gamma",
percentage: 50,
description: "partial rollout",
owner: "t",
sunsetAfter: "2099-01-01",
},
{
name: "test-beta",
percentage: 0,
@@ -61,6 +72,13 @@ vi.mock("@hyperframes/core/canary-registry", async () => {
owner: "t",
sunsetAfter: "2099-01-01",
},
{
name: "test-gamma",
percentage: 50,
description: "",
owner: "t",
sunsetAfter: "2099-01-01",
},
{
name: "test-beta",
percentage: 0,
@@ -84,6 +102,7 @@ beforeEach(() => {
policyState.runtimeOverride = null;
delete process.env.HF_CANARY_TEST_ALPHA;
delete process.env.HF_CANARY_TEST_BETA;
delete process.env.HF_CANARY_TEST_GAMMA;
});
describe("telemetry opt-out is canary opt-out", () => {
@@ -121,6 +140,7 @@ describe("telemetry opt-out is canary opt-out", () => {
configState.telemetryEnabled = false;
expect(canaryEventProperties()).toEqual({
"$feature/canary-test-alpha": "false",
"$feature/canary-test-gamma": "false",
"$feature/canary-test-beta": "false",
});
});
@@ -130,16 +150,18 @@ describe("bucketing unit", () => {
it("buckets on the bucketSeed when present — the unit that survives config wipes", async () => {
const { evaluateCanary } = await import("@hyperframes/core/canary");
configState.bucketSeed = "5f1c9d2e-0000-4000-8000-aaaaaaaaaaaa";
const viaBinding = resolveCanary("test-alpha").bucket;
const viaBinding = resolveCanary("test-gamma").bucket;
// 50, not 100: at 100 evaluateCanary short-circuits before bucketing and
// reports no bucket at all, which would make this comparison vacuous.
const bySeed = evaluateCanary({
feature: "test-alpha",
feature: "test-gamma",
unitId: configState.bucketSeed,
percentage: 100,
percentage: 50,
}).bucket;
const byId = evaluateCanary({
feature: "test-alpha",
feature: "test-gamma",
unitId: configState.anonymousId,
percentage: 100,
percentage: 50,
}).bucket;
expect(viaBinding).toBe(bySeed);
// Only meaningful if the two units actually bucket differently.
@@ -148,13 +170,15 @@ describe("bucketing unit", () => {
it("falls back to the anonymousId when no seed exists (failed legacy backfill)", async () => {
const { evaluateCanary } = await import("@hyperframes/core/canary");
const viaBinding = resolveCanary("test-alpha").bucket;
const viaBinding = resolveCanary("test-gamma").bucket;
const byId = evaluateCanary({
feature: "test-alpha",
feature: "test-gamma",
unitId: configState.anonymousId,
percentage: 100,
percentage: 50,
}).bucket;
expect(viaBinding).toBe(byId);
// Both undefined would satisfy toBe — assert a bucket was actually computed.
expect(viaBinding).toEqual(expect.any(Number));
});
});
@@ -178,16 +202,16 @@ describe("CLI canary binding", () => {
it("excludes CI from percentage enrolment, but an override still reaches it", () => {
systemState.is_ci = true;
expect(resolveCanary("test-alpha")).toMatchObject({ enabled: false, reason: "excluded" });
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: false, reason: "excluded" });
__resetCanaryCacheForTests();
process.env.HF_CANARY_TEST_ALPHA = "on";
expect(resolveCanary("test-alpha")).toMatchObject({ enabled: true, reason: "forced_on" });
process.env.HF_CANARY_TEST_GAMMA = "on";
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: true, reason: "forced_on" });
});
it("fails closed when the install has no anonymousId", () => {
configState.anonymousId = "";
expect(resolveCanary("test-alpha")).toMatchObject({ enabled: false, reason: "no_unit_id" });
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: false, reason: "no_unit_id" });
});
it("memoizes so a decision cannot change mid-process", () => {
@@ -202,6 +226,7 @@ describe("CLI canary binding", () => {
it("emits PostHog flag-shaped properties for every registered canary", () => {
expect(canaryEventProperties()).toEqual({
"$feature/canary-test-alpha": "true",
"$feature/canary-test-gamma": expect.stringMatching(/^(true|false)$/),
"$feature/canary-test-beta": "false",
});
+14
View File
@@ -58,9 +58,18 @@ function telemetryActive(): boolean {
*/
const decisions = new Map<string, CanaryDecision>();
// The memo is scoped to a telemetry posture, not to the process. Within one
// render nothing may change (a render that starts enrolled must finish
// enrolled), but `hyperframes preview` is a long-lived server that re-serves
// decisions on every page load — and it used to keep serving pre-opt-out
// decisions for hours after `hyperframes telemetry disable`, which is exactly
// what the docs promise cannot happen.
let decisionsTelemetryPosture: boolean | undefined;
/** Test-only: drop memoized decisions so cases don't leak into each other. */
export function __resetCanaryCacheForTests(): void {
decisions.clear();
decisionsTelemetryPosture = undefined;
}
/** The uncached decision. Split out so `resolveCanary` is purely the memo. */
@@ -105,6 +114,11 @@ function decideCanary(name: string): CanaryDecision {
* rollout control, and a typo in one must never take down a render.
*/
export function resolveCanary(name: string): CanaryDecision {
const posture = telemetryActive();
if (decisionsTelemetryPosture !== posture) {
decisions.clear();
decisionsTelemetryPosture = posture;
}
const cached = decisions.get(name);
if (cached) return cached;
+125
View File
@@ -36,6 +36,14 @@ vi.mock("node:fs", () => ({
}),
}));
// The backfill warning is suppressed under a telemetry runtime override (a
// dev build counts as one, which vitest is), so the posture is controlled
// explicitly rather than inherited from the test environment.
const policyState = { runtimeOverride: null as string | null };
vi.mock("./policy.js", () => ({
telemetryRuntimeOverride: () => policyState.runtimeOverride,
}));
// Derived here rather than exported from config.ts: the pre-move path is
// frozen history, so pinning the literal is the point — an export would just
// let a rename pass silently, and it has no non-test consumer.
@@ -446,6 +454,7 @@ describe("seed backfill write failure is surfaced", () => {
});
it("warns once when the backfilled seed cannot be persisted", async () => {
policyState.runtimeOverride = null;
// A config predating bucketSeed, on an unwritable home directory.
const seeded = { telemetryEnabled: true, anonymousId: "id", telemetryNoticeShown: true };
fsState.files.set(CONFIG_PATH, JSON.stringify(seeded));
@@ -553,3 +562,119 @@ describe("the breaker latch is authoritative from install-state", () => {
});
});
});
describe("install-state authority — negative latch and seed", () => {
let readConfig: typeof import("./config.js").readConfig;
let readConfigFresh: typeof import("./config.js").readConfigFresh;
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
let STATE_PATH: typeof import("./config.js").STATE_PATH;
beforeEach(async () => {
fsState.files.clear();
vi.resetModules();
({ readConfig, readConfigFresh, CONFIG_PATH, STATE_PATH } = await import("./config.js"));
});
// Only `true` is monotonic across processes. Caching `false` froze a stale
// negative in a long-lived process (the preview server), so a breaker
// tripped by another process was never picked up.
it("re-reads a negative latch: false -> external trip -> stale config -> fresh read is true", () => {
fsState.files.set(STATE_PATH, JSON.stringify({ markerAt: "2026-07-28T00:00:00.000Z" }));
fsState.files.set(
CONFIG_PATH,
JSON.stringify({ telemetryEnabled: true, anonymousId: "id", bucketSeed: "seed" }),
);
expect(readConfig().deParallelRouterTrialFired).toBeUndefined();
// Another process trips the breaker and mirrors it...
fsState.files.set(
STATE_PATH,
JSON.stringify({ markerAt: "2026-07-28T00:00:00.000Z", deParallelRouterTrialFired: true }),
);
// ...and a stale writer rewrites config.json without the flag.
fsState.files.set(
CONFIG_PATH,
JSON.stringify({ telemetryEnabled: true, anonymousId: "id", bucketSeed: "seed" }),
);
expect(readConfigFresh().deParallelRouterTrialFired).toBe(true);
});
// The latch got read-side authority; the seed did not, so the two stores
// could hold different seeds indefinitely and a re-mint flipped every cohort.
it("prefers the install-state seed over a restored config.json seed", () => {
fsState.files.set(
STATE_PATH,
JSON.stringify({ markerAt: "2026-07-28T00:00:00.000Z", bucketSeed: "seed-B" }),
);
fsState.files.set(
CONFIG_PATH,
JSON.stringify({ telemetryEnabled: true, anonymousId: "id", bucketSeed: "seed-A" }),
);
expect(readConfig().bucketSeed).toBe("seed-B");
});
// A corrupt file OUTSIDE ~/.hyperframes survived the documented reset and
// reported a predecessor forever.
it("deletes an unreadable pre-move state file instead of reporting it forever", () => {
fsState.files.set(LEGACY_STATE_PATH, "{truncated");
const config = readConfig();
expect(config.stateFileCorrupt).toBe(true);
expect(fsState.files.has(LEGACY_STATE_PATH), "legacy copy removed").toBe(false);
});
});
describe("an unwritable config dir must not re-roll the seed", () => {
let readConfig: typeof import("./config.js").readConfig;
let readConfigFresh: typeof import("./config.js").readConfigFresh;
beforeEach(async () => {
fsState.files.clear();
policyState.runtimeOverride = null;
vi.resetModules();
({ readConfig, readConfigFresh } = await import("./config.js"));
});
// No config.json AND an unwritable dir: cachedConfig was never set, so the
// next read re-minted and the seed re-rolled — on every command, forever.
it("keeps one seed for the process when the initial write fails", async () => {
const fs = await import("node:fs");
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error("EACCES: permission denied");
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const first = readConfig();
const second = readConfig();
expect(first.bucketSeed).toBeTruthy();
expect(second.bucketSeed).toBe(first.bucketSeed);
expect(second.anonymousId).toBe(first.anonymousId);
// And the user is told, rather than churning silently.
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
vi.mocked(fs.writeFileSync).mockImplementation((path, content) => {
fsState.files.set(String(path), String(content));
});
});
it("stays silent for an install that opted out of telemetry", async () => {
policyState.runtimeOverride = "HYPERFRAMES_NO_TELEMETRY";
const fs = await import("node:fs");
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error("EACCES: permission denied");
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
readConfigFresh();
// The only consequence is unstable canary cohorts, and an opted-out
// install is never enrolled in one — so this line was pure noise in CI
// logs, on every invocation, unsilenceable.
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
vi.mocked(fs.writeFileSync).mockImplementation((path, content) => {
fsState.files.set(String(path), String(content));
});
});
});
+123 -50
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { homedir } from "node:os";
import { randomUUID } from "node:crypto";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { telemetryRuntimeOverride } from "./policy.js";
// ---------------------------------------------------------------------------
// Config directory: ~/.hyperframes/
@@ -124,6 +125,12 @@ let seedBackfillWarned = false;
function warnSeedBackfillFailed(error: string | undefined): void {
if (seedBackfillWarned) return;
seedBackfillWarned = true;
// Suppressed when the user has opted out of telemetry: the only consequence
// of the failed write is unstable CANARY cohorts, and an opted-out install
// is not enrolled in any. Printing anyway put an unsilenceable line into
// CI render logs on every invocation for a user who wants no telemetry at
// all. `hyperframes doctor` still surfaces it on demand.
if (telemetryRuntimeOverride() !== null) return;
console.warn(
`[hyperframes] Could not persist telemetry config${error ? `: ${error}` : ""}. ` +
"Canary cohort assignment will not be stable across runs.",
@@ -147,9 +154,12 @@ function backfillBucketSeed(config: HyperframesConfig): void {
if (!write.ok) warnSeedBackfillFailed(write.error);
}
// The latch is monotonic — once tripped it never untrips — so one read per
// process is enough, and readConfig is hot (every command, every render).
let latchFromStateFile: boolean | undefined;
// ONLY the positive is cached. The latch is monotonic across processes in one
// direction: once some process trips it, it stays tripped. A cached `false`
// is not monotonic — another process can trip the breaker while this one is
// alive, and a long-lived process (the preview/studio server) would then hold
// a stale negative for hours. So `true` short-circuits and `false` re-reads.
let latchedFiredSeen = false;
/**
* Is the breaker latched according to install-state?
@@ -163,11 +173,22 @@ let latchFromStateFile: boolean | undefined;
* to prefer the safety fact.
*/
function installStateLatchedFired(): boolean {
if (latchFromStateFile === undefined) {
const state = readInstallState();
latchFromStateFile = isInstallState(state) && state.deParallelRouterTrialFired === true;
}
return latchFromStateFile;
if (latchedFiredSeen) return true;
const state = readInstallState();
latchedFiredSeen = isInstallState(state) && state.deParallelRouterTrialFired === true;
return latchedFiredSeen;
}
// Same shape as the latch memo, and same reason for existing: readConfig is
// hot. A seed never changes once recorded, so the positive is cacheable.
let seedFromStateFile: string | undefined;
/** The seed install-state has recorded for this machine, if any. */
function installStateSeed(): string | undefined {
if (seedFromStateFile !== undefined) return seedFromStateFile;
const state = readInstallState();
if (isInstallState(state) && state.bucketSeed !== undefined) seedFromStateFile = state.bucketSeed;
return seedFromStateFile;
}
/** Narrow the parse result to a usable record. */
@@ -192,6 +213,12 @@ function readInstallState(): InstallState | InstallStateMiss {
}
const legacy = parseInstallState(LEGACY_STATE_FILE);
if (!isInstallState(legacy)) {
// Unreadable legacy copy: nothing to migrate, so drop it here too. It
// used to survive, and since it lives OUTSIDE ~/.hyperframes it then
// reported predecessorFound/stateFileCorrupt forever on a machine the
// user had already reset with `rm -rf ~/.hyperframes` — poisoning the one
// metric this file exists to produce.
if (legacy === "corrupt") removeLegacyStateFile();
// Corruption at EITHER location still means this machine had an install.
return current === "corrupt" || legacy === "corrupt" ? "corrupt" : "absent";
}
@@ -281,7 +308,8 @@ function applyInstallState(config: HyperframesConfig, wantFired: boolean): void
if (next !== null) writeInstallState(next);
stateMarkerSynced = true;
stateFiredSynced = wantFired || state?.deParallelRouterTrialFired === true;
if (wantFired) latchFromStateFile = true;
if (wantFired) latchedFiredSeen = true;
if (next?.bucketSeed !== undefined) seedFromStateFile = next.bucketSeed;
}
function syncInstallState(config: HyperframesConfig): boolean {
@@ -296,6 +324,23 @@ function syncInstallState(config: HyperframesConfig): boolean {
}
}
/**
* Mint a fresh config, persist it, and cache it EVEN IF the write failed.
*
* Without the unconditional cache the next readConfig in the same process
* re-minted, re-rolling bucketSeed along with the id — and across processes an
* unwritable ~/.hyperframes (read-only mount, root-owned after a sudo run,
* full disk) meant a fresh cohort on every single command, which is exactly
* the unbounded cumulative exposure the seed exists to prevent.
*/
function mintAndCacheConfig(): HyperframesConfig {
const config = mintConfig();
const write = writeConfigWithResult(config);
if (!write.ok) warnSeedBackfillFailed(write.error);
cachedConfig = { ...config };
return { ...config };
}
/**
* Build a brand-new config for an install with no (readable) config file,
* consulting the install-state file for what a previous install on this
@@ -501,55 +546,83 @@ function parseRecentRenders(value: unknown): RecentRenderRecord[] | undefined {
* Read the config file, creating it with defaults if it doesn't exist.
* Returns a mutable copy — call `writeConfig()` to persist changes.
*/
/**
* Materialize a parsed config object, applying defaults and the explicit
* type guards. Split out of readConfig purely for size — that function is
* otherwise one long object literal plus four control-flow branches.
*/
/** Fields that are pure passthrough — no default, no validation. */
function passthroughFields(parsed: Partial<HyperframesConfig>): Partial<HyperframesConfig> {
return {
lastUpdateCheck: parsed.lastUpdateCheck,
latestVersion: parsed.latestVersion,
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
pendingUpdate: parsed.pendingUpdate,
completedUpdate: parsed.completedUpdate,
lastSkillsCheck: parsed.lastSkillsCheck,
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
skillsOutdatedCount: parsed.skillsOutdatedCount,
skillsMissingCount: parsed.skillsMissingCount,
skillsRemovedCount: parsed.skillsRemovedCount,
};
}
/**
* Fields that need an explicit type guard or a cross-store merge, split from
* the plain defaults so neither block is complex on its own.
*/
function guardedFields(parsed: Partial<HyperframesConfig>): Partial<HyperframesConfig> {
return {
// Explicit `=== true`/typeof-number checks rather than a truthy/nullish
// read — a hand-edited or corrupted config could plausibly carry a
// non-boolean/non-number JSON value (e.g. the STRING "false", which is
// truthy in JS) for these two fields specifically, since they're read
// with a bare truthy check at the call site (review finding).
// `|| installStateLatchedFired()` — a latch recorded in install-state
// wins over an untripped config.json, never the reverse.
deParallelRouterTrialFired:
parsed.deParallelRouterTrialFired === true || installStateLatchedFired() ? true : undefined,
deParallelRouterTrialRenderCount:
typeof parsed.deParallelRouterTrialRenderCount === "number"
? parsed.deParallelRouterTrialRenderCount
: undefined,
predecessorFound:
typeof parsed.predecessorFound === "boolean" ? parsed.predecessorFound : undefined,
stateFileCorrupt: parsed.stateFileCorrupt === true ? true : undefined,
// Install-state wins, exactly as it does for the latch. Without this the
// two stores could hold different seeds indefinitely: nextInstallState
// is write-once (state's seed always survives), but the read took
// config.json's blindly — so restoring or syncing only config.json left
// the install bucketing on A while install-state kept B, and the next
// re-mint silently flipped every cohort at once.
bucketSeed: installStateSeed() ?? parseNonEmptyString(parsed.bucketSeed),
};
}
function materializeConfig(parsed: Partial<HyperframesConfig>): HyperframesConfig {
return {
...passthroughFields(parsed),
telemetryEnabled: parsed.telemetryEnabled ?? DEFAULT_CONFIG.telemetryEnabled,
anonymousId: parsed.anonymousId || randomUUID(),
telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown,
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
renderSuccessCount: parsed.renderSuccessCount ?? DEFAULT_CONFIG.renderSuccessCount,
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
...guardedFields(parsed),
recentRenders: parseRecentRenders(parsed.recentRenders),
};
}
export function readConfig(): HyperframesConfig {
if (cachedConfig) return { ...cachedConfig };
if (!existsSync(CONFIG_FILE)) {
const config = mintConfig();
writeConfig(config);
return config;
}
if (!existsSync(CONFIG_FILE)) return mintAndCacheConfig();
try {
const raw = readFileSync(CONFIG_FILE, "utf-8");
const parsed = JSON.parse(raw) as Partial<HyperframesConfig>;
const config: HyperframesConfig = {
telemetryEnabled: parsed.telemetryEnabled ?? DEFAULT_CONFIG.telemetryEnabled,
anonymousId: parsed.anonymousId || randomUUID(),
telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown,
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
renderSuccessCount: parsed.renderSuccessCount ?? DEFAULT_CONFIG.renderSuccessCount,
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
lastUpdateCheck: parsed.lastUpdateCheck,
latestVersion: parsed.latestVersion,
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
pendingUpdate: parsed.pendingUpdate,
completedUpdate: parsed.completedUpdate,
lastSkillsCheck: parsed.lastSkillsCheck,
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
skillsOutdatedCount: parsed.skillsOutdatedCount,
skillsMissingCount: parsed.skillsMissingCount,
skillsRemovedCount: parsed.skillsRemovedCount,
// Explicit `=== true`/typeof-number checks rather than a truthy/nullish
// read — a hand-edited or corrupted config could plausibly carry a
// non-boolean/non-number JSON value (e.g. the STRING "false", which is
// truthy in JS) for these two fields specifically, since they're read
// with a bare truthy check at the call site (review finding).
// `|| installStateLatchedFired()` — a latch recorded in install-state
// wins over an untripped config.json, never the reverse.
deParallelRouterTrialFired:
parsed.deParallelRouterTrialFired === true || installStateLatchedFired() ? true : undefined,
deParallelRouterTrialRenderCount:
typeof parsed.deParallelRouterTrialRenderCount === "number"
? parsed.deParallelRouterTrialRenderCount
: undefined,
predecessorFound:
typeof parsed.predecessorFound === "boolean" ? parsed.predecessorFound : undefined,
stateFileCorrupt: parsed.stateFileCorrupt === true ? true : undefined,
bucketSeed: parseNonEmptyString(parsed.bucketSeed),
recentRenders: parseRecentRenders(parsed.recentRenders),
};
const config = materializeConfig(parsed);
// One-time backfill for configs predating the bucket seed: prefer the
// recorded seed if a previous install already wrote one, else mint.
+3 -1
View File
@@ -5,7 +5,9 @@ async function loadPolicy(options?: { devMode?: boolean; apiKey?: string }) {
vi.doMock("../utils/env.js", () => ({
isDevMode: () => options?.devMode ?? false,
}));
vi.doMock("./transport.js", () => ({
// The key moved to a leaf module to break a config -> policy -> transport
// -> config import cycle; policy.ts reads it from there now.
vi.doMock("./posthogKey.js", () => ({
POSTHOG_API_KEY: options?.apiKey ?? "phc_test",
}));
return import("./policy.js");
+1 -1
View File
@@ -1,5 +1,5 @@
import { isDevMode } from "../utils/env.js";
import { POSTHOG_API_KEY } from "./transport.js";
import { POSTHOG_API_KEY } from "./posthogKey.js";
export type TelemetryStatusSource =
| "config"
+10
View File
@@ -0,0 +1,10 @@
/**
* The PostHog write-only ingest key, as a LEAF module.
*
* It lives here rather than in transport.ts because `policy.ts` needs it (an
* unconfigured key is itself a telemetry opt-out) and importing transport for
* one constant created `config.ts -> policy.ts -> transport.ts -> config.ts`.
* A cycle through the telemetry config is the kind that bites at module-init
* time, so the constant moved instead of the dependency being tolerated.
*/
export const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
+2 -1
View File
@@ -1,10 +1,11 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { POSTHOG_API_KEY } from "./posthogKey.js";
import { readConfig } from "./config.js";
// This is a public project API key — safe to embed in client-side code.
// It only allows writing events, not reading data.
export const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
const POSTHOG_HOST = "https://us.i.posthog.com";
const FLUSH_TIMEOUT_MS = 5_000;