From 1a075e62b0b555efb9d980b44f62d64524a7a636 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 15 Feb 2026 22:44:04 -0800 Subject: [PATCH] audit follow-up: burn down lint hotspots and dedupe channel gating flows --- .../2026-02-16-codebase-audit-report.md | 22 +- docs/plans/state.json | 13 +- src/channels/discord/adapter.ts | 55 ++-- src/channels/slack/adapter.ts | 70 +++-- src/channels/utils.test.ts | 91 +++++- src/channels/utils.ts | 56 ++++ src/channels/whatsapp/adapter.ts | 52 ++-- src/daemon/routing.test.ts | 93 +++--- src/frontends/tui/minimal.test.ts | 81 ++++-- src/gateway/handlers/handlers.test.ts | 266 ++++++++++-------- 10 files changed, 518 insertions(+), 281 deletions(-) diff --git a/docs/plans/analysis/2026-02-16-codebase-audit-report.md b/docs/plans/analysis/2026-02-16-codebase-audit-report.md index 8a5732f..49fd2d6 100644 --- a/docs/plans/analysis/2026-02-16-codebase-audit-report.md +++ b/docs/plans/analysis/2026-02-16-codebase-audit-report.md @@ -19,8 +19,8 @@ Scope: Production-risk-first audit of bugs, code improvements, and feature oppor - ✅ F-003 addressed: tool execution now has an `AbortSignal` contract, executor triggers abort on timeout, high-risk tools (`shell.exec`, sandbox docker exec, `process.start`, browser tools, `web.fetch`, `web.search`) respond to cancellation, and executor regression tests verify cancellable tools do not apply side effects after timeout. - ✅ F-015 addressed: retry defaults no longer classify timeout-style failures as non-retryable, improving resilience for transient timeout conditions. - ✅ F-011 addressed: Slack user-name resolution now uses bounded TTL+LRU caching to prevent unbounded growth. -- ◑ F-013 partially addressed: reset-command normalization is now shared across Discord/Slack/WhatsApp adapters via `src/channels/utils.ts`, reducing duplicated command-parsing logic. -- ◑ F-004 partially addressed: lint error baseline is restored (`pnpm lint` now passes with 0 errors), while warning-burn-down remains open. +- ✅ F-013 addressed: shared channel utilities now cover reset normalization/building plus reusable allowlist, mention-gating, and pairing-gating flows across Discord/Slack/WhatsApp adapters. +- ◑ F-004 partially addressed: lint error baseline is restored (`pnpm lint` now passes with 0 errors) and warning burn-down has progressed from `466` to `323`; additional warning debt remains. ## Executive Summary @@ -28,14 +28,14 @@ Current health snapshot: - `pnpm typecheck`: passing - `pnpm build`: passing - `pnpm test:run`: passing (`140/140` files, `1773/1773` tests) -- `pnpm lint`: passing with warnings only (`0 errors`, `466 warnings`) +- `pnpm lint`: passing with warnings only (`0 errors`, `323 warnings`) Top conclusions: - A critical Web UI security issue exists in markdown rendering (unsanitized HTML insertion). - Runtime configuration edits from the settings page appear non-persistent across restart. - Tool timeout behavior likely allows underlying side effects to continue after timeout. - Gateway request-body handling and WebSocket ingress controls need abuse protections. -- Lint error-level gate is restored, but warning debt remains high. +- Lint error-level gate is restored, and warning debt is trending down but still high. ## Methodology and Scope @@ -126,7 +126,7 @@ Remediation update (2026-02-16): - Severity: Medium - Impact: CI noise, reduced confidence in static analysis, and slower defect detection. - Evidence: - - `pnpm -s lint` => `0 errors`, `466 warnings` + - `pnpm -s lint` => `0 errors`, `323 warnings` - Error concentration: - `src/daemon/models.ts` (90 errors) - `src/cli/tui.ts` (25 errors) @@ -145,7 +145,10 @@ Remediation update (2026-02-16): Remediation update (2026-02-16): - Stage 1 complete: fixed all error-level ESLint violations in impacted high-error files so `pnpm lint` now passes with `0` errors. -- Stage 2 in progress: warning-burn-down reduced to `466` warnings via targeted low-risk test cleanup (non-null assertion removal). +- Stage 2 in progress: warning-burn-down reduced to `323` warnings via targeted hotspot cleanup in: + - `src/gateway/handlers/handlers.test.ts` + - `src/daemon/routing.test.ts` + - `src/frontends/tui/minimal.test.ts` ### F-005 Medium: ESLint browser globals mismatch causes avoidable UI lint failures @@ -267,8 +270,9 @@ Remediation update (2026-02-16): - Extract shared middleware utilities for common inbound/outbound behaviors. Remediation update (2026-02-16): -- Added shared `normalizeResetCommandText()` utility and migrated Discord/Slack/WhatsApp adapters to use it, reducing repeated reset-command parsing logic. -- Added shared `buildResetInboundMessage()` utility and migrated Discord/Slack/WhatsApp adapters to use it, reducing repeated reset-metadata construction logic. +- Added shared `normalizeResetCommandText()` + `buildResetInboundMessage()` utilities and migrated Discord/Slack/WhatsApp adapters to use them. +- Added shared `isAllowedByAllowlist()`, `shouldIgnoreForMissingMention()`, and `allowTrustedOrPairedSender()` channel utilities. +- Migrated Discord/Slack/WhatsApp adapters to use shared allowlist, mention-gating, and pairing-gating flows with adapter-specific transport hooks. ### F-014 Low: ModelRouter listener API has destructive setter footgun @@ -449,7 +453,7 @@ pnpm -s lint Observed outcomes: - Typecheck/build/test: passing. -- Lint: passing with warnings only (`0` errors, `466` warnings). +- Lint: passing with warnings only (`0` errors, `323` warnings). Historical pre-remediation lint error concentration snapshot: - `src/daemon/models.ts`: 90 errors diff --git a/docs/plans/state.json b/docs/plans/state.json index d6be0dc..a10844a 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -2615,10 +2615,10 @@ "test_status": "pnpm test:run src/channels/slack/adapter.test.ts + pnpm typecheck passing" }, "audit-followup-channel-reset-command-dedup": { - "status": "in_progress", + "status": "completed", "date": "2026-02-16", "updated": "2026-02-16", - "summary": "Started reducing channel adapter duplication by extracting shared reset-command normalization and reset message construction utilities, and migrating Discord/Slack/WhatsApp adapters to use them.", + "summary": "Completed broader channel adapter dedup by extracting shared adapter utilities for allowlist gating, mention gating, and pairing access flow. Migrated Discord/Slack/WhatsApp to shared reset normalization + reset message builder + shared gating helpers while preserving channel-specific behavior.", "files_modified": [ "src/channels/utils.ts", "src/channels/utils.test.ts", @@ -2627,7 +2627,7 @@ "src/channels/whatsapp/adapter.ts", "docs/plans/analysis/2026-02-16-codebase-audit-report.md" ], - "test_status": "pnpm test:run src/channels/utils.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts + pnpm typecheck + pnpm lint passing" + "test_status": "pnpm test:run src/channels/utils.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts + pnpm lint passing (0 errors, warning debt remains)" }, "audit-followup-lint-error-baseline": { "status": "completed", @@ -2652,16 +2652,19 @@ "status": "in_progress", "date": "2026-02-16", "updated": "2026-02-16", - "summary": "Started stage-2 lint warning reduction with low-risk test cleanup: removed non-null assertions and added explicit guards/helpers in selected tests, reducing warning count from 539 to 466 while keeping lint/typecheck/tests green.", + "summary": "Continued stage-2 lint warning reduction with hotspot-focused cleanup in `gateway/handlers/handlers.test.ts`, `daemon/routing.test.ts`, and `frontends/tui/minimal.test.ts`. Replaced broad `any` casts with typed helper casts/unknown-path accessors and removed non-null assertions in routing tests. Warning count reduced from 466 to 323 (143 warnings burned down) with lint/test suites still green.", "files_modified": [ "src/tools/builtin/browser/tools.test.ts", "src/channels/telegram/adapter.test.ts", "src/tools/builtin/system-info.test.ts", "src/mcp/manager.test.ts", "src/skills/loader.test.ts", + "src/gateway/handlers/handlers.test.ts", + "src/daemon/routing.test.ts", + "src/frontends/tui/minimal.test.ts", "docs/plans/analysis/2026-02-16-codebase-audit-report.md" ], - "test_status": "pnpm test:run src/tools/builtin/browser/tools.test.ts src/channels/telegram/adapter.test.ts src/tools/builtin/system-info.test.ts src/mcp/manager.test.ts src/skills/loader.test.ts + pnpm typecheck + pnpm lint passing (0 errors, 466 warnings)" + "test_status": "pnpm test:run src/channels/utils.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts src/daemon/routing.test.ts src/gateway/handlers/handlers.test.ts src/frontends/tui/minimal.test.ts + pnpm lint passing (0 errors, 323 warnings)" } }, "overall_progress": { diff --git a/src/channels/discord/adapter.ts b/src/channels/discord/adapter.ts index 2e3077d..e9efafe 100644 --- a/src/channels/discord/adapter.ts +++ b/src/channels/discord/adapter.ts @@ -17,7 +17,14 @@ import type { ChannelAdapter, ChannelStatus, } from '../types.js'; -import { buildResetInboundMessage, normalizeResetCommandText, splitMessage } from '../utils.js'; +import { + allowTrustedOrPairedSender, + buildResetInboundMessage, + isAllowedByAllowlist, + normalizeResetCommandText, + shouldIgnoreForMissingMention, + splitMessage, +} from '../utils.js'; import type { PairingManager } from '../pairing.js'; /** Configuration for the Discord channel adapter. */ @@ -98,7 +105,7 @@ export class DiscordAdapter implements ChannelAdapter { // ── Message handler — route inbound messages ── this.client.on(Events.MessageCreate, (message: DiscordMessage) => { - this.handleMessage(message); + void this.handleMessage(message); }); // Log in and wait for the ready event @@ -162,7 +169,7 @@ export class DiscordAdapter implements ChannelAdapter { } /** Internal: process an inbound Discord message. */ - private handleMessage(message: DiscordMessage): void { + private async handleMessage(message: DiscordMessage): Promise { if (!this.messageHandler) {return;} // Ignore bot messages @@ -174,42 +181,42 @@ export class DiscordAdapter implements ChannelAdapter { if (!isDM) { // Check allowed guild IDs if ( - this.config.allowedGuildIds && - this.config.allowedGuildIds.length > 0 && - !this.config.allowedGuildIds.includes(message.guild!.id) + !isAllowedByAllowlist(message.guild!.id, this.config.allowedGuildIds) ) { return; } // Check allowed channel IDs - if ( - this.config.allowedChannelIds && - this.config.allowedChannelIds.length > 0 && - !this.config.allowedChannelIds.includes(message.channelId) - ) { + if (!isAllowedByAllowlist(message.channelId, this.config.allowedChannelIds)) { return; } // ── Mention requirement in guild channels ── - const requireMention = this.config.requireMention ?? true; - if (requireMention && this.client?.user) { - if (!message.mentions.has(this.client.user)) { - return; - } + if (this.client?.user && shouldIgnoreForMissingMention({ + requireMention: this.config.requireMention, + defaultRequireMention: true, + mentionsBot: message.mentions.has(this.client.user), + })) { + return; } } else { // DM pairing check — if pairing is enabled, require approval - const pm = this.config.pairingManager; - if (pm?.enabled && !pm.isApproved('discord', message.channelId)) { - const text = message.content.trim(); - if (text && pm.validateCode('discord', message.channelId, text)) { - try { + if (this.config.pairingManager?.enabled) { + if (!await allowTrustedOrPairedSender({ + pairingManager: this.config.pairingManager, + channel: 'discord', + senderId: message.channelId, + text: message.content ?? '', + isTrusted: false, + onPaired: async () => { if ('send' in message.channel) { - (message.channel as any).send('Pairing successful! You can now chat with Flynn.'); + await (message.channel as { send: (content: string) => Promise }) + .send('Pairing successful! You can now chat with Flynn.'); } - } catch { /* ignore send errors */ } + }, + })) { + return; } - return; } } diff --git a/src/channels/slack/adapter.ts b/src/channels/slack/adapter.ts index 2641384..acf9cce 100644 --- a/src/channels/slack/adapter.ts +++ b/src/channels/slack/adapter.ts @@ -15,7 +15,14 @@ import type { ChannelAdapter, ChannelStatus, } from '../types.js'; -import { buildResetInboundMessage, normalizeResetCommandText, splitMessage } from '../utils.js'; +import { + allowTrustedOrPairedSender, + buildResetInboundMessage, + isAllowedByAllowlist, + normalizeResetCommandText, + shouldIgnoreForMissingMention, + splitMessage, +} from '../utils.js'; import type { PairingManager } from '../pairing.js'; /** Configuration for the Slack channel adapter. */ @@ -295,46 +302,37 @@ export class SlackAdapter implements ChannelAdapter { if (!channelId) {return;} // Check allowed channel IDs - if ( - this.config.allowedChannelIds && - this.config.allowedChannelIds.length > 0 && - !this.config.allowedChannelIds.includes(channelId) - ) { - // Pairing fallback — check if the Slack user is approved or sending a valid code - const pm = this.config.pairingManager; - const userId = message.user; - if (pm?.enabled && userId) { - if (pm.isApproved('slack', userId)) { - // Approved — fall through to normal message handling - } else { - const text = (message.text ?? '').trim(); - if (text && pm.validateCode('slack', userId, text)) { - // Code validated — send confirmation via Slack - if (this.app) { - const threadTs = message.thread_ts ?? message.ts ?? ''; - try { - await this.app.client.chat.postMessage({ - channel: channelId, - text: 'Pairing successful! You can now chat with Flynn.', - thread_ts: threadTs || undefined, - }); - } catch { /* ignore send errors */ } - } - } - return; - } - } else { + if (!isAllowedByAllowlist(channelId, this.config.allowedChannelIds)) { + const senderId = message.user ?? ''; + const allowed = await allowTrustedOrPairedSender({ + pairingManager: this.config.pairingManager, + channel: 'slack', + senderId, + text: message.text ?? '', + isTrusted: false, + onPaired: async () => { + if (!this.app) {return;} + const threadTs = message.thread_ts ?? message.ts ?? ''; + await this.app.client.chat.postMessage({ + channel: channelId, + text: 'Pairing successful! You can now chat with Flynn.', + thread_ts: threadTs || undefined, + }); + }, + }); + if (!allowed) { return; } } // Mention requirement - const requireMention = this.config.requireMention ?? false; - if (requireMention && this.botUserId) { - const mentionPattern = `<@${this.botUserId}>`; - if (!(message.text ?? '').includes(mentionPattern)) { - return; - } + const mentionPattern = this.botUserId ? `<@${this.botUserId}>` : undefined; + if (shouldIgnoreForMissingMention({ + requireMention: mentionPattern ? this.config.requireMention : false, + defaultRequireMention: false, + mentionsBot: mentionPattern ? (message.text ?? '').includes(mentionPattern) : false, + })) { + return; } // Note: Slack doesn't expose a typing indicator API for bots diff --git a/src/channels/utils.test.ts b/src/channels/utils.test.ts index e202b55..7c22204 100644 --- a/src/channels/utils.test.ts +++ b/src/channels/utils.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { buildResetInboundMessage, normalizeResetCommandText, splitMessage } from './utils.js'; +import { + allowTrustedOrPairedSender, + buildResetInboundMessage, + isAllowedByAllowlist, + normalizeResetCommandText, + shouldIgnoreForMissingMention, + splitMessage, +} from './utils.js'; +import { PairingManager } from './pairing.js'; describe('splitMessage', () => { it('returns single chunk for empty string', () => { @@ -133,3 +141,84 @@ describe('buildResetInboundMessage', () => { expect(message.metadata).toEqual({ isCommand: true, command: 'reset' }); }); }); + +describe('isAllowedByAllowlist', () => { + it('allows all values when allowlist is missing', () => { + expect(isAllowedByAllowlist('C123')).toBe(true); + }); + + it('allows all values when allowlist is empty', () => { + expect(isAllowedByAllowlist('C123', [])).toBe(true); + }); + + it('filters values when allowlist is present', () => { + expect(isAllowedByAllowlist('C123', ['C123', 'C456'])).toBe(true); + expect(isAllowedByAllowlist('C999', ['C123', 'C456'])).toBe(false); + }); +}); + +describe('shouldIgnoreForMissingMention', () => { + it('ignores when mention is required and missing', () => { + expect(shouldIgnoreForMissingMention({ + requireMention: true, + defaultRequireMention: false, + mentionsBot: false, + })).toBe(true); + }); + + it('does not ignore when mention is required and present', () => { + expect(shouldIgnoreForMissingMention({ + requireMention: true, + defaultRequireMention: false, + mentionsBot: true, + })).toBe(false); + }); + + it('uses default when requireMention is undefined', () => { + expect(shouldIgnoreForMissingMention({ + requireMention: undefined, + defaultRequireMention: true, + mentionsBot: false, + })).toBe(true); + }); +}); + +describe('allowTrustedOrPairedSender', () => { + it('allows trusted senders immediately', async () => { + const pm = new PairingManager({ enabled: true, codeTtl: 60_000, codeLength: 6 }); + await expect(allowTrustedOrPairedSender({ + pairingManager: pm, + channel: 'slack', + senderId: 'U1', + text: 'hello', + isTrusted: true, + })).resolves.toBe(true); + }); + + it('blocks untrusted senders when pairing is disabled', async () => { + const pm = new PairingManager({ enabled: false, codeTtl: 60_000, codeLength: 6 }); + await expect(allowTrustedOrPairedSender({ + pairingManager: pm, + channel: 'slack', + senderId: 'U1', + text: 'hello', + isTrusted: false, + })).resolves.toBe(false); + }); + + it('consumes valid pairing code and runs onPaired callback', async () => { + const pm = new PairingManager({ enabled: true, codeTtl: 60_000, codeLength: 6 }); + const code = pm.generateCode('test'); + let paired = false; + await expect(allowTrustedOrPairedSender({ + pairingManager: pm, + channel: 'slack', + senderId: 'U1', + text: code, + isTrusted: false, + onPaired: () => { paired = true; }, + })).resolves.toBe(false); + expect(paired).toBe(true); + expect(pm.isApproved('slack', 'U1')).toBe(true); + }); +}); diff --git a/src/channels/utils.ts b/src/channels/utils.ts index 2979c04..45da27d 100644 --- a/src/channels/utils.ts +++ b/src/channels/utils.ts @@ -2,6 +2,7 @@ * Shared utilities for channel adapters. */ import type { Attachment, InboundMessage } from './types.js'; +import type { PairingManager } from './pairing.js'; /** * Split a long message into chunks that respect a platform's character limit. @@ -44,6 +45,61 @@ export function normalizeResetCommandText(text: string): string { return text; } +/** Check whether a value is allowed by an optional allowlist (empty/missing list means allow-all). */ +export function isAllowedByAllowlist(value: string, allowlist?: string[]): boolean { + if (!allowlist || allowlist.length === 0) { + return true; + } + return allowlist.includes(value); +} + +/** + * Returns true when mention gating should ignore the message. + * `requireMention` defaults per adapter; if enabled, the message must mention the bot. + */ +export function shouldIgnoreForMissingMention(params: { + requireMention: boolean | undefined; + defaultRequireMention: boolean; + mentionsBot: boolean; +}): boolean { + const needsMention = params.requireMention ?? params.defaultRequireMention; + return needsMention && !params.mentionsBot; +} + +interface PairingGateParams { + pairingManager?: PairingManager; + channel: InboundMessage['channel']; + senderId: string; + text: string; + /** True for trusted senders/channels that may bypass pairing. */ + isTrusted: boolean; + /** Optional side effect when a pairing code is accepted. */ + onPaired?: () => Promise | void; +} + +/** + * Shared pairing gate for channel adapters. + * Returns true when the inbound message should continue to normal processing. + */ +export async function allowTrustedOrPairedSender(params: PairingGateParams): Promise { + const pm = params.pairingManager; + if (params.isTrusted) { + return true; + } + if (!pm?.enabled) { + return false; + } + if (pm.isApproved(params.channel, params.senderId)) { + return true; + } + const code = params.text.trim(); + if (!code || !pm.validateCode(params.channel, params.senderId, code)) { + return false; + } + await params.onPaired?.(); + return false; +} + interface ResetMessageParams { id: string; channel: InboundMessage['channel']; diff --git a/src/channels/whatsapp/adapter.ts b/src/channels/whatsapp/adapter.ts index 7d91f49..5271131 100644 --- a/src/channels/whatsapp/adapter.ts +++ b/src/channels/whatsapp/adapter.ts @@ -17,7 +17,13 @@ import type { ChannelAdapter, ChannelStatus, } from '../types.js'; -import { buildResetInboundMessage, normalizeResetCommandText, splitMessage } from '../utils.js'; +import { + allowTrustedOrPairedSender, + buildResetInboundMessage, + normalizeResetCommandText, + shouldIgnoreForMissingMention, + splitMessage, +} from '../utils.js'; import type { PairingManager } from '../pairing.js'; /** Configuration for the WhatsApp channel adapter. */ @@ -224,15 +230,15 @@ export class WhatsAppAdapter implements ChannelAdapter { } // Mention requirement in group chats - const requireMention = this.config.requireMention ?? true; - if (requireMention) { + if (this.botId && shouldIgnoreForMissingMention({ + requireMention: this.config.requireMention, + defaultRequireMention: true, + mentionsBot: message.body?.includes(`@${this.botId.replace(/@c\.us$/, '')}`) || + (message as unknown as { mentionedIds?: string[] }).mentionedIds?.some((id) => id === this.botId) === true, + })) { // WhatsApp mentions use @phone_number format in body // Also check for mentions in the message mentionedIds - const mentionsBot = this.botId - ? message.body?.includes(`@${this.botId.replace(/@c\.us$/, '')}`) || - (message as any).mentionedIds?.some((id: string) => id === this.botId) - : false; - if (!mentionsBot) {return;} + return; } } @@ -244,24 +250,18 @@ export class WhatsAppAdapter implements ChannelAdapter { this.config.allowedNumbers.length > 0 && !this.config.allowedNumbers.includes(phoneNumber) ) { - // Pairing fallback — check if the sender is approved or sending a valid code - const pm = this.config.pairingManager; - if (pm?.enabled) { - if (pm.isApproved('whatsapp', phoneNumber)) { - // Approved — fall through to normal message handling - } else { - const text = (message.body ?? '').trim(); - if (text && pm.validateCode('whatsapp', phoneNumber, text)) { - // Code validated — send confirmation via WhatsApp - if (this.client) { - try { - await this.client.sendMessage(from, 'Pairing successful! You can now chat with Flynn.'); - } catch { /* ignore send errors */ } - } - } - return; - } - } else { + const allowed = await allowTrustedOrPairedSender({ + pairingManager: this.config.pairingManager, + channel: 'whatsapp', + senderId: phoneNumber, + text: message.body ?? '', + isTrusted: false, + onPaired: async () => { + if (!this.client) {return;} + await this.client.sendMessage(from, 'Pairing successful! You can now chat with Flynn.'); + }, + }); + if (!allowed) { return; } } diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 6aea353..0535e84 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -7,6 +7,10 @@ import { AgentOrchestrator } from '../backends/index.js'; import { CommandRegistry, registerBuiltinCommands } from '../commands/index.js'; import { ComponentRegistry } from '../intents/index.js'; import { RoutingPolicy } from '../routing/index.js'; +import type { OutboundMessage } from '../channels/index.js'; + +type MessageRouterDeps = Parameters[0]; +type MessageRouterInput = Parameters['handler']>[0]; describe('daemon agent routing integration', () => { it('resolves agent config for channel messages', () => { @@ -25,7 +29,9 @@ describe('daemon agent routing integration', () => { // Discord user gets coder const discordAgent = router.resolve('discord', 'user123'); expect(discordAgent).toBe('coder'); - expect(registry.get(discordAgent!)!.systemPrompt).toBe('Write code.'); + expect(discordAgent).toBeDefined(); + const discordAgentConfig = discordAgent ? registry.get(discordAgent) : undefined; + expect(discordAgentConfig?.systemPrompt).toBe('Write code.'); // Telegram admin gets coder const telegramAdmin = router.resolve('telegram', 'admin'); @@ -34,7 +40,9 @@ describe('daemon agent routing integration', () => { // Random telegram user gets assistant const telegramUser = router.resolve('telegram', 'random'); expect(telegramUser).toBe('assistant'); - expect(registry.get(telegramUser!)!.systemPrompt).toBe('Be helpful.'); + expect(telegramUser).toBeDefined(); + const telegramUserConfig = telegramUser ? registry.get(telegramUser) : undefined; + expect(telegramUserConfig?.systemPrompt).toBe('Be helpful.'); }); it('uses default agent when no routing configured', () => { @@ -91,18 +99,18 @@ describe('daemon command fast-path integration', () => { const router = createMessageRouter({ sessionManager: { getSession: vi.fn(() => session), - } as any, + } as MessageRouterDeps['sessionManager'], modelRouter: { getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), getLabel: (tier: string) => tier, - } as any, + } as MessageRouterDeps['modelRouter'], systemPrompt: 'test prompt', toolRegistry: { clone() { return this; }, register: vi.fn(), - } as any, - toolExecutor: {} as any, + } as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as MessageRouterDeps['toolExecutor'], config: { agents: { primary_tier: 'default', @@ -118,7 +126,7 @@ describe('daemon command fast-path integration', () => { }, compaction: { enabled: false }, models: { default: { provider: 'anthropic', model: 'claude' } }, - } as any, + } as MessageRouterDeps['config'], commandRegistry, }); @@ -129,7 +137,7 @@ describe('daemon command fast-path integration', () => { senderId: 'user-1', text: '/reset', metadata: { isCommand: true, command: 'reset' }, - } as any, reply); + } as MessageRouterInput, reply); expect(processSpy).not.toHaveBeenCalled(); expect(session.deleteConfig).toHaveBeenCalledWith('modelTier'); @@ -155,18 +163,18 @@ describe('daemon command fast-path integration', () => { const router = createMessageRouter({ sessionManager: { getSession: vi.fn(() => session), - } as any, + } as MessageRouterDeps['sessionManager'], modelRouter: { getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), getLabel: (tier: string) => tier, - } as any, + } as MessageRouterDeps['modelRouter'], systemPrompt: 'test prompt', toolRegistry: { clone() { return this; }, register: vi.fn(), - } as any, - toolExecutor: {} as any, + } as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as MessageRouterDeps['toolExecutor'], config: { agents: { primary_tier: 'default', @@ -182,7 +190,7 @@ describe('daemon command fast-path integration', () => { }, compaction: { enabled: false }, models: { default: { provider: 'anthropic', model: 'claude' } }, - } as any, + } as MessageRouterDeps['config'], commandRegistry, }); @@ -193,7 +201,7 @@ describe('daemon command fast-path integration', () => { senderId: 'user-4', text: '/model fast', metadata: { isCommand: true, command: 'model', commandArgs: 'fast' }, - } as any, reply); + } as MessageRouterInput, reply); expect(processSpy).not.toHaveBeenCalled(); expect(setModelTierSpy).toHaveBeenCalledWith('fast'); @@ -239,18 +247,18 @@ describe('daemon command fast-path integration', () => { const router = createMessageRouter({ sessionManager: { getSession: vi.fn(() => session), - } as any, + } as MessageRouterDeps['sessionManager'], modelRouter: { getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), getLabel: (tier: string) => tier, - } as any, + } as MessageRouterDeps['modelRouter'], systemPrompt: 'test prompt', toolRegistry: { clone() { return this; }, register: vi.fn(), - } as any, - toolExecutor: {} as any, + } as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as MessageRouterDeps['toolExecutor'], config: { intents: { enabled: true }, agents: { @@ -267,7 +275,7 @@ describe('daemon command fast-path integration', () => { }, compaction: { enabled: false }, models: { default: { provider: 'anthropic', model: 'claude' } }, - } as any, + } as MessageRouterDeps['config'], commandRegistry, intentRegistry, agentConfigRegistry, @@ -280,7 +288,7 @@ describe('daemon command fast-path integration', () => { senderId: 'user-2', text: 'deploy backend now', metadata: { isCommand: true, command: 'reset' }, - } as any, vi.fn(async () => {})); + } as MessageRouterInput, vi.fn(async () => {})); const keys = Array.from(router.agents.keys()); expect(keys.some(key => key.includes(':coder'))).toBe(true); @@ -332,18 +340,18 @@ describe('daemon command fast-path integration', () => { const router = createMessageRouter({ sessionManager: { getSession: vi.fn(() => session), - } as any, + } as MessageRouterDeps['sessionManager'], modelRouter: { getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), getLabel: (tier: string) => tier, - } as any, + } as MessageRouterDeps['modelRouter'], systemPrompt: 'test prompt', toolRegistry: { clone() { return this; }, register: vi.fn(), - } as any, - toolExecutor: {} as any, + } as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as MessageRouterDeps['toolExecutor'], config: { intents: { enabled: true }, agents: { @@ -360,7 +368,7 @@ describe('daemon command fast-path integration', () => { }, compaction: { enabled: false }, models: { default: { provider: 'anthropic', model: 'claude' } }, - } as any, + } as MessageRouterDeps['config'], commandRegistry, intentRegistry, routingPolicy, @@ -374,7 +382,7 @@ describe('daemon command fast-path integration', () => { senderId: 'user-3', text: 'deploy backend now', metadata: { isCommand: true, command: 'reset' }, - } as any, vi.fn(async () => {})); + } as MessageRouterInput, vi.fn(async () => {})); const keys = Array.from(router.agents.keys()); expect(keys.some(key => key.includes(':assistant'))).toBe(true); @@ -404,15 +412,15 @@ describe('daemon audio routing integration', () => { registerBuiltinCommands(commandRegistry); const router = createMessageRouter({ - sessionManager: { getSession: vi.fn(() => session) } as any, + sessionManager: { getSession: vi.fn(() => session) } as MessageRouterDeps['sessionManager'], modelRouter: { getAvailableTiers: () => ['default'], getAllLabels: () => ({ default: 'default' }), getLabel: (tier: string) => tier, - } as any, + } as MessageRouterDeps['modelRouter'], systemPrompt: 'test prompt', - toolRegistry: { clone() { return this; }, register: vi.fn() } as any, - toolExecutor: {} as any, + toolRegistry: { clone() { return this; }, register: vi.fn() } as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as MessageRouterDeps['toolExecutor'], config: { agents: { primary_tier: 'default', @@ -430,7 +438,7 @@ describe('daemon audio routing integration', () => { // Anthropic doesn't support native audio; ensures routing hits the non-audio path. models: { default: { provider: 'anthropic', model: 'claude' } }, audio: { enabled: false }, - } as any, + } as MessageRouterDeps['config'], commandRegistry, }); @@ -442,11 +450,12 @@ describe('daemon audio routing integration', () => { text: '', attachments: [{ mimeType: 'audio/ogg', data: 'ZGF0YQ==', filename: 'voice.ogg' }], timestamp: Date.now(), - } as any, reply); + } as MessageRouterInput, reply); expect(processSpy).not.toHaveBeenCalled(); expect(reply).toHaveBeenCalledTimes(1); - const msg = (reply.mock.calls[0] as unknown as any[])[0] as { text?: string }; + const firstReply = reply.mock.calls[0]?.[0] as OutboundMessage | undefined; + const msg = firstReply as { text?: string }; expect(String(msg.text)).toContain('audio transcription is not configured'); }); @@ -454,12 +463,12 @@ describe('daemon audio routing integration', () => { const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process').mockResolvedValue('ok'); // Mock transcription endpoint call. - const fetchSpy = vi.spyOn(globalThis, 'fetch' as any).mockResolvedValue({ + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, statusText: 'OK', json: async () => ({ text: 'hello world' }), - } as any); + } as Response); const session = { id: 'telegram:user-voice-2', @@ -476,15 +485,15 @@ describe('daemon audio routing integration', () => { registerBuiltinCommands(commandRegistry); const router = createMessageRouter({ - sessionManager: { getSession: vi.fn(() => session) } as any, + sessionManager: { getSession: vi.fn(() => session) } as MessageRouterDeps['sessionManager'], modelRouter: { getAvailableTiers: () => ['default'], getAllLabels: () => ({ default: 'default' }), getLabel: (tier: string) => tier, - } as any, + } as MessageRouterDeps['modelRouter'], systemPrompt: 'test prompt', - toolRegistry: { clone() { return this; }, register: vi.fn() } as any, - toolExecutor: {} as any, + toolRegistry: { clone() { return this; }, register: vi.fn() } as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as MessageRouterDeps['toolExecutor'], config: { agents: { primary_tier: 'default', @@ -504,7 +513,7 @@ describe('daemon audio routing integration', () => { enabled: true, provider: { type: 'openai', endpoint: 'https://example.com/v1/audio/transcriptions', api_key: 'sk-test', model: 'whisper-1' }, }, - } as any, + } as MessageRouterDeps['config'], commandRegistry, }); @@ -519,14 +528,14 @@ describe('daemon audio routing integration', () => { { mimeType: 'image/jpeg', data: 'aW1n', filename: 'img.jpg' }, ], timestamp: Date.now(), - } as any, reply); + } as MessageRouterInput, reply); expect(fetchSpy).toHaveBeenCalled(); expect(processSpy).toHaveBeenCalledTimes(1); const [calledText, calledAttachments] = processSpy.mock.calls[0] ?? []; expect(String(calledText)).toContain('[Voice message]: hello world'); expect(String(calledText)).toContain('caption'); - const atts = calledAttachments as any[] | undefined; + const atts = calledAttachments as Array<{ mimeType: string }> | undefined; expect(atts?.some(a => a.mimeType === 'audio/ogg')).toBe(false); expect(atts?.some(a => a.mimeType === 'image/jpeg')).toBe(true); }); diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index 0915fa4..b584d48 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -1,8 +1,41 @@ import { describe, it, expect, vi } from 'vitest'; import { formatPrompt, parseCommand } from './minimal.js'; import type { ModelConfig } from '../../config/schema.js'; +import type { ManagedSession } from '../../session/index.js'; +import type { ModelClient } from '../../models/types.js'; +import type { ModelRouter } from '../../models/router.js'; +import type { NativeAgent } from '../../backends/native/agent.js'; import { MinimalTui } from './minimal.js'; +type TuiRouterStub = Pick & + Partial & + Partial & { + getLocalProviderName: () => string | undefined; + setLocalClient: ReturnType; + }; + +function asSession(value: unknown): ManagedSession { + return value as ManagedSession; +} + +function asRouter(value: unknown): ModelClient & ModelRouter { + return value as ModelClient & ModelRouter; +} + +function asAgent(value: unknown): NativeAgent { + return value as NativeAgent; +} + +function minimalTuiPrivates(value: MinimalTui): { + handleBackendCommand: (provider: string) => Promise; + handleModelCommand: (tier: string, providerModel?: string) => void; +} { + return value as unknown as { + handleBackendCommand: (provider: string) => Promise; + handleModelCommand: (tier: string, providerModel?: string) => void; + }; +} + describe('formatPrompt', () => { it('formats default prompt', () => { const prompt = formatPrompt('default'); @@ -47,10 +80,11 @@ describe('MinimalTui backend command', () => { replaceHistory: vi.fn(), }; - const mockRouter = { + const mockRouter: TuiRouterStub = { getTier: () => 'default' as const, getAvailableTiers: () => ['default', 'local'], setTier: vi.fn(() => true), + getLabel: (tier: string) => tier, getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), chat: vi.fn(), @@ -66,15 +100,15 @@ describe('MinimalTui backend command', () => { }; const tui = new MinimalTui({ - session: mockSession as any, - modelClient: mockRouter as any, - modelRouter: mockRouter as any, + session: asSession(mockSession), + modelClient: asRouter(mockRouter), + modelRouter: asRouter(mockRouter), systemPrompt: 'test', localProviders, }); // Access private method for testing - await (tui as any).handleBackendCommand('llamacpp'); + await minimalTuiPrivates(tui).handleBackendCommand('llamacpp'); expect(mockRouter.setLocalClient).toHaveBeenCalled(); }); @@ -88,10 +122,11 @@ describe('MinimalTui backend command', () => { replaceHistory: vi.fn(), }; - const mockRouter = { + const mockRouter: TuiRouterStub = { getTier: () => 'default' as const, getAvailableTiers: () => ['default', 'local'], setTier: vi.fn(() => true), + getLabel: (tier: string) => tier, getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), chat: vi.fn(), @@ -105,15 +140,15 @@ describe('MinimalTui backend command', () => { }; const tui = new MinimalTui({ - session: mockSession as any, - modelClient: mockRouter as any, - modelRouter: mockRouter as any, - agent: mockAgent as any, + session: asSession(mockSession), + modelClient: asRouter(mockRouter), + modelRouter: asRouter(mockRouter), + agent: asAgent(mockAgent), systemPrompt: 'test', }); // Call private handleModelCommand to switch to local - (tui as any).handleModelCommand('local'); + minimalTuiPrivates(tui).handleModelCommand('local'); expect(mockRouter.setTier).toHaveBeenCalledWith('local'); expect(mockAgent.setModelTier).toHaveBeenCalledWith('local'); @@ -132,10 +167,11 @@ describe('MinimalTui backend command', () => { replaceHistory: vi.fn(), }; - const mockRouter = { + const mockRouter: TuiRouterStub = { getTier: () => 'default' as const, getAvailableTiers: () => ['default', 'local'], setTier: vi.fn(() => true), + getLabel: (tier: string) => tier, getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), setClient: vi.fn(), @@ -145,9 +181,9 @@ describe('MinimalTui backend command', () => { }; const tui = new MinimalTui({ - session: mockSession as any, - modelClient: mockRouter as any, - modelRouter: mockRouter as any, + session: asSession(mockSession), + modelClient: asRouter(mockRouter), + modelRouter: asRouter(mockRouter), systemPrompt: 'test', modelProviderConfigs: { openrouter: { @@ -159,7 +195,7 @@ describe('MinimalTui backend command', () => { }, }); - (tui as any).handleModelCommand('default', 'openrouter/deepseek/deepseek-chat'); + minimalTuiPrivates(tui).handleModelCommand('default', 'openrouter/deepseek/deepseek-chat'); expect(mockRouter.setClient).toHaveBeenCalledOnce(); expect(mockRouter.setTierStrict).toHaveBeenCalledWith('default', true); @@ -186,10 +222,11 @@ describe('MinimalTui backend command', () => { replaceHistory: vi.fn(), }; - const mockRouter = { + const mockRouter: TuiRouterStub = { getTier: () => 'fast' as const, getAvailableTiers: () => ['default', 'fast', 'local'], setTier: vi.fn(() => true), + getLabel: (tier: string) => tier, getLocalProviderName: () => 'ollama', setLocalClient: vi.fn(), setClient: vi.fn(), @@ -205,10 +242,10 @@ describe('MinimalTui backend command', () => { }; const tui = new MinimalTui({ - session: mockSession as any, - modelClient: mockRouter as any, - modelRouter: mockRouter as any, - agent: mockAgent as any, + session: asSession(mockSession), + modelClient: asRouter(mockRouter), + modelRouter: asRouter(mockRouter), + agent: asAgent(mockAgent), systemPrompt: 'test', modelProviderConfigs: { openrouter: { @@ -220,7 +257,7 @@ describe('MinimalTui backend command', () => { }, }); - (tui as any).handleModelCommand('default', 'openrouter/deepseek/deepseek-chat'); + minimalTuiPrivates(tui).handleModelCommand('default', 'openrouter/deepseek/deepseek-chat'); expect(mockRouter.setTier).toHaveBeenCalledWith('default'); expect(mockAgent.setModelTier).toHaveBeenCalledWith('default'); diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 4810358..05ac45b 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -16,6 +16,45 @@ import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, Outbo import { ComponentRegistry } from '../../intents/index.js'; import { RoutingPolicy } from '../../routing/index.js'; +function asSessionHandlerSessionManager(value: unknown): Parameters[0]['sessionManager'] { + return value as Parameters[0]['sessionManager']; +} + +function asToolRegistry(value: unknown): Parameters[0]['toolRegistry'] { + return value as Parameters[0]['toolRegistry']; +} + +function asToolExecutor(value: unknown): Parameters[0]['toolExecutor'] { + return value as Parameters[0]['toolExecutor']; +} + +function asSessionBridge(value: unknown): Parameters[0]['sessionBridge'] { + return value as Parameters[0]['sessionBridge']; +} + +function asHistorySessionManager(value: unknown): Parameters[0]['sessionManager'] { + return value as Parameters[0]['sessionManager']; +} + +function asConfigValue(value: unknown): Parameters[0]['config'] { + return value as Parameters[0]['config']; +} + +function asRedactInput(value: unknown): Parameters[0] { + return value as Parameters[0]; +} + +function getPath(value: unknown, ...path: string[]): unknown { + let current: unknown = value; + for (const key of path) { + if (!current || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[key]; + } + return current; +} + describe('system handlers', () => { const deps = { startTime: Date.now() - 60_000, @@ -45,21 +84,21 @@ describe('system handlers', () => { const req: GatewayRequest = { id: 2, method: 'system.services' }; const result = await handlers['system.services'](req) as GatewayResponse; expect(result.id).toBe(2); - expect((result.result as any).services).toEqual([]); + expect(getPath(result.result, 'services')).toEqual([]); }); it('system.services returns services from getServices callback', async () => { const handlers = createSystemHandlers({ ...deps, - getServices: () => ([ + getServices: () => [ { name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' }, { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 }, - ] as any), + ], }); const req: GatewayRequest = { id: 3, method: 'system.services' }; const result = await handlers['system.services'](req) as GatewayResponse; - expect((result.result as any).services).toEqual([ + expect(getPath(result.result, 'services')).toEqual([ { name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' }, { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 }, ]); @@ -69,8 +108,8 @@ describe('system handlers', () => { const req: GatewayRequest = { id: 4, method: 'system.presence' }; const result = await handlers['system.presence'](req) as GatewayResponse; expect(result.id).toBe(4); - expect((result.result as any).presence).toEqual([]); - expect((result.result as any).summary).toEqual({ total: 0, online: 0, offline: 0 }); + expect(getPath(result.result, 'presence')).toEqual([]); + expect(getPath(result.result, 'summary')).toEqual({ total: 0, online: 0, offline: 0 }); }); it('system.presence returns filtered presence entries', async () => { @@ -111,9 +150,10 @@ describe('system handlers', () => { params: { channel: 'telegram', status: 'online', limit: 10 }, }; const result = await handlers['system.presence'](req) as GatewayResponse; - expect((result.result as any).presence).toHaveLength(1); - expect((result.result as any).presence[0].channel).toBe('telegram'); - expect((result.result as any).summary).toEqual({ total: 1, online: 1, offline: 0 }); + const presence = getPath(result.result, 'presence') as Array<{ channel: string }>; + expect(presence).toHaveLength(1); + expect(presence[0]?.channel).toBe('telegram'); + expect(getPath(result.result, 'summary')).toEqual({ total: 1, online: 1, offline: 0 }); }); }); @@ -197,7 +237,7 @@ describe('session handlers', () => { }; const handlers = createSessionHandlers({ - sessionManager: mockSessionManager as any, + sessionManager: asSessionHandlerSessionManager(mockSessionManager), }); beforeEach(() => { @@ -274,8 +314,8 @@ describe('tool handlers', () => { }; const handlers = createToolHandlers({ - toolRegistry: mockRegistry as any, - toolExecutor: mockExecutor as any, + toolRegistry: asToolRegistry(mockRegistry), + toolExecutor: asToolExecutor(mockExecutor), }); beforeEach(() => { @@ -334,7 +374,7 @@ describe('agent handlers', () => { const laneQueue = new LaneQueue(); const handlers = createAgentHandlers({ - sessionBridge: mockBridge as any, + sessionBridge: asSessionBridge(mockBridge), laneQueue, }); @@ -357,7 +397,7 @@ describe('agent handlers', () => { expect(sent).toHaveLength(1); const doneEvent = sent[0] as GatewayEvent; expect(doneEvent.event).toBe('done'); - expect((doneEvent.data as any).content).toBe('response text'); + expect(getPath(doneEvent.data, 'content')).toBe('response text'); }); it('agent.send passes attachments to agent.process', async () => { @@ -472,7 +512,7 @@ describe('agent handlers', () => { const errorEvent = sent[0] as GatewayEvent; expect(errorEvent.event).toBe('error'); - expect((errorEvent.data as any).message).toBe('model failed'); + expect(getPath(errorEvent.data, 'message')).toBe('model failed'); }); it('agent.send sets and cleans up tool use callback', async () => { @@ -501,8 +541,8 @@ describe('agent handlers', () => { const req: GatewayRequest = { id: 7, method: 'agent.cancel', params: { connectionId: 'conn-1' } }; const result = await handlers['agent.cancel'](req) as GatewayResponse; - expect((result.result as any).cancelled).toBe(true); - expect((result.result as any).message).toContain('Cancellation requested'); + expect(getPath(result.result, 'cancelled')).toBe(true); + expect(getPath(result.result, 'message')).toContain('Cancellation requested'); expect(mockBridge.cancel).toHaveBeenCalledWith('conn-1'); }); @@ -511,8 +551,8 @@ describe('agent handlers', () => { const req: GatewayRequest = { id: 8, method: 'agent.cancel', params: { connectionId: 'conn-1' } }; const result = await handlers['agent.cancel'](req) as GatewayResponse; - expect((result.result as any).cancelled).toBe(false); - expect((result.result as any).message).toContain('No active operation'); + expect(getPath(result.result, 'cancelled')).toBe(false); + expect(getPath(result.result, 'message')).toContain('No active operation'); }); }); @@ -607,11 +647,12 @@ describe('routing handlers', () => { describe('history handlers', () => { it('history.search returns ranked results', async () => { + const historySessionManager = asHistorySessionManager({ + searchHistory: () => [{ sessionId: 'ws:test', messageId: 1, role: 'user', content: 'deploy', score: 0.9, createdAt: 123 }], + reindexHistory: () => 0, + }); const handlers = createHistoryHandlers({ - sessionManager: { - searchHistory: () => [{ sessionId: 'ws:test', messageId: 1, role: 'user', content: 'deploy', score: 0.9, createdAt: 123 }], - reindexHistory: () => 0, - } as any, + sessionManager: historySessionManager, }); const req: GatewayRequest = { id: 13, method: 'history.search', params: { query: 'deploy' } }; @@ -621,11 +662,12 @@ describe('history handlers', () => { }); it('history.reindex returns count', async () => { + const historySessionManager = asHistorySessionManager({ + searchHistory: () => [], + reindexHistory: () => 42, + }); const handlers = createHistoryHandlers({ - sessionManager: { - searchHistory: () => [], - reindexHistory: () => 42, - } as any, + sessionManager: historySessionManager, }); const req: GatewayRequest = { id: 14, method: 'history.reindex' }; @@ -650,7 +692,7 @@ describe('system.restart handler', () => { const result = await handlers['system.restart'](req) as GatewayResponse; expect(result.id).toBe(1); - expect((result.result as any).restarting).toBe(true); + expect(getPath(result.result, 'restarting')).toBe(true); // Restart is called asynchronously via queueMicrotask await new Promise((resolve) => queueMicrotask(resolve)); @@ -691,21 +733,20 @@ describe('config handlers', () => { it('config.get returns redacted config', async () => { const config = makeConfig(); - const handlers = createConfigHandlers({ config: config as any }); + const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 1, method: 'config.get' }; const result = await handlers['config.get'](req) as GatewayResponse; - const r = result.result as Record; - expect(r.telegram.bot_token).toBe('***'); - expect(r.models.default.api_key).toBe('***'); + expect(getPath(result.result, 'telegram', 'bot_token')).toBe('***'); + expect(getPath(result.result, 'models', 'default', 'api_key')).toBe('***'); // Non-secret values are preserved - expect(r.server.port).toBe(18800); - expect(r.hooks.confirm).toEqual(['shell.exec']); + expect(getPath(result.result, 'server', 'port')).toBe(18800); + expect(getPath(result.result, 'hooks', 'confirm')).toEqual(['shell.exec']); }); it('config.patch applies valid patches', async () => { const config = makeConfig(); - const handlers = createConfigHandlers({ config: config as any }); + const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 2, method: 'config.patch', @@ -729,7 +770,7 @@ describe('config handlers', () => { it('config.patch rejects unknown keys', async () => { const config = makeConfig(); - const handlers = createConfigHandlers({ config: config as any }); + const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 3, method: 'config.patch', @@ -750,7 +791,7 @@ describe('config handlers', () => { it('config.patch rejects invalid value types', async () => { const config = makeConfig(); - const handlers = createConfigHandlers({ config: config as any }); + const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 4, method: 'config.patch', @@ -771,7 +812,10 @@ describe('config handlers', () => { it('config.patch persists changes when persistence callback is provided', async () => { const config = makeConfig(); const persist = vi.fn(); - const handlers = createConfigHandlers({ config: config as any, persistConfig: persist as any }); + const handlers = createConfigHandlers({ + config: asConfigValue(config), + persistConfig: persist as () => Promise, + }); const req: GatewayRequest = { id: 6, method: 'config.patch', @@ -791,7 +835,10 @@ describe('config handlers', () => { const config = makeConfig(); const before = [...config.hooks.confirm]; const persist = vi.fn().mockRejectedValue(new Error('disk full')); - const handlers = createConfigHandlers({ config: config as any, persistConfig: persist as any }); + const handlers = createConfigHandlers({ + config: asConfigValue(config), + persistConfig: persist as () => Promise, + }); const req: GatewayRequest = { id: 7, method: 'config.patch', @@ -809,7 +856,7 @@ describe('config handlers', () => { it('config.patch requires patches object', async () => { const config = makeConfig(); - const handlers = createConfigHandlers({ config: config as any }); + const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 5, method: 'config.patch', params: {} }; const result = await handlers['config.patch'](req) as GatewayError; @@ -874,135 +921,122 @@ describe('redactConfig – comprehensive credential redaction', () => { } it('redacts telegram.bot_token', () => { - const result = redactConfig(makeFullConfig() as any); - expect((result.telegram as any).bot_token).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'telegram', 'bot_token')).toBe('***'); }); it('redacts discord.bot_token', () => { - const result = redactConfig(makeFullConfig() as any); - expect((result.discord as any).bot_token).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'discord', 'bot_token')).toBe('***'); }); it('redacts slack.bot_token, app_token, and signing_secret', () => { - const result = redactConfig(makeFullConfig() as any); - const slack = result.slack as any; - expect(slack.bot_token).toBe('***'); - expect(slack.app_token).toBe('***'); - expect(slack.signing_secret).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'slack', 'bot_token')).toBe('***'); + expect(getPath(result, 'slack', 'app_token')).toBe('***'); + expect(getPath(result, 'slack', 'signing_secret')).toBe('***'); }); it('redacts matrix.access_token', () => { - const result = redactConfig(makeFullConfig() as any); - expect((result.matrix as any).access_token).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'matrix', 'access_token')).toBe('***'); }); it('redacts server.token', () => { - const result = redactConfig(makeFullConfig() as any); - expect((result.server as any).token).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'server', 'token')).toBe('***'); }); it('redacts model api_key and auth_token for all tiers', () => { - const result = redactConfig(makeFullConfig() as any); - const models = result.models as any; - - expect(models.default.api_key).toBe('***'); - expect(models.default.auth_token).toBe('***'); - expect(models.fast.api_key).toBe('***'); - expect(models.complex.auth_token).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'models', 'default', 'api_key')).toBe('***'); + expect(getPath(result, 'models', 'default', 'auth_token')).toBe('***'); + expect(getPath(result, 'models', 'fast', 'api_key')).toBe('***'); + expect(getPath(result, 'models', 'complex', 'auth_token')).toBe('***'); // local has no keys — should remain unchanged - expect(models.local.api_key).toBeUndefined(); + expect(getPath(result, 'models', 'local', 'api_key')).toBeUndefined(); }); it('redacts model fallback api_key and auth_token', () => { - const result = redactConfig(makeFullConfig() as any); - const models = result.models as any; - - expect(models.default.fallback.api_key).toBe('***'); - expect(models.default.fallback.auth_token).toBe('***'); - expect(models.fast.fallback.api_key).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'models', 'default', 'fallback', 'api_key')).toBe('***'); + expect(getPath(result, 'models', 'default', 'fallback', 'auth_token')).toBe('***'); + expect(getPath(result, 'models', 'fast', 'fallback', 'api_key')).toBe('***'); }); it('redacts local_providers api_key, auth_token, and their fallbacks', () => { - const result = redactConfig(makeFullConfig() as any); - const ollama = (result.models as any).local_providers.ollama; - - expect(ollama.api_key).toBe('***'); - expect(ollama.auth_token).toBe('***'); - expect(ollama.fallback.api_key).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'models', 'local_providers', 'ollama', 'api_key')).toBe('***'); + expect(getPath(result, 'models', 'local_providers', 'ollama', 'auth_token')).toBe('***'); + expect(getPath(result, 'models', 'local_providers', 'ollama', 'fallback', 'api_key')).toBe('***'); }); it('redacts web_search.api_key', () => { - const result = redactConfig(makeFullConfig() as any); - expect((result.web_search as any).api_key).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'web_search', 'api_key')).toBe('***'); }); it('redacts audio.transcription_api_key', () => { - const result = redactConfig(makeFullConfig() as any); - expect((result.audio as any).transcription_api_key).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'audio', 'transcription_api_key')).toBe('***'); }); it('redacts memory.embedding.api_key', () => { - const result = redactConfig(makeFullConfig() as any); - expect((result.memory as any).embedding.api_key).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'memory', 'embedding', 'api_key')).toBe('***'); }); it('redacts automation webhook secrets', () => { - const result = redactConfig(makeFullConfig() as any); - const webhooks = (result.automation as any).webhooks; - - expect(webhooks[0].secret).toBe('***'); - expect(webhooks[1].secret).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'automation', 'webhooks', '0', 'secret')).toBe('***'); + expect(getPath(result, 'automation', 'webhooks', '1', 'secret')).toBe('***'); // Webhook without a secret should remain unaffected - expect(webhooks[2].secret).toBeUndefined(); + expect(getPath(result, 'automation', 'webhooks', '2', 'secret')).toBeUndefined(); }); it('redacts automation gmail credentials_file and token_file', () => { - const result = redactConfig(makeFullConfig() as any); - const gmail = (result.automation as any).gmail; - - expect(gmail.credentials_file).toBe('***'); - expect(gmail.token_file).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'automation', 'gmail', 'credentials_file')).toBe('***'); + expect(getPath(result, 'automation', 'gmail', 'token_file')).toBe('***'); }); it('redacts all MCP server env vars', () => { - const result = redactConfig(makeFullConfig() as any); - const servers = (result.mcp as any).servers; - - expect(servers[0].env.API_KEY).toBe('***'); - expect(servers[0].env.DATABASE_URL).toBe('***'); + const result = redactConfig(asRedactInput(makeFullConfig())); + expect(getPath(result, 'mcp', 'servers', '0', 'env', 'API_KEY')).toBe('***'); + expect(getPath(result, 'mcp', 'servers', '0', 'env', 'DATABASE_URL')).toBe('***'); // Server without env should be unaffected - expect(servers[1].env).toBeUndefined(); + expect(getPath(result, 'mcp', 'servers', '1', 'env')).toBeUndefined(); }); it('preserves non-secret fields', () => { - const result = redactConfig(makeFullConfig() as any); + const result = redactConfig(asRedactInput(makeFullConfig())); // telegram - expect((result.telegram as any).allowed_chat_ids).toEqual([1]); - expect((result.telegram as any).require_mention).toBe(true); + expect(getPath(result, 'telegram', 'allowed_chat_ids')).toEqual([1]); + expect(getPath(result, 'telegram', 'require_mention')).toBe(true); // discord - expect((result.discord as any).allowed_guild_ids).toEqual(['g1']); + expect(getPath(result, 'discord', 'allowed_guild_ids')).toEqual(['g1']); // slack - expect((result.slack as any).allowed_channel_ids).toEqual([]); + expect(getPath(result, 'slack', 'allowed_channel_ids')).toEqual([]); // server - expect((result.server as any).port).toBe(18800); - expect((result.server as any).tailscale).toBeDefined(); + expect(getPath(result, 'server', 'port')).toBe(18800); + expect(getPath(result, 'server', 'tailscale')).toBeDefined(); // models - expect((result.models as any).default.provider).toBe('anthropic'); - expect((result.models as any).default.model).toBe('claude'); - expect((result.models as any).fallback_chain).toEqual(['anthropic']); + expect(getPath(result, 'models', 'default', 'provider')).toBe('anthropic'); + expect(getPath(result, 'models', 'default', 'model')).toBe('claude'); + expect(getPath(result, 'models', 'fallback_chain')).toEqual(['anthropic']); // web_search - expect((result.web_search as any).provider).toBe('brave'); - expect((result.web_search as any).max_results).toBe(5); + expect(getPath(result, 'web_search', 'provider')).toBe('brave'); + expect(getPath(result, 'web_search', 'max_results')).toBe(5); // audio - expect((result.audio as any).transcription_model).toBe('whisper-1'); + expect(getPath(result, 'audio', 'transcription_model')).toBe('whisper-1'); // memory - expect((result.memory as any).embedding.model).toBe('text-embedding-3-small'); + expect(getPath(result, 'memory', 'embedding', 'model')).toBe('text-embedding-3-small'); // hooks - expect((result.hooks as any).confirm).toEqual(['shell.exec']); + expect(getPath(result, 'hooks', 'confirm')).toEqual(['shell.exec']); // mcp - expect((result.mcp as any).servers[0].name).toBe('my-server'); - expect((result.mcp as any).servers[0].command).toBe('node'); + expect(getPath(result, 'mcp', 'servers', '0', 'name')).toBe('my-server'); + expect(getPath(result, 'mcp', 'servers', '0', 'command')).toBe('node'); }); it('handles missing optional sections gracefully', () => { @@ -1013,8 +1047,8 @@ describe('redactConfig – comprehensive credential redaction', () => { hooks: { confirm: [], log: [], silent: [] }, }; // Should not throw even when discord, slack, automation, mcp, etc. are absent - const result = redactConfig(minimal as any); - expect((result.telegram as any).bot_token).toBe('***'); + const result = redactConfig(asRedactInput(minimal)); + expect(getPath(result, 'telegram', 'bot_token')).toBe('***'); expect(result.discord).toBeUndefined(); expect(result.slack).toBeUndefined(); expect(result.automation).toBeUndefined(); @@ -1022,7 +1056,7 @@ describe('redactConfig – comprehensive credential redaction', () => { it('does not mutate the original config object', () => { const config = makeFullConfig(); - redactConfig(config as any); + redactConfig(asRedactInput(config)); // Original secrets should still be intact expect(config.telegram.bot_token).toBe('tg-secret'); expect(config.models.default.api_key).toBe('sk-def');