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:
William Valentin
2026-02-05 20:00:36 -08:00
parent 282a15d2b9
commit aa95f2132c
19 changed files with 4123 additions and 37 deletions
+98
View File
@@ -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 });
},
};
}
+142
View File
@@ -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);
});
});
+2
View File
@@ -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';
+21 -1
View File
@@ -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;
},
};
}
+14
View File
@@ -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);