From 0e48884f56c6c008f047a7926d3b2cb924170625 Mon Sep 17 00:00:00 2001 From: Nourrisse Florian <3023852+Flo5k5@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:40:33 +0200 Subject: [PATCH] feat: local feature flag overrides via ~/.claude/feature-flags.json (#639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: local feature flag overrides via ~/.claude/feature-flags.json Replace the GrowthBook no-op stub with a local JSON file reader that gives open-build users control over ~50 tengu_* feature flags without needing Anthropic's GrowthBook server. How it works: - On first flag lookup, lazily reads ~/.claude/feature-flags.json - Returns the configured value if the key exists, defaultValue otherwise - When the file is absent, behavior is identical to the current stub - CLAUDE_FEATURE_FLAGS_FILE env var overrides the file path (CI/testing) Example ~/.claude/feature-flags.json: { "tengu_kairos_cron": true, "tengu_scratch": true } Continues the infrastructure work from #315 and #352. This is a prerequisite for replacing remaining USER_TYPE gates with local config. * fix: use ESM imports and validate JSON shape in growthbook stub - Replace require('fs'/'path'/'os') with ESM imports (node: prefix) to avoid ReferenceError in ESM bundle output - Validate JSON.parse result is a plain object before using `in` operator to prevent TypeError on non-object JSON values Addresses Copilot review comments on #639 * fix: reset flags cache in resetGrowthBook and refreshGrowthBookFeatures Set _flags back to undefined so subsequent lookups re-read the JSON file. Enables runtime reload and proper test isolation. Addresses Copilot review comment on #639 * docs: explain why checkSecurityRestrictionGate is excluded from local flags This is a remote killswitch for bypassPermissions mode — exposing it via the local JSON file would let users accidentally disable --dangerously-skip-permissions without understanding why. * test: add unit tests for growthbook stub local feature flags Covers: valid JSON loading, missing file fallback, malformed JSON, non-object JSON (primitive, array), cache invalidation via resetGrowthBook/refreshGrowthBookFeatures, all getter variants, and checkSecurityRestrictionGate always returning false. 12 tests, 21 assertions. * fix: use Object.hasOwn instead of in operator for flag lookup Prevents inherited prototype properties (toString, constructor, etc.) from being returned as flag values. Addresses Copilot review comment on #639 * fix: align gate stub signatures and add Boolean coercion Address remaining Copilot review feedback: - checkSecurityRestrictionGate: accept gate param to match real signature - checkStatsigFeatureGate/checkGate: coerce with Boolean() like real impl --- scripts/no-telemetry-growthbook-stub.test.ts | 146 +++++++++++++++++++ scripts/no-telemetry-plugin.ts | 49 +++++-- 2 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 scripts/no-telemetry-growthbook-stub.test.ts diff --git a/scripts/no-telemetry-growthbook-stub.test.ts b/scripts/no-telemetry-growthbook-stub.test.ts new file mode 100644 index 00000000..da0d33e1 --- /dev/null +++ b/scripts/no-telemetry-growthbook-stub.test.ts @@ -0,0 +1,146 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test' +import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +// --------------------------------------------------------------------------- +// Setup: extract the growthbook stub from no-telemetry-plugin.ts, write it to +// a temp .mjs file, and dynamically import it so we can test the real code +// that gets bundled. +// --------------------------------------------------------------------------- + +const pluginSource = readFileSync(join(__dirname, 'no-telemetry-plugin.ts'), 'utf-8') +const stubMatch = pluginSource.match(/'services\/analytics\/growthbook': `([\s\S]*?)`/) +if (!stubMatch) throw new Error('Could not extract growthbook stub from no-telemetry-plugin.ts') + +const testDir = join(tmpdir(), `growthbook-stub-test-${process.pid}`) +const stubFile = join(testDir, 'growthbook-stub.mjs') +const flagsFile = join(testDir, 'test-flags.json') + +mkdirSync(testDir, { recursive: true }) +writeFileSync(stubFile, stubMatch[1]) + +// Point the stub at our test flags file (checked by _loadFlags on first access) +process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile + +const stub = await import(stubFile) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('growthbook stub — local feature flag overrides', () => { + beforeEach(() => { + stub.resetGrowthBook() + try { unlinkSync(flagsFile) } catch { /* may not exist */ } + }) + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }) + delete process.env.CLAUDE_FEATURE_FLAGS_FILE + }) + + // ── File absent ────────────────────────────────────────────────── + + test('returns defaultValue when flags file is absent', () => { + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 42)).toBe(42) + }) + + test('getAllGrowthBookFeatures returns {} when file is absent', () => { + expect(stub.getAllGrowthBookFeatures()).toEqual({}) + }) + + // ── Valid JSON object ──────────────────────────────────────────── + + test('loads and returns values from a valid JSON file', () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true, tengu_bar: 'hello' })) + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', false)).toBe(true) + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_bar', 'default')).toBe('hello') + }) + + test('returns defaultValue for keys not present in the file', () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true })) + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_missing', 99)).toBe(99) + }) + + test('getAllGrowthBookFeatures returns the full flags object', () => { + const flags = { tengu_a: true, tengu_b: false, tengu_c: 42 } + writeFileSync(flagsFile, JSON.stringify(flags)) + + expect(stub.getAllGrowthBookFeatures()).toEqual(flags) + }) + + // ── Malformed / non-object JSON ────────────────────────────────── + + test('falls back to defaults on malformed JSON', () => { + writeFileSync(flagsFile, '{not valid json!!!') + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback') + }) + + test('falls back to defaults when JSON is a primitive (true)', () => { + writeFileSync(flagsFile, 'true') + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback') + }) + + test('falls back to defaults when JSON is an array', () => { + writeFileSync(flagsFile, '["a", "b"]') + + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback') + }) + + // ── Cache invalidation ─────────────────────────────────────────── + + test('resetGrowthBook clears cache so the file is re-read', () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'first' })) + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first') + + // Update the file — cached value is still 'first' + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'second' })) + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first') + + // After reset, the new value is picked up + stub.resetGrowthBook() + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('second') + }) + + test('refreshGrowthBookFeatures clears cache', async () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v1' })) + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v1') + + writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v2' })) + await stub.refreshGrowthBookFeatures() + expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v2') + }) + + // ── Multiple getter variants ───────────────────────────────────── + + test('all getter functions read from local flags', async () => { + writeFileSync(flagsFile, JSON.stringify({ tengu_gate: true, tengu_config: { a: 1 } })) + + expect(await stub.getFeatureValue_DEPRECATED('tengu_gate', false)).toBe(true) + stub.resetGrowthBook() + expect(stub.getFeatureValue_CACHED_WITH_REFRESH('tengu_gate', false)).toBe(true) + stub.resetGrowthBook() + expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(true) + stub.resetGrowthBook() + expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_gate')).toBe(true) + stub.resetGrowthBook() + expect(await stub.getDynamicConfig_BLOCKS_ON_INIT('tengu_config', {})).toEqual({ a: 1 }) + stub.resetGrowthBook() + expect(stub.getDynamicConfig_CACHED_MAY_BE_STALE('tengu_config', {})).toEqual({ a: 1 }) + }) + + // ── Security gate ──────────────────────────────────────────────── + + test('checkSecurityRestrictionGate always returns false regardless of flags', async () => { + writeFileSync(flagsFile, JSON.stringify({ + tengu_disable_bypass_permissions_mode: true, + })) + + expect(await stub.checkSecurityRestrictionGate()).toBe(false) + }) +}) diff --git a/scripts/no-telemetry-plugin.ts b/scripts/no-telemetry-plugin.ts index c0ad74d8..97fb54a3 100644 --- a/scripts/no-telemetry-plugin.ts +++ b/scripts/no-telemetry-plugin.ts @@ -34,28 +34,55 @@ export function _resetForTesting() {} `, 'services/analytics/growthbook': ` +import _fs from 'node:fs'; +import _path from 'node:path'; +import _os from 'node:os'; + +let _flags = undefined; + +function _loadFlags() { + if (_flags !== undefined) return; + try { + const flagsPath = process.env.CLAUDE_FEATURE_FLAGS_FILE + || _path.join(_os.homedir(), '.claude', 'feature-flags.json'); + const parsed = JSON.parse(_fs.readFileSync(flagsPath, 'utf-8')); + _flags = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null; + } catch { + _flags = null; + } +} + +function _getFlagValue(key, defaultValue) { + _loadFlags(); + if (_flags != null && Object.hasOwn(_flags, key)) return _flags[key]; + return defaultValue; +} + const noop = () => {}; export function onGrowthBookRefresh() { return noop; } export function hasGrowthBookEnvOverride() { return false; } -export function getAllGrowthBookFeatures() { return {}; } +export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; } export function getGrowthBookConfigOverrides() { return {}; } export function setGrowthBookConfigOverride() {} export function clearGrowthBookConfigOverrides() {} export function getApiBaseUrlHost() { return undefined; } export const initializeGrowthBook = async () => null; -export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return defaultValue; } -export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return defaultValue; } -export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return defaultValue; } -export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE() { return false; } -export async function checkSecurityRestrictionGate() { return false; } -export async function checkGate_CACHED_OR_BLOCKING() { return false; } +export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return _getFlagValue(feature, defaultValue); } +export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return _getFlagValue(feature, defaultValue); } +export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return _getFlagValue(feature, defaultValue); } +export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(gate) { return Boolean(_getFlagValue(gate, false)); } +// Security killswitch — always false in the open build. Anthropic uses this +// gate to remotely disable bypassPermissions mode; exposing it via local flags +// would let users accidentally lock themselves out of --dangerously-skip-permissions. +export async function checkSecurityRestrictionGate(gate) { return false; } +export async function checkGate_CACHED_OR_BLOCKING(gate) { return Boolean(_getFlagValue(gate, false)); } export function refreshGrowthBookAfterAuthChange() {} -export function resetGrowthBook() {} -export async function refreshGrowthBookFeatures() {} +export function resetGrowthBook() { _flags = undefined; } +export async function refreshGrowthBookFeatures() { _flags = undefined; } export function setupPeriodicGrowthBookRefresh() {} export function stopPeriodicGrowthBookRefresh() {} -export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return defaultValue; } -export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return defaultValue; } +export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return _getFlagValue(configName, defaultValue); } +export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return _getFlagValue(configName, defaultValue); } `, 'services/analytics/sink': `