From fcdbfd186c997fbdf2792aa72988434d00b768bc Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 19:38:33 -0500 Subject: [PATCH 1/8] M1: Assistant config + skill feature flag enforcement (#10152) * feat: add skill feature flags config and enforcement Co-Authored-By: Claude * fix: enforce feature flags on included child skills and dynamic prompt section Add isSkillFeatureEnabled checks in skill_load for child skills in both the body-loading loop and the loaded_skill marker loop, so flag-OFF child skills are fully hidden. Also filter hardcoded browser/twitter references in buildDynamicSkillWorkflowSection through isSkillFeatureEnabled so the system prompt does not advertise disabled skills. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude --- .../skill-feature-flags-integration.test.ts | 168 ++++++++ .../src/__tests__/skill-feature-flags.test.ts | 154 ++++++++ .../__tests__/skill-load-feature-flag.test.ts | 138 +++++++ .../skill-projection-feature-flag.test.ts | 361 ++++++++++++++++++ assistant/src/config/schema.ts | 3 + assistant/src/config/skill-state.ts | 19 + assistant/src/config/system-prompt.ts | 39 +- assistant/src/config/types.ts | 8 + assistant/src/daemon/session-skill-tools.ts | 14 +- assistant/src/tools/skills/load.ts | 29 +- 10 files changed, 913 insertions(+), 20 deletions(-) create mode 100644 assistant/src/__tests__/skill-feature-flags-integration.test.ts create mode 100644 assistant/src/__tests__/skill-feature-flags.test.ts create mode 100644 assistant/src/__tests__/skill-load-feature-flag.test.ts create mode 100644 assistant/src/__tests__/skill-projection-feature-flag.test.ts diff --git a/assistant/src/__tests__/skill-feature-flags-integration.test.ts b/assistant/src/__tests__/skill-feature-flags-integration.test.ts new file mode 100644 index 00000000000..dc59a1b8463 --- /dev/null +++ b/assistant/src/__tests__/skill-feature-flags-integration.test.ts @@ -0,0 +1,168 @@ +/** + * Integration tests for skill feature flag enforcement at system prompt, + * skill_load, and session-skill-tools projection layers. + */ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Test-scoped temp directory and config state +// --------------------------------------------------------------------------- + +const TEST_DIR = join(tmpdir(), `vellum-skill-flags-test-${crypto.randomUUID()}`); + +let currentConfig: Record = { + sandbox: { enabled: false, backend: 'native' }, + featureFlags: {}, +}; + +mock.module('../util/platform.js', () => ({ + getRootDir: () => TEST_DIR, + getDataDir: () => TEST_DIR, + getWorkspaceDir: () => TEST_DIR, + getWorkspaceConfigPath: () => join(TEST_DIR, 'config.json'), + getWorkspaceSkillsDir: () => join(TEST_DIR, 'skills'), + getWorkspaceHooksDir: () => join(TEST_DIR, 'hooks'), + getWorkspacePromptPath: (file: string) => join(TEST_DIR, file), + ensureDataDir: () => {}, + getSocketPath: () => join(TEST_DIR, 'vellum.sock'), + getPidPath: () => join(TEST_DIR, 'vellum.pid'), + getDbPath: () => join(TEST_DIR, 'data', 'assistant.db'), + getLogPath: () => join(TEST_DIR, 'logs', 'vellum.log'), + getHistoryPath: () => join(TEST_DIR, 'history'), + getHooksDir: () => join(TEST_DIR, 'hooks'), + getIpcBlobDir: () => join(TEST_DIR, 'ipc-blobs'), + getSandboxRootDir: () => join(TEST_DIR, 'sandbox'), + getSandboxWorkingDir: () => TEST_DIR, + getInterfacesDir: () => join(TEST_DIR, 'interfaces'), + isMacOS: () => false, + isLinux: () => false, + isWindows: () => false, + getPlatformName: () => 'linux', + getClipboardCommand: () => null, + removeSocketFile: () => {}, + migratePath: () => {}, + migrateToWorkspaceLayout: () => {}, + migrateToDataLayout: () => {}, +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), + isDebug: () => false, + truncateForLog: (v: string) => v, +})); + +mock.module('../config/loader.js', () => ({ + getConfig: () => currentConfig, +})); + +mock.module('../config/user-reference.js', () => ({ + resolveUserReference: () => 'TestUser', +})); + +mock.module('../security/parental-control-store.js', () => ({ + getParentalControlSettings: () => ({ enabled: false, contentRestrictions: [], blockedToolCategories: [] }), +})); + +mock.module('../tools/credentials/metadata-store.js', () => ({ + listCredentialMetadata: () => [], +})); + +const { buildSystemPrompt } = await import('../config/system-prompt.js'); + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + // Reset config to defaults before each test + currentConfig = { + sandbox: { enabled: false, backend: 'native' }, + featureFlags: {}, + }; +}); + +afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createSkillOnDisk(id: string, name: string, description: string): void { + const skillsDir = join(TEST_DIR, 'skills'); + mkdirSync(join(skillsDir, id), { recursive: true }); + writeFileSync( + join(skillsDir, id, 'SKILL.md'), + `---\nname: "${name}"\ndescription: "${description}"\n---\n\nInstructions for ${id}.\n`, + ); + // Ensure SKILLS.md index references the skill + const indexPath = join(skillsDir, 'SKILLS.md'); + const existing = existsSync(indexPath) ? require('node:fs').readFileSync(indexPath, 'utf-8') : ''; + writeFileSync(indexPath, existing + `- ${id}\n`); +} + +// --------------------------------------------------------------------------- +// System prompt — feature flag filtering +// --------------------------------------------------------------------------- + +describe('buildSystemPrompt feature flag filtering', () => { + test('flag OFF skill does not appear in section', () => { + createSkillOnDisk('browser', 'Browser', 'Web browsing automation'); + createSkillOnDisk('twitter', 'Twitter', 'Post to X/Twitter'); + + currentConfig = { + sandbox: { enabled: false, backend: 'native' }, + featureFlags: { 'skills.browser.enabled': false }, + }; + + const result = buildSystemPrompt(); + + // twitter should be visible, browser should not + expect(result).toContain('id="twitter"'); + expect(result).not.toContain('id="browser"'); + }); + + test('all skills visible when featureFlags is empty', () => { + createSkillOnDisk('browser', 'Browser', 'Web browsing automation'); + createSkillOnDisk('twitter', 'Twitter', 'Post to X/Twitter'); + + currentConfig = { + sandbox: { enabled: false, backend: 'native' }, + featureFlags: {}, + }; + + const result = buildSystemPrompt(); + + expect(result).toContain('id="browser"'); + expect(result).toContain('id="twitter"'); + }); + + test('all skills hidden when all flags are OFF', () => { + createSkillOnDisk('browser', 'Browser', 'Web browsing automation'); + createSkillOnDisk('twitter', 'Twitter', 'Post to X/Twitter'); + + currentConfig = { + sandbox: { enabled: false, backend: 'native' }, + featureFlags: { + 'skills.browser.enabled': false, + 'skills.twitter.enabled': false, + }, + }; + + const result = buildSystemPrompt(); + + expect(result).not.toContain(''); + expect(result).not.toContain('id="browser"'); + expect(result).not.toContain('id="twitter"'); + }); +}); diff --git a/assistant/src/__tests__/skill-feature-flags.test.ts b/assistant/src/__tests__/skill-feature-flags.test.ts new file mode 100644 index 00000000000..27b447f5ab3 --- /dev/null +++ b/assistant/src/__tests__/skill-feature-flags.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from 'bun:test'; + +import type { AssistantConfig } from '../config/schema.js'; +import { isSkillFeatureEnabled, resolveSkillStates } from '../config/skill-state.js'; +import type { SkillSummary } from '../config/skills.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a minimal AssistantConfig with optional featureFlags. */ +function makeConfig(overrides: Partial = {}): AssistantConfig { + return { + featureFlags: {}, + skills: { + entries: {}, + load: { extraDirs: [], watch: true, watchDebounceMs: 250 }, + install: { nodeManager: 'npm' }, + allowBundled: null, + }, + ...overrides, + } as AssistantConfig; +} + +/** Create a minimal SkillSummary for testing. */ +function makeSkill(id: string, source: 'bundled' | 'managed' = 'bundled'): SkillSummary { + return { + id, + name: `${id} skill`, + description: `Description for ${id}`, + directoryPath: `/fake/skills/${id}`, + skillFilePath: `/fake/skills/${id}/SKILL.md`, + bundled: source === 'bundled', + userInvocable: true, + disableModelInvocation: false, + source, + }; +} + +// --------------------------------------------------------------------------- +// isSkillFeatureEnabled +// --------------------------------------------------------------------------- + +describe('isSkillFeatureEnabled', () => { + test('returns true when featureFlags section is empty', () => { + const config = makeConfig({ featureFlags: {} }); + expect(isSkillFeatureEnabled('browser', config)).toBe(true); + }); + + test('returns true when skill key is missing (default enabled)', () => { + const config = makeConfig({ + featureFlags: { 'skills.other.enabled': true }, + }); + expect(isSkillFeatureEnabled('browser', config)).toBe(true); + }); + + test('returns true when skill key is explicitly true', () => { + const config = makeConfig({ + featureFlags: { 'skills.browser.enabled': true }, + }); + expect(isSkillFeatureEnabled('browser', config)).toBe(true); + }); + + test('returns false when skill key is explicitly false', () => { + const config = makeConfig({ + featureFlags: { 'skills.browser.enabled': false }, + }); + expect(isSkillFeatureEnabled('browser', config)).toBe(false); + }); + + test('returns true when featureFlags is undefined', () => { + const config = makeConfig(); + // Simulate a config that somehow has no featureFlags key + delete (config as Record).featureFlags; + expect(isSkillFeatureEnabled('browser', config)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// resolveSkillStates — feature flag filtering +// --------------------------------------------------------------------------- + +describe('resolveSkillStates with feature flags', () => { + test('flag OFF skill does not appear in resolved list', () => { + const catalog = [makeSkill('browser'), makeSkill('twitter')]; + const config = makeConfig({ + featureFlags: { 'skills.browser.enabled': false }, + }); + + const resolved = resolveSkillStates(catalog, config); + const ids = resolved.map((r) => r.summary.id); + + expect(ids).not.toContain('browser'); + expect(ids).toContain('twitter'); + }); + + test('flag ON skill appears normally', () => { + const catalog = [makeSkill('browser'), makeSkill('twitter')]; + const config = makeConfig({ + featureFlags: { 'skills.browser.enabled': true, 'skills.twitter.enabled': true }, + }); + + const resolved = resolveSkillStates(catalog, config); + const ids = resolved.map((r) => r.summary.id); + + expect(ids).toContain('browser'); + expect(ids).toContain('twitter'); + }); + + test('missing flag key defaults to enabled', () => { + const catalog = [makeSkill('browser')]; + const config = makeConfig({ featureFlags: {} }); + + const resolved = resolveSkillStates(catalog, config); + expect(resolved.length).toBe(1); + expect(resolved[0].summary.id).toBe('browser'); + }); + + test('feature flag OFF takes precedence over user-enabled config entry', () => { + const catalog = [makeSkill('browser')]; + const config = makeConfig({ + featureFlags: { 'skills.browser.enabled': false }, + skills: { + entries: { browser: { enabled: true } }, + load: { extraDirs: [], watch: true, watchDebounceMs: 250 }, + install: { nodeManager: 'npm' }, + allowBundled: null, + }, + }); + + const resolved = resolveSkillStates(catalog, config); + // The skill should not appear at all — feature flag is a higher-priority gate + expect(resolved.length).toBe(0); + }); + + test('multiple skills with mixed flags', () => { + const catalog = [ + makeSkill('browser'), + makeSkill('twitter'), + makeSkill('deploy'), + ]; + const config = makeConfig({ + featureFlags: { + 'skills.browser.enabled': false, + 'skills.deploy.enabled': false, + }, + }); + + const resolved = resolveSkillStates(catalog, config); + const ids = resolved.map((r) => r.summary.id); + + expect(ids).toEqual(['twitter']); + }); +}); diff --git a/assistant/src/__tests__/skill-load-feature-flag.test.ts b/assistant/src/__tests__/skill-load-feature-flag.test.ts new file mode 100644 index 00000000000..eaa6cbd669e --- /dev/null +++ b/assistant/src/__tests__/skill-load-feature-flag.test.ts @@ -0,0 +1,138 @@ +/** + * Tests that skill_load rejects loading a skill whose feature flag is OFF + * with a deterministic error message. + */ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +const TEST_DIR = join(tmpdir(), `vellum-skill-load-flag-test-${crypto.randomUUID()}`); + +let currentConfig: Record = { + featureFlags: {}, +}; + +const platformOverrides: Record unknown> = { + getRootDir: () => TEST_DIR, + getDataDir: () => TEST_DIR, + ensureDataDir: () => {}, + getSocketPath: () => join(TEST_DIR, 'vellum.sock'), + getPidPath: () => join(TEST_DIR, 'vellum.pid'), + getDbPath: () => join(TEST_DIR, 'data', 'assistant.db'), + getLogPath: () => join(TEST_DIR, 'logs', 'vellum.log'), + getWorkspaceDir: () => join(TEST_DIR, 'workspace'), + getWorkspaceSkillsDir: () => join(TEST_DIR, 'skills'), + getWorkspaceConfigPath: () => join(TEST_DIR, 'workspace', 'config.json'), + getWorkspaceHooksDir: () => join(TEST_DIR, 'workspace', 'hooks'), + getWorkspacePromptPath: (f: unknown) => join(TEST_DIR, 'workspace', String(f)), + getInterfacesDir: () => join(TEST_DIR, 'interfaces'), + getHooksDir: () => join(TEST_DIR, 'hooks'), + getIpcBlobDir: () => join(TEST_DIR, 'blobs'), + getSandboxRootDir: () => join(TEST_DIR, 'sandbox'), + getSandboxWorkingDir: () => join(TEST_DIR, 'sandbox', 'work'), + getHistoryPath: () => join(TEST_DIR, 'history'), + getSessionTokenPath: () => join(TEST_DIR, 'session-token'), + readSessionToken: () => null, + getClipboardCommand: () => null, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getPlatformName: () => process.platform, + migratePath: () => {}, + migrateToWorkspaceLayout: () => {}, + migrateToDataLayout: () => {}, + removeSocketFile: () => {}, +}; +mock.module('../util/platform.js', () => platformOverrides); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +mock.module('../config/loader.js', () => ({ + getConfig: () => currentConfig, +})); + +await import('../tools/skills/load.js'); +const { getTool } = await import('../tools/registry.js'); + +function writeSkill(skillId: string, name: string, description: string, body: string): void { + const skillDir = join(TEST_DIR, 'skills', skillId); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\nname: "${name}"\ndescription: "${description}"\n---\n\n${body}\n`, + ); +} + +async function executeSkillLoad(input: Record): Promise<{ content: string; isError: boolean }> { + const tool = getTool('skill_load'); + if (!tool) throw new Error('skill_load tool was not registered'); + + const result = await tool.execute(input, { + workingDir: '/tmp', + sessionId: 'session-1', + conversationId: 'conversation-1', + }); + return { content: result.content, isError: result.isError }; +} + +describe('skill_load feature flag enforcement', () => { + beforeEach(() => { + mkdirSync(join(TEST_DIR, 'skills'), { recursive: true }); + currentConfig = { featureFlags: {} }; + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test('returns deterministic error for flag OFF skill', async () => { + writeSkill('browser', 'Browser', 'Web browsing', 'Use the browser.'); + writeFileSync(join(TEST_DIR, 'skills', 'SKILLS.md'), '- browser\n'); + + currentConfig = { + featureFlags: { 'skills.browser.enabled': false }, + }; + + const result = await executeSkillLoad({ skill: 'browser' }); + + expect(result.isError).toBe(true); + expect(result.content).toContain('disabled by feature flag'); + expect(result.content).toContain('browser'); + }); + + test('loads skill normally when flag is ON', async () => { + writeSkill('browser', 'Browser', 'Web browsing', 'Use the browser.'); + writeFileSync(join(TEST_DIR, 'skills', 'SKILLS.md'), '- browser\n'); + + currentConfig = { + featureFlags: { 'skills.browser.enabled': true }, + }; + + const result = await executeSkillLoad({ skill: 'browser' }); + + expect(result.isError).toBe(false); + expect(result.content).toContain('Skill: Browser'); + }); + + test('loads skill normally when flag key is absent (defaults to enabled)', async () => { + writeSkill('browser', 'Browser', 'Web browsing', 'Use the browser.'); + writeFileSync(join(TEST_DIR, 'skills', 'SKILLS.md'), '- browser\n'); + + currentConfig = { + featureFlags: {}, + }; + + const result = await executeSkillLoad({ skill: 'browser' }); + + expect(result.isError).toBe(false); + expect(result.content).toContain('Skill: Browser'); + }); +}); diff --git a/assistant/src/__tests__/skill-projection-feature-flag.test.ts b/assistant/src/__tests__/skill-projection-feature-flag.test.ts new file mode 100644 index 00000000000..1f57ebc3e76 --- /dev/null +++ b/assistant/src/__tests__/skill-projection-feature-flag.test.ts @@ -0,0 +1,361 @@ +/** + * Tests that projectSkillTools drops flag-OFF active skills from projected + * tools, even when conversation history contains old markers for those skills. + */ +import * as realFs from 'node:fs'; + +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +import type { SkillSummary, SkillToolManifest } from '../config/skills.js'; +import { RiskLevel } from '../permissions/types.js'; +import type { Message } from '../providers/types.js'; +import type { Tool } from '../tools/types.js'; + +// --------------------------------------------------------------------------- +// Mock state +// --------------------------------------------------------------------------- + +let mockCatalog: SkillSummary[] = []; +let mockManifests: Record = {}; +let mockRegisteredTools: Map = new Map(); +let mockUnregisteredSkillIds: string[] = []; +let mockSkillRefCount: Map = new Map(); + +let currentConfig: Record = { featureFlags: {} }; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +mock.module('../config/skills.js', () => ({ + loadSkillCatalog: () => mockCatalog, +})); + +mock.module('../config/loader.js', () => ({ + getConfig: () => currentConfig, +})); + +mock.module('../skills/active-skill-tools.js', () => { + const parseMarkers = (messages: Message[]) => { + const skillLoadUseIds = new Set(); + for (const msg of messages) { + for (const block of msg.content) { + if (block.type === 'tool_use' && block.name === 'skill_load') { + skillLoadUseIds.add(block.id); + } + } + } + const re = //g; + const seen = new Set(); + const entries: Array<{ id: string; version?: string }> = []; + for (const msg of messages) { + for (const block of msg.content) { + if (block.type !== 'tool_result') continue; + if (!skillLoadUseIds.has(block.tool_use_id)) continue; + const text = block.content; + if (!text) continue; + for (const m of text.matchAll(re)) { + if (!seen.has(m[1])) { + seen.add(m[1]); + const entry: { id: string; version?: string } = { id: m[1] }; + if (m[2]) entry.version = m[2]; + entries.push(entry); + } + } + } + } + return entries; + }; + + return { + deriveActiveSkills: (messages: Message[]) => parseMarkers(messages), + deriveActiveSkillIds: (messages: Message[]) => parseMarkers(messages).map((e) => e.id), + }; +}); + +mock.module('../skills/tool-manifest.js', () => ({ + parseToolManifestFile: (filePath: string) => { + const parts = filePath.split('/'); + const skillId = parts[parts.length - 2]; + const manifest = mockManifests[skillId]; + if (!manifest) throw new Error(`Mock: no manifest for skill "${skillId}"`); + return manifest; + }, +})); + +mock.module('../tools/skills/skill-tool-factory.js', () => ({ + createSkillToolsFromManifest: ( + entries: SkillToolManifest['tools'], + skillId: string, + _skillDir: string, + versionHash: string, + bundled?: boolean, + ): Tool[] => { + return entries.map((entry) => ({ + name: entry.name, + description: entry.description, + category: entry.category, + defaultRiskLevel: RiskLevel.Medium, + origin: 'skill' as const, + ownerSkillId: skillId, + ownerSkillVersionHash: versionHash, + ownerSkillBundled: bundled ?? undefined, + getDefinition: () => ({ + name: entry.name, + description: entry.description, + input_schema: entry.input_schema as object, + }), + execute: async () => ({ content: '', isError: false }), + })); + }, +})); + +mock.module('../tools/registry.js', () => ({ + registerSkillTools: (tools: Tool[]) => { + const skillIds = new Set(); + for (const tool of tools) { + const skillId = tool.ownerSkillId!; + skillIds.add(skillId); + const existing = mockRegisteredTools.get(skillId) ?? []; + existing.push(tool); + mockRegisteredTools.set(skillId, existing); + } + for (const id of skillIds) { + mockSkillRefCount.set(id, (mockSkillRefCount.get(id) ?? 0) + 1); + } + return tools; + }, + unregisterSkillTools: (skillId: string) => { + mockUnregisteredSkillIds.push(skillId); + const current = mockSkillRefCount.get(skillId) ?? 0; + if (current > 1) { + mockSkillRefCount.set(skillId, current - 1); + return; + } + mockSkillRefCount.delete(skillId); + mockRegisteredTools.delete(skillId); + }, + getTool: (name: string): Tool | undefined => { + let found: Tool | undefined; + for (const tools of mockRegisteredTools.values()) { + for (const tool of tools) { + if (tool.name === name) found = tool; + } + } + return found; + }, + getSkillToolNames: () => { + const names: string[] = []; + for (const tools of mockRegisteredTools.values()) { + for (const tool of tools) { + names.push(tool.name); + } + } + return names; + }, +})); + +mock.module('node:fs', () => ({ + ...realFs, + existsSync: (p: string) => { + if (typeof p === 'string' && p.endsWith('TOOLS.json')) { + const parts = p.split('/'); + const skillId = parts[parts.length - 2]; + return skillId in mockManifests; + } + return realFs.existsSync(p); + }, +})); + +mock.module('../skills/version-hash.js', () => ({ + computeSkillVersionHash: (skillDir: string) => { + const parts = skillDir.split('/'); + const skillId = parts[parts.length - 1]; + return `v1:default-hash-${skillId}`; + }, +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => ({ + info: () => {}, + warn: () => {}, + debug: () => {}, + error: () => {}, + }), +})); + +// --------------------------------------------------------------------------- +// Import module under test (after mocks) +// --------------------------------------------------------------------------- + +const { projectSkillTools, resetSkillToolProjection } = await import( + '../daemon/session-skill-tools.js' +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSkill(id: string): SkillSummary { + return { + id, + name: id, + description: `Skill ${id}`, + directoryPath: `/skills/${id}`, + skillFilePath: `/skills/${id}/SKILL.md`, + userInvocable: true, + disableModelInvocation: false, + source: 'managed', + }; +} + +function makeManifest(toolNames: string[]): SkillToolManifest { + return { + version: 1, + tools: toolNames.map((name) => ({ + name, + description: `Tool ${name}`, + category: 'test', + risk: 'medium' as const, + input_schema: { type: 'object', properties: {} }, + executor: 'run.ts', + execution_target: 'host' as const, + })), + }; +} + +/** Build conversation history with a loaded_skill marker. */ +function buildHistoryWithMarker(skillId: string): Message[] { + return [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tu-1', name: 'skill_load', input: { skill: skillId } }], + }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'tu-1', + content: `Loaded.\n\n`, + }], + }, + ]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('projectSkillTools feature flag enforcement', () => { + beforeEach(() => { + mockCatalog = []; + mockManifests = {}; + mockRegisteredTools = new Map(); + mockUnregisteredSkillIds = []; + mockSkillRefCount = new Map(); + currentConfig = { featureFlags: {} }; + resetSkillToolProjection(); + }); + + test('no skill tools projected for flag OFF skill even with old markers', () => { + mockCatalog = [makeSkill('browser')]; + mockManifests = { browser: makeManifest(['browser_navigate', 'browser_click']) }; + + // History contains a marker from before the flag was turned off + const history = buildHistoryWithMarker('browser'); + const prevActive = new Map(); + + // Feature flag is OFF + currentConfig = { featureFlags: { 'skills.browser.enabled': false } }; + + const result = projectSkillTools(history, { previouslyActiveSkillIds: prevActive }); + + // No tools should be projected + expect(result.toolDefinitions).toHaveLength(0); + expect(result.allowedToolNames.size).toBe(0); + }); + + test('skill tools projected normally when flag is ON', () => { + mockCatalog = [makeSkill('browser')]; + mockManifests = { browser: makeManifest(['browser_navigate', 'browser_click']) }; + + const history = buildHistoryWithMarker('browser'); + const prevActive = new Map(); + + // Feature flag is ON + currentConfig = { featureFlags: { 'skills.browser.enabled': true } }; + + const result = projectSkillTools(history, { previouslyActiveSkillIds: prevActive }); + + expect(result.toolDefinitions).toHaveLength(2); + expect(result.allowedToolNames.has('browser_navigate')).toBe(true); + expect(result.allowedToolNames.has('browser_click')).toBe(true); + }); + + test('skill tools projected normally when flag key is absent (defaults to enabled)', () => { + mockCatalog = [makeSkill('browser')]; + mockManifests = { browser: makeManifest(['browser_navigate']) }; + + const history = buildHistoryWithMarker('browser'); + const prevActive = new Map(); + + // featureFlags is empty — should default to enabled + currentConfig = { featureFlags: {} }; + + const result = projectSkillTools(history, { previouslyActiveSkillIds: prevActive }); + + expect(result.toolDefinitions).toHaveLength(1); + expect(result.allowedToolNames.has('browser_navigate')).toBe(true); + }); + + test('mixed flag-on and flag-off skills — only flag-on tools projected', () => { + mockCatalog = [makeSkill('browser'), makeSkill('twitter')]; + mockManifests = { + browser: makeManifest(['browser_navigate']), + twitter: makeManifest(['twitter_post']), + }; + + const history: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'tu-1', name: 'skill_load', input: { skill: 'browser' } }, + ], + }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'tu-1', + content: '', + }], + }, + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'tu-2', name: 'skill_load', input: { skill: 'twitter' } }, + ], + }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'tu-2', + content: '', + }], + }, + ]; + const prevActive = new Map(); + + // browser is OFF, twitter is ON + currentConfig = { + featureFlags: { 'skills.browser.enabled': false }, + }; + + const result = projectSkillTools(history, { previouslyActiveSkillIds: prevActive }); + + const toolNames = result.toolDefinitions.map((t) => t.name); + expect(toolNames).toContain('twitter_post'); + expect(toolNames).not.toContain('browser_navigate'); + }); +}); diff --git a/assistant/src/config/schema.ts b/assistant/src/config/schema.ts index a66ae92058f..a1433859ab4 100644 --- a/assistant/src/config/schema.ts +++ b/assistant/src/config/schema.ts @@ -218,6 +218,9 @@ export const AssistantConfigSchema = z.object({ daemon: DaemonConfigSchema.default({} as any), notifications: NotificationsConfigSchema.default({} as any), ui: UiConfigSchema.default({} as any), + featureFlags: z + .record(z.string(), z.boolean({ error: 'featureFlags values must be booleans' })) + .default({} as any), }).superRefine((config, ctx) => { if (config.contextWindow?.targetInputTokens != null && config.contextWindow?.maxInputTokens != null && config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) { diff --git a/assistant/src/config/skill-state.ts b/assistant/src/config/skill-state.ts index d86716c2548..8c5b20634f2 100644 --- a/assistant/src/config/skill-state.ts +++ b/assistant/src/config/skill-state.ts @@ -12,6 +12,20 @@ export interface ResolvedSkill { configEntry?: SkillEntryConfig; } +/** + * Check whether a skill's feature flag is enabled. + * Feature flag key format: `skills..enabled`. + * Missing key defaults to `true` (enabled); explicit `false` means disabled. + */ +export function isSkillFeatureEnabled(skillId: string, config: AssistantConfig): boolean { + const flags = config.featureFlags; + if (!flags) return true; + const key = `skills.${skillId}.enabled`; + const value = flags[key]; + if (value === undefined) return true; + return value !== false; +} + export function resolveSkillStates( catalog: SkillSummary[], config: AssistantConfig, @@ -20,6 +34,11 @@ export function resolveSkillStates( const { entries, allowBundled } = config.skills ?? { entries: {}, allowBundled: null }; for (const skill of catalog) { + // Feature flag gate: if the flag is explicitly OFF, skip this skill entirely + if (!isSkillFeatureEnabled(skill.id, config)) { + continue; + } + // Filter bundled skills by allowlist if (skill.source === 'bundled' && allowBundled != null && !allowBundled.includes(skill.id)) { continue; diff --git a/assistant/src/config/system-prompt.ts b/assistant/src/config/system-prompt.ts index 46f3f595d6e..4bf2e3f5384 100644 --- a/assistant/src/config/system-prompt.ts +++ b/assistant/src/config/system-prompt.ts @@ -7,6 +7,7 @@ import { listCredentialMetadata } from '../tools/credentials/metadata-store.js'; import { getLogger } from '../util/logger.js'; import { getWorkspaceDir, getWorkspacePromptPath, isMacOS } from '../util/platform.js'; import { getConfig } from './loader.js'; +import { isSkillFeatureEnabled } from './skill-state.js'; import { loadSkillCatalog, type SkillSummary } from './skills.js'; import { resolveUserReference } from './user-reference.js'; @@ -721,19 +722,23 @@ function readPromptFile(path: string): string | null { function appendSkillsCatalog(basePrompt: string): string { const skills = loadSkillCatalog(); + const config = getConfig(); + + // Filter out skills whose feature flag is explicitly OFF + const flagFiltered = skills.filter(s => isSkillFeatureEnabled(s.id, config)); const sections: string[] = [basePrompt]; - const catalog = formatSkillsCatalog(skills); + const catalog = formatSkillsCatalog(flagFiltered); if (catalog) sections.push(catalog); - sections.push(buildDynamicSkillWorkflowSection()); + sections.push(buildDynamicSkillWorkflowSection(config)); return sections.join('\n\n'); } -function buildDynamicSkillWorkflowSection(): string { - return [ +function buildDynamicSkillWorkflowSection(config: import('./schema.js').AssistantConfig): string { + const lines = [ '## Dynamic Skill Authoring Workflow', '', 'When no existing tool or skill can satisfy a request:', @@ -745,13 +750,25 @@ function buildDynamicSkillWorkflowSection(): string { '', '**Never persist or delete skills without explicit user confirmation.** To remove: `delete_managed_skill`.', 'After a skill is written or deleted, the next turn may run in a recreated session due to file-watcher eviction. Continue normally.', - '', - '### Browser Skill Prerequisite', - 'If you need browser capabilities (navigating web pages, clicking elements, extracting content) and `browser_*` tools are not available, load the "browser" skill first using `skill_load`.', - '', - '### X (Twitter) Skill', - 'When the user asks to post, reply, or interact with X/Twitter, load the "twitter" skill using `skill_load`. Do NOT use computer-use or the browser skill for X — the X skill provides CLI commands (`vellum x post`, `vellum x reply`) that are faster and more reliable.', - ].join('\n'); + ]; + + if (isSkillFeatureEnabled('browser', config)) { + lines.push( + '', + '### Browser Skill Prerequisite', + 'If you need browser capabilities (navigating web pages, clicking elements, extracting content) and `browser_*` tools are not available, load the "browser" skill first using `skill_load`.', + ); + } + + if (isSkillFeatureEnabled('twitter', config)) { + lines.push( + '', + '### X (Twitter) Skill', + 'When the user asks to post, reply, or interact with X/Twitter, load the "twitter" skill using `skill_load`. Do NOT use computer-use or the browser skill for X — the X skill provides CLI commands (`vellum x post`, `vellum x reply`) that are faster and more reliable.', + ); + } + + return lines.join('\n'); } function escapeXml(str: string): string { diff --git a/assistant/src/config/types.ts b/assistant/src/config/types.ts index a52e642fd44..03c4e45e544 100644 --- a/assistant/src/config/types.ts +++ b/assistant/src/config/types.ts @@ -43,3 +43,11 @@ export type { UiConfig, WorkspaceGitConfig, } from './schema.js'; + +/** + * Feature flags are a top-level config section (Record). + * Skill feature flags use the key format `skills..enabled`. + * Missing key defaults to `true` (enabled); explicit `false` disables everywhere. + */ +export type FeatureFlags = Record; + diff --git a/assistant/src/daemon/session-skill-tools.ts b/assistant/src/daemon/session-skill-tools.ts index fa03e260171..dafa4c4e52f 100644 --- a/assistant/src/daemon/session-skill-tools.ts +++ b/assistant/src/daemon/session-skill-tools.ts @@ -11,6 +11,8 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; +import { getConfig } from '../config/loader.js'; +import { isSkillFeatureEnabled } from '../config/skill-state.js'; import type { SkillSummary, SkillToolManifest } from '../config/skills.js'; import { loadSkillCatalog } from '../config/skills.js'; import type { Message, ToolDefinition } from '../providers/types.js'; @@ -215,7 +217,17 @@ export function projectSkillTools( // Union of context-derived and preactivated IDs const contextIds = contextEntries.map((e) => e.id); - const activeIds = new Set([...contextIds, ...preactivated]); + const allCandidateIds = new Set([...contextIds, ...preactivated]); + + // Feature flag gate: drop skills whose feature flag is explicitly OFF, + // even if they have markers in conversation history from before the flag was turned off. + const config = getConfig(); + const activeIds = new Set(); + for (const id of allCandidateIds) { + if (isSkillFeatureEnabled(id, config)) { + activeIds.add(id); + } + } // Determine which skills were removed since last projection const removedIds = new Set(); diff --git a/assistant/src/tools/skills/load.ts b/assistant/src/tools/skills/load.ts index fd84eb1a0ce..5aeade16ffb 100644 --- a/assistant/src/tools/skills/load.ts +++ b/assistant/src/tools/skills/load.ts @@ -1,3 +1,5 @@ +import { getConfig } from '../../config/loader.js'; +import { isSkillFeatureEnabled } from '../../config/skill-state.js'; import type { SkillSummary } from '../../config/skills.js'; import { loadSkillBySelector, loadSkillCatalog } from '../../config/skills.js'; import { RiskLevel } from '../../permissions/types.js'; @@ -46,6 +48,15 @@ export class SkillLoadTool implements Tool { const skill = loaded.skill; + // Feature flag gate: reject loading if the skill's feature flag is OFF + const config = getConfig(); + if (!isSkillFeatureEnabled(skill.id, config)) { + return { + content: `Error: skill "${skill.id}" is currently unavailable (disabled by feature flag)`, + isError: true, + }; + } + // Load catalog for include validation and child metadata output let catalogIndex: Map | undefined; if (skill.includes && skill.includes.length > 0) { @@ -83,14 +94,15 @@ export class SkillLoadTool implements Tool { const childLines: string[] = []; for (const childId of skill.includes) { const child = catalogIndex.get(childId); - if (child) { - childLines.push(` - ${child.id}: ${child.name} — ${child.description} (${child.skillFilePath})`); - - // Load the included skill's body content - const childLoaded = loadSkillBySelector(childId); - if (childLoaded.skill && childLoaded.skill.body.length > 0) { - includedBodies.push(`--- Included Skill: ${childLoaded.skill.name} (${childId}) ---\n${childLoaded.skill.body}`); - } + if (!child) continue; + if (!isSkillFeatureEnabled(childId, config)) continue; + + childLines.push(` - ${child.id}: ${child.name} — ${child.description} (${child.skillFilePath})`); + + // Load the included skill's body content + const childLoaded = loadSkillBySelector(childId); + if (childLoaded.skill && childLoaded.skill.body.length > 0) { + includedBodies.push(`--- Included Skill: ${childLoaded.skill.name} (${childId}) ---\n${childLoaded.skill.body}`); } } immediateChildrenSection = `Included Skills (immediate):\n${childLines.join('\n')}`; @@ -113,6 +125,7 @@ export class SkillLoadTool implements Tool { for (const childId of skill.includes) { const child = catalogIndex.get(childId); if (!child) continue; + if (!isSkillFeatureEnabled(childId, config)) continue; let childHash: string | undefined; try { childHash = computeSkillVersionHash(child.directoryPath); From 33b69053d466625da900ff59ddfd6b57bd57719e Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 19:55:24 -0500 Subject: [PATCH 2/8] M2: Gateway feature-flags API + persistence (#10165) * feat: add gateway feature-flags REST API with config persistence Co-Authored-By: Claude * fix: handle malformed URI encoding and config parse errors in feature-flags - Wrap decodeURIComponent in try/catch in gateway/src/index.ts to return 400 Bad Request on malformed percent-encoding instead of crashing to the global error handler with a misleading 500. - Refactor readConfigFile to use a discriminated union result type that distinguishes "file doesn't exist" (returns empty config) from "file exists but can't be parsed" (returns error). The PATCH handler now returns 500 with a descriptive message when the config file is malformed, preventing silent data loss. The GET handler gracefully degrades to empty flags on parse errors (no data loss risk). Co-Authored-By: Claude Opus 4.6 * fix: validate parsed config is a plain object in readConfigFile --------- Co-authored-by: Claude --- .../src/__tests__/feature-flags-route.test.ts | 354 ++++++++++++++++++ gateway/src/http/routes/feature-flags.ts | 163 ++++++++ gateway/src/index.ts | 47 +++ 3 files changed, 564 insertions(+) create mode 100644 gateway/src/__tests__/feature-flags-route.test.ts create mode 100644 gateway/src/http/routes/feature-flags.ts diff --git a/gateway/src/__tests__/feature-flags-route.test.ts b/gateway/src/__tests__/feature-flags-route.test.ts new file mode 100644 index 00000000000..a4dceb18dcf --- /dev/null +++ b/gateway/src/__tests__/feature-flags-route.test.ts @@ -0,0 +1,354 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomBytes } from "node:crypto"; + +// Use an isolated temp directory so tests don't touch the real workspace config +const testDir = join(tmpdir(), `vellum-ff-test-${randomBytes(6).toString("hex")}`); +const vellumRoot = join(testDir, ".vellum"); +const workspaceDir = join(vellumRoot, "workspace"); +const configPath = join(workspaceDir, "config.json"); + +const savedBaseDataDir = process.env.BASE_DATA_DIR; + +beforeEach(() => { + process.env.BASE_DATA_DIR = testDir; + mkdirSync(workspaceDir, { recursive: true }); +}); + +afterEach(() => { + if (savedBaseDataDir === undefined) { + delete process.env.BASE_DATA_DIR; + } else { + process.env.BASE_DATA_DIR = savedBaseDataDir; + } + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // best effort cleanup + } +}); + +const { createFeatureFlagsGetHandler, createFeatureFlagsPatchHandler } = await import( + "../http/routes/feature-flags.js" +); + +describe("GET /v1/feature-flags handler", () => { + test("returns empty flags array when config file does not exist", async () => { + // Don't create the config file + if (existsSync(configPath)) { + rmSync(configPath); + } + + const handler = createFeatureFlagsGetHandler(); + const res = await handler(new Request("http://gateway.test/v1/feature-flags")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.flags).toEqual([]); + }); + + test("returns empty flags array when config has no featureFlags key", async () => { + writeFileSync(configPath, JSON.stringify({ sms: { phoneNumber: "+1234" } })); + + const handler = createFeatureFlagsGetHandler(); + const res = await handler(new Request("http://gateway.test/v1/feature-flags")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.flags).toEqual([]); + }); + + test("returns stored feature flags", async () => { + writeFileSync( + configPath, + JSON.stringify({ + featureFlags: { + "skills.browser.enabled": true, + "skills.twitter.enabled": false, + }, + }), + ); + + const handler = createFeatureFlagsGetHandler(); + const res = await handler(new Request("http://gateway.test/v1/feature-flags")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.flags).toEqual([ + { key: "skills.browser.enabled", enabled: true }, + { key: "skills.twitter.enabled", enabled: false }, + ]); + }); + + test("ignores non-boolean values in featureFlags", async () => { + writeFileSync( + configPath, + JSON.stringify({ + featureFlags: { + "skills.browser.enabled": true, + "skills.bad.enabled": "yes", + "skills.number.enabled": 1, + }, + }), + ); + + const handler = createFeatureFlagsGetHandler(); + const res = await handler(new Request("http://gateway.test/v1/feature-flags")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.flags).toEqual([{ key: "skills.browser.enabled", enabled: true }]); + }); +}); + +describe("PATCH /v1/feature-flags/:flagKey handler", () => { + test("creates a new feature flag", async () => { + writeFileSync(configPath, JSON.stringify({})); + + const handler = createFeatureFlagsPatchHandler(); + const res = await handler( + new Request("http://gateway.test/v1/feature-flags/skills.browser.enabled", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + "skills.browser.enabled", + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ key: "skills.browser.enabled", enabled: true }); + + // Verify persistence + const config = JSON.parse(readFileSync(configPath, "utf-8")); + expect(config.featureFlags["skills.browser.enabled"]).toBe(true); + }); + + test("updates an existing feature flag", async () => { + writeFileSync( + configPath, + JSON.stringify({ + featureFlags: { "skills.browser.enabled": true }, + }), + ); + + const handler = createFeatureFlagsPatchHandler(); + const res = await handler( + new Request("http://gateway.test/v1/feature-flags/skills.browser.enabled", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }), + "skills.browser.enabled", + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ key: "skills.browser.enabled", enabled: false }); + + const config = JSON.parse(readFileSync(configPath, "utf-8")); + expect(config.featureFlags["skills.browser.enabled"]).toBe(false); + }); + + test("preserves unknown config keys when writing", async () => { + writeFileSync( + configPath, + JSON.stringify({ + sms: { phoneNumber: "+1234567890" }, + email: { address: "test@example.com" }, + featureFlags: { "skills.existing.enabled": true }, + }), + ); + + const handler = createFeatureFlagsPatchHandler(); + await handler( + new Request("http://gateway.test/v1/feature-flags/skills.new.enabled", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + "skills.new.enabled", + ); + + const config = JSON.parse(readFileSync(configPath, "utf-8")); + expect(config.sms).toEqual({ phoneNumber: "+1234567890" }); + expect(config.email).toEqual({ address: "test@example.com" }); + expect(config.featureFlags["skills.existing.enabled"]).toBe(true); + expect(config.featureFlags["skills.new.enabled"]).toBe(true); + }); + + test("creates config file and directories when they do not exist", async () => { + // Remove the workspace dir to test directory creation + rmSync(workspaceDir, { recursive: true, force: true }); + + const handler = createFeatureFlagsPatchHandler(); + const res = await handler( + new Request("http://gateway.test/v1/feature-flags/skills.test.enabled", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + "skills.test.enabled", + ); + + expect(res.status).toBe(200); + expect(existsSync(configPath)).toBe(true); + + const config = JSON.parse(readFileSync(configPath, "utf-8")); + expect(config.featureFlags["skills.test.enabled"]).toBe(true); + }); + + // Validation tests + test("rejects empty flag key", async () => { + const handler = createFeatureFlagsPatchHandler(); + const res = await handler( + new Request("http://gateway.test/v1/feature-flags/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + "", + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("non-empty"); + }); + + test("rejects key not matching skills..enabled format", async () => { + const handler = createFeatureFlagsPatchHandler(); + + const invalidKeys = [ + "random.key", + "skills.enabled", + "skills..enabled", + "skills.UPPERCASE.enabled", + "skills.browser.disabled", + "other.browser.enabled", + "skills.browser.enabled.extra", + ]; + + for (const key of invalidKeys) { + const res = await handler( + new Request(`http://gateway.test/v1/feature-flags/${key}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + key, + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid flag key format"); + } + }); + + test("accepts valid skill key formats", async () => { + writeFileSync(configPath, JSON.stringify({})); + const handler = createFeatureFlagsPatchHandler(); + + const validKeys = [ + "skills.browser.enabled", + "skills.twitter.enabled", + "skills.my-skill.enabled", + "skills.my_skill.enabled", + "skills.skill123.enabled", + ]; + + for (const key of validKeys) { + const res = await handler( + new Request(`http://gateway.test/v1/feature-flags/${key}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }), + key, + ); + + expect(res.status).toBe(200); + } + }); + + test("rejects non-boolean enabled value", async () => { + const handler = createFeatureFlagsPatchHandler(); + + const invalidValues = ["true", 1, null, undefined]; + for (const value of invalidValues) { + const res = await handler( + new Request("http://gateway.test/v1/feature-flags/skills.test.enabled", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: value }), + }), + "skills.test.enabled", + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("boolean"); + } + }); + + test("rejects invalid JSON body", async () => { + const handler = createFeatureFlagsPatchHandler(); + const res = await handler( + new Request("http://gateway.test/v1/feature-flags/skills.test.enabled", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: "not json", + }), + "skills.test.enabled", + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("valid JSON"); + }); + + test("rejects missing body", async () => { + const handler = createFeatureFlagsPatchHandler(); + const res = await handler( + new Request("http://gateway.test/v1/feature-flags/skills.test.enabled", { + method: "PATCH", + }), + "skills.test.enabled", + ); + + expect(res.status).toBe(400); + }); + + test("atomic write does not corrupt config on successful write", async () => { + // Write initial config + const initial = { + sms: { phoneNumber: "+1234" }, + featureFlags: { "skills.a.enabled": true }, + }; + writeFileSync(configPath, JSON.stringify(initial)); + + const handler = createFeatureFlagsPatchHandler(); + await handler( + new Request("http://gateway.test/v1/feature-flags/skills.b.enabled", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }), + "skills.b.enabled", + ); + + // Verify the file is valid JSON and contains all expected data + const raw = readFileSync(configPath, "utf-8"); + const config = JSON.parse(raw); + expect(config.sms).toEqual({ phoneNumber: "+1234" }); + expect(config.featureFlags["skills.a.enabled"]).toBe(true); + expect(config.featureFlags["skills.b.enabled"]).toBe(false); + + // Verify no temp files left behind + const { readdirSync } = await import("node:fs"); + const files = readdirSync(workspaceDir); + const tmpFiles = files.filter((f: string) => f.endsWith(".tmp")); + expect(tmpFiles.length).toBe(0); + }); +}); diff --git a/gateway/src/http/routes/feature-flags.ts b/gateway/src/http/routes/feature-flags.ts new file mode 100644 index 00000000000..80e3caf1437 --- /dev/null +++ b/gateway/src/http/routes/feature-flags.ts @@ -0,0 +1,163 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { randomBytes } from "node:crypto"; +import { getRootDir } from "../../credential-reader.js"; +import { getLogger } from "../../logger.js"; + +const log = getLogger("feature-flags"); + +/** + * Only allow keys matching `skills..enabled` for the initial rollout. + * The skillId segment must be a non-empty string of lowercase alphanumeric chars, + * hyphens, and underscores. + */ +const ALLOWED_KEY_RE = /^skills\.[a-z0-9_-]+\.enabled$/; + +function getConfigPath(): string { + return join(getRootDir(), "workspace", "config.json"); +} + +type ConfigReadResult = + | { ok: true; data: Record } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "malformed"; detail: string }; + +function readConfigFile(): ConfigReadResult { + const cfgPath = getConfigPath(); + if (!existsSync(cfgPath)) { + return { ok: true, data: {} }; + } + try { + const raw = readFileSync(cfgPath, "utf-8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, reason: "malformed", detail: "Config file is not a JSON object" }; + } + return { ok: true, data: parsed }; + } catch (err) { + return { ok: false, reason: "malformed", detail: String(err) }; + } +} + +/** + * Atomically write the config file: write to a temporary file in the same + * directory, then rename. This avoids partial-file corruption if the process + * crashes mid-write. + */ +function writeConfigFileAtomic(data: Record): void { + const cfgPath = getConfigPath(); + const dir = dirname(cfgPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const tmpPath = join(dir, `.config.${randomBytes(6).toString("hex")}.tmp`); + writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8"); + renameSync(tmpPath, cfgPath); +} + +export type FeatureFlagEntry = { + key: string; + enabled: boolean; +}; + +export function createFeatureFlagsGetHandler() { + return async (_req: Request): Promise => { + try { + const result = readConfigFile(); + // For GET, a malformed config degrades gracefully to empty flags + const config = result.ok ? result.data : {}; + const flags: Record = {}; + const raw = config.featureFlags; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "boolean") { + flags[k] = v; + } + } + } + + const entries: FeatureFlagEntry[] = Object.entries(flags).map( + ([key, enabled]) => ({ key, enabled }), + ); + + return Response.json({ flags: entries }); + } catch (err) { + log.error({ err }, "Failed to read feature flags"); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } + }; +} + +export function createFeatureFlagsPatchHandler() { + return async (req: Request, flagKey: string): Promise => { + // Validate flagKey is non-empty and matches allowed key charset + if (!flagKey) { + return Response.json( + { error: "Flag key must be non-empty" }, + { status: 400 }, + ); + } + + if (!ALLOWED_KEY_RE.test(flagKey)) { + return Response.json( + { error: "Invalid flag key format. Must match: skills..enabled" }, + { status: 400 }, + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json( + { error: "Request body must be valid JSON" }, + { status: 400 }, + ); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return Response.json( + { error: "Request body must be a JSON object" }, + { status: 400 }, + ); + } + + const { enabled } = body as { enabled?: unknown }; + if (typeof enabled !== "boolean") { + return Response.json( + { error: "\"enabled\" must be a boolean" }, + { status: 400 }, + ); + } + + try { + const result = readConfigFile(); + if (!result.ok) { + log.error({ reason: result.reason, detail: result.detail }, "Config file is malformed, refusing to overwrite"); + return Response.json( + { error: "Config file is malformed, cannot safely write" }, + { status: 500 }, + ); + } + + const config = result.data; + + // Preserve existing config keys; only update featureFlags + const existingFlags = + config.featureFlags && typeof config.featureFlags === "object" && !Array.isArray(config.featureFlags) + ? (config.featureFlags as Record) + : {}; + + config.featureFlags = { ...existingFlags, [flagKey]: enabled }; + + writeConfigFileAtomic(config); + + log.info({ flagKey, enabled }, "Feature flag updated"); + + return Response.json({ key: flagKey, enabled }); + } catch (err) { + log.error({ err, flagKey }, "Failed to update feature flag"); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } + }; +} diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 739a3b8151f..f57739fba17 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -26,6 +26,7 @@ import { createWhatsAppDeliverHandler } from "./http/routes/whatsapp-deliver.js" import { createSlackDeliverHandler } from "./http/routes/slack-deliver.js"; import { createOAuthCallbackHandler } from "./http/routes/oauth-callback.js"; import { createPairingProxyHandler } from "./http/routes/pairing-proxy.js"; +import { createFeatureFlagsGetHandler, createFeatureFlagsPatchHandler } from "./http/routes/feature-flags.js"; import { validateBearerToken } from "./http/auth/bearer.js"; import { getLogger, initLogger } from "./logger.js"; import { CircuitBreakerOpenError } from "./runtime/client.js"; @@ -145,6 +146,8 @@ function main() { const handleSlackDeliver = createSlackDeliverHandler(config); const handleOAuthCallback = createOAuthCallbackHandler(config); const pairingProxy = createPairingProxyHandler(config); + const handleFeatureFlagsGet = createFeatureFlagsGetHandler(); + const handleFeatureFlagsPatch = createFeatureFlagsPatchHandler(); const handleRuntimeProxy = config.runtimeProxyEnabled ? createRuntimeProxyHandler(config) @@ -397,6 +400,50 @@ function main() { return res; } + // ── Feature flags API ── + if (url.pathname === "/v1/feature-flags" && req.method === "GET") { + if (!config.runtimeBearerToken) { + return Response.json( + { error: "Service not configured: bearer token required" }, + { status: 503 }, + ); + } + const authResult = validateBearerToken( + tracedReq.headers.get("authorization"), + config.runtimeBearerToken, + ); + if (!authResult.authorized) { + authRateLimiter.recordFailure(getClientIp(req, svr, config.trustProxy)); + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + return handleFeatureFlagsGet(tracedReq); + } + + const featureFlagPatchMatch = url.pathname.match(/^\/v1\/feature-flags\/(.+)$/); + if (featureFlagPatchMatch && req.method === "PATCH") { + if (!config.runtimeBearerToken) { + return Response.json( + { error: "Service not configured: bearer token required" }, + { status: 503 }, + ); + } + const authResult = validateBearerToken( + tracedReq.headers.get("authorization"), + config.runtimeBearerToken, + ); + if (!authResult.authorized) { + authRateLimiter.recordFailure(getClientIp(req, svr, config.trustProxy)); + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + let flagKey: string; + try { + flagKey = decodeURIComponent(featureFlagPatchMatch[1]); + } catch { + return Response.json({ error: "Invalid flag key encoding" }, { status: 400 }); + } + return handleFeatureFlagsPatch(tracedReq, flagKey); + } + if (handleRuntimeProxy) { const res = await handleRuntimeProxy(tracedReq, getClientIp(req, svr, config.trustProxy)); if (res.status === 401) { From 5a74de530ec60452fcf23747ef01af25290466d2 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 20:09:17 -0500 Subject: [PATCH 3/8] M3: Client-only PATCH auth split for feature flags (#10171) * feat: add client-only PATCH auth split for feature-flags Co-Authored-By: Claude * fix: remove rate-limit on 403 and handle identical token edge case - Remove authRateLimiter.recordFailure() from 403 path to avoid penalizing legitimately authenticated clients who used the wrong token type (Issue 1) - Only record failures on 401 (truly invalid authentication) - Skip runtime-token rejection when FEATURE_FLAG_TOKEN is identical to runtimeBearerToken to support single-token deployments (Issue 2) Addresses review feedback on PR #10171 --------- Co-authored-by: Claude --- gateway/src/config.ts | 49 ++++- gateway/src/feature-flags-auth.test.ts | 256 +++++++++++++++++++++++++ gateway/src/index.ts | 92 ++++++++- 3 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 gateway/src/feature-flags-auth.test.ts diff --git a/gateway/src/config.ts b/gateway/src/config.ts index 6ca9acbaf0d..b6a2e5b3c58 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -1,5 +1,6 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { getLogger, type LogFileConfig } from "./logger.js"; import { getRootDir, readKeychainCredential, readCredential, readTwilioCredentials, readWhatsAppCredentials, readSlackChannelCredentials } from "./credential-reader.js"; @@ -89,6 +90,8 @@ export type GatewayConfig = { slackDeliverAuthBypass: boolean; /** When true, trust X-Forwarded-For for client IP resolution (set when behind a reverse proxy). */ trustProxy: boolean; + /** Dedicated token for authenticating PATCH /v1/feature-flags/* requests (distinct from runtimeBearerToken). */ + featureFlagToken: string | undefined; }; function parseRoutingJson(raw: string): RoutingEntry[] { @@ -130,6 +133,40 @@ function readHttpTokenFile(): string | null { } } +function getFeatureFlagTokenPath(): string { + return process.env.FEATURE_FLAG_TOKEN_PATH + ?? join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum", "feature-flag-token"); +} + +/** + * Read the feature-flag token from file, generating a new one if the file + * doesn't exist. This follows the same pattern as http-token but uses a + * separate file so the two tokens are independently revocable. + */ +function readOrGenerateFeatureFlagToken(): string | null { + const tokenPath = getFeatureFlagTokenPath(); + try { + const existing = readFileSync(tokenPath, "utf-8").trim(); + if (existing) return existing; + } catch { + // File doesn't exist — generate below + } + + try { + const dir = dirname(tokenPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const token = randomBytes(32).toString("hex"); + writeFileSync(tokenPath, token + "\n", { mode: 0o600 }); + log.info({ path: tokenPath }, "Generated new feature-flag token"); + return token; + } catch (err) { + log.warn({ err, path: tokenPath }, "Failed to generate feature-flag token file"); + return null; + } +} + export function loadConfig(): GatewayConfig { const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN || undefined; const telegramWebhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET || undefined; @@ -201,6 +238,12 @@ export function loadConfig(): GatewayConfig { const runtimeGatewayOriginSecret = process.env.RUNTIME_GATEWAY_ORIGIN_SECRET || runtimeBearerToken; + // Dedicated feature-flag client token: env var takes precedence, then file + // (auto-generated on first run). Intentionally separate from runtimeBearerToken + // so PATCH /v1/feature-flags/* can reject the runtime token. + const featureFlagToken = + process.env.FEATURE_FLAG_TOKEN || readOrGenerateFeatureFlagToken() || undefined; + const MAX_TIMEOUT_MS = 2_147_483_647; // 2^31 - 1, max safe setTimeout delay const shutdownDrainMsRaw = process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "5000"; @@ -467,6 +510,7 @@ export function loadConfig(): GatewayConfig { hasSlackChannelAppToken: !!slackChannelAppToken, slackDeliverAuthBypass, trustProxy, + hasFeatureFlagToken: !!featureFlagToken, }, "Configuration loaded", ); @@ -517,6 +561,7 @@ export function loadConfig(): GatewayConfig { slackChannelAppToken, slackDeliverAuthBypass, trustProxy, + featureFlagToken, }; } diff --git a/gateway/src/feature-flags-auth.test.ts b/gateway/src/feature-flags-auth.test.ts new file mode 100644 index 00000000000..8d4ecbaecde --- /dev/null +++ b/gateway/src/feature-flags-auth.test.ts @@ -0,0 +1,256 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** + * Integration tests for the feature-flags auth split: + * + * - PATCH /v1/feature-flags/:key requires the dedicated feature-flag token + * - PATCH /v1/feature-flags/:key rejects the runtime bearer token with 403 + * - PATCH /v1/feature-flags/:key with no token returns 401 + * - GET /v1/feature-flags accepts the runtime bearer token + * - GET /v1/feature-flags accepts the feature-flag token + */ + +const RUNTIME_TOKEN = "test-runtime-bearer-token"; +const FEATURE_FLAG_TOKEN = "test-feature-flag-client-token"; + +let server: ReturnType; +let baseUrl: string; +let tmpDir: string; + +beforeAll(async () => { + // Create isolated temp directory for config and token files + tmpDir = mkdtempSync(join(tmpdir(), "gw-ff-auth-test-")); + const vellumDir = join(tmpDir, ".vellum"); + mkdirSync(join(vellumDir, "workspace"), { recursive: true }); + + // Write http-token file + writeFileSync(join(vellumDir, "http-token"), RUNTIME_TOKEN); + + // Write feature-flag-token file + writeFileSync(join(vellumDir, "feature-flag-token"), FEATURE_FLAG_TOKEN); + + // Write a minimal config.json so the feature-flags handler can read it + writeFileSync( + join(vellumDir, "workspace", "config.json"), + JSON.stringify({ featureFlags: { "skills.test-skill.enabled": true } }), + ); + + // Set environment so loadConfig picks up our temp directory + process.env.BASE_DATA_DIR = tmpDir; + // Prevent env vars from overriding file-based tokens + delete process.env.RUNTIME_BEARER_TOKEN; + delete process.env.RUNTIME_PROXY_BEARER_TOKEN; + delete process.env.FEATURE_FLAG_TOKEN; + delete process.env.FEATURE_FLAG_TOKEN_PATH; + delete process.env.VELLUM_HTTP_TOKEN_PATH; + + // Import after env is set so loadConfig reads our temp files + const { loadConfig } = await import("./config.js"); + const { validateBearerToken } = await import("./http/auth/bearer.js"); + const { createFeatureFlagsGetHandler, createFeatureFlagsPatchHandler } = + await import("./http/routes/feature-flags.js"); + + const config = loadConfig(); + const handleFeatureFlagsGet = createFeatureFlagsGetHandler(); + const handleFeatureFlagsPatch = createFeatureFlagsPatchHandler(); + + // Verify tokens loaded correctly + if (config.runtimeBearerToken !== RUNTIME_TOKEN) { + throw new Error( + `Expected runtimeBearerToken to be "${RUNTIME_TOKEN}", got "${config.runtimeBearerToken}"`, + ); + } + if (config.featureFlagToken !== FEATURE_FLAG_TOKEN) { + throw new Error( + `Expected featureFlagToken to be "${FEATURE_FLAG_TOKEN}", got "${config.featureFlagToken}"`, + ); + } + + // Start a minimal Bun server that replicates only the feature-flag auth + // routing from index.ts (avoids starting the full gateway with all its + // side effects like Telegram reconciliation and credential watchers). + server = Bun.serve({ + port: 0, // random available port + async fetch(req) { + const url = new URL(req.url); + + // GET /v1/feature-flags — accepts either token + if (url.pathname === "/v1/feature-flags" && req.method === "GET") { + if (!config.runtimeBearerToken && !config.featureFlagToken) { + return Response.json( + { error: "Service not configured: bearer token required" }, + { status: 503 }, + ); + } + const authHeader = req.headers.get("authorization"); + let authorized = false; + if (config.runtimeBearerToken) { + const runtimeAuth = validateBearerToken(authHeader, config.runtimeBearerToken); + if (runtimeAuth.authorized) authorized = true; + } + if (!authorized && config.featureFlagToken) { + const flagAuth = validateBearerToken(authHeader, config.featureFlagToken); + if (flagAuth.authorized) authorized = true; + } + if (!authorized) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + return handleFeatureFlagsGet(req); + } + + // PATCH /v1/feature-flags/:flagKey — requires feature-flag token + const patchMatch = url.pathname.match(/^\/v1\/feature-flags\/(.+)$/); + if (patchMatch && req.method === "PATCH") { + if (!config.featureFlagToken) { + return Response.json( + { error: "Service not configured: feature-flag token required" }, + { status: 503 }, + ); + } + + // Explicitly reject runtime bearer token + if (config.runtimeBearerToken) { + const isRuntimeToken = validateBearerToken( + req.headers.get("authorization"), + config.runtimeBearerToken, + ); + if (isRuntimeToken.authorized) { + return Response.json( + { error: "Forbidden: runtime token cannot be used for feature-flag mutations" }, + { status: 403 }, + ); + } + } + + const authResult = validateBearerToken( + req.headers.get("authorization"), + config.featureFlagToken, + ); + if (!authResult.authorized) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + let flagKey: string; + try { + flagKey = decodeURIComponent(patchMatch[1]); + } catch { + return Response.json({ error: "Invalid flag key encoding" }, { status: 400 }); + } + return handleFeatureFlagsPatch(req, flagKey); + } + + return Response.json({ error: "Not found" }, { status: 404 }); + }, + }); + + baseUrl = `http://localhost:${server.port}`; +}); + +afterAll(() => { + server?.stop(true); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +describe("PATCH /v1/feature-flags/:key auth", () => { + test("rejects request with runtime bearer token (403)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags/skills.my-skill.enabled`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${RUNTIME_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toContain("runtime token"); + }); + + test("succeeds with feature-flag client token", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags/skills.my-skill.enabled`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${FEATURE_FLAG_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.key).toBe("skills.my-skill.enabled"); + expect(body.enabled).toBe(true); + }); + + test("rejects request with no token (401)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags/skills.my-skill.enabled`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: false }), + }); + expect(res.status).toBe(401); + }); + + test("rejects request with wrong token (401)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags/skills.my-skill.enabled`, { + method: "PATCH", + headers: { + Authorization: "Bearer totally-wrong-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: false }), + }); + expect(res.status).toBe(401); + }); +}); + +describe("GET /v1/feature-flags auth", () => { + test("succeeds with runtime bearer token", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags`, { + method: "GET", + headers: { + Authorization: `Bearer ${RUNTIME_TOKEN}`, + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.flags).toBeDefined(); + }); + + test("succeeds with feature-flag client token", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags`, { + method: "GET", + headers: { + Authorization: `Bearer ${FEATURE_FLAG_TOKEN}`, + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.flags).toBeDefined(); + }); + + test("rejects request with no token (401)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags`, { + method: "GET", + }); + expect(res.status).toBe(401); + }); + + test("rejects request with wrong token (401)", async () => { + const res = await fetch(`${baseUrl}/v1/feature-flags`, { + method: "GET", + headers: { + Authorization: "Bearer totally-wrong-token", + }, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/gateway/src/index.ts b/gateway/src/index.ts index f57739fba17..b552b0eaa44 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -116,6 +116,54 @@ function startHttpTokenWatcher(cfg: GatewayConfig): FSWatcher | null { } } +/** + * Watch `~/.vellum/feature-flag-token` and update the config when the file + * changes. Mirrors startHttpTokenWatcher but for the feature-flag client token. + */ +function startFeatureFlagTokenWatcher(cfg: GatewayConfig): FSWatcher | null { + const tokenPath = process.env.FEATURE_FLAG_TOKEN_PATH + ?? join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum", "feature-flag-token"); + + const dir = dirname(tokenPath); + try { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } catch (err) { + log.warn({ err, path: dir }, "Cannot create token directory, skipping feature-flag-token watcher"); + return null; + } + + let debounceTimer: ReturnType | null = null; + + function refresh(): void { + if (process.env.FEATURE_FLAG_TOKEN) return; + + try { + const token = readFileSync(tokenPath, "utf-8").trim() || undefined; + if (token && token !== cfg.featureFlagToken) { + cfg.featureFlagToken = token; + log.info("Feature-flag token refreshed from file"); + } + } catch { + // File doesn't exist yet + } + } + + try { + const watcher = watch(existsSync(tokenPath) ? tokenPath : dir, { persistent: false }, (_event, filename) => { + if (!existsSync(tokenPath) && filename !== "feature-flag-token") return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(refresh, 500); + }); + log.info({ path: tokenPath }, "Watching feature-flag-token for changes"); + return watcher; + } catch (err) { + log.warn({ err, path: tokenPath }, "Failed to watch feature-flag-token file"); + return null; + } +} + function main() { const config = loadConfig(); initLogger(config.logFile); @@ -402,17 +450,24 @@ function main() { // ── Feature flags API ── if (url.pathname === "/v1/feature-flags" && req.method === "GET") { - if (!config.runtimeBearerToken) { + if (!config.runtimeBearerToken && !config.featureFlagToken) { return Response.json( { error: "Service not configured: bearer token required" }, { status: 503 }, ); } - const authResult = validateBearerToken( - tracedReq.headers.get("authorization"), - config.runtimeBearerToken, - ); - if (!authResult.authorized) { + // GET accepts either the runtime bearer token or the feature-flag token + const authHeader = tracedReq.headers.get("authorization"); + let authorized = false; + if (config.runtimeBearerToken) { + const runtimeAuth = validateBearerToken(authHeader, config.runtimeBearerToken); + if (runtimeAuth.authorized) authorized = true; + } + if (!authorized && config.featureFlagToken) { + const flagAuth = validateBearerToken(authHeader, config.featureFlagToken); + if (flagAuth.authorized) authorized = true; + } + if (!authorized) { authRateLimiter.recordFailure(getClientIp(req, svr, config.trustProxy)); return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -421,15 +476,32 @@ function main() { const featureFlagPatchMatch = url.pathname.match(/^\/v1\/feature-flags\/(.+)$/); if (featureFlagPatchMatch && req.method === "PATCH") { - if (!config.runtimeBearerToken) { + if (!config.featureFlagToken) { return Response.json( - { error: "Service not configured: bearer token required" }, + { error: "Service not configured: feature-flag token required" }, { status: 503 }, ); } + + // Explicitly reject the runtime bearer token on PATCH even if it is + // otherwise valid — PATCH requires the dedicated feature-flag token. + // Skip this check if both tokens are identical (allows single-token deployments). + if (config.runtimeBearerToken && config.runtimeBearerToken !== config.featureFlagToken) { + const isRuntimeToken = validateBearerToken( + tracedReq.headers.get("authorization"), + config.runtimeBearerToken, + ); + if (isRuntimeToken.authorized) { + return Response.json( + { error: "Forbidden: runtime token cannot be used for feature-flag mutations" }, + { status: 403 }, + ); + } + } + const authResult = validateBearerToken( tracedReq.headers.get("authorization"), - config.runtimeBearerToken, + config.featureFlagToken, ); if (!authResult.authorized) { authRateLimiter.recordFailure(getClientIp(req, svr, config.trustProxy)); @@ -611,6 +683,7 @@ function main() { configFileWatcher.start(); const httpTokenWatcher = startHttpTokenWatcher(config); + const featureFlagTokenWatcher = startFeatureFlagTokenWatcher(config); const drainMs = config.shutdownDrainMs; @@ -620,6 +693,7 @@ function main() { credentialWatcher.stop(); configFileWatcher.stop(); httpTokenWatcher?.close(); + featureFlagTokenWatcher?.close(); telegramDedupCache.stopCleanup(); smsDedupCache.stopCleanup(); whatsappDedupCache.stopCleanup(); From b2e31c70fe992c6c201cdf7e1120b6f483ca18c1 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 20:24:15 -0500 Subject: [PATCH 4/8] M4: Client token plumbing (macOS/iOS) (#10175) * feat: add feature-flag client token plumbing for macOS/iOS Co-Authored-By: Claude * fix: throw on unavailable transport, always use gateway on macOS, clear stale token Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude --- .../ios/Views/Settings/QRPairingSheet.swift | 17 +++- clients/shared/IPC/DaemonClient.swift | 90 +++++++++++++++++++ clients/shared/IPC/DaemonConfig.swift | 18 +++- clients/shared/IPC/HTTPDaemonClient.swift | 38 ++++++++ 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/clients/ios/Views/Settings/QRPairingSheet.swift b/clients/ios/Views/Settings/QRPairingSheet.swift index 8e01e1304fb..f3dde7074c1 100644 --- a/clients/ios/Views/Settings/QRPairingSheet.swift +++ b/clients/ios/Views/Settings/QRPairingSheet.swift @@ -361,11 +361,13 @@ struct QRPairingSheet: View { return } let localLanUrl = response["localLanUrl"] as? String + let featureFlagToken = response["featureFlagToken"] as? String savePairingConfig( bearerToken: bearerToken, gatewayUrl: gatewayUrl, hostId: payload.hostId, - localLanUrl: localLanUrl + localLanUrl: localLanUrl, + featureFlagToken: featureFlagToken ) connectToMac() @@ -433,11 +435,13 @@ struct QRPairingSheet: View { return } let localLanUrl = json["localLanUrl"] as? String + let featureFlagToken = json["featureFlagToken"] as? String savePairingConfig( bearerToken: bearerToken, gatewayUrl: gatewayUrl, hostId: payload.hostId, - localLanUrl: localLanUrl + localLanUrl: localLanUrl, + featureFlagToken: featureFlagToken ) connectToMac() @@ -462,12 +466,19 @@ struct QRPairingSheet: View { // MARK: - Config Persistence - private func savePairingConfig(bearerToken: String, gatewayUrl: String, hostId: String, localLanUrl: String?) { + private func savePairingConfig(bearerToken: String, gatewayUrl: String, hostId: String, localLanUrl: String?, featureFlagToken: String? = nil) { UserDefaults.standard.set(gatewayUrl, forKey: UserDefaultsKeys.gatewayBaseURL) _ = APIKeyManager.shared.setAPIKey(bearerToken, provider: "runtime-bearer-token") if !hostId.isEmpty { UserDefaults.standard.set(hostId, forKey: "gateway_host_id") } + if let ffToken = featureFlagToken, !ffToken.isEmpty { + _ = APIKeyManager.shared.setAPIKey(ffToken, provider: "feature-flag-token") + } else { + // Clear any stale token from a previous pairing so we don't + // authenticate with an invalid credential on re-pair. + _ = APIKeyManager.shared.deleteAPIKey(provider: "feature-flag-token") + } // Generate conversation key if missing if UserDefaults.standard.string(forKey: UserDefaultsKeys.conversationKey)?.isEmpty != false { diff --git a/clients/shared/IPC/DaemonClient.swift b/clients/shared/IPC/DaemonClient.swift index 0db9cef8a7b..fb55953b52a 100644 --- a/clients/shared/IPC/DaemonClient.swift +++ b/clients/shared/IPC/DaemonClient.swift @@ -80,6 +80,12 @@ public func resolveHttpTokenPath(environment: [String: String]? = nil) -> String return resolveVellumDir(environment: environment) + "/http-token" } +/// Resolve the feature-flag bearer token path. +/// Uses BASE_DATA_DIR when set to match daemon root resolution. +public func resolveFeatureFlagTokenPath(environment: [String: String]? = nil) -> String { + return resolveVellumDir(environment: environment) + "/feature-flag-token" +} + /// Resolve the daemon PID file path, honoring `BASE_DATA_DIR`. public func resolvePidPath(environment: [String: String]? = nil) -> String { return resolveVellumDir(environment: environment) + "/vellum.pid" @@ -104,6 +110,25 @@ public func readHttpToken(environment: [String: String]? = nil) -> String? { return token } +/// Read the feature-flag bearer token from disk. +/// Used to authenticate PATCH /v1/feature-flags/:flagKey requests. +public func readFeatureFlagToken(environment: [String: String]? = nil) -> String? { + let tokenPath = resolveFeatureFlagTokenPath(environment: environment) + let data: Data + do { + data = try Data(contentsOf: URL(fileURLWithPath: tokenPath)) + } catch { + log.error("Failed to read feature-flag token from \(tokenPath, privacy: .private): \(error)") + return nil + } + guard let token = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty else { + return nil + } + return token +} + /// Protocol for daemon client communication, enabling dependency injection and testing. @MainActor public protocol DaemonClientProtocol { @@ -1506,4 +1531,69 @@ public final class DaemonClient: ObservableObject, DaemonClientProtocol { try send(ApprovedDevicesClearMessage()) } + // MARK: - Feature Flags + + /// Toggle a feature flag via the gateway's PATCH /v1/feature-flags/:flagKey endpoint. + /// Uses the dedicated feature-flag token (not the runtime bearer token) for auth. + /// + /// On macOS, always calls the local gateway directly (port 7830) because + /// `httpTransport` may point at the runtime HTTP server (port 7821) when + /// `localHttpEnabled` is active — the runtime doesn't serve feature-flag routes. + /// On iOS, delegates to `httpTransport` which targets the remote gateway. + public func setFeatureFlag(key: String, enabled: Bool) async throws { + guard let token = config.featureFlagToken, !token.isEmpty else { + throw FeatureFlagError.missingToken + } + + #if os(macOS) + // Always call the gateway directly on macOS. httpTransport may target the + // runtime (localHttpEnabled), which doesn't have the feature-flag route. + let gatewayPort = ProcessInfo.processInfo.environment["GATEWAY_PORT"] + .flatMap(Int.init) ?? 7830 + let baseURL = "http://127.0.0.1:\(gatewayPort)" + let encoded = key.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? key + guard let url = URL(string: "\(baseURL)/v1/feature-flags/\(encoded)") else { + throw FeatureFlagError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 10 + + let body: [String: Any] = ["enabled": enabled] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw FeatureFlagError.requestFailed(statusCode) + } + #else + // iOS: httpTransport targets the remote gateway, which serves feature-flag routes. + guard let httpTransport else { + throw FeatureFlagError.requestFailed(0) + } + try await httpTransport.setFeatureFlag(key: key, enabled: enabled, featureFlagToken: token) + #endif + } + + public enum FeatureFlagError: Error, LocalizedError { + case missingToken + case invalidURL + case requestFailed(Int) + + public var errorDescription: String? { + switch self { + case .missingToken: + return "Feature-flag token not available" + case .invalidURL: + return "Invalid feature-flag endpoint URL" + case .requestFailed(let code): + return "Feature-flag request failed (HTTP \(code))" + } + } + } + } diff --git a/clients/shared/IPC/DaemonConfig.swift b/clients/shared/IPC/DaemonConfig.swift index 23ab182cded..b9e5906a122 100644 --- a/clients/shared/IPC/DaemonConfig.swift +++ b/clients/shared/IPC/DaemonConfig.swift @@ -50,6 +50,11 @@ public struct DaemonConfig { public let transport: Transport + /// Feature-flag bearer token for authenticating PATCH /v1/feature-flags/:flagKey requests. + /// On macOS this is read from `~/.vellum/feature-flag-token`. + /// On iOS this is received during QR pairing and stored in the Keychain. + public let featureFlagToken: String? + #if os(macOS) /// Socket path, for backwards compatibility. /// Returns the socket path if using socket transport, otherwise the default path. @@ -65,6 +70,7 @@ public struct DaemonConfig { /// Convenience initializer for socket transport (backwards compatible). public init(socketPath: String) { self.transport = .socket(path: socketPath) + self.featureFlagToken = readFeatureFlagToken() } public static var `default`: DaemonConfig { @@ -72,8 +78,13 @@ public struct DaemonConfig { } #endif - public init(transport: Transport) { + public init(transport: Transport, featureFlagToken: String? = nil) { self.transport = transport + #if os(macOS) + self.featureFlagToken = featureFlagToken ?? readFeatureFlagToken() + #else + self.featureFlagToken = featureFlagToken + #endif } #if os(iOS) @@ -90,6 +101,7 @@ public struct DaemonConfig { public static func fromUserDefaults() -> DaemonConfig { // gateway_base_url is set by QR pairing (v4). let httpBaseURL = UserDefaults.standard.string(forKey: "gateway_base_url").flatMap { $0.isEmpty ? nil : $0 } + let featureFlagToken = APIKeyManager.shared.getAPIKey(provider: "feature-flag-token") if let baseURL = httpBaseURL { let bearerToken = APIKeyManager.shared.getAPIKey(provider: "runtime-bearer-token") let conversationKey: String @@ -99,12 +111,12 @@ public struct DaemonConfig { conversationKey = UUID().uuidString UserDefaults.standard.set(conversationKey, forKey: "conversation_key") } - return DaemonConfig(transport: .http(baseURL: baseURL, bearerToken: bearerToken, conversationKey: conversationKey)) + return DaemonConfig(transport: .http(baseURL: baseURL, bearerToken: bearerToken, conversationKey: conversationKey), featureFlagToken: featureFlagToken) } // No gateway URL configured — return a placeholder HTTP config that won't connect. // The user needs to pair via QR code (which sets gateway_base_url) before connecting. - return DaemonConfig(transport: .http(baseURL: "", bearerToken: nil, conversationKey: "")) + return DaemonConfig(transport: .http(baseURL: "", bearerToken: nil, conversationKey: ""), featureFlagToken: featureFlagToken) } #endif } diff --git a/clients/shared/IPC/HTTPDaemonClient.swift b/clients/shared/IPC/HTTPDaemonClient.swift index 5f8c98ac3c5..f2c9b0e6bd3 100644 --- a/clients/shared/IPC/HTTPDaemonClient.swift +++ b/clients/shared/IPC/HTTPDaemonClient.swift @@ -589,6 +589,44 @@ final class HTTPTransport { } } + // MARK: - Feature Flags + + /// Toggle a feature flag via the gateway's PATCH endpoint. + /// Uses the dedicated feature-flag token (not the runtime bearer token) for auth. + func setFeatureFlag(key: String, enabled: Bool, featureFlagToken: String) async throws { + let encoded = key.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? key + guard let url = URL(string: "\(baseURL)/v1/feature-flags/\(encoded)") else { + throw HTTPTransportError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(featureFlagToken)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 10 + + let body: [String: Any] = ["enabled": enabled] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw HTTPTransportError.healthCheckFailed + } + + if http.statusCode == 401 { + log.error("Feature flag PATCH failed: authentication error (401)") + throw HTTPTransportError.healthCheckFailed + } + + guard (200..<300).contains(http.statusCode) else { + let errorBody = String(data: data, encoding: .utf8) ?? "unknown" + log.error("Feature flag PATCH failed (\(http.statusCode)): \(errorBody)") + throw HTTPTransportError.healthCheckFailed + } + + log.info("Feature flag '\(key)' set to \(enabled)") + } + // MARK: - Remote Identity /// Fetch identity info from the remote daemon's `GET /v1/identity` endpoint. From 6b0d7b07fd5ba7826dc975cdb3e042f90737beaa Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 20:31:20 -0500 Subject: [PATCH 5/8] docs: add feature-flags architecture documentation (#10181) Co-authored-by: Claude --- ARCHITECTURE.md | 2 ++ assistant/ARCHITECTURE.md | 31 ++++++++++++++++++++++++++++++- gateway/ARCHITECTURE.md | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce2fa1be885..7817b43a146 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,6 +24,7 @@ This file is the cross-system architecture index. Detailed designs live in domai - Notification producers emit through `emitNotificationSignal()` to preserve decisioning and audit invariants. Reminder routing metadata (`routingIntent`, `routingHints`) flows through the signal and is enforced post-decision to control multi-channel fanout. The decision engine produces per-channel thread actions (`start_new` / `reuse_existing`) validated against a candidate set; `notification_thread_created` IPC is emitted only on actual creation, not on reuse. - Memory extraction/recall must enforce actor-role provenance gates for untrusted actors. - Trusted contact ingress ACL is channel-agnostic; identity binding adapts per channel (chat ID, E.164 phone, external user ID) without channel-specific branching. +- Feature flags (`featureFlags` in workspace config) control skill availability. When a skill's flag is OFF, it is excluded from all exposure surfaces: client skill lists, system prompt catalog, `skill_load`, runtime tool projection, and included child skills. The gateway owns the `/v1/feature-flags` REST API; the daemon reads flags from config at each enforcement point. ## System Overview @@ -221,6 +222,7 @@ graph TB GW_SLACK_DELIVER["Slack Deliver
/deliver/slack
(internal, from runtime)"] GW_OAUTH["OAuth Callback
/webhooks/oauth/callback"] GW_PROXY["Runtime Proxy
(optional, bearer auth)"] + GW_FEATURE_FLAGS["Feature Flags API
GET /v1/feature-flags
PATCH /v1/feature-flags/:key"] GW_PROBES["/healthz + /readyz
k8s liveness/readiness"] end diff --git a/assistant/ARCHITECTURE.md b/assistant/ARCHITECTURE.md index 9040d2d7050..40f9fbce301 100644 --- a/assistant/ARCHITECTURE.md +++ b/assistant/ARCHITECTURE.md @@ -305,6 +305,34 @@ Release-driven update notification system that surfaces release notes to the ass --- +### Skill Feature Flags — Enforcement Points + +Feature flags allow external clients to disable individual skills at runtime without restarting the daemon. Flags are stored in `~/.vellum/workspace/config.json` under the `featureFlags` key (managed by the gateway's `/v1/feature-flags` API — see [`gateway/ARCHITECTURE.md`](../gateway/ARCHITECTURE.md)). The daemon's config watcher hot-reloads this file, so flag changes take effect on the next tool resolution or session. + +**Flag key format:** `skills..enabled`. A missing key defaults to enabled; only an explicit `false` disables a skill. + +**Guarantee:** When a skill's feature flag is OFF, the skill is unavailable everywhere — it cannot appear in client UIs, model context, or runtime tool execution. This is enforced at five independent points: + +| Enforcement Point | Module | Effect | +|-------------------|--------|--------| +| **1. Client skill list** | `resolveSkillStates()` in `config/skill-state.ts` | Skills with flag OFF are excluded from the resolved list returned to IPC clients (macOS skill list, settings UI). The skill never appears in the client. | +| **2. System prompt skill catalog** | `appendSkillsCatalog()` in `config/system-prompt.ts` | The model-visible `## Skills Catalog` section in the system prompt filters out flagged-off skills. The model cannot see or reference them. | +| **3. `skill_load` tool** | `executeSkillLoad()` in `tools/skills/load.ts` | If the model attempts to load a flagged-off skill by name, the tool returns an error: `"skill is currently unavailable (disabled by feature flag)"`. | +| **4. Runtime tool projection** | `projectSkillTools()` in `daemon/session-skill-tools.ts` | Even if a skill was previously active in a session (has `` markers in history), the per-turn projection drops it when the flag is OFF. Already-registered tools are unregistered. | +| **5. Included child skills** | `executeSkillLoad()` in `tools/skills/load.ts` | When a parent skill includes children via the `includes` directive, each child is independently checked against its feature flag. Flagged-off children are silently excluded from the loaded skill content. | + +The shared gate function `isSkillFeatureEnabled(skillId, config)` in `config/skill-state.ts` is used by all five enforcement points for consistency. + +**Key source files:** + +| File | Purpose | +|------|---------| +| `src/config/skill-state.ts` | `isSkillFeatureEnabled()` — shared gate function; `resolveSkillStates()` — enforcement point 1 | +| `src/config/system-prompt.ts` | `appendSkillsCatalog()` — enforcement point 2 | +| `src/tools/skills/load.ts` | `executeSkillLoad()` — enforcement points 3 and 5 | +| `src/daemon/session-skill-tools.ts` | `projectSkillTools()` — enforcement point 4 | +| `src/config/schema.ts` | `featureFlags` field definition in `AssistantConfig` (Zod schema) | +| `src/daemon/handlers/skills.ts` | `handleSkillsList()` — uses `resolveSkillStates()` for IPC client responses | --- @@ -362,10 +390,11 @@ graph LR subgraph "~/.vellum/ (Root Files)" SOCK["vellum.sock
Unix domain socket"] TRUST["protected/trust.json
Tool permission rules"] + FF_TOKEN["feature-flag-token
Dedicated auth for PATCH /v1/feature-flags"] end subgraph "~/.vellum/workspace/ (Workspace Files)" - CONFIG["config files
Hot-reloaded by daemon"] + CONFIG["config files
Hot-reloaded by daemon
(includes featureFlags)"] ONBOARD_PLAYBOOKS["onboarding/playbooks/
[channel]_onboarding.md
assistant-updatable checklists"] ONBOARD_REGISTRY["onboarding/playbooks/registry.json
channel-start index for fast-path + reconciliation"] APPS_STORE["data/apps/
.json + pages/*.html
prebuilt Home Base seeded here"] diff --git a/gateway/ARCHITECTURE.md b/gateway/ARCHITECTURE.md index 60011752797..57ca8b089d9 100644 --- a/gateway/ARCHITECTURE.md +++ b/gateway/ARCHITECTURE.md @@ -29,6 +29,40 @@ Internet +-- /webhooks/* --> BLOCKED (404, never forwarded to runtime) ``` +### Feature Flags API + +The gateway exposes a REST API for reading and mutating feature flags. Feature flags control which skills are available to the assistant — when a flag is OFF, the corresponding skill is excluded from every exposure surface in the assistant (see [`assistant/ARCHITECTURE.md`](../assistant/ARCHITECTURE.md) for enforcement points). + +**Endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/feature-flags` | List all feature flags from workspace config | +| PATCH | `/v1/feature-flags/:key` | Set a single feature flag. Body: `{ "enabled": true\|false }` | + +**Storage:** Flags are persisted in `~/.vellum/workspace/config.json` under the `featureFlags` key as a `Record`. The gateway reads and writes this file directly (atomic temp + rename for writes). The daemon's config watcher hot-reloads changes, so flag mutations take effect on the next session or tool resolution without a restart. + +**Flag key format:** Only keys matching `skills..enabled` are accepted for the initial rollout. Other key patterns are rejected with 400. + +**Authentication boundary:** + +The feature-flags API uses a dedicated token stored at `~/.vellum/feature-flag-token`, separate from the runtime bearer token (`~/.vellum/http-token`). This separation ensures that clients with feature-flag access cannot access runtime endpoints, and vice versa. + +| Operation | Accepted tokens | +|-----------|----------------| +| `GET /v1/feature-flags` | Runtime bearer token OR feature-flag token | +| `PATCH /v1/feature-flags/:key` | Feature-flag token ONLY (runtime token is explicitly rejected) | + +The feature-flag token is auto-generated on first gateway startup if the file does not exist. The gateway watches the token file for changes and hot-reloads without restart. + +**Key source files:** + +| File | Purpose | +|------|---------| +| `gateway/src/http/routes/feature-flags.ts` | GET and PATCH handlers; config read/write logic | +| `gateway/src/config.ts` | `readOrGenerateFeatureFlagToken()` — token provisioning; `featureFlagToken` config field | +| `gateway/src/index.ts` | Route registration, auth enforcement (dual-token for GET, flag-token-only for PATCH), token file watcher | + ### Channel Binding Lifecycle (Lane Separation) Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop thread list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering sessions during desktop session restoration. From e2a74b21e189d56f9f54c65303848ff0bb541285 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 20:40:56 -0500 Subject: [PATCH 6/8] fix: use active transport for macOS remote mode, preserve token on re-pair Co-Authored-By: Claude Opus 4.6 --- .../ios/Views/Settings/QRPairingSheet.swift | 6 ++---- clients/shared/IPC/DaemonClient.swift | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/clients/ios/Views/Settings/QRPairingSheet.swift b/clients/ios/Views/Settings/QRPairingSheet.swift index f3dde7074c1..2858f8c804d 100644 --- a/clients/ios/Views/Settings/QRPairingSheet.swift +++ b/clients/ios/Views/Settings/QRPairingSheet.swift @@ -474,11 +474,9 @@ struct QRPairingSheet: View { } if let ffToken = featureFlagToken, !ffToken.isEmpty { _ = APIKeyManager.shared.setAPIKey(ffToken, provider: "feature-flag-token") - } else { - // Clear any stale token from a previous pairing so we don't - // authenticate with an invalid credential on re-pair. - _ = APIKeyManager.shared.deleteAPIKey(provider: "feature-flag-token") } + // Don't delete on absence — the server may not send featureFlagToken yet, + // so a missing field shouldn't wipe a previously stored token. // Generate conversation key if missing if UserDefaults.standard.string(forKey: UserDefaultsKeys.conversationKey)?.isEmpty != false { diff --git a/clients/shared/IPC/DaemonClient.swift b/clients/shared/IPC/DaemonClient.swift index fb55953b52a..fdbb5090c4a 100644 --- a/clients/shared/IPC/DaemonClient.swift +++ b/clients/shared/IPC/DaemonClient.swift @@ -1536,18 +1536,26 @@ public final class DaemonClient: ObservableObject, DaemonClientProtocol { /// Toggle a feature flag via the gateway's PATCH /v1/feature-flags/:flagKey endpoint. /// Uses the dedicated feature-flag token (not the runtime bearer token) for auth. /// - /// On macOS, always calls the local gateway directly (port 7830) because - /// `httpTransport` may point at the runtime HTTP server (port 7821) when - /// `localHttpEnabled` is active — the runtime doesn't serve feature-flag routes. - /// On iOS, delegates to `httpTransport` which targets the remote gateway. + /// On macOS in remote mode (`httpTransport` is set), delegates to `httpTransport` + /// which targets the remote gateway. In local mode (`httpTransport` is nil), calls + /// the local gateway directly (port 7830) because the runtime HTTP server doesn't + /// serve feature-flag routes. + /// On iOS, always delegates to `httpTransport` which targets the remote gateway. public func setFeatureFlag(key: String, enabled: Bool) async throws { guard let token = config.featureFlagToken, !token.isEmpty else { throw FeatureFlagError.missingToken } #if os(macOS) - // Always call the gateway directly on macOS. httpTransport may target the - // runtime (localHttpEnabled), which doesn't have the feature-flag route. + // Remote mode: httpTransport targets the remote gateway, which serves + // feature-flag routes. Use it directly instead of hitting localhost. + if let httpTransport = self.httpTransport { + try await httpTransport.setFeatureFlag(key: key, enabled: enabled, featureFlagToken: token) + return + } + + // Local mode: call the gateway directly. The runtime HTTP server + // (localHttpEnabled) doesn't have the feature-flag route. let gatewayPort = ProcessInfo.processInfo.environment["GATEWAY_PORT"] .flatMap(Int.init) ?? 7830 let baseURL = "http://127.0.0.1:\(gatewayPort)" From cdcb35d1b580faf980f1d037fe8a88fa8b8b6bc2 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 21:27:20 -0500 Subject: [PATCH 7/8] fix: address review feedback on final PR #10183 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. [P1] Fix gateway type-check: remove dead `not_found` variant from ConfigReadResult union (fixes narrowing bug on `result.detail`), make `featureFlagToken` optional in GatewayConfig type. 2. [P1] Add featureFlagToken to pairing responses: runtime now reads ~/.vellum/feature-flag-token and includes it in approval responses so iOS receives the token during QR pairing. 3. [P1] Fix PATCH auth bypass when tokens equal: loadConfig() now detects token collision and regenerates the feature-flag token to ensure the auth split is always enforceable. 4. [P2] Gate guardian-verification section on feature flags: system prompt skips buildGuardianVerificationRoutingSection() when guardian-verify-setup skill flag is OFF. 5. [P2] Fix failing integration test: removed incorrect assertion that disappears when only browser/twitter flags are OFF (bundled skills remain visible). 6. [P2] Fix macOS local HTTP transport: setFeatureFlag now checks if httpTransport.baseURL is localhost before delegating — local HTTP mode (localHttpEnabled) falls through to the direct gateway call. 7. [P2] Accept dotted skill IDs in feature-flag keys: updated regex from [a-z0-9_-]+ to [a-z0-9][a-z0-9._-]* to match managed skill ID validation. --- .../skill-feature-flags-integration.test.ts | 4 +- assistant/src/config/system-prompt.ts | 5 ++- assistant/src/runtime/http-server.ts | 15 ++++++- .../src/runtime/routes/pairing-routes.ts | 4 ++ clients/shared/IPC/DaemonClient.swift | 27 ++++++++----- .../src/__tests__/feature-flags-route.test.ts | 1 + gateway/src/config.ts | 39 ++++++++++++++++++- gateway/src/http/routes/feature-flags.ts | 5 +-- gateway/src/index.ts | 4 +- 9 files changed, 85 insertions(+), 19 deletions(-) diff --git a/assistant/src/__tests__/skill-feature-flags-integration.test.ts b/assistant/src/__tests__/skill-feature-flags-integration.test.ts index dc59a1b8463..825c72849fe 100644 --- a/assistant/src/__tests__/skill-feature-flags-integration.test.ts +++ b/assistant/src/__tests__/skill-feature-flags-integration.test.ts @@ -147,7 +147,7 @@ describe('buildSystemPrompt feature flag filtering', () => { expect(result).toContain('id="twitter"'); }); - test('all skills hidden when all flags are OFF', () => { + test('flagged-off skills hidden even when all workspace skill flags are OFF', () => { createSkillOnDisk('browser', 'Browser', 'Web browsing automation'); createSkillOnDisk('twitter', 'Twitter', 'Post to X/Twitter'); @@ -161,7 +161,7 @@ describe('buildSystemPrompt feature flag filtering', () => { const result = buildSystemPrompt(); - expect(result).not.toContain(''); + // browser and twitter should be hidden; bundled skills may still appear expect(result).not.toContain('id="browser"'); expect(result).not.toContain('id="twitter"'); }); diff --git a/assistant/src/config/system-prompt.ts b/assistant/src/config/system-prompt.ts index 4bf2e3f5384..7b9ea33b569 100644 --- a/assistant/src/config/system-prompt.ts +++ b/assistant/src/config/system-prompt.ts @@ -141,9 +141,12 @@ export function buildSystemPrompt(tier: ResponseTier = 'high'): string { // ── Extended sections (medium + high) ── if (tier !== 'low') { + const config = getConfig(); parts.push(buildToolPermissionSection()); parts.push(buildTaskScheduleReminderRoutingSection()); - parts.push(buildGuardianVerificationRoutingSection()); + if (isSkillFeatureEnabled('guardian-verify-setup', config)) { + parts.push(buildGuardianVerificationRoutingSection()); + } parts.push(buildAttachmentSection()); parts.push(buildInChatConfigurationSection()); parts.push(buildChannelCommandIntentSection()); diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index d166fe347c4..c7fa6bde3da 100644 --- a/assistant/src/runtime/http-server.ts +++ b/assistant/src/runtime/http-server.ts @@ -6,7 +6,7 @@ */ import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import type { ServerWebSocket } from 'bun'; @@ -237,11 +237,24 @@ export class RuntimeHttpServer { this.pairingBroadcast = fn; } + /** Read the feature-flag client token from disk so it can be included in pairing approval responses. */ + private readFeatureFlagToken(): string | undefined { + try { + const baseDir = process.env.BASE_DATA_DIR?.trim() || require('node:os').homedir(); + const tokenPath = join(baseDir, '.vellum', 'feature-flag-token'); + const token = readFileSync(tokenPath, 'utf-8').trim(); + return token || undefined; + } catch { + return undefined; + } + } + private get pairingContext(): PairingHandlerContext { const ipcBroadcast = this.pairingBroadcast; return { pairingStore: this.pairingStore, bearerToken: this.bearerToken, + featureFlagToken: this.readFeatureFlagToken(), pairingBroadcast: ipcBroadcast ? (msg) => { // Broadcast to IPC socket clients (local Unix socket) diff --git a/assistant/src/runtime/routes/pairing-routes.ts b/assistant/src/runtime/routes/pairing-routes.ts index 5a583e4c4c3..c0465e70415 100644 --- a/assistant/src/runtime/routes/pairing-routes.ts +++ b/assistant/src/runtime/routes/pairing-routes.ts @@ -17,6 +17,8 @@ const log = getLogger('runtime-http'); export interface PairingHandlerContext { pairingStore: PairingStore; bearerToken: string | undefined; + /** Feature-flag client token to include in pairing approval responses so iOS can PATCH flags. */ + featureFlagToken: string | undefined; pairingBroadcast?: (msg: ServerMessage) => void; } @@ -90,6 +92,7 @@ export async function handlePairingRequest(req: Request, ctx: PairingHandlerCont bearerToken: ctx.bearerToken, gatewayUrl: entry.gatewayUrl, localLanUrl: entry.localLanUrl, + ...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}), }); } @@ -138,6 +141,7 @@ export function handlePairingStatus(url: URL, ctx: PairingHandlerContext): Respo bearerToken: entry.bearerToken, gatewayUrl: entry.gatewayUrl, localLanUrl: entry.localLanUrl, + ...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}), }); } diff --git a/clients/shared/IPC/DaemonClient.swift b/clients/shared/IPC/DaemonClient.swift index fdbb5090c4a..ca432677d46 100644 --- a/clients/shared/IPC/DaemonClient.swift +++ b/clients/shared/IPC/DaemonClient.swift @@ -1536,10 +1536,10 @@ public final class DaemonClient: ObservableObject, DaemonClientProtocol { /// Toggle a feature flag via the gateway's PATCH /v1/feature-flags/:flagKey endpoint. /// Uses the dedicated feature-flag token (not the runtime bearer token) for auth. /// - /// On macOS in remote mode (`httpTransport` is set), delegates to `httpTransport` - /// which targets the remote gateway. In local mode (`httpTransport` is nil), calls - /// the local gateway directly (port 7830) because the runtime HTTP server doesn't - /// serve feature-flag routes. + /// On macOS: if `httpTransport` targets a **remote** gateway (non-localhost baseURL), + /// delegates to it. Otherwise (socket transport or local HTTP via `localHttpEnabled`), + /// calls the local gateway directly on port 7830 because the runtime HTTP server + /// doesn't serve feature-flag routes. /// On iOS, always delegates to `httpTransport` which targets the remote gateway. public func setFeatureFlag(key: String, enabled: Bool) async throws { guard let token = config.featureFlagToken, !token.isEmpty else { @@ -1547,15 +1547,16 @@ public final class DaemonClient: ObservableObject, DaemonClientProtocol { } #if os(macOS) - // Remote mode: httpTransport targets the remote gateway, which serves - // feature-flag routes. Use it directly instead of hitting localhost. - if let httpTransport = self.httpTransport { + // Remote mode: httpTransport targets a non-local gateway (e.g. cloud). + // Delegate to it directly. When localHttpEnabled is on, httpTransport + // points at localhost (the runtime), which does NOT serve feature-flag + // routes — so we must fall through to the local gateway path below. + if let httpTransport = self.httpTransport, !Self.isLocalBaseURL(httpTransport.baseURL) { try await httpTransport.setFeatureFlag(key: key, enabled: enabled, featureFlagToken: token) return } - // Local mode: call the gateway directly. The runtime HTTP server - // (localHttpEnabled) doesn't have the feature-flag route. + // Local mode (socket, TCP, or local HTTP): call the gateway directly. let gatewayPort = ProcessInfo.processInfo.environment["GATEWAY_PORT"] .flatMap(Int.init) ?? 7830 let baseURL = "http://127.0.0.1:\(gatewayPort)" @@ -1587,6 +1588,14 @@ public final class DaemonClient: ObservableObject, DaemonClientProtocol { #endif } + /// Returns true if the given base URL points to localhost / 127.0.0.1. + private static func isLocalBaseURL(_ urlString: String) -> Bool { + guard let comps = URLComponents(string: urlString), let host = comps.host?.lowercased() else { + return false + } + return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" + } + public enum FeatureFlagError: Error, LocalizedError { case missingToken case invalidURL diff --git a/gateway/src/__tests__/feature-flags-route.test.ts b/gateway/src/__tests__/feature-flags-route.test.ts index a4dceb18dcf..0ae4f9f07ac 100644 --- a/gateway/src/__tests__/feature-flags-route.test.ts +++ b/gateway/src/__tests__/feature-flags-route.test.ts @@ -256,6 +256,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => { "skills.my-skill.enabled", "skills.my_skill.enabled", "skills.skill123.enabled", + "skills.my.dotted.skill.enabled", ]; for (const key of validKeys) { diff --git a/gateway/src/config.ts b/gateway/src/config.ts index b6a2e5b3c58..4dc45dca221 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -91,7 +91,7 @@ export type GatewayConfig = { /** When true, trust X-Forwarded-For for client IP resolution (set when behind a reverse proxy). */ trustProxy: boolean; /** Dedicated token for authenticating PATCH /v1/feature-flags/* requests (distinct from runtimeBearerToken). */ - featureFlagToken: string | undefined; + featureFlagToken?: string | undefined; }; function parseRoutingJson(raw: string): RoutingEntry[] { @@ -143,6 +143,24 @@ function getFeatureFlagTokenPath(): string { * doesn't exist. This follows the same pattern as http-token but uses a * separate file so the two tokens are independently revocable. */ +/** Force-generate a fresh feature-flag token, overwriting any existing file. */ +function regenerateFeatureFlagToken(): string | null { + const tokenPath = getFeatureFlagTokenPath(); + try { + const dir = dirname(tokenPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const token = randomBytes(32).toString("hex"); + writeFileSync(tokenPath, token + "\n", { mode: 0o600 }); + log.info({ path: tokenPath }, "Regenerated feature-flag token to resolve collision"); + return token; + } catch (err) { + log.warn({ err, path: tokenPath }, "Failed to regenerate feature-flag token file"); + return null; + } +} + function readOrGenerateFeatureFlagToken(): string | null { const tokenPath = getFeatureFlagTokenPath(); try { @@ -241,9 +259,26 @@ export function loadConfig(): GatewayConfig { // Dedicated feature-flag client token: env var takes precedence, then file // (auto-generated on first run). Intentionally separate from runtimeBearerToken // so PATCH /v1/feature-flags/* can reject the runtime token. - const featureFlagToken = + let featureFlagToken: string | undefined = process.env.FEATURE_FLAG_TOKEN || readOrGenerateFeatureFlagToken() || undefined; + // Guard: if the feature-flag token collides with the runtime bearer token, + // the PATCH auth split is unenforceable. Regenerate the feature-flag token + // unless it was explicitly pinned via env var. + if (featureFlagToken && runtimeBearerToken && featureFlagToken === runtimeBearerToken) { + if (process.env.FEATURE_FLAG_TOKEN) { + log.warn("FEATURE_FLAG_TOKEN equals RUNTIME_BEARER_TOKEN — PATCH auth split is degraded"); + } else { + log.warn("Feature-flag token collides with runtime bearer token — regenerating"); + const newToken = regenerateFeatureFlagToken(); + if (newToken) { + featureFlagToken = newToken; + } else { + log.error("Failed to regenerate feature-flag token; PATCH auth split is degraded"); + } + } + } + const MAX_TIMEOUT_MS = 2_147_483_647; // 2^31 - 1, max safe setTimeout delay const shutdownDrainMsRaw = process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "5000"; diff --git a/gateway/src/http/routes/feature-flags.ts b/gateway/src/http/routes/feature-flags.ts index 80e3caf1437..b700dce014b 100644 --- a/gateway/src/http/routes/feature-flags.ts +++ b/gateway/src/http/routes/feature-flags.ts @@ -9,9 +9,9 @@ const log = getLogger("feature-flags"); /** * Only allow keys matching `skills..enabled` for the initial rollout. * The skillId segment must be a non-empty string of lowercase alphanumeric chars, - * hyphens, and underscores. + * dots, hyphens, and underscores (matching managed skill ID validation). */ -const ALLOWED_KEY_RE = /^skills\.[a-z0-9_-]+\.enabled$/; +const ALLOWED_KEY_RE = /^skills\.[a-z0-9][a-z0-9._-]*\.enabled$/; function getConfigPath(): string { return join(getRootDir(), "workspace", "config.json"); @@ -19,7 +19,6 @@ function getConfigPath(): string { type ConfigReadResult = | { ok: true; data: Record } - | { ok: false; reason: "not_found" } | { ok: false; reason: "malformed"; detail: string }; function readConfigFile(): ConfigReadResult { diff --git a/gateway/src/index.ts b/gateway/src/index.ts index b552b0eaa44..effcdc25f81 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -485,7 +485,9 @@ function main() { // Explicitly reject the runtime bearer token on PATCH even if it is // otherwise valid — PATCH requires the dedicated feature-flag token. - // Skip this check if both tokens are identical (allows single-token deployments). + // The !== guard handles the (unlikely) edge case where both tokens are + // equal due to explicit env-var override — loadConfig() logs a warning + // and regenerates in the normal case to prevent collision. if (config.runtimeBearerToken && config.runtimeBearerToken !== config.featureFlagToken) { const isRuntimeToken = validateBearerToken( tracedReq.headers.get("authorization"), From 5ca50281e2664b869c080be4cf443fbec1eb1a95 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Thu, 26 Feb 2026 21:29:05 -0500 Subject: [PATCH 8/8] Migrate hatch visibility to skill flag and remove deprecated client flags --- .../MainWindow/PanelCoordinator.swift | 3 -- .../Settings/SettingsAccountTab.swift | 20 ++++++++++- clients/shared/Features/DemoOverlayView.swift | 35 ------------------- .../shared/Utilities/FeatureFlagManager.swift | 6 ---- 4 files changed, 19 insertions(+), 45 deletions(-) delete mode 100644 clients/shared/Features/DemoOverlayView.swift diff --git a/clients/macos/vellum-assistant/Features/MainWindow/PanelCoordinator.swift b/clients/macos/vellum-assistant/Features/MainWindow/PanelCoordinator.swift index 22141413a23..5901feb66bc 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/PanelCoordinator.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/PanelCoordinator.swift @@ -474,9 +474,6 @@ extension MainWindowView { } } .animation(VAnimation.fast, value: conversationZoomManager.showZoomIndicator) - .overlay(alignment: .bottomTrailing) { - DemoOverlayView() - } } } diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsAccountTab.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsAccountTab.swift index 72885c143dd..c1ac8581d09 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsAccountTab.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsAccountTab.swift @@ -24,6 +24,11 @@ struct SettingsAccountTab: View { @State private var devModeTapCount: Int = 0 @State private var devModeMessage: String? + private static let hatchNewAssistantSkillFlagKeys: [String] = [ + "skills.hatch_new_assistant.enabled", + "skills.hatch-new-assistant.enabled" + ] + var body: some View { VStack(alignment: .leading, spacing: VSpacing.xl) { accountSection @@ -383,9 +388,22 @@ struct SettingsAccountTab: View { // MARK: - Hatch New Assistant + private var isHatchNewAssistantEnabled: Bool { + let config = WorkspaceConfigIO.read() + guard let featureFlags = config["featureFlags"] as? [String: Any] else { + return true + } + for key in Self.hatchNewAssistantSkillFlagKeys { + if let enabled = featureFlags[key] as? Bool { + return enabled + } + } + return true + } + @ViewBuilder private var hatchNewAssistantSection: some View { - if FeatureFlagManager.shared.isEnabled(.hatchNewAssistantEnabled) { + if isHatchNewAssistantEnabled { VStack(alignment: .leading, spacing: VSpacing.md) { Text("Hatch New Assistant") .font(VFont.sectionTitle) diff --git a/clients/shared/Features/DemoOverlayView.swift b/clients/shared/Features/DemoOverlayView.swift deleted file mode 100644 index 045f7e082b8..00000000000 --- a/clients/shared/Features/DemoOverlayView.swift +++ /dev/null @@ -1,35 +0,0 @@ -import SwiftUI - -public struct DemoOverlayView: View { - @State private var isHovered = false - - public init() {} - - public var body: some View { - if FeatureFlagManager.shared.isEnabled(.demo) { - HStack(spacing: VSpacing.sm) { - Circle() - .fill(Emerald._500) - .frame(width: 8, height: 8) - Text("Demo Mode") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(VColor.textPrimary) - } - .padding(.horizontal, VSpacing.md) - .padding(.vertical, VSpacing.xs) - .background(VColor.surface.opacity(isHovered ? 1 : 0.85)) - .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.surfaceBorder, lineWidth: 1) - ) - .vShadow(VShadow.sm) - .onHover { hovering in - withAnimation(VAnimation.fast) { - isHovered = hovering - } - } - .padding(VSpacing.md) - } - } -} diff --git a/clients/shared/Utilities/FeatureFlagManager.swift b/clients/shared/Utilities/FeatureFlagManager.swift index d4d29b89f32..8c11ce6587c 100644 --- a/clients/shared/Utilities/FeatureFlagManager.swift +++ b/clients/shared/Utilities/FeatureFlagManager.swift @@ -3,18 +3,12 @@ import Foundation private let flagPrefix = "VELLUM_FLAG_" public enum FeatureFlag: String, CaseIterable { - case demo case userHostedEnabled = "user_hosted_enabled" - case featureFlagEditorEnabled = "feature_flag_editor_enabled" - case hatchNewAssistantEnabled = "hatch_new_assistant_enabled" case localHttpEnabled = "local_http_enabled" public var displayName: String { switch self { - case .demo: return "Demo" case .userHostedEnabled: return "User Hosted Enabled" - case .featureFlagEditorEnabled: return "Feature Flag Editor Enabled" - case .hatchNewAssistantEnabled: return "Hatch New Assistant Enabled" case .localHttpEnabled: return "Local HTTP Enabled" } }