Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions kiloclaw/packages/secret-catalog/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return new Set(
SECRET_CATALOG.filter(e => e.category === category).flatMap(e => e.fields.map(f => f.key))
);
}
1 change: 1 addition & 0 deletions kiloclaw/packages/secret-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
FIELD_KEY_TO_ENTRY,
ALL_SECRET_ENV_VARS,
getEntriesByCategory,
getFieldKeysByCategory,
} from './catalog';

export type { SecretFieldKey } from './catalog';
Expand Down
91 changes: 91 additions & 0 deletions kiloclaw/src/routes/kiloclaw.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> },
});
expect(result.telegram).toBe(false);
});
});
40 changes: 32 additions & 8 deletions kiloclaw/src/routes/kiloclaw.ts
Original file line number Diff line number Diff line change
@@ -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).
*
Expand All @@ -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),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Keep the legacy channels field for a rollout window

cloud and the KiloClaw worker deploy independently (KILOCLAW_API_URL points the web app at an external worker). Removing channels here means an older web build will see config.channels === undefined and temporarily render every channel as not configured until the frontend rollout completes. Returning both configuredSecrets and the legacy channels booleans for one release avoids that cross-service break.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, this is documented in the Reviewer Notes. Both the worker and Next.js app change in this PR and deploy within minutes of each other. If briefly out of sync, the only effect is channels temporarily showing "Not configured" in the Settings tab. No errors, no data loss, tokens remain functional on the Fly machine.

});
});

Expand All @@ -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<string, unknown> | null;
channels?: Record<string, unknown> | null;
}): Record<string, boolean> {
const result: Record<string, boolean> = {};

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 };
36 changes: 2 additions & 34 deletions src/app/(app)/claw/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,6 @@ import { VersionPinCard } from './VersionPinCard';

type ClawMutations = ReturnType<typeof useKiloClawMutations>;

/**
* 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,
Expand Down Expand Up @@ -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 ?? {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Frontend-first rollout marks every channel as not configured

This component now reads only configuredSecrets. If the Next.js deploy lands before the worker change, older /api/kiloclaw/config responses only contain channels, so all entries fall back to false until the worker is rolled out. Keep the old config.channels mapping as a temporary fallback until both deployments are live.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prev acknowledged, this is documented in the Reviewer Notes. Both the worker and Next.js app change in this PR and deploy within minutes of each other. If briefly out of sync, the only effect is channels temporarily showing "Not configured" in the Settings tab. No errors, no data loss, tokens remain functional on the Fly machine.


function handleSave() {
if (hasModelSelectionError) {
Expand Down Expand Up @@ -310,7 +278,7 @@ export function SettingsTab({
<SecretEntrySection
key={entry.id}
entry={entry}
configured={isEntryConfigured(entry.id, channelStatus)}
configured={configuredSecrets[entry.id] ?? false}
mutations={mutations}
onSecretsChanged={onChannelsChanged}
isDirty={dirtyChannels.has(entry.id)}
Expand Down
8 changes: 2 additions & 6 deletions src/lib/kiloclaw/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,8 @@ export type UserConfigResponse = {
kilocodeDefaultModel: string | null;
hasKiloCodeApiKey: boolean;
kilocodeApiKeyExpiresAt?: string | null;
channels: {
telegram: boolean;
discord: boolean;
slackBot: boolean;
slackApp: boolean;
};
/** Per catalog entry ID → whether all fields for that entry are configured. */
configuredSecrets: Record<string, boolean>;
};

/** Response from POST /api/platform/doctor */
Expand Down
Loading