mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 21:36:57 +10:00
1156 lines
40 KiB
TypeScript
1156 lines
40 KiB
TypeScript
|
|
import { GrowthBook } from '@growthbook/growthbook'
|
|||
|
|
import { isEqual, memoize } from 'lodash-es'
|
|||
|
|
import {
|
|||
|
|
getIsNonInteractiveSession,
|
|||
|
|
getSessionTrustAccepted,
|
|||
|
|
} from '../../bootstrap/state.js'
|
|||
|
|
import { getGrowthBookClientKey } from '../../constants/keys.js'
|
|||
|
|
import {
|
|||
|
|
checkHasTrustDialogAccepted,
|
|||
|
|
getGlobalConfig,
|
|||
|
|
saveGlobalConfig,
|
|||
|
|
} from '../../utils/config.js'
|
|||
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|||
|
|
import { toError } from '../../utils/errors.js'
|
|||
|
|
import { getAuthHeaders } from '../../utils/http.js'
|
|||
|
|
import { logError } from '../../utils/log.js'
|
|||
|
|
import { createSignal } from '../../utils/signal.js'
|
|||
|
|
import { jsonStringify } from '../../utils/slowOperations.js'
|
|||
|
|
import {
|
|||
|
|
type GitHubActionsMetadata,
|
|||
|
|
getUserForGrowthBook,
|
|||
|
|
} from '../../utils/user.js'
|
|||
|
|
import {
|
|||
|
|
is1PEventLoggingEnabled,
|
|||
|
|
logGrowthBookExperimentTo1P,
|
|||
|
|
} from './firstPartyEventLogger.js'
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* User attributes sent to GrowthBook for targeting.
|
|||
|
|
* Uses UUID suffix (not Uuid) to align with GrowthBook conventions.
|
|||
|
|
*/
|
|||
|
|
export type GrowthBookUserAttributes = {
|
|||
|
|
id: string
|
|||
|
|
sessionId: string
|
|||
|
|
deviceID: string
|
|||
|
|
platform: 'win32' | 'darwin' | 'linux'
|
|||
|
|
apiBaseUrlHost?: string
|
|||
|
|
organizationUUID?: string
|
|||
|
|
accountUUID?: string
|
|||
|
|
userType?: string
|
|||
|
|
subscriptionType?: string
|
|||
|
|
rateLimitTier?: string
|
|||
|
|
firstTokenTime?: number
|
|||
|
|
email?: string
|
|||
|
|
appVersion?: string
|
|||
|
|
github?: GitHubActionsMetadata
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Malformed feature response from API that uses "value" instead of "defaultValue".
|
|||
|
|
* This is a workaround until the API is fixed.
|
|||
|
|
*/
|
|||
|
|
type MalformedFeatureDefinition = {
|
|||
|
|
value?: unknown
|
|||
|
|
defaultValue?: unknown
|
|||
|
|
[key: string]: unknown
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let client: GrowthBook | null = null
|
|||
|
|
|
|||
|
|
// Named handler refs so resetGrowthBook can remove them to prevent accumulation
|
|||
|
|
let currentBeforeExitHandler: (() => void) | null = null
|
|||
|
|
let currentExitHandler: (() => void) | null = null
|
|||
|
|
|
|||
|
|
// Track whether auth was available when the client was created
|
|||
|
|
// This allows us to detect when we need to recreate with fresh auth headers
|
|||
|
|
let clientCreatedWithAuth = false
|
|||
|
|
|
|||
|
|
// Store experiment data from payload for logging exposures later
|
|||
|
|
type StoredExperimentData = {
|
|||
|
|
experimentId: string
|
|||
|
|
variationId: number
|
|||
|
|
inExperiment?: boolean
|
|||
|
|
hashAttribute?: string
|
|||
|
|
hashValue?: string
|
|||
|
|
}
|
|||
|
|
const experimentDataByFeature = new Map<string, StoredExperimentData>()
|
|||
|
|
|
|||
|
|
// Cache for remote eval feature values - workaround for SDK not respecting remoteEval response
|
|||
|
|
// The SDK's setForcedFeatures also doesn't work reliably with remoteEval
|
|||
|
|
const remoteEvalFeatureValues = new Map<string, unknown>()
|
|||
|
|
|
|||
|
|
// Track features accessed before init that need exposure logging
|
|||
|
|
const pendingExposures = new Set<string>()
|
|||
|
|
|
|||
|
|
// Track features that have already had their exposure logged this session (dedup)
|
|||
|
|
// This prevents firing duplicate exposure events when getFeatureValue_CACHED_MAY_BE_STALE
|
|||
|
|
// is called repeatedly in hot paths (e.g., isAutoMemoryEnabled in render loops)
|
|||
|
|
const loggedExposures = new Set<string>()
|
|||
|
|
|
|||
|
|
// Track re-initialization promise for security gate checks
|
|||
|
|
// When GrowthBook is re-initializing (e.g., after auth change), security gate checks
|
|||
|
|
// should wait for init to complete to avoid returning stale values
|
|||
|
|
let reinitializingPromise: Promise<unknown> | null = null
|
|||
|
|
|
|||
|
|
// Listeners notified when GrowthBook feature values refresh (initial init or
|
|||
|
|
// periodic refresh). Use for systems that bake feature values into long-lived
|
|||
|
|
// objects at construction time (e.g. firstPartyEventLogger reads
|
|||
|
|
// tengu_1p_event_batch_config once and builds a LoggerProvider with it) and
|
|||
|
|
// need to rebuild when config changes. Per-call readers like
|
|||
|
|
// getEventSamplingConfig / isSinkKilled don't need this — they're already
|
|||
|
|
// reactive.
|
|||
|
|
//
|
|||
|
|
// NOT cleared by resetGrowthBook — subscribers register once (typically in
|
|||
|
|
// init.ts) and must survive auth-change resets.
|
|||
|
|
type GrowthBookRefreshListener = () => void | Promise<void>
|
|||
|
|
const refreshed = createSignal()
|
|||
|
|
|
|||
|
|
/** Call a listener with sync-throw and async-rejection both routed to logError. */
|
|||
|
|
function callSafe(listener: GrowthBookRefreshListener): void {
|
|||
|
|
try {
|
|||
|
|
// Promise.resolve() normalizes sync returns and Promises so both
|
|||
|
|
// sync throws (caught by outer try) and async rejections (caught
|
|||
|
|
// by .catch) hit logError. Without the .catch, an async listener
|
|||
|
|
// that rejects becomes an unhandled rejection — the try/catch
|
|||
|
|
// only sees the Promise, not its eventual rejection.
|
|||
|
|
void Promise.resolve(listener()).catch(e => {
|
|||
|
|
logError(e)
|
|||
|
|
})
|
|||
|
|
} catch (e) {
|
|||
|
|
logError(e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Register a callback to fire when GrowthBook feature values refresh.
|
|||
|
|
* Returns an unsubscribe function.
|
|||
|
|
*
|
|||
|
|
* If init has already completed with features by the time this is called
|
|||
|
|
* (remoteEvalFeatureValues is populated), the listener fires once on the
|
|||
|
|
* next microtask. This catch-up handles the race where GB's network response
|
|||
|
|
* lands before the REPL's useEffect commits — on external builds with fast
|
|||
|
|
* networks and MCP-heavy configs, init can finish in ~100ms while REPL mount
|
|||
|
|
* takes ~600ms (see #20951 external-build trace at 30.540 vs 31.046).
|
|||
|
|
*
|
|||
|
|
* Change detection is on the subscriber: the callback fires on every refresh;
|
|||
|
|
* use isEqual against your last-seen config to decide whether to act.
|
|||
|
|
*/
|
|||
|
|
export function onGrowthBookRefresh(
|
|||
|
|
listener: GrowthBookRefreshListener,
|
|||
|
|
): () => void {
|
|||
|
|
let subscribed = true
|
|||
|
|
const unsubscribe = refreshed.subscribe(() => callSafe(listener))
|
|||
|
|
if (remoteEvalFeatureValues.size > 0) {
|
|||
|
|
queueMicrotask(() => {
|
|||
|
|
// Re-check: listener may have been removed, or resetGrowthBook may have
|
|||
|
|
// cleared the Map, between registration and this microtask running.
|
|||
|
|
if (subscribed && remoteEvalFeatureValues.size > 0) {
|
|||
|
|
callSafe(listener)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return () => {
|
|||
|
|
subscribed = false
|
|||
|
|
unsubscribe()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Parse env var overrides for GrowthBook features.
|
|||
|
|
* Set CLAUDE_INTERNAL_FC_OVERRIDES to a JSON object mapping feature keys to values
|
|||
|
|
* to bypass remote eval and disk cache. Useful for eval harnesses that need to
|
|||
|
|
* test specific feature flag configurations. Only active when USER_TYPE is 'ant'.
|
|||
|
|
*
|
|||
|
|
* Example: CLAUDE_INTERNAL_FC_OVERRIDES='{"my_feature": true, "my_config": {"key": "val"}}'
|
|||
|
|
*/
|
|||
|
|
let envOverrides: Record<string, unknown> | null = null
|
|||
|
|
let envOverridesParsed = false
|
|||
|
|
|
|||
|
|
function getEnvOverrides(): Record<string, unknown> | null {
|
|||
|
|
if (!envOverridesParsed) {
|
|||
|
|
envOverridesParsed = true
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
const raw = process.env.CLAUDE_INTERNAL_FC_OVERRIDES
|
|||
|
|
if (raw) {
|
|||
|
|
try {
|
|||
|
|
envOverrides = JSON.parse(raw) as Record<string, unknown>
|
|||
|
|
logForDebugging(
|
|||
|
|
`GrowthBook: Using env var overrides for ${Object.keys(envOverrides!).length} features: ${Object.keys(envOverrides!).join(', ')}`,
|
|||
|
|
)
|
|||
|
|
} catch {
|
|||
|
|
logError(
|
|||
|
|
new Error(
|
|||
|
|
`GrowthBook: Failed to parse CLAUDE_INTERNAL_FC_OVERRIDES: ${raw}`,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return envOverrides
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Check if a feature has an env-var override (CLAUDE_INTERNAL_FC_OVERRIDES).
|
|||
|
|
* When true, _CACHED_MAY_BE_STALE will return the override without touching
|
|||
|
|
* disk or network — callers can skip awaiting init for that feature.
|
|||
|
|
*/
|
|||
|
|
export function hasGrowthBookEnvOverride(feature: string): boolean {
|
|||
|
|
const overrides = getEnvOverrides()
|
|||
|
|
return overrides !== null && feature in overrides
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Local config overrides set via /config Gates tab (ant-only). Checked after
|
|||
|
|
* env-var overrides — env wins so eval harnesses remain deterministic. Unlike
|
|||
|
|
* getEnvOverrides this is not memoized: the user can change overrides at
|
|||
|
|
* runtime, and getGlobalConfig() is already memory-cached (pointer-chase)
|
|||
|
|
* until the next saveGlobalConfig() invalidates it.
|
|||
|
|
*/
|
|||
|
|
function getConfigOverrides(): Record<string, unknown> | undefined {
|
|||
|
|
if (process.env.USER_TYPE !== 'ant') return undefined
|
|||
|
|
try {
|
|||
|
|
return getGlobalConfig().growthBookOverrides
|
|||
|
|
} catch {
|
|||
|
|
// getGlobalConfig() throws before configReadingAllowed is set (early
|
|||
|
|
// main.tsx startup path). Same degrade as the disk-cache fallback below.
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Enumerate all known GrowthBook features and their current resolved values
|
|||
|
|
* (not including overrides). In-memory payload first, disk cache fallback —
|
|||
|
|
* same priority as the getters. Used by the /config Gates tab.
|
|||
|
|
*/
|
|||
|
|
export function getAllGrowthBookFeatures(): Record<string, unknown> {
|
|||
|
|
if (remoteEvalFeatureValues.size > 0) {
|
|||
|
|
return Object.fromEntries(remoteEvalFeatureValues)
|
|||
|
|
}
|
|||
|
|
return getGlobalConfig().cachedGrowthBookFeatures ?? {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getGrowthBookConfigOverrides(): Record<string, unknown> {
|
|||
|
|
return getConfigOverrides() ?? {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Set or clear a single config override. Pass undefined to clear.
|
|||
|
|
* Fires onGrowthBookRefresh listeners so systems that bake gate values into
|
|||
|
|
* long-lived objects (useMainLoopModel, useSkillsChange, etc.) rebuild —
|
|||
|
|
* otherwise overriding e.g. tengu_ant_model_override wouldn't actually
|
|||
|
|
* change the model until the next periodic refresh.
|
|||
|
|
*/
|
|||
|
|
export function setGrowthBookConfigOverride(
|
|||
|
|
feature: string,
|
|||
|
|
value: unknown,
|
|||
|
|
): void {
|
|||
|
|
if (process.env.USER_TYPE !== 'ant') return
|
|||
|
|
try {
|
|||
|
|
saveGlobalConfig(c => {
|
|||
|
|
const current = c.growthBookOverrides ?? {}
|
|||
|
|
if (value === undefined) {
|
|||
|
|
if (!(feature in current)) return c
|
|||
|
|
const { [feature]: _, ...rest } = current
|
|||
|
|
if (Object.keys(rest).length === 0) {
|
|||
|
|
const { growthBookOverrides: __, ...configWithout } = c
|
|||
|
|
return configWithout
|
|||
|
|
}
|
|||
|
|
return { ...c, growthBookOverrides: rest }
|
|||
|
|
}
|
|||
|
|
if (isEqual(current[feature], value)) return c
|
|||
|
|
return { ...c, growthBookOverrides: { ...current, [feature]: value } }
|
|||
|
|
})
|
|||
|
|
// Subscribers do their own change detection (see onGrowthBookRefresh docs),
|
|||
|
|
// so firing on a no-op write is fine.
|
|||
|
|
refreshed.emit()
|
|||
|
|
} catch (e) {
|
|||
|
|
logError(e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function clearGrowthBookConfigOverrides(): void {
|
|||
|
|
if (process.env.USER_TYPE !== 'ant') return
|
|||
|
|
try {
|
|||
|
|
saveGlobalConfig(c => {
|
|||
|
|
if (
|
|||
|
|
!c.growthBookOverrides ||
|
|||
|
|
Object.keys(c.growthBookOverrides).length === 0
|
|||
|
|
) {
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
const { growthBookOverrides: _, ...rest } = c
|
|||
|
|
return rest
|
|||
|
|
})
|
|||
|
|
refreshed.emit()
|
|||
|
|
} catch (e) {
|
|||
|
|
logError(e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Log experiment exposure for a feature if it has experiment data.
|
|||
|
|
* Deduplicates within a session - each feature is logged at most once.
|
|||
|
|
*/
|
|||
|
|
function logExposureForFeature(feature: string): void {
|
|||
|
|
// Skip if already logged this session (dedup)
|
|||
|
|
if (loggedExposures.has(feature)) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const expData = experimentDataByFeature.get(feature)
|
|||
|
|
if (expData) {
|
|||
|
|
loggedExposures.add(feature)
|
|||
|
|
logGrowthBookExperimentTo1P({
|
|||
|
|
experimentId: expData.experimentId,
|
|||
|
|
variationId: expData.variationId,
|
|||
|
|
userAttributes: getUserAttributes(),
|
|||
|
|
experimentMetadata: {
|
|||
|
|
feature_id: feature,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Process a remote eval payload from the GrowthBook server and populate
|
|||
|
|
* local caches. Called after both initial client.init() and after
|
|||
|
|
* client.refreshFeatures() so that _BLOCKS_ON_INIT callers see fresh values
|
|||
|
|
* across the process lifetime, not just init-time snapshots.
|
|||
|
|
*
|
|||
|
|
* Without this running on refresh, remoteEvalFeatureValues freezes at its
|
|||
|
|
* init-time snapshot and getDynamicConfig_BLOCKS_ON_INIT returns stale values
|
|||
|
|
* for the entire process lifetime — which broke the tengu_max_version_config
|
|||
|
|
* kill switch for long-running sessions.
|
|||
|
|
*/
|
|||
|
|
async function processRemoteEvalPayload(
|
|||
|
|
gbClient: GrowthBook,
|
|||
|
|
): Promise<boolean> {
|
|||
|
|
// WORKAROUND: Transform remote eval response format
|
|||
|
|
// The API returns { "value": ... } but SDK expects { "defaultValue": ... }
|
|||
|
|
// TODO: Remove this once the API is fixed to return correct format
|
|||
|
|
const payload = gbClient.getPayload()
|
|||
|
|
// Empty object is truthy — without the length check, `{features: {}}`
|
|||
|
|
// (transient server bug, truncated response) would pass, clear the maps
|
|||
|
|
// below, return true, and syncRemoteEvalToDisk would wholesale-write `{}`
|
|||
|
|
// to disk: total flag blackout for every process sharing ~/.claude.json.
|
|||
|
|
if (!payload?.features || Object.keys(payload.features).length === 0) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Clear before rebuild so features removed between refreshes don't
|
|||
|
|
// leave stale ghost entries that short-circuit getFeatureValueInternal.
|
|||
|
|
experimentDataByFeature.clear()
|
|||
|
|
|
|||
|
|
const transformedFeatures: Record<string, MalformedFeatureDefinition> = {}
|
|||
|
|
for (const [key, feature] of Object.entries(payload.features)) {
|
|||
|
|
const f = feature as MalformedFeatureDefinition
|
|||
|
|
if ('value' in f && !('defaultValue' in f)) {
|
|||
|
|
transformedFeatures[key] = {
|
|||
|
|
...f,
|
|||
|
|
defaultValue: f.value,
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
transformedFeatures[key] = f
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Store experiment data for later logging when feature is accessed
|
|||
|
|
if (f.source === 'experiment' && f.experimentResult) {
|
|||
|
|
const expResult = f.experimentResult as {
|
|||
|
|
variationId?: number
|
|||
|
|
}
|
|||
|
|
const exp = f.experiment as { key?: string } | undefined
|
|||
|
|
if (exp?.key && expResult.variationId !== undefined) {
|
|||
|
|
experimentDataByFeature.set(key, {
|
|||
|
|
experimentId: exp.key,
|
|||
|
|
variationId: expResult.variationId,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Re-set the payload with transformed features
|
|||
|
|
await gbClient.setPayload({
|
|||
|
|
...payload,
|
|||
|
|
features: transformedFeatures,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// WORKAROUND: Cache the evaluated values directly from remote eval response.
|
|||
|
|
// The SDK's evalFeature() tries to re-evaluate rules locally, ignoring the
|
|||
|
|
// pre-evaluated 'value' from remoteEval. setForcedFeatures also doesn't work
|
|||
|
|
// reliably. So we cache values ourselves and use them in getFeatureValueInternal.
|
|||
|
|
remoteEvalFeatureValues.clear()
|
|||
|
|
for (const [key, feature] of Object.entries(transformedFeatures)) {
|
|||
|
|
// Under remoteEval:true the server pre-evaluates. Whether the answer
|
|||
|
|
// lands in `value` (current API) or `defaultValue` (post-TODO API shape),
|
|||
|
|
// it's the authoritative value for this user. Guarding on both keeps
|
|||
|
|
// syncRemoteEvalToDisk correct across a partial or full API migration.
|
|||
|
|
const v = 'value' in feature ? feature.value : feature.defaultValue
|
|||
|
|
if (v !== undefined) {
|
|||
|
|
remoteEvalFeatureValues.set(key, v)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Write the complete remoteEvalFeatureValues map to disk. Called exactly
|
|||
|
|
* once per successful processRemoteEvalPayload — never from a failure path,
|
|||
|
|
* so init-timeout poisoning is structurally impossible (the .catch() at init
|
|||
|
|
* never reaches here).
|
|||
|
|
*
|
|||
|
|
* Wholesale replace (not merge): features deleted server-side are dropped
|
|||
|
|
* from disk on the next successful payload. Ant builds ⊇ external, so
|
|||
|
|
* switching builds is safe — the write is always a complete answer for this
|
|||
|
|
* process's SDK key.
|
|||
|
|
*/
|
|||
|
|
function syncRemoteEvalToDisk(): void {
|
|||
|
|
const fresh = Object.fromEntries(remoteEvalFeatureValues)
|
|||
|
|
const config = getGlobalConfig()
|
|||
|
|
if (isEqual(config.cachedGrowthBookFeatures, fresh)) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
saveGlobalConfig(current => ({
|
|||
|
|
...current,
|
|||
|
|
cachedGrowthBookFeatures: fresh,
|
|||
|
|
}))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Check if GrowthBook operations should be enabled
|
|||
|
|
*/
|
|||
|
|
function isGrowthBookEnabled(): boolean {
|
|||
|
|
// GrowthBook depends on 1P event logging.
|
|||
|
|
return is1PEventLoggingEnabled()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Hostname of ANTHROPIC_BASE_URL when it points at a non-Anthropic proxy.
|
|||
|
|
*
|
|||
|
|
* Enterprise-proxy deployments (Epic, Marble, etc.) typically use
|
|||
|
|
* apiKeyHelper auth, which means isAnthropicAuthEnabled() returns false and
|
|||
|
|
* organizationUUID/accountUUID/email are all absent from GrowthBook
|
|||
|
|
* attributes. Without this, there's no stable attribute to target them on
|
|||
|
|
* — only per-device IDs. See src/utils/auth.ts isAnthropicAuthEnabled().
|
|||
|
|
*
|
|||
|
|
* Returns undefined for unset/default (api.anthropic.com) so the attribute
|
|||
|
|
* is absent for direct-API users. Hostname only — no path/query/creds.
|
|||
|
|
*/
|
|||
|
|
export function getApiBaseUrlHost(): string | undefined {
|
|||
|
|
const baseUrl = process.env.ANTHROPIC_BASE_URL
|
|||
|
|
if (!baseUrl) return undefined
|
|||
|
|
try {
|
|||
|
|
const host = new URL(baseUrl).host
|
|||
|
|
if (host === 'api.anthropic.com') return undefined
|
|||
|
|
return host
|
|||
|
|
} catch {
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Get user attributes for GrowthBook from CoreUserData
|
|||
|
|
*/
|
|||
|
|
function getUserAttributes(): GrowthBookUserAttributes {
|
|||
|
|
const user = getUserForGrowthBook()
|
|||
|
|
|
|||
|
|
// For ants, always try to include email from OAuth config even if ANTHROPIC_API_KEY is set.
|
|||
|
|
// This ensures GrowthBook targeting by email works regardless of auth method.
|
|||
|
|
let email = user.email
|
|||
|
|
if (!email && process.env.USER_TYPE === 'ant') {
|
|||
|
|
email = getGlobalConfig().oauthAccount?.emailAddress
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const apiBaseUrlHost = getApiBaseUrlHost()
|
|||
|
|
|
|||
|
|
const attributes = {
|
|||
|
|
id: user.deviceId,
|
|||
|
|
sessionId: user.sessionId,
|
|||
|
|
deviceID: user.deviceId,
|
|||
|
|
platform: user.platform,
|
|||
|
|
...(apiBaseUrlHost && { apiBaseUrlHost }),
|
|||
|
|
...(user.organizationUuid && { organizationUUID: user.organizationUuid }),
|
|||
|
|
...(user.accountUuid && { accountUUID: user.accountUuid }),
|
|||
|
|
...(user.userType && { userType: user.userType }),
|
|||
|
|
...(user.subscriptionType && { subscriptionType: user.subscriptionType }),
|
|||
|
|
...(user.rateLimitTier && { rateLimitTier: user.rateLimitTier }),
|
|||
|
|
...(user.firstTokenTime && { firstTokenTime: user.firstTokenTime }),
|
|||
|
|
...(email && { email }),
|
|||
|
|
...(user.appVersion && { appVersion: user.appVersion }),
|
|||
|
|
...(user.githubActionsMetadata && {
|
|||
|
|
githubActionsMetadata: user.githubActionsMetadata,
|
|||
|
|
}),
|
|||
|
|
}
|
|||
|
|
return attributes
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Get or create the GrowthBook client instance
|
|||
|
|
*/
|
|||
|
|
const getGrowthBookClient = memoize(
|
|||
|
|
(): { client: GrowthBook; initialized: Promise<void> } | null => {
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const attributes = getUserAttributes()
|
|||
|
|
const clientKey = getGrowthBookClientKey()
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
logForDebugging(
|
|||
|
|
`GrowthBook: Creating client with clientKey=${clientKey}, attributes: ${jsonStringify(attributes)}`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
const baseUrl =
|
|||
|
|
process.env.USER_TYPE === 'ant'
|
|||
|
|
? process.env.CLAUDE_CODE_GB_BASE_URL || 'https://api.anthropic.com/'
|
|||
|
|
: 'https://api.anthropic.com/'
|
|||
|
|
|
|||
|
|
// Skip auth if trust hasn't been established yet
|
|||
|
|
// This prevents executing apiKeyHelper commands before the trust dialog
|
|||
|
|
// Non-interactive sessions implicitly have workspace trust
|
|||
|
|
// getSessionTrustAccepted() covers the case where the TrustDialog auto-resolved
|
|||
|
|
// without persisting trust for the specific CWD (e.g., home directory) —
|
|||
|
|
// showSetupScreens() sets this after the trust dialog flow completes.
|
|||
|
|
const hasTrust =
|
|||
|
|
checkHasTrustDialogAccepted() ||
|
|||
|
|
getSessionTrustAccepted() ||
|
|||
|
|
getIsNonInteractiveSession()
|
|||
|
|
const authHeaders = hasTrust
|
|||
|
|
? getAuthHeaders()
|
|||
|
|
: { headers: {}, error: 'trust not established' }
|
|||
|
|
const hasAuth = !authHeaders.error
|
|||
|
|
clientCreatedWithAuth = hasAuth
|
|||
|
|
|
|||
|
|
// Capture in local variable so the init callback operates on THIS client,
|
|||
|
|
// not a later client if reinitialization happens before init completes
|
|||
|
|
const thisClient = new GrowthBook({
|
|||
|
|
apiHost: baseUrl,
|
|||
|
|
clientKey,
|
|||
|
|
attributes,
|
|||
|
|
remoteEval: true,
|
|||
|
|
// Re-fetch when user ID or org changes (org change = login to different org)
|
|||
|
|
cacheKeyAttributes: ['id', 'organizationUUID'],
|
|||
|
|
// Add auth headers if available
|
|||
|
|
...(authHeaders.error
|
|||
|
|
? {}
|
|||
|
|
: { apiHostRequestHeaders: authHeaders.headers }),
|
|||
|
|
// Debug logging for Ants
|
|||
|
|
...(process.env.USER_TYPE === 'ant'
|
|||
|
|
? {
|
|||
|
|
log: (msg: string, ctx: Record<string, unknown>) => {
|
|||
|
|
logForDebugging(`GrowthBook: ${msg} ${jsonStringify(ctx)}`)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
: {}),
|
|||
|
|
})
|
|||
|
|
client = thisClient
|
|||
|
|
|
|||
|
|
if (!hasAuth) {
|
|||
|
|
// No auth available yet — skip HTTP init, rely on disk-cached values.
|
|||
|
|
// initializeGrowthBook() will reset and re-create with auth when available.
|
|||
|
|
return { client: thisClient, initialized: Promise.resolve() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const initialized = thisClient
|
|||
|
|
.init({ timeout: 5000 })
|
|||
|
|
.then(async result => {
|
|||
|
|
// Guard: if this client was replaced by a newer one, skip processing
|
|||
|
|
if (client !== thisClient) {
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
logForDebugging(
|
|||
|
|
'GrowthBook: Skipping init callback for replaced client',
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
logForDebugging(
|
|||
|
|
`GrowthBook initialized successfully, source: ${result.source}, success: ${result.success}`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hadFeatures = await processRemoteEvalPayload(thisClient)
|
|||
|
|
// Re-check: processRemoteEvalPayload yields at `await setPayload`.
|
|||
|
|
// Microtask-only today (no encryption, no sticky-bucket service), but
|
|||
|
|
// the guard at the top of this callback runs before that await;
|
|||
|
|
// this runs after.
|
|||
|
|
if (client !== thisClient) return
|
|||
|
|
|
|||
|
|
if (hadFeatures) {
|
|||
|
|
for (const feature of pendingExposures) {
|
|||
|
|
logExposureForFeature(feature)
|
|||
|
|
}
|
|||
|
|
pendingExposures.clear()
|
|||
|
|
syncRemoteEvalToDisk()
|
|||
|
|
// Notify subscribers: remoteEvalFeatureValues is populated and
|
|||
|
|
// disk is freshly synced. _CACHED_MAY_BE_STALE reads memory first
|
|||
|
|
// (#22295), so subscribers see fresh values immediately.
|
|||
|
|
refreshed.emit()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Log what features were loaded
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
const features = thisClient.getFeatures()
|
|||
|
|
if (features) {
|
|||
|
|
const featureKeys = Object.keys(features)
|
|||
|
|
logForDebugging(
|
|||
|
|
`GrowthBook loaded ${featureKeys.length} features: ${featureKeys.slice(0, 10).join(', ')}${featureKeys.length > 10 ? '...' : ''}`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
.catch(error => {
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
logError(toError(error))
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Register cleanup handlers for graceful shutdown (named refs so resetGrowthBook can remove them)
|
|||
|
|
currentBeforeExitHandler = () => client?.destroy()
|
|||
|
|
currentExitHandler = () => client?.destroy()
|
|||
|
|
process.on('beforeExit', currentBeforeExitHandler)
|
|||
|
|
process.on('exit', currentExitHandler)
|
|||
|
|
|
|||
|
|
return { client: thisClient, initialized }
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Initialize GrowthBook client (blocks until ready)
|
|||
|
|
*/
|
|||
|
|
export const initializeGrowthBook = memoize(
|
|||
|
|
async (): Promise<GrowthBook | null> => {
|
|||
|
|
let clientWrapper = getGrowthBookClient()
|
|||
|
|
if (!clientWrapper) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check if auth has become available since the client was created
|
|||
|
|
// If so, we need to recreate the client with fresh auth headers
|
|||
|
|
// Only check if trust is established to avoid triggering apiKeyHelper before trust dialog
|
|||
|
|
if (!clientCreatedWithAuth) {
|
|||
|
|
const hasTrust =
|
|||
|
|
checkHasTrustDialogAccepted() ||
|
|||
|
|
getSessionTrustAccepted() ||
|
|||
|
|
getIsNonInteractiveSession()
|
|||
|
|
if (hasTrust) {
|
|||
|
|
const currentAuth = getAuthHeaders()
|
|||
|
|
if (!currentAuth.error) {
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
logForDebugging(
|
|||
|
|
'GrowthBook: Auth became available after client creation, reinitializing',
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
// Use resetGrowthBook to properly destroy old client and stop periodic refresh
|
|||
|
|
// This prevents double-init where old client's init promise continues running
|
|||
|
|
resetGrowthBook()
|
|||
|
|
clientWrapper = getGrowthBookClient()
|
|||
|
|
if (!clientWrapper) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await clientWrapper.initialized
|
|||
|
|
|
|||
|
|
// Set up periodic refresh after successful initialization
|
|||
|
|
// This is called here (not separately) so it's always re-established after any reinit
|
|||
|
|
setupPeriodicGrowthBookRefresh()
|
|||
|
|
|
|||
|
|
return clientWrapper.client
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Get a feature value with a default fallback - blocks until initialized.
|
|||
|
|
* @internal Used by both deprecated and cached functions.
|
|||
|
|
*/
|
|||
|
|
async function getFeatureValueInternal<T>(
|
|||
|
|
feature: string,
|
|||
|
|
defaultValue: T,
|
|||
|
|
logExposure: boolean,
|
|||
|
|
): Promise<T> {
|
|||
|
|
// Check env var overrides first (for eval harnesses)
|
|||
|
|
const overrides = getEnvOverrides()
|
|||
|
|
if (overrides && feature in overrides) {
|
|||
|
|
return overrides[feature] as T
|
|||
|
|
}
|
|||
|
|
const configOverrides = getConfigOverrides()
|
|||
|
|
if (configOverrides && feature in configOverrides) {
|
|||
|
|
return configOverrides[feature] as T
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return defaultValue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const growthBookClient = await initializeGrowthBook()
|
|||
|
|
if (!growthBookClient) {
|
|||
|
|
return defaultValue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Use cached remote eval values if available (workaround for SDK bug)
|
|||
|
|
let result: T
|
|||
|
|
if (remoteEvalFeatureValues.has(feature)) {
|
|||
|
|
result = remoteEvalFeatureValues.get(feature) as T
|
|||
|
|
} else {
|
|||
|
|
result = growthBookClient.getFeatureValue(feature, defaultValue) as T
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Log experiment exposure using stored experiment data
|
|||
|
|
if (logExposure) {
|
|||
|
|
logExposureForFeature(feature)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
logForDebugging(
|
|||
|
|
`GrowthBook: getFeatureValue("${feature}") = ${jsonStringify(result)}`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* @deprecated Use getFeatureValue_CACHED_MAY_BE_STALE instead, which is non-blocking.
|
|||
|
|
* This function blocks on GrowthBook initialization which can slow down startup.
|
|||
|
|
*/
|
|||
|
|
export async function getFeatureValue_DEPRECATED<T>(
|
|||
|
|
feature: string,
|
|||
|
|
defaultValue: T,
|
|||
|
|
): Promise<T> {
|
|||
|
|
return getFeatureValueInternal(feature, defaultValue, true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Get a feature value from disk cache immediately. Pure read — disk is
|
|||
|
|
* populated by syncRemoteEvalToDisk on every successful payload (init +
|
|||
|
|
* periodic refresh), not by this function.
|
|||
|
|
*
|
|||
|
|
* This is the preferred method for startup-critical paths and sync contexts.
|
|||
|
|
* The value may be stale if the cache was written by a previous process.
|
|||
|
|
*/
|
|||
|
|
export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
|
|||
|
|
feature: string,
|
|||
|
|
defaultValue: T,
|
|||
|
|
): T {
|
|||
|
|
// Check env var overrides first (for eval harnesses)
|
|||
|
|
const overrides = getEnvOverrides()
|
|||
|
|
if (overrides && feature in overrides) {
|
|||
|
|
return overrides[feature] as T
|
|||
|
|
}
|
|||
|
|
const configOverrides = getConfigOverrides()
|
|||
|
|
if (configOverrides && feature in configOverrides) {
|
|||
|
|
return configOverrides[feature] as T
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return defaultValue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Log experiment exposure if data is available, otherwise defer until after init
|
|||
|
|
if (experimentDataByFeature.has(feature)) {
|
|||
|
|
logExposureForFeature(feature)
|
|||
|
|
} else {
|
|||
|
|
pendingExposures.add(feature)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// In-memory payload is authoritative once processRemoteEvalPayload has run.
|
|||
|
|
// Disk is also fresh by then (syncRemoteEvalToDisk runs synchronously inside
|
|||
|
|
// init), so this is correctness-equivalent to the disk read below — but it
|
|||
|
|
// skips the config JSON parse and is what onGrowthBookRefresh subscribers
|
|||
|
|
// depend on to read fresh values the instant they're notified.
|
|||
|
|
if (remoteEvalFeatureValues.has(feature)) {
|
|||
|
|
return remoteEvalFeatureValues.get(feature) as T
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fall back to disk cache (survives across process restarts)
|
|||
|
|
try {
|
|||
|
|
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[feature]
|
|||
|
|
return cached !== undefined ? (cached as T) : defaultValue
|
|||
|
|
} catch {
|
|||
|
|
return defaultValue
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* @deprecated Disk cache is now synced on every successful payload load
|
|||
|
|
* (init + 20min/6h periodic refresh). The per-feature TTL never fetched
|
|||
|
|
* fresh data from the server — it only re-wrote in-memory state to disk,
|
|||
|
|
* which is now redundant. Use getFeatureValue_CACHED_MAY_BE_STALE directly.
|
|||
|
|
*/
|
|||
|
|
export function getFeatureValue_CACHED_WITH_REFRESH<T>(
|
|||
|
|
feature: string,
|
|||
|
|
defaultValue: T,
|
|||
|
|
_refreshIntervalMs: number,
|
|||
|
|
): T {
|
|||
|
|
return getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Check a Statsig feature gate value via GrowthBook, with fallback to Statsig cache.
|
|||
|
|
*
|
|||
|
|
* **MIGRATION ONLY**: This function is for migrating existing Statsig gates to GrowthBook.
|
|||
|
|
* For new features, use `getFeatureValue_CACHED_MAY_BE_STALE()` instead.
|
|||
|
|
*
|
|||
|
|
* - Checks GrowthBook disk cache first
|
|||
|
|
* - Falls back to Statsig's cachedStatsigGates during migration
|
|||
|
|
* - The value may be stale if the cache hasn't been updated recently
|
|||
|
|
*
|
|||
|
|
* @deprecated Use getFeatureValue_CACHED_MAY_BE_STALE() for new code. This function
|
|||
|
|
* exists only to support migration of existing Statsig gates.
|
|||
|
|
*/
|
|||
|
|
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
|
|||
|
|
gate: string,
|
|||
|
|
): boolean {
|
|||
|
|
// Check env var overrides first (for eval harnesses)
|
|||
|
|
const overrides = getEnvOverrides()
|
|||
|
|
if (overrides && gate in overrides) {
|
|||
|
|
return Boolean(overrides[gate])
|
|||
|
|
}
|
|||
|
|
const configOverrides = getConfigOverrides()
|
|||
|
|
if (configOverrides && gate in configOverrides) {
|
|||
|
|
return Boolean(configOverrides[gate])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Log experiment exposure if data is available, otherwise defer until after init
|
|||
|
|
if (experimentDataByFeature.has(gate)) {
|
|||
|
|
logExposureForFeature(gate)
|
|||
|
|
} else {
|
|||
|
|
pendingExposures.add(gate)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Return cached value immediately from disk
|
|||
|
|
// First check GrowthBook cache, then fall back to Statsig cache for migration
|
|||
|
|
const config = getGlobalConfig()
|
|||
|
|
const gbCached = config.cachedGrowthBookFeatures?.[gate]
|
|||
|
|
if (gbCached !== undefined) {
|
|||
|
|
return Boolean(gbCached)
|
|||
|
|
}
|
|||
|
|
// Fallback to Statsig cache for migration period
|
|||
|
|
return config.cachedStatsigGates?.[gate] ?? false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Check a security restriction gate, waiting for re-init if in progress.
|
|||
|
|
*
|
|||
|
|
* Use this for security-critical gates where we need fresh values after auth changes.
|
|||
|
|
*
|
|||
|
|
* Behavior:
|
|||
|
|
* - If GrowthBook is re-initializing (e.g., after login), waits for it to complete
|
|||
|
|
* - Otherwise, returns cached value immediately (Statsig cache first, then GrowthBook)
|
|||
|
|
*
|
|||
|
|
* Statsig cache is checked first as a safety measure for security-related checks:
|
|||
|
|
* if the Statsig cache indicates the gate is enabled, we honor it.
|
|||
|
|
*/
|
|||
|
|
export async function checkSecurityRestrictionGate(
|
|||
|
|
gate: string,
|
|||
|
|
): Promise<boolean> {
|
|||
|
|
// Check env var overrides first (for eval harnesses)
|
|||
|
|
const overrides = getEnvOverrides()
|
|||
|
|
if (overrides && gate in overrides) {
|
|||
|
|
return Boolean(overrides[gate])
|
|||
|
|
}
|
|||
|
|
const configOverrides = getConfigOverrides()
|
|||
|
|
if (configOverrides && gate in configOverrides) {
|
|||
|
|
return Boolean(configOverrides[gate])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// If re-initialization is in progress, wait for it to complete
|
|||
|
|
// This ensures we get fresh values after auth changes
|
|||
|
|
if (reinitializingPromise) {
|
|||
|
|
await reinitializingPromise
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check Statsig cache first - it may have correct value from previous logged-in session
|
|||
|
|
const config = getGlobalConfig()
|
|||
|
|
const statsigCached = config.cachedStatsigGates?.[gate]
|
|||
|
|
if (statsigCached !== undefined) {
|
|||
|
|
return Boolean(statsigCached)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Then check GrowthBook cache
|
|||
|
|
const gbCached = config.cachedGrowthBookFeatures?.[gate]
|
|||
|
|
if (gbCached !== undefined) {
|
|||
|
|
return Boolean(gbCached)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// No cache - return false (don't block on init for uncached gates)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Check a boolean entitlement gate with fallback-to-blocking semantics.
|
|||
|
|
*
|
|||
|
|
* Fast path: if the disk cache already says `true`, return it immediately.
|
|||
|
|
* Slow path: if disk says `false`/missing, await GrowthBook init and fetch the
|
|||
|
|
* fresh server value (max ~5s). Disk is populated by syncRemoteEvalToDisk
|
|||
|
|
* inside init, so by the time the slow path returns, disk already has the
|
|||
|
|
* fresh value — no write needed here.
|
|||
|
|
*
|
|||
|
|
* Use for user-invoked features (e.g. /remote-control) that are gated on
|
|||
|
|
* subscription/org, where a stale `false` would unfairly block access but a
|
|||
|
|
* stale `true` is acceptable (the server is the real gatekeeper).
|
|||
|
|
*/
|
|||
|
|
export async function checkGate_CACHED_OR_BLOCKING(
|
|||
|
|
gate: string,
|
|||
|
|
): Promise<boolean> {
|
|||
|
|
// Check env var overrides first (for eval harnesses)
|
|||
|
|
const overrides = getEnvOverrides()
|
|||
|
|
if (overrides && gate in overrides) {
|
|||
|
|
return Boolean(overrides[gate])
|
|||
|
|
}
|
|||
|
|
const configOverrides = getConfigOverrides()
|
|||
|
|
if (configOverrides && gate in configOverrides) {
|
|||
|
|
return Boolean(configOverrides[gate])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fast path: disk cache already says true — trust it
|
|||
|
|
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[gate]
|
|||
|
|
if (cached === true) {
|
|||
|
|
// Log experiment exposure if data is available, otherwise defer
|
|||
|
|
if (experimentDataByFeature.has(gate)) {
|
|||
|
|
logExposureForFeature(gate)
|
|||
|
|
} else {
|
|||
|
|
pendingExposures.add(gate)
|
|||
|
|
}
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Slow path: disk says false/missing — may be stale, fetch fresh
|
|||
|
|
return getFeatureValueInternal(gate, false, true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Refresh GrowthBook after auth changes (login/logout).
|
|||
|
|
*
|
|||
|
|
* NOTE: This must destroy and recreate the client because GrowthBook's
|
|||
|
|
* apiHostRequestHeaders cannot be updated after client creation.
|
|||
|
|
*/
|
|||
|
|
export function refreshGrowthBookAfterAuthChange(): void {
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// Reset the client completely to get fresh auth headers
|
|||
|
|
// This is necessary because apiHostRequestHeaders can't be updated after creation
|
|||
|
|
resetGrowthBook()
|
|||
|
|
|
|||
|
|
// resetGrowthBook cleared remoteEvalFeatureValues. If re-init below
|
|||
|
|
// times out (hadFeatures=false) or short-circuits on !hasAuth (logout),
|
|||
|
|
// the init-callback notify never fires — subscribers stay synced to the
|
|||
|
|
// previous account's memoized state. Notify here so they re-read now
|
|||
|
|
// (falls to disk cache). If re-init succeeds, they'll notify again with
|
|||
|
|
// fresh values; if not, at least they're synced to the post-reset state.
|
|||
|
|
refreshed.emit()
|
|||
|
|
|
|||
|
|
// Reinitialize with fresh auth headers and attributes
|
|||
|
|
// Track this promise so security gate checks can wait for it.
|
|||
|
|
// .catch before .finally: initializeGrowthBook can reject if its sync
|
|||
|
|
// helpers throw (getGrowthBookClient, getAuthHeaders, resetGrowthBook —
|
|||
|
|
// clientWrapper.initialized itself has its own .catch so never rejects),
|
|||
|
|
// and .finally re-settles with the original rejection — the sync
|
|||
|
|
// try/catch below cannot catch async rejections.
|
|||
|
|
reinitializingPromise = initializeGrowthBook()
|
|||
|
|
.catch(error => {
|
|||
|
|
logError(toError(error))
|
|||
|
|
return null
|
|||
|
|
})
|
|||
|
|
.finally(() => {
|
|||
|
|
reinitializingPromise = null
|
|||
|
|
})
|
|||
|
|
} catch (error) {
|
|||
|
|
if (process.env.NODE_ENV === 'development') {
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
logError(toError(error))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Reset GrowthBook client state (primarily for testing)
|
|||
|
|
*/
|
|||
|
|
export function resetGrowthBook(): void {
|
|||
|
|
stopPeriodicGrowthBookRefresh()
|
|||
|
|
// Remove process handlers before destroying client to prevent accumulation
|
|||
|
|
if (currentBeforeExitHandler) {
|
|||
|
|
process.off('beforeExit', currentBeforeExitHandler)
|
|||
|
|
currentBeforeExitHandler = null
|
|||
|
|
}
|
|||
|
|
if (currentExitHandler) {
|
|||
|
|
process.off('exit', currentExitHandler)
|
|||
|
|
currentExitHandler = null
|
|||
|
|
}
|
|||
|
|
client?.destroy()
|
|||
|
|
client = null
|
|||
|
|
clientCreatedWithAuth = false
|
|||
|
|
reinitializingPromise = null
|
|||
|
|
experimentDataByFeature.clear()
|
|||
|
|
pendingExposures.clear()
|
|||
|
|
loggedExposures.clear()
|
|||
|
|
remoteEvalFeatureValues.clear()
|
|||
|
|
getGrowthBookClient.cache?.clear?.()
|
|||
|
|
initializeGrowthBook.cache?.clear?.()
|
|||
|
|
envOverrides = null
|
|||
|
|
envOverridesParsed = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Periodic refresh interval (matches Statsig's 6-hour interval)
|
|||
|
|
const GROWTHBOOK_REFRESH_INTERVAL_MS =
|
|||
|
|
process.env.USER_TYPE !== 'ant'
|
|||
|
|
? 6 * 60 * 60 * 1000 // 6 hours
|
|||
|
|
: 20 * 60 * 1000 // 20 min (for ants)
|
|||
|
|
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
|||
|
|
let beforeExitListener: (() => void) | null = null
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Light refresh - re-fetch features from server without recreating client.
|
|||
|
|
* Use this for periodic refresh when auth headers haven't changed.
|
|||
|
|
*
|
|||
|
|
* Unlike refreshGrowthBookAfterAuthChange() which destroys and recreates the client,
|
|||
|
|
* this preserves client state and just fetches fresh feature values.
|
|||
|
|
*/
|
|||
|
|
export async function refreshGrowthBookFeatures(): Promise<void> {
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const growthBookClient = await initializeGrowthBook()
|
|||
|
|
if (!growthBookClient) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await growthBookClient.refreshFeatures()
|
|||
|
|
|
|||
|
|
// Guard: if this client was replaced during the in-flight refresh
|
|||
|
|
// (e.g. refreshGrowthBookAfterAuthChange ran), skip processing the
|
|||
|
|
// stale payload. Mirrors the init-callback guard above.
|
|||
|
|
if (growthBookClient !== client) {
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
logForDebugging(
|
|||
|
|
'GrowthBook: Skipping refresh processing for replaced client',
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Rebuild remoteEvalFeatureValues from the refreshed payload so that
|
|||
|
|
// _BLOCKS_ON_INIT callers (e.g. getMaxVersion for the auto-update kill
|
|||
|
|
// switch) see fresh values, not the stale init-time snapshot.
|
|||
|
|
const hadFeatures = await processRemoteEvalPayload(growthBookClient)
|
|||
|
|
// Same re-check as init path: covers the setPayload yield inside
|
|||
|
|
// processRemoteEvalPayload (the guard above only covers refreshFeatures).
|
|||
|
|
if (growthBookClient !== client) return
|
|||
|
|
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
logForDebugging('GrowthBook: Light refresh completed')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Gate on hadFeatures: if the payload was empty/malformed,
|
|||
|
|
// remoteEvalFeatureValues wasn't rebuilt — skip both the no-op disk
|
|||
|
|
// write and the spurious subscriber churn (clearCommandMemoizationCaches
|
|||
|
|
// + getCommands + 4× model re-renders).
|
|||
|
|
if (hadFeatures) {
|
|||
|
|
syncRemoteEvalToDisk()
|
|||
|
|
refreshed.emit()
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
if (process.env.NODE_ENV === 'development') {
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
logError(toError(error))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Set up periodic refresh of GrowthBook features.
|
|||
|
|
* Uses light refresh (refreshGrowthBookFeatures) to re-fetch without recreating client.
|
|||
|
|
*
|
|||
|
|
* Call this after initialization for long-running sessions to ensure
|
|||
|
|
* feature values stay fresh. Matches Statsig's 6-hour refresh interval.
|
|||
|
|
*/
|
|||
|
|
export function setupPeriodicGrowthBookRefresh(): void {
|
|||
|
|
if (!isGrowthBookEnabled()) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Clear any existing interval to avoid duplicates
|
|||
|
|
if (refreshInterval) {
|
|||
|
|
clearInterval(refreshInterval)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
refreshInterval = setInterval(() => {
|
|||
|
|
void refreshGrowthBookFeatures()
|
|||
|
|
}, GROWTHBOOK_REFRESH_INTERVAL_MS)
|
|||
|
|
// Allow process to exit naturally - this timer shouldn't keep the process alive
|
|||
|
|
refreshInterval.unref?.()
|
|||
|
|
|
|||
|
|
// Register cleanup listener only once
|
|||
|
|
if (!beforeExitListener) {
|
|||
|
|
beforeExitListener = () => {
|
|||
|
|
stopPeriodicGrowthBookRefresh()
|
|||
|
|
}
|
|||
|
|
process.once('beforeExit', beforeExitListener)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Stop periodic refresh (for testing or cleanup)
|
|||
|
|
*/
|
|||
|
|
export function stopPeriodicGrowthBookRefresh(): void {
|
|||
|
|
if (refreshInterval) {
|
|||
|
|
clearInterval(refreshInterval)
|
|||
|
|
refreshInterval = null
|
|||
|
|
}
|
|||
|
|
if (beforeExitListener) {
|
|||
|
|
process.removeListener('beforeExit', beforeExitListener)
|
|||
|
|
beforeExitListener = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// Dynamic Config Functions
|
|||
|
|
// These are semantic wrappers around feature functions for Statsig API parity.
|
|||
|
|
// In GrowthBook, dynamic configs are just features with object values.
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Get a dynamic config value - blocks until GrowthBook is initialized.
|
|||
|
|
* Prefer getFeatureValue_CACHED_MAY_BE_STALE for startup-critical paths.
|
|||
|
|
*/
|
|||
|
|
export async function getDynamicConfig_BLOCKS_ON_INIT<T>(
|
|||
|
|
configName: string,
|
|||
|
|
defaultValue: T,
|
|||
|
|
): Promise<T> {
|
|||
|
|
return getFeatureValue_DEPRECATED(configName, defaultValue)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Get a dynamic config value from disk cache immediately. Pure read — see
|
|||
|
|
* getFeatureValue_CACHED_MAY_BE_STALE.
|
|||
|
|
* This is the preferred method for startup-critical paths and sync contexts.
|
|||
|
|
*
|
|||
|
|
* In GrowthBook, dynamic configs are just features with object values.
|
|||
|
|
*/
|
|||
|
|
export function getDynamicConfig_CACHED_MAY_BE_STALE<T>(
|
|||
|
|
configName: string,
|
|||
|
|
defaultValue: T,
|
|||
|
|
): T {
|
|||
|
|
return getFeatureValue_CACHED_MAY_BE_STALE(configName, defaultValue)
|
|||
|
|
}
|