diff --git a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts index 143b2894c..90c01cb7f 100644 --- a/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts +++ b/kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts @@ -7,6 +7,7 @@ import { ENV_VAR_TO_FIELD_KEY, FIELD_KEY_TO_ENTRY, getEntriesByCategory, + getFieldKeysByCategory, } from '../catalog.js'; import { validateFieldValue } from '../validation.js'; import type { SecretIconKey, SecretCatalogEntry } from '../types.js'; @@ -175,6 +176,22 @@ describe('Secret Catalog', () => { }); }); + describe('getFieldKeysByCategory', () => { + it('returns all channel field keys', () => { + const keys = getFieldKeysByCategory('channel'); + expect(keys).toContain('telegramBotToken'); + expect(keys).toContain('discordBotToken'); + expect(keys).toContain('slackBotToken'); + expect(keys).toContain('slackAppToken'); + expect(keys.size).toBe(4); + }); + + it('returns empty set for categories with no entries', () => { + const keys = getFieldKeysByCategory('tool'); + expect(keys.size).toBe(0); + }); + }); + describe('getInjectionMethod', () => { const baseEntry: SecretCatalogEntry = { id: 'test', diff --git a/kiloclaw/packages/secret-catalog/src/catalog.ts b/kiloclaw/packages/secret-catalog/src/catalog.ts index e3921c531..157be5bf9 100644 --- a/kiloclaw/packages/secret-catalog/src/catalog.ts +++ b/kiloclaw/packages/secret-catalog/src/catalog.ts @@ -142,3 +142,13 @@ export function getEntriesByCategory(category: SecretCategory): SecretCatalogEnt return orderA - orderB; }); } + +/** + * Get the set of all field keys for a given category. + * Allocates a new Set on each call — cache the result if used in a hot path. + */ +export function getFieldKeysByCategory(category: SecretCategory): ReadonlySet { + return new Set( + SECRET_CATALOG.filter(e => e.category === category).flatMap(e => e.fields.map(f => f.key)) + ); +} diff --git a/kiloclaw/packages/secret-catalog/src/index.ts b/kiloclaw/packages/secret-catalog/src/index.ts index 545d4d509..cfae16498 100644 --- a/kiloclaw/packages/secret-catalog/src/index.ts +++ b/kiloclaw/packages/secret-catalog/src/index.ts @@ -28,6 +28,7 @@ export { FIELD_KEY_TO_ENTRY, ALL_SECRET_ENV_VARS, getEntriesByCategory, + getFieldKeysByCategory, } from './catalog'; export type { SecretFieldKey } from './catalog'; diff --git a/kiloclaw/src/routes/kiloclaw.test.ts b/kiloclaw/src/routes/kiloclaw.test.ts new file mode 100644 index 000000000..a17d38b22 --- /dev/null +++ b/kiloclaw/src/routes/kiloclaw.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { buildConfiguredSecrets } from './kiloclaw'; + +describe('buildConfiguredSecrets', () => { + const envelope = { + encryptedData: 'x', + encryptedDEK: 'y', + algorithm: 'rsa-aes-256-gcm', + version: 1, + }; + + it('returns all entries as false when no secrets are configured', () => { + const result = buildConfiguredSecrets({}); + expect(result).toEqual({ telegram: false, discord: false, slack: false }); + }); + + it('marks entry as configured when encryptedSecrets has the env var key', () => { + const result = buildConfiguredSecrets({ + encryptedSecrets: { TELEGRAM_BOT_TOKEN: envelope }, + }); + expect(result.telegram).toBe(true); + expect(result.discord).toBe(false); + expect(result.slack).toBe(false); + }); + + it('marks multi-field entry as configured only when ALL fields are present', () => { + const partial = buildConfiguredSecrets({ + encryptedSecrets: { SLACK_BOT_TOKEN: envelope }, + }); + expect(partial.slack).toBe(false); + + const full = buildConfiguredSecrets({ + encryptedSecrets: { SLACK_BOT_TOKEN: envelope, SLACK_APP_TOKEN: envelope }, + }); + expect(full.slack).toBe(true); + }); + + it('falls back to legacy channels storage when encryptedSecrets is absent', () => { + const result = buildConfiguredSecrets({ + channels: { telegramBotToken: envelope, discordBotToken: envelope }, + }); + expect(result.telegram).toBe(true); + expect(result.discord).toBe(true); + expect(result.slack).toBe(false); + }); + + it('prefers encryptedSecrets over legacy channels', () => { + const result = buildConfiguredSecrets({ + encryptedSecrets: { TELEGRAM_BOT_TOKEN: envelope }, + channels: { telegramBotToken: envelope, discordBotToken: envelope }, + }); + expect(result.telegram).toBe(true); + expect(result.discord).toBe(true); + }); + + it('handles legacy channels with all slack fields', () => { + const result = buildConfiguredSecrets({ + channels: { slackBotToken: envelope, slackAppToken: envelope }, + }); + expect(result.slack).toBe(true); + }); + + it('does not use legacy channels fallback for non-channel category entries', () => { + // If a non-channel entry were added, legacy channels storage should not count + // This tests that CHANNEL_FIELD_KEYS gate is effective — a key not in the + // channel category won't match even if present in config.channels + const result = buildConfiguredSecrets({ + channels: { someNonChannelKey: envelope }, + }); + // All current entries are channels, so this just verifies no crash + expect(result.telegram).toBe(false); + expect(result.discord).toBe(false); + expect(result.slack).toBe(false); + }); + + it('uses entry.id as the result key', () => { + const result = buildConfiguredSecrets({}); + const keys = Object.keys(result); + expect(keys).toContain('telegram'); + expect(keys).toContain('discord'); + expect(keys).toContain('slack'); + expect(keys).toHaveLength(3); + }); + + it('treats null values as not configured', () => { + const result = buildConfiguredSecrets({ + encryptedSecrets: { TELEGRAM_BOT_TOKEN: null as unknown as Record }, + }); + expect(result.telegram).toBe(false); + }); +}); diff --git a/kiloclaw/src/routes/kiloclaw.ts b/kiloclaw/src/routes/kiloclaw.ts index 2bfeccec8..6b5b28622 100644 --- a/kiloclaw/src/routes/kiloclaw.ts +++ b/kiloclaw/src/routes/kiloclaw.ts @@ -1,12 +1,15 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types'; -import { SECRET_CATALOG } from '@kilocode/kiloclaw-secret-catalog'; +import { SECRET_CATALOG, getFieldKeysByCategory } from '@kilocode/kiloclaw-secret-catalog'; /** Channel env var names — excluded from secretCount (channels have their own counts). */ const CHANNEL_ENV_VARS = new Set( SECRET_CATALOG.filter(e => e.category === 'channel').flatMap(e => e.fields.map(f => f.envVar)) ); +/** Channel field keys — used to check legacy `channels` storage for backward compat. */ +const CHANNEL_FIELD_KEYS = getFieldKeysByCategory('channel'); + /** * User-facing KiloClaw routes (JWT auth via authMiddleware). * @@ -31,12 +34,7 @@ kiloclaw.get('/config', async c => { kilocodeDefaultModel: config.kilocodeDefaultModel ?? null, hasKiloCodeApiKey: !!config.kilocodeApiKey, kilocodeApiKeyExpiresAt: config.kilocodeApiKeyExpiresAt ?? null, - channels: { - telegram: !!config.channels?.telegramBotToken, - discord: !!config.channels?.discordBotToken, - slackBot: !!config.channels?.slackBotToken, - slackApp: !!config.channels?.slackAppToken, - }, + configuredSecrets: buildConfiguredSecrets(config), }); }); @@ -50,4 +48,30 @@ kiloclaw.get('/status', async c => { return c.json(status); }); -export { kiloclaw }; +/** + * Derive per-entry configured status from the catalog. + * + * Checks both `encryptedSecrets` (new path) and legacy `channels` storage + * so that instances provisioned before the catalog migration still report + * correct status. An entry is "configured" when ALL its fields have a value. + */ +function buildConfiguredSecrets(config: { + encryptedSecrets?: Record | null; + channels?: Record | null; +}): Record { + const result: Record = {}; + + for (const entry of SECRET_CATALOG) { + result[entry.id] = entry.fields.every(field => { + // Check new encryptedSecrets storage (keyed by env var name) + if (config.encryptedSecrets?.[field.envVar] != null) return true; + // Fall back to legacy channels storage (keyed by field key) + if (CHANNEL_FIELD_KEYS.has(field.key) && config.channels?.[field.key] != null) return true; + return false; + }); + } + + return result; +} + +export { kiloclaw, buildConfiguredSecrets }; diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index c1954da43..a23692bff 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -26,33 +26,6 @@ import { VersionPinCard } from './VersionPinCard'; type ClawMutations = ReturnType; -/** - * Maps a catalog entry ID to whether the entry is "configured" based on - * the channel status from the config endpoint. The config endpoint returns - * per-field booleans (telegram, discord, slackBot, slackApp) rather than - * per-entry booleans, so we need this bridge mapping. - * - * IMPORTANT: This switch must be updated when new channel entries are added - * to the secret catalog. Unknown entry IDs silently return false ("Not configured"). - * The proper fix is to make the config endpoint return per-entry-id status - * derived from the catalog, eliminating this manual mapping. - */ -function isEntryConfigured( - entryId: string, - channelStatus: { telegram: boolean; discord: boolean; slackBot: boolean; slackApp: boolean } -): boolean { - switch (entryId) { - case 'telegram': - return channelStatus.telegram; - case 'discord': - return channelStatus.discord; - case 'slack': - return channelStatus.slackBot && channelStatus.slackApp; - default: - return false; - } -} - export function SettingsTab({ status, mutations, @@ -116,12 +89,7 @@ export function SettingsTab({ '2026.2.26' ); - const channelStatus = config?.channels ?? { - telegram: false, - discord: false, - slackBot: false, - slackApp: false, - }; + const configuredSecrets = config?.configuredSecrets ?? {}; function handleSave() { if (hasModelSelectionError) { @@ -310,7 +278,7 @@ export function SettingsTab({ ; }; /** Response from POST /api/platform/doctor */