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
This commit is contained in:
@@ -2,10 +2,12 @@ import type { GatewayRequest, GatewayAttachment, OutboundMessage } from '../prot
|
||||
import type { SendFn } from '../router.js';
|
||||
import { makeEvent, makeError, ErrorCode } from '../protocol.js';
|
||||
import type { SessionBridge } from '../session-bridge.js';
|
||||
import type { LaneQueue } from '../lane-queue.js';
|
||||
import type { Attachment } from '../../channels/types.js';
|
||||
|
||||
export interface AgentHandlerDeps {
|
||||
sessionBridge: SessionBridge;
|
||||
laneQueue: LaneQueue;
|
||||
}
|
||||
|
||||
export function createAgentHandlers(deps: AgentHandlerDeps) {
|
||||
@@ -21,51 +23,56 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required (set by server)');
|
||||
}
|
||||
|
||||
if (deps.sessionBridge.isBusy(connectionId)) {
|
||||
return makeError(request.id, ErrorCode.AgentBusy, 'Agent is already processing a request');
|
||||
}
|
||||
|
||||
const agent = deps.sessionBridge.getAgent(connectionId);
|
||||
if (!agent) {
|
||||
return makeError(request.id, ErrorCode.SessionNotFound, 'No agent for this connection');
|
||||
}
|
||||
|
||||
deps.sessionBridge.setBusy(connectionId, true);
|
||||
// Queue by session ID so multiple connections sharing a session are serialised.
|
||||
// Falls back to connectionId if session lookup fails (shouldn't happen).
|
||||
const sessionId = deps.sessionBridge.getSessionId(connectionId);
|
||||
const laneId = sessionId ?? connectionId;
|
||||
|
||||
// Set up tool use callback to emit streaming events
|
||||
deps.sessionBridge.setOnToolUse(connectionId, (event) => {
|
||||
if (event.type === 'start') {
|
||||
send(makeEvent(request.id, 'tool_start', { tool: event.tool, args: event.args }));
|
||||
} else if (event.type === 'end') {
|
||||
send(makeEvent(request.id, 'tool_end', {
|
||||
tool: event.tool,
|
||||
result: event.result ? {
|
||||
success: event.result.success,
|
||||
output: event.result.output,
|
||||
error: event.result.error,
|
||||
} : undefined,
|
||||
// Enqueue the work — if the lane is idle it runs immediately,
|
||||
// otherwise it waits for earlier requests on the same session to finish.
|
||||
return deps.laneQueue.enqueue(laneId, async () => {
|
||||
deps.sessionBridge.setBusy(connectionId, true);
|
||||
|
||||
// Set up tool use callback to emit streaming events
|
||||
deps.sessionBridge.setOnToolUse(connectionId, (event) => {
|
||||
if (event.type === 'start') {
|
||||
send(makeEvent(request.id, 'tool_start', { tool: event.tool, args: event.args }));
|
||||
} else if (event.type === 'end') {
|
||||
send(makeEvent(request.id, 'tool_end', {
|
||||
tool: event.tool,
|
||||
result: event.result ? {
|
||||
success: event.result.success,
|
||||
output: event.result.output,
|
||||
error: event.result.error,
|
||||
} : undefined,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Convert gateway attachments to channel attachments
|
||||
const attachments: Attachment[] | undefined = params.attachments?.map(a => ({
|
||||
mimeType: a.mimeType,
|
||||
data: a.data,
|
||||
url: a.url,
|
||||
filename: a.filename,
|
||||
}));
|
||||
|
||||
const response = await agent.process(params.message!, attachments);
|
||||
send(makeEvent(request.id, 'done', { content: response }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
send(makeEvent(request.id, 'error', { code: ErrorCode.InternalError, message }));
|
||||
} finally {
|
||||
deps.sessionBridge.setBusy(connectionId, false);
|
||||
deps.sessionBridge.setOnToolUse(connectionId, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Convert gateway attachments to channel attachments
|
||||
const attachments: Attachment[] | undefined = params.attachments?.map(a => ({
|
||||
mimeType: a.mimeType,
|
||||
data: a.data,
|
||||
url: a.url,
|
||||
filename: a.filename,
|
||||
}));
|
||||
|
||||
const response = await agent.process(params.message, attachments);
|
||||
send(makeEvent(request.id, 'done', { content: response }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
send(makeEvent(request.id, 'error', { code: ErrorCode.InternalError, message }));
|
||||
} finally {
|
||||
deps.sessionBridge.setBusy(connectionId, false);
|
||||
deps.sessionBridge.setOnToolUse(connectionId, undefined);
|
||||
}
|
||||
},
|
||||
|
||||
'agent.cancel': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
|
||||
@@ -8,30 +8,90 @@ export interface ConfigHandlerDeps {
|
||||
|
||||
/**
|
||||
* Redact sensitive values from config before returning.
|
||||
* Replaces API keys, tokens, and passwords with "***".
|
||||
* 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.
|
||||
*/
|
||||
function redactConfig(config: Config): Record<string, unknown> {
|
||||
export function redactConfig(config: Config): Record<string, unknown> {
|
||||
const raw = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
|
||||
|
||||
// Redact telegram bot token
|
||||
const telegram = raw.telegram as Record<string, unknown> | undefined;
|
||||
if (telegram?.bot_token) {
|
||||
telegram.bot_token = '***';
|
||||
}
|
||||
// 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] = '***';
|
||||
}
|
||||
};
|
||||
|
||||
// Redact model keys/tokens
|
||||
// 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'] as const) {
|
||||
for (const tier of ['default', 'fast', 'complex', 'local']) {
|
||||
const m = models[tier] as Record<string, unknown> | undefined;
|
||||
if (m?.api_key) m.api_key = '***';
|
||||
if (m?.auth_token) m.auth_token = '***';
|
||||
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)) {
|
||||
if (provider.api_key) provider.api_key = '***';
|
||||
if (provider.auth_token) provider.auth_token = '***';
|
||||
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] = '***';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createSystemHandlers } from './system.js';
|
||||
import type { TokenUsageEntry } from './system.js';
|
||||
import { createSessionHandlers } from './sessions.js';
|
||||
import { createToolHandlers } from './tools.js';
|
||||
import { createAgentHandlers } from './agent.js';
|
||||
import { createConfigHandlers } from './config.js';
|
||||
import { createConfigHandlers, redactConfig } from './config.js';
|
||||
import { LaneQueue } from '../lane-queue.js';
|
||||
import { ErrorCode } from '../protocol.js';
|
||||
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
|
||||
|
||||
@@ -33,6 +35,64 @@ describe('system handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('system.tokenUsage handler', () => {
|
||||
it('returns empty sessions when no getTokenUsage provided', async () => {
|
||||
const handlers = createSystemHandlers({
|
||||
startTime: Date.now(),
|
||||
version: '0.1.0',
|
||||
getSessionCount: () => 0,
|
||||
getToolCount: () => 0,
|
||||
getConnectionCount: () => 0,
|
||||
});
|
||||
|
||||
const req: GatewayRequest = { id: 1, method: 'system.tokenUsage' };
|
||||
const result = await handlers['system.tokenUsage'](req) as GatewayResponse;
|
||||
|
||||
expect(result.id).toBe(1);
|
||||
const r = result.result as { sessions: unknown[] };
|
||||
expect(r.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns session usage data from getTokenUsage callback', async () => {
|
||||
const mockUsage: TokenUsageEntry[] = [
|
||||
{
|
||||
sessionId: 'telegram:user1',
|
||||
primary: { inputTokens: 1000, outputTokens: 500, calls: 3 },
|
||||
delegation: { fast: { inputTokens: 200, outputTokens: 100, calls: 1 } },
|
||||
total: { inputTokens: 1200, outputTokens: 600, calls: 4, estimatedCost: 0.0234 },
|
||||
},
|
||||
{
|
||||
sessionId: 'ws:abc-123',
|
||||
primary: { inputTokens: 50, outputTokens: 25, calls: 1 },
|
||||
delegation: {},
|
||||
total: { inputTokens: 50, outputTokens: 25, calls: 1, estimatedCost: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const handlers = createSystemHandlers({
|
||||
startTime: Date.now(),
|
||||
version: '0.1.0',
|
||||
getSessionCount: () => 2,
|
||||
getToolCount: () => 0,
|
||||
getConnectionCount: () => 1,
|
||||
getTokenUsage: () => mockUsage,
|
||||
});
|
||||
|
||||
const req: GatewayRequest = { id: 2, method: 'system.tokenUsage' };
|
||||
const result = await handlers['system.tokenUsage'](req) as GatewayResponse;
|
||||
|
||||
expect(result.id).toBe(2);
|
||||
const r = result.result as { sessions: typeof mockUsage };
|
||||
expect(r.sessions).toHaveLength(2);
|
||||
expect(r.sessions[0].sessionId).toBe('telegram:user1');
|
||||
expect(r.sessions[0].total.inputTokens).toBe(1200);
|
||||
expect(r.sessions[0].total.estimatedCost).toBe(0.0234);
|
||||
expect(r.sessions[0].delegation.fast.inputTokens).toBe(200);
|
||||
expect(r.sessions[1].sessionId).toBe('ws:abc-123');
|
||||
expect(r.sessions[1].total.calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session handlers', () => {
|
||||
const mockHistory = [
|
||||
{ role: 'user' as const, content: 'hello' },
|
||||
@@ -188,8 +248,11 @@ describe('agent handlers', () => {
|
||||
setOnToolUse: vi.fn(),
|
||||
};
|
||||
|
||||
const laneQueue = new LaneQueue();
|
||||
|
||||
const handlers = createAgentHandlers({
|
||||
sessionBridge: mockBridge as any,
|
||||
laneQueue,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -260,13 +323,38 @@ describe('agent handlers', () => {
|
||||
expect(result.error.message).toContain('message');
|
||||
});
|
||||
|
||||
it('agent.send rejects when busy', async () => {
|
||||
mockBridge.isBusy.mockReturnValue(true);
|
||||
const req: GatewayRequest = { id: 3, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } };
|
||||
const send = vi.fn();
|
||||
const result = await handlers['agent.send'](req, send) as GatewayError;
|
||||
it('agent.send queues concurrent requests instead of rejecting', async () => {
|
||||
// Simulate the first request blocking
|
||||
let resolveFirst!: () => void;
|
||||
const firstBlocks = new Promise<void>((r) => { resolveFirst = r; });
|
||||
let callCount = 0;
|
||||
mockAgent.process.mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
await firstBlocks;
|
||||
return 'first response';
|
||||
}
|
||||
return 'second response';
|
||||
});
|
||||
|
||||
expect(result.error.code).toBe(ErrorCode.AgentBusy);
|
||||
const req1: GatewayRequest = { id: 3, method: 'agent.send', params: { message: 'first', connectionId: 'conn-1' } };
|
||||
const req2: GatewayRequest = { id: 4, method: 'agent.send', params: { message: 'second', connectionId: 'conn-1' } };
|
||||
const sent1: OutboundMessage[] = [];
|
||||
const sent2: OutboundMessage[] = [];
|
||||
|
||||
const p1 = handlers['agent.send'](req1, vi.fn((msg: OutboundMessage) => sent1.push(msg)));
|
||||
const p2 = handlers['agent.send'](req2, vi.fn((msg: OutboundMessage) => sent2.push(msg)));
|
||||
|
||||
// Release the first request
|
||||
resolveFirst();
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
// Both should have completed — no AgentBusy error
|
||||
expect(sent1).toHaveLength(1);
|
||||
expect((sent1[0] as GatewayEvent).event).toBe('done');
|
||||
expect(sent2).toHaveLength(1);
|
||||
expect((sent2[0] as GatewayEvent).event).toBe('done');
|
||||
expect(mockAgent.process).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('agent.send handles errors gracefully', async () => {
|
||||
@@ -452,3 +540,210 @@ describe('config handlers', () => {
|
||||
expect(result.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactConfig – comprehensive credential redaction', () => {
|
||||
/**
|
||||
* Build a full config object with secrets in every possible location.
|
||||
* Optional sections (discord, slack, etc.) are included to test redaction.
|
||||
*/
|
||||
function makeFullConfig() {
|
||||
return {
|
||||
telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true },
|
||||
discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true },
|
||||
slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false },
|
||||
server: { tailscale_only: true, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true },
|
||||
models: {
|
||||
default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def',
|
||||
fallback: { provider: 'openai' as const, model: 'gpt-4', api_key: 'sk-def-fb', auth_token: 'at-def-fb' },
|
||||
},
|
||||
fast: { provider: 'openai' as const, model: 'gpt-4o-mini', api_key: 'sk-fast',
|
||||
fallback: { provider: 'gemini' as const, model: 'gemini-flash', api_key: 'sk-fast-fb' },
|
||||
},
|
||||
complex: { provider: 'anthropic' as const, model: 'claude-opus', auth_token: 'at-complex' },
|
||||
local: { provider: 'ollama' as const, model: 'llama3' },
|
||||
fallback_chain: ['anthropic'],
|
||||
local_providers: {
|
||||
ollama: { provider: 'ollama' as const, model: 'llama3', api_key: 'lp-key', auth_token: 'lp-token',
|
||||
fallback: { provider: 'llamacpp' as const, model: 'llama', api_key: 'lp-fb-key' },
|
||||
},
|
||||
},
|
||||
thinking: { anthropic: { budgetTokens: 4096 }, openai: { reasoningEffort: 'medium' as const }, gemini: { budgetTokens: 4096 } },
|
||||
},
|
||||
web_search: { provider: 'brave' as const, api_key: 'brave-key', endpoint: 'https://api.brave.com', max_results: 5 },
|
||||
audio: { transcription_endpoint: 'https://api.openai.com', transcription_api_key: 'audio-key', transcription_model: 'whisper-1' },
|
||||
memory: {
|
||||
enabled: true, auto_extract: true, max_context_tokens: 2000,
|
||||
embedding: { enabled: true, provider: 'openai' as const, model: 'text-embedding-3-small', api_key: 'embed-key', dimensions: 1536, chunk_size: 512, chunk_overlap: 50, top_k: 5, hybrid_weight: 0.7 },
|
||||
},
|
||||
automation: {
|
||||
cron: [],
|
||||
webhooks: [
|
||||
{ name: 'github', secret: 'wh-secret-1', message: '{{body}}', output: { channel: 'telegram', peer: '123' }, enabled: true },
|
||||
{ name: 'gitlab', secret: 'wh-secret-2', message: '{{body}}', output: { channel: 'telegram', peer: '456' }, enabled: true },
|
||||
{ name: 'no-secret', message: '{{body}}', output: { channel: 'telegram', peer: '789' }, enabled: true },
|
||||
],
|
||||
gmail: { enabled: true, credentials_file: '/path/to/creds.json', token_file: '/path/to/token.json', watch_labels: ['INBOX'], poll_interval: '60s', output: { channel: 'telegram', peer: '123' }, message: 'new email' },
|
||||
heartbeat: { enabled: false, interval: '5m', checks: ['gateway'], failure_threshold: 2, disk_threshold_mb: 100 },
|
||||
},
|
||||
mcp: {
|
||||
servers: [
|
||||
{ name: 'my-server', command: 'node', args: ['server.js'], env: { API_KEY: 'mcp-api-key', DATABASE_URL: 'postgres://secret@host/db' } },
|
||||
{ name: 'no-env', command: 'python', args: ['app.py'] },
|
||||
],
|
||||
},
|
||||
hooks: { confirm: ['shell.exec'], log: [], silent: [] },
|
||||
backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } },
|
||||
};
|
||||
}
|
||||
|
||||
it('redacts telegram.bot_token', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.telegram as any).bot_token).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts discord.bot_token', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.discord as any).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('***');
|
||||
});
|
||||
|
||||
it('redacts server.token', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.server as any).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('***');
|
||||
// local has no keys — should remain unchanged
|
||||
expect(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('***');
|
||||
});
|
||||
|
||||
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('***');
|
||||
});
|
||||
|
||||
it('redacts web_search.api_key', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.web_search as any).api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts audio.transcription_api_key', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.audio as any).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('***');
|
||||
});
|
||||
|
||||
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('***');
|
||||
// Webhook without a secret should remain unaffected
|
||||
expect(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('***');
|
||||
});
|
||||
|
||||
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('***');
|
||||
// Server without env should be unaffected
|
||||
expect(servers[1].env).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves non-secret fields', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
|
||||
// telegram
|
||||
expect((result.telegram as any).allowed_chat_ids).toEqual([1]);
|
||||
expect((result.telegram as any).require_mention).toBe(true);
|
||||
// discord
|
||||
expect((result.discord as any).allowed_guild_ids).toEqual(['g1']);
|
||||
// slack
|
||||
expect((result.slack as any).allowed_channel_ids).toEqual([]);
|
||||
// server
|
||||
expect((result.server as any).port).toBe(18800);
|
||||
expect((result.server as any).tailscale_only).toBe(true);
|
||||
// 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']);
|
||||
// web_search
|
||||
expect((result.web_search as any).provider).toBe('brave');
|
||||
expect((result.web_search as any).max_results).toBe(5);
|
||||
// audio
|
||||
expect((result.audio as any).transcription_model).toBe('whisper-1');
|
||||
// memory
|
||||
expect((result.memory as any).embedding.model).toBe('text-embedding-3-small');
|
||||
// hooks
|
||||
expect((result.hooks as any).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');
|
||||
});
|
||||
|
||||
it('handles missing optional sections gracefully', () => {
|
||||
const minimal = {
|
||||
telegram: { bot_token: 'tok', allowed_chat_ids: [1] },
|
||||
models: { default: { provider: 'anthropic' as const, model: 'claude' }, fallback_chain: [] },
|
||||
server: { port: 18800 },
|
||||
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('***');
|
||||
expect(result.discord).toBeUndefined();
|
||||
expect(result.slack).toBeUndefined();
|
||||
expect(result.automation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not mutate the original config object', () => {
|
||||
const config = makeFullConfig();
|
||||
redactConfig(config as any);
|
||||
// Original secrets should still be intact
|
||||
expect(config.telegram.bot_token).toBe('tg-secret');
|
||||
expect(config.models.default.api_key).toBe('sk-def');
|
||||
expect(config.server.token).toBe('bearer-secret');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { createSystemHandlers } from './system.js';
|
||||
export type { SystemHandlerDeps } from './system.js';
|
||||
export type { SystemHandlerDeps, TokenUsageEntry } from './system.js';
|
||||
export { createSessionHandlers } from './sessions.js';
|
||||
export type { SessionHandlerDeps } from './sessions.js';
|
||||
export { createToolHandlers } from './tools.js';
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||
|
||||
/** Per-session token usage report returned by system.tokenUsage. */
|
||||
export interface TokenUsageEntry {
|
||||
sessionId: string;
|
||||
primary: { inputTokens: number; outputTokens: number; calls: number };
|
||||
delegation: Record<string, { inputTokens: number; outputTokens: number; calls: number }>;
|
||||
total: { inputTokens: number; outputTokens: number; calls: number; estimatedCost: number };
|
||||
}
|
||||
|
||||
export interface SystemHandlerDeps {
|
||||
startTime: number;
|
||||
version: string;
|
||||
@@ -11,6 +19,8 @@ export interface SystemHandlerDeps {
|
||||
restart?: () => Promise<void>;
|
||||
getChannels?: () => Array<{ name: string; status: string }>;
|
||||
getUsage?: () => { totalSessions: number; activeConnections: number };
|
||||
/** Optional callback to retrieve per-session token usage data. */
|
||||
getTokenUsage?: () => TokenUsageEntry[];
|
||||
}
|
||||
|
||||
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
@@ -60,5 +70,10 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
tools: deps.getToolCount(),
|
||||
});
|
||||
},
|
||||
|
||||
'system.tokenUsage': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const sessions = deps.getTokenUsage?.() ?? [];
|
||||
return makeResponse(request.id, { sessions });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user