feat: add channel adapter abstraction with Telegram and WebChat adapters
Implement Phase 3 channel adapters that decouple message sources from the agent via a uniform ChannelAdapter interface and ChannelRegistry. - Add ChannelAdapter/InboundMessage/OutboundMessage types - Add ChannelRegistry for adapter lifecycle and message routing - Add TelegramAdapter (grammy bot, auth middleware, confirmations, chunking) - Add WebChatAdapter (thin shim over GatewayServer) - Refactor daemon to use ChannelRegistry with per-channel-per-user agents - Add config.get/config.patch gateway handlers (Phase 2 loose end) - Add system.restart gateway handler (Phase 2 loose end) - Add implementation plans and design docs Tests: 225 passing (33 new channel adapter + gateway handler tests)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
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, and passwords with "***".
|
||||
*/
|
||||
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 = '***';
|
||||
}
|
||||
|
||||
// Redact model keys/tokens
|
||||
const models = raw.models as Record<string, unknown> | undefined;
|
||||
if (models) {
|
||||
for (const tier of ['default', 'fast', 'complex', 'local'] as const) {
|
||||
const m = models[tier] as Record<string, unknown> | undefined;
|
||||
if (m?.api_key) m.api_key = '***';
|
||||
if (m?.auth_token) m.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 = '***';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createSystemHandlers } from './system.js';
|
||||
import { createSessionHandlers } from './sessions.js';
|
||||
import { createToolHandlers } from './tools.js';
|
||||
import { createAgentHandlers } from './agent.js';
|
||||
import { createConfigHandlers } from './config.js';
|
||||
import { ErrorCode } from '../protocol.js';
|
||||
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
|
||||
|
||||
@@ -269,3 +270,144 @@ describe('agent handlers', () => {
|
||||
expect((result.result as any).cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('system.restart handler', () => {
|
||||
it('returns restarting:true and calls restart callback', async () => {
|
||||
const restartFn = vi.fn(async () => {});
|
||||
const handlers = createSystemHandlers({
|
||||
startTime: Date.now(),
|
||||
version: '0.1.0',
|
||||
getSessionCount: () => 0,
|
||||
getToolCount: () => 0,
|
||||
getConnectionCount: () => 0,
|
||||
restart: restartFn,
|
||||
});
|
||||
|
||||
const req: GatewayRequest = { id: 1, method: 'system.restart' };
|
||||
const result = await handlers['system.restart'](req) as GatewayResponse;
|
||||
|
||||
expect(result.id).toBe(1);
|
||||
expect((result.result as any).restarting).toBe(true);
|
||||
|
||||
// Restart is called asynchronously via queueMicrotask
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
expect(restartFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns error when restart is not available', async () => {
|
||||
const handlers = createSystemHandlers({
|
||||
startTime: Date.now(),
|
||||
version: '0.1.0',
|
||||
getSessionCount: () => 0,
|
||||
getToolCount: () => 0,
|
||||
getConnectionCount: () => 0,
|
||||
});
|
||||
|
||||
const req: GatewayRequest = { id: 2, method: 'system.restart' };
|
||||
const result = await handlers['system.restart'](req) as GatewayError;
|
||||
|
||||
expect(result.error.code).toBe(ErrorCode.InternalError);
|
||||
expect(result.error.message).toContain('not available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config handlers', () => {
|
||||
function makeConfig() {
|
||||
return {
|
||||
telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] },
|
||||
server: { tailscale_only: true, localhost: true, port: 18800 },
|
||||
models: {
|
||||
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
|
||||
fallback_chain: ['anthropic'],
|
||||
},
|
||||
backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } },
|
||||
hooks: { confirm: ['shell.exec'], log: [], silent: [] },
|
||||
mcp: { servers: [] },
|
||||
};
|
||||
}
|
||||
|
||||
it('config.get returns redacted config', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
const req: GatewayRequest = { id: 1, method: 'config.get' };
|
||||
const result = await handlers['config.get'](req) as GatewayResponse;
|
||||
|
||||
const r = result.result as Record<string, any>;
|
||||
expect(r.telegram.bot_token).toBe('***');
|
||||
expect(r.models.default.api_key).toBe('***');
|
||||
// Non-secret values are preserved
|
||||
expect(r.server.port).toBe(18800);
|
||||
expect(r.hooks.confirm).toEqual(['shell.exec']);
|
||||
});
|
||||
|
||||
it('config.patch applies valid patches', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
const req: GatewayRequest = {
|
||||
id: 2,
|
||||
method: 'config.patch',
|
||||
params: {
|
||||
patches: {
|
||||
'hooks.confirm': ['shell.exec', 'file.write'],
|
||||
'hooks.log': ['file.read'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await handlers['config.patch'](req) as GatewayResponse;
|
||||
|
||||
const r = result.result as { applied: string[]; rejected: string[] };
|
||||
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log']);
|
||||
expect(r.rejected).toEqual([]);
|
||||
// Verify the config was actually mutated
|
||||
expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
|
||||
expect(config.hooks.log).toEqual(['file.read']);
|
||||
});
|
||||
|
||||
it('config.patch rejects unknown keys', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
const req: GatewayRequest = {
|
||||
id: 3,
|
||||
method: 'config.patch',
|
||||
params: {
|
||||
patches: {
|
||||
'telegram.bot_token': 'hacked',
|
||||
'hooks.confirm': [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await handlers['config.patch'](req) as GatewayResponse;
|
||||
|
||||
const r = result.result as { applied: string[]; rejected: string[] };
|
||||
expect(r.applied).toEqual(['hooks.confirm']);
|
||||
expect(r.rejected).toEqual(['telegram.bot_token']);
|
||||
});
|
||||
|
||||
it('config.patch rejects invalid value types', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
const req: GatewayRequest = {
|
||||
id: 4,
|
||||
method: 'config.patch',
|
||||
params: {
|
||||
patches: {
|
||||
'hooks.confirm': 'not-an-array',
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await handlers['config.patch'](req) as GatewayResponse;
|
||||
|
||||
const r = result.result as { applied: string[]; rejected: string[] };
|
||||
expect(r.applied).toEqual([]);
|
||||
expect(r.rejected).toEqual(['hooks.confirm']);
|
||||
});
|
||||
|
||||
it('config.patch requires patches object', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
const req: GatewayRequest = { id: 5, method: 'config.patch', params: {} };
|
||||
const result = await handlers['config.patch'](req) as GatewayError;
|
||||
|
||||
expect(result.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,3 +6,5 @@ export { createToolHandlers } from './tools.js';
|
||||
export type { ToolHandlerDeps } from './tools.js';
|
||||
export { createAgentHandlers } from './agent.js';
|
||||
export type { AgentHandlerDeps } from './agent.js';
|
||||
export { createConfigHandlers } from './config.js';
|
||||
export type { ConfigHandlerDeps } from './config.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeResponse } from '../protocol.js';
|
||||
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||
|
||||
export interface SystemHandlerDeps {
|
||||
startTime: number;
|
||||
@@ -7,6 +7,8 @@ export interface SystemHandlerDeps {
|
||||
getSessionCount: () => number;
|
||||
getToolCount: () => number;
|
||||
getConnectionCount: () => number;
|
||||
/** Optional callback to trigger a graceful restart. If not provided, system.restart returns an error. */
|
||||
restart?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
@@ -21,5 +23,23 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
connections: deps.getConnectionCount(),
|
||||
});
|
||||
},
|
||||
|
||||
'system.restart': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.restart) {
|
||||
return makeError(request.id, ErrorCode.InternalError, 'Restart not available in this environment');
|
||||
}
|
||||
|
||||
// Send response before initiating restart (client receives confirmation)
|
||||
const response = makeResponse(request.id, { restarting: true });
|
||||
|
||||
// Schedule restart after response is sent (next tick)
|
||||
queueMicrotask(() => {
|
||||
deps.restart!().catch((err) => {
|
||||
console.error('Restart failed:', err);
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
createSessionHandlers,
|
||||
createToolHandlers,
|
||||
createAgentHandlers,
|
||||
createConfigHandlers,
|
||||
} from './handlers/index.js';
|
||||
import type { SessionManager } from '../session/manager.js';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { ToolRegistry } from '../tools/registry.js';
|
||||
import type { ToolExecutor } from '../tools/executor.js';
|
||||
|
||||
@@ -34,6 +36,9 @@ export interface GatewayServerConfig {
|
||||
version?: string;
|
||||
auth?: AuthConfig;
|
||||
uiDir?: string;
|
||||
config?: Config;
|
||||
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
||||
restart?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class GatewayServer {
|
||||
@@ -67,6 +72,7 @@ export class GatewayServer {
|
||||
getSessionCount: () => this.sessionBridge.listSessions().length,
|
||||
getToolCount: () => this.config.toolRegistry.list().length,
|
||||
getConnectionCount: () => this.sessionBridge.connectionCount,
|
||||
restart: this.config.restart,
|
||||
});
|
||||
|
||||
const sessionHandlers = createSessionHandlers({
|
||||
@@ -82,6 +88,14 @@ export class GatewayServer {
|
||||
sessionBridge: this.sessionBridge,
|
||||
});
|
||||
|
||||
// Config handlers (only if config object is provided)
|
||||
if (this.config.config) {
|
||||
const configHandlers = createConfigHandlers({ config: this.config.config });
|
||||
for (const [method, handler] of Object.entries(configHandlers)) {
|
||||
this.router.register(method, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Register all methods
|
||||
for (const [method, handler] of Object.entries(systemHandlers)) {
|
||||
this.router.register(method, handler);
|
||||
|
||||
Reference in New Issue
Block a user