Skip to content

Commit e59eae2

Browse files
authored
feat(kiloclaw): replace hardcoded channel env maps with secret catalog lookups (#907)
<!-- PR title format: type(scope): description — e.g., feat(auth): add SSO login --> <!-- Keep the title under 72 characters, use imperative mood, no trailing period. --> ## Summary Replace hardcoded channel-to-env-var mappings in the CF Worker with catalog-derived lookups from @kilocode/kiloclaw-secret-catalog (PR1, #857). Behavior is identical — only the source of truth changes. - Replace CHANNEL_ENV_MAP in encryption.ts with FIELD_KEY_TO_ENV_VAR from catalog - Replace hardcoded SENSITIVE_KEYS in env.ts with catalog-derived ALL_SECRET_ENV_VARS - Unknown channel keys in DO state are gracefully skipped (warn + continue) instead of silently ignored - Add ALL_SECRET_ENV_VARS export to the secret catalog package <!-- What changed and why? Keep this brief and outcome-focused. --> <!-- Include any architectural changes and enough context for a reviewer unfamiliar with this code area. --> ## Verification <!-- List the checks you ran and what passed. Add command output notes when useful. --> - [x] - pnpm --filter kiloclaw run test — 505 tests pass (504 existing + 1 new) - [x] - pnpm --filter @kilocode/kiloclaw-secret-catalog run test — 30 tests pass - [x] - pnpm --filter kiloclaw run typecheck — clean - [x] - New buildEnvVars equivalence test confirms catalog-derived SENSITIVE_KEYS classifies - [x] all 4 channel env vars identically to the old hardcoded set - [x] - New test confirms unknown channel keys are skipped gracefully without blocking - [x] machine startup ## Visual Changes <!-- If UI/visual behavior changed, add before/after screenshots in the table below. --> <!-- If there are no visual changes, replace the table with: N/A --> N/A | Before | After | | ------ | ----- | | | | ## Reviewer Notes - Depends on #857 (already merged) - Non-breaking: catalog contains the exact same 4 field→env-var mappings that were hardcoded (TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, SLACK_APP_TOKEN) - One subtle behavioral change: decryptChannelTokens now iterates over input channels keys instead of the old map keys. Unknown keys produce a console.warn + skip rather than being silently ignored — same outcome (key not in result) but with observability This doesn't touch storage. It only changes how the worker reads stored data: - DO state format is unchanged — channels field still holds EncryptedChannelTokens - Decryption logic (decryptWithPrivateKey) is unchanged - Only the key→env-var mapping source changed (hardcoded map → catalog map with identical values) <!-- Optional: reviewer focus areas, edge cases, or context that helps review quickly. -->
2 parents e6f74eb + 99487b6 commit e59eae2

8 files changed

Lines changed: 88 additions & 17 deletions

File tree

kiloclaw/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"dependencies": {
2222
"@kilocode/db": "workspace:*",
2323
"@kilocode/encryption": "workspace:*",
24+
"@kilocode/kiloclaw-secret-catalog": "workspace:*",
2425
"@kilocode/worker-utils": "workspace:*",
2526
"drizzle-orm": "catalog:",
2627
"hono": "catalog:",

kiloclaw/packages/secret-catalog/src/catalog.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ export const FIELD_KEY_TO_ENTRY: ReadonlyMap<string, SecretCatalogEntry> = new M
119119
SECRET_CATALOG.flatMap(entry => entry.fields.map(field => [field.key, entry]))
120120
);
121121

122+
/** Set of all env var names from catalog entries (for SENSITIVE_KEYS classification) */
123+
export const ALL_SECRET_ENV_VARS: ReadonlySet<string> = new Set(
124+
SECRET_CATALOG.flatMap(entry => entry.fields.map(field => field.envVar))
125+
);
126+
122127
/**
123128
* Get all entries for a given category, sorted by order (undefined sorts last).
124129
*/

kiloclaw/packages/secret-catalog/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export {
2525
ALL_SECRET_FIELD_KEYS,
2626
FIELD_KEY_TO_ENV_VAR,
2727
FIELD_KEY_TO_ENTRY,
28+
ALL_SECRET_ENV_VARS,
2829
getEntriesByCategory,
2930
} from './catalog.js';
3031

kiloclaw/src/gateway/env.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,4 +292,37 @@ describe('buildEnvVars', () => {
292292
})
293293
).rejects.toThrow('valid shell identifier');
294294
});
295+
296+
// ─── Catalog-derived SENSITIVE_KEYS equivalence ───────────────────────
297+
// Verifies that switching from hardcoded SENSITIVE_KEYS to catalog-derived
298+
// ALL_SECRET_ENV_VARS produces identical classification behavior.
299+
// The catalog contains the exact same 4 env var names that were hardcoded.
300+
301+
it('classifies all catalog channel env vars as sensitive (catalog-derived SENSITIVE_KEYS)', async () => {
302+
const env = createMockEnv({ AGENT_ENV_VARS_PRIVATE_KEY: testPrivateKey });
303+
304+
// Provide all 4 channel env var names as plaintext user env vars.
305+
// They should all land in the sensitive bucket because SENSITIVE_KEYS
306+
// is now derived from the catalog (which includes all 4).
307+
const result = await buildEnvVars(env, SANDBOX_ID, SECRET, {
308+
envVars: {
309+
TELEGRAM_BOT_TOKEN: 'tg-plain',
310+
DISCORD_BOT_TOKEN: 'discord-plain',
311+
SLACK_BOT_TOKEN: 'slack-bot-plain',
312+
SLACK_APP_TOKEN: 'slack-app-plain',
313+
},
314+
});
315+
316+
// All 4 must be in sensitive — same behavior as the old hardcoded set
317+
expect(result.sensitive.TELEGRAM_BOT_TOKEN).toBe('tg-plain');
318+
expect(result.sensitive.DISCORD_BOT_TOKEN).toBe('discord-plain');
319+
expect(result.sensitive.SLACK_BOT_TOKEN).toBe('slack-bot-plain');
320+
expect(result.sensitive.SLACK_APP_TOKEN).toBe('slack-app-plain');
321+
322+
// None should leak into the plaintext env bucket
323+
expect(result.env.TELEGRAM_BOT_TOKEN).toBeUndefined();
324+
expect(result.env.DISCORD_BOT_TOKEN).toBeUndefined();
325+
expect(result.env.SLACK_BOT_TOKEN).toBeUndefined();
326+
expect(result.env.SLACK_APP_TOKEN).toBeUndefined();
327+
});
295328
});

kiloclaw/src/gateway/env.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ALL_SECRET_ENV_VARS } from '@kilocode/kiloclaw-secret-catalog';
12
import type { KiloClawEnv } from '../types';
23
import type { EncryptedEnvelope, EncryptedChannelTokens } from '../schemas/instance-config';
34
import { deriveGatewayToken } from '../auth/gateway-token';
@@ -30,14 +31,13 @@ export type EnvVarsBuild = {
3031
/**
3132
* Env var names that are always classified as sensitive.
3233
* Values for these keys go into the `sensitive` bucket.
34+
*
35+
* Derived from the secret catalog to automatically include all channel/secret env vars.
3336
*/
3437
const SENSITIVE_KEYS = new Set([
3538
'KILOCODE_API_KEY',
3639
'OPENCLAW_GATEWAY_TOKEN',
37-
'TELEGRAM_BOT_TOKEN',
38-
'DISCORD_BOT_TOKEN',
39-
'SLACK_BOT_TOKEN',
40-
'SLACK_APP_TOKEN',
40+
...ALL_SECRET_ENV_VARS,
4141
]);
4242

4343
/**

kiloclaw/src/utils/encryption.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,5 +233,25 @@ describe('encryption utilities', () => {
233233
const channels: EncryptedChannelTokens = {};
234234
expect(decryptChannelTokens(channels, privateKey)).toEqual({});
235235
});
236+
237+
it('skips unknown channel keys not in the secret catalog (warn + continue)', () => {
238+
// Simulate a channels object with a key that is not registered in the secret
239+
// catalog (e.g. stale DO state from a rolled-back schema change, or a key
240+
// added to EncryptedChannelTokens before the catalog was updated).
241+
// The function should skip the unknown key gracefully rather than throwing,
242+
// so a single unrecognised key does not prevent the machine from starting.
243+
const channels = {
244+
unknownFutureToken: encryptForTest('some-token', publicKey),
245+
telegramBotToken: encryptForTest('tg-token', publicKey),
246+
} as unknown as EncryptedChannelTokens;
247+
248+
const result = decryptChannelTokens(channels, privateKey);
249+
250+
// Known key is still decrypted correctly
251+
expect(result.TELEGRAM_BOT_TOKEN).toBe('tg-token');
252+
// Unknown key is silently skipped — no entry in result
253+
expect(Object.keys(result)).not.toContain('unknownFutureToken');
254+
expect(Object.keys(result)).toHaveLength(1);
255+
});
236256
});
237257
});

kiloclaw/src/utils/encryption.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import { createDecipheriv, privateDecrypt, constants } from 'crypto';
14+
import { FIELD_KEY_TO_ENV_VAR } from '@kilocode/kiloclaw-secret-catalog';
1415
import type { EncryptedEnvelope, EncryptedChannelTokens } from '../schemas/instance-config';
1516

1617
export class EncryptionConfigurationError extends Error {
@@ -145,32 +146,39 @@ export function mergeEnvVarsWithSecrets(
145146
return result;
146147
}
147148

148-
/**
149-
* Channel token env var mapping.
150-
* Maps channel config keys to the container env var names expected by start-openclaw.sh.
151-
*/
152-
const CHANNEL_ENV_MAP: Record<keyof EncryptedChannelTokens, string> = {
153-
telegramBotToken: 'TELEGRAM_BOT_TOKEN',
154-
discordBotToken: 'DISCORD_BOT_TOKEN',
155-
slackBotToken: 'SLACK_BOT_TOKEN',
156-
slackAppToken: 'SLACK_APP_TOKEN',
157-
};
158-
159149
/**
160150
* Decrypt encrypted channel tokens and map to container env var names.
161151
*
152+
* Uses FIELD_KEY_TO_ENV_VAR from the secret catalog to map channel config keys
153+
* to the container env var names expected by start-openclaw.sh.
154+
*
162155
* Example: channels.telegramBotToken -> TELEGRAM_BOT_TOKEN
156+
*
157+
* Unknown keys (e.g. stale DO state from a rolled-back schema change) are
158+
* skipped with a console.warn rather than throwing, so a single unrecognised
159+
* key does not prevent the machine from starting.
163160
*/
164161
export function decryptChannelTokens(
165162
channels: EncryptedChannelTokens,
166163
privateKeyPem: string
167164
): Record<string, string> {
168165
const result: Record<string, string> = {};
169166

170-
for (const channelKey of Object.keys(CHANNEL_ENV_MAP) as (keyof EncryptedChannelTokens)[]) {
167+
for (const channelKey of Object.keys(channels) as (keyof EncryptedChannelTokens)[]) {
171168
const envelope = channels[channelKey];
172169
if (envelope) {
173-
result[CHANNEL_ENV_MAP[channelKey]] = decryptWithPrivateKey(envelope, privateKeyPem);
170+
const envVarName = FIELD_KEY_TO_ENV_VAR.get(channelKey);
171+
if (!envVarName) {
172+
// Gracefully skip unknown keys rather than hard-failing.
173+
// This can happen if DO state contains a key that was removed from the
174+
// catalog (e.g. after a schema rollback) or added to EncryptedChannelTokens
175+
// before the catalog was updated.
176+
console.warn(
177+
`[decryptChannelTokens] Unknown channel key '${channelKey}' — not in secret catalog. Skipping.`
178+
);
179+
continue;
180+
}
181+
result[envVarName] = decryptWithPrivateKey(envelope, privateKeyPem);
174182
}
175183
}
176184

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)