Files
flynn/src/gateway/handlers/config.ts
T
William Valentin 9be8f76bc7 feat: implement Tier 3 features — lane queue, credential redaction, token dashboard, xAI, Voyage AI
- Lane Queue: per-session FIFO queue in gateway replacing reject-when-busy (9 tests)
- Credential Redaction: redactConfig() expanded to cover 18+ secret fields (16 tests)
- Web UI Token Dashboard: system.tokenUsage endpoint + Usage page with summary cards
- xAI (Grok) Provider: OpenAI-compatible client with model pricing
- Voyage AI Embeddings: new embedding provider with configurable dimensions (5 tests)
- Update gap analysis: 90→95 match (70%→74%), Tier 3 section marked DONE
- Update state.json: test count 1001→1034, add tier3_completion entry

Total: 1034 tests passing across 85 files, typecheck clean
2026-02-09 10:32:57 -08:00

159 lines
5.4 KiB
TypeScript

import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
import type { Config } from '../../config/index.js';
export interface ConfigHandlerDeps {
config: Config;
}
/**
* Redact sensitive values from config before returning.
* Replaces API keys, tokens, passwords, and other credentials with "***".
*
* Covers: telegram, discord, slack, server, models (tiers + fallbacks + local_providers),
* web_search, audio, memory.embedding, automation (webhooks + gmail), and mcp server env vars.
*/
export function redactConfig(config: Config): Record<string, unknown> {
const raw = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
// Helper: redact specified keys on an object if they exist and are non-nullish
const redact = (obj: Record<string, unknown> | undefined, ...keys: string[]) => {
if (!obj) return;
for (const key of keys) {
if (obj[key] !== undefined && obj[key] !== null) obj[key] = '***';
}
};
// Telegram
redact(raw.telegram as Record<string, unknown>, 'bot_token');
// Discord
redact(raw.discord as Record<string, unknown>, 'bot_token');
// Slack
redact(raw.slack as Record<string, unknown>, 'bot_token', 'app_token', 'signing_secret');
// Server (gateway bearer token)
redact(raw.server as Record<string, unknown>, 'token');
// Models — tiers, their fallbacks, and local_providers (+ their fallbacks)
const models = raw.models as Record<string, unknown> | undefined;
if (models) {
for (const tier of ['default', 'fast', 'complex', 'local']) {
const m = models[tier] as Record<string, unknown> | undefined;
redact(m, 'api_key', 'auth_token');
const fb = m?.fallback as Record<string, unknown> | undefined;
redact(fb, 'api_key', 'auth_token');
}
const localProviders = models.local_providers as Record<string, Record<string, unknown>> | undefined;
if (localProviders) {
for (const provider of Object.values(localProviders)) {
redact(provider, 'api_key', 'auth_token');
const fb = provider.fallback as Record<string, unknown> | undefined;
redact(fb, 'api_key', 'auth_token');
}
}
}
// Web search
redact(raw.web_search as Record<string, unknown>, 'api_key');
// Audio
redact(raw.audio as Record<string, unknown>, 'transcription_api_key');
// Memory → embedding
const memory = raw.memory as Record<string, unknown> | undefined;
if (memory) {
redact(memory.embedding as Record<string, unknown>, 'api_key');
}
// Automation — webhook HMAC secrets and gmail credential paths
const automation = raw.automation as Record<string, unknown> | undefined;
if (automation) {
const webhooks = automation.webhooks as Record<string, unknown>[] | undefined;
if (webhooks) {
for (const wh of webhooks) {
redact(wh, 'secret');
}
}
const gmail = automation.gmail as Record<string, unknown> | undefined;
redact(gmail, 'credentials_file', 'token_file');
}
// MCP server env vars (may contain API keys or other secrets)
const mcp = raw.mcp as Record<string, unknown> | undefined;
if (mcp) {
const servers = mcp.servers as Record<string, unknown>[] | undefined;
if (servers) {
for (const srv of servers) {
if (srv.env && typeof srv.env === 'object') {
const env = srv.env as Record<string, unknown>;
for (const key of Object.keys(env)) {
env[key] = '***';
}
}
}
}
}
return raw;
}
/** Keys that are safe to update at runtime via config.patch. */
const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean> = {
'hooks.confirm': (config, value) => {
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) return false;
config.hooks.confirm = value as string[];
return true;
},
'hooks.log': (config, value) => {
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) return false;
config.hooks.log = value as string[];
return true;
},
'hooks.silent': (config, value) => {
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) return false;
config.hooks.silent = value as string[];
return true;
},
'server.localhost': (config, value) => {
if (typeof value !== 'boolean') return false;
config.server.localhost = value;
return true;
},
};
export function createConfigHandlers(deps: ConfigHandlerDeps) {
return {
'config.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
return makeResponse(request.id, redactConfig(deps.config));
},
'config.patch': async (request: GatewayRequest): Promise<OutboundMessage> => {
const patches = request.params?.patches;
if (!patches || typeof patches !== 'object' || Array.isArray(patches)) {
return makeError(request.id, ErrorCode.InvalidRequest, 'params.patches must be an object of { key: value } pairs');
}
const applied: string[] = [];
const rejected: string[] = [];
for (const [key, value] of Object.entries(patches as Record<string, unknown>)) {
const patcher = PATCHABLE_KEYS[key];
if (!patcher) {
rejected.push(key);
continue;
}
const ok = patcher(deps.config, value);
if (ok) {
applied.push(key);
} else {
rejected.push(key);
}
}
return makeResponse(request.id, { applied, rejected });
},
};
}