feat(gateway): add node capability negotiation foundation

This commit is contained in:
William Valentin
2026-02-16 12:14:25 -08:00
parent de0c1f41b3
commit d9f7807ab2
17 changed files with 675 additions and 7 deletions
+27
View File
@@ -138,6 +138,33 @@ describe('configSchema — server', () => {
expect(result.server.discovery.txt).toEqual({});
});
it('defaults node policy settings', () => {
const result = configSchema.parse(minimalConfig);
expect(result.server.nodes.enabled).toBe(false);
expect(result.server.nodes.allowed_roles).toEqual(['companion']);
expect(result.server.nodes.feature_gates).toEqual({});
});
it('accepts custom node policy settings', () => {
const result = configSchema.parse({
...minimalConfig,
server: {
nodes: {
enabled: true,
allowed_roles: ['companion', 'observer'],
feature_gates: {
'ui.canvas': true,
'fs.sync': false,
},
},
},
});
expect(result.server.nodes.enabled).toBe(true);
expect(result.server.nodes.allowed_roles).toEqual(['companion', 'observer']);
expect(result.server.nodes.feature_gates['ui.canvas']).toBe(true);
expect(result.server.nodes.feature_gates['fs.sync']).toBe(false);
});
it('accepts custom discovery settings', () => {
const result = configSchema.parse({
...minimalConfig,
+11
View File
@@ -79,6 +79,15 @@ const serverDiscoverySchema = z.object({
txt: z.record(z.string(), z.string()).default({}),
}).default({});
const serverNodePolicySchema = z.object({
/** Enable node registration/capability RPC surface. */
enabled: z.boolean().default(false),
/** Allowed node roles for node.register. */
allowed_roles: z.array(z.string().min(1)).default(['companion']),
/** Optional feature gates exposed via system/node capability APIs. */
feature_gates: z.record(z.string(), z.boolean()).default({}),
}).default({});
const serverSchema = z.object({
tailscale: tailscaleSchema,
localhost: z.boolean().default(true),
@@ -97,6 +106,8 @@ const serverSchema = z.object({
ws_rate_limit: wsRateLimitSchema,
/** Per-session gateway lane queue behavior. */
queue: laneQueueSchema,
/** Optional companion-node registration/capability settings. */
nodes: serverNodePolicySchema,
/** Optional Bonjour/mDNS advertisement settings. */
discovery: serverDiscoverySchema,
});
+5
View File
@@ -354,6 +354,11 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
),
},
},
nodes: {
enabled: config.server.nodes.enabled,
allowedRoles: config.server.nodes.allowed_roles,
featureGates: config.server.nodes.feature_gates,
},
discovery: {
enabled: config.server.discovery.enabled,
serviceName: config.server.discovery.service_name,
+53 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { authenticateRequest } from './auth.js';
import { authenticateRequest, authorizeNodeMethod } from './auth.js';
import type { IncomingMessage } from 'http';
function mockRequest(headers: Record<string, string> = {}): IncomingMessage {
@@ -127,3 +127,55 @@ describe('authenticateRequest', () => {
});
});
});
describe('authorizeNodeMethod', () => {
it('allows non-node methods', () => {
const result = authorizeNodeMethod({ enabled: false, method: 'system.health' });
expect(result.authenticated).toBe(true);
});
it('blocks node methods when node RPC is disabled', () => {
const result = authorizeNodeMethod({ enabled: false, method: 'node.capabilities.get' });
expect(result.authenticated).toBe(false);
expect(result.error).toContain('disabled');
});
it('allows node.register without prior registration', () => {
const result = authorizeNodeMethod({ enabled: true, method: 'node.register' });
expect(result.authenticated).toBe(true);
});
it('requires role for scoped node methods', () => {
const result = authorizeNodeMethod({ enabled: true, method: 'node.capabilities.get' });
expect(result.authenticated).toBe(false);
expect(result.error).toContain('not registered');
});
it('enforces allowed role list and method scopes', () => {
const deniedRole = authorizeNodeMethod({
enabled: true,
method: 'node.capabilities.get',
nodeRole: 'observer',
allowedRoles: ['companion'],
});
expect(deniedRole.authenticated).toBe(false);
const deniedMethod = authorizeNodeMethod({
enabled: true,
method: 'node.admin.reset',
nodeRole: 'companion',
allowedRoles: ['companion'],
roleScopes: { companion: ['node.capabilities.get'] },
});
expect(deniedMethod.authenticated).toBe(false);
const allowed = authorizeNodeMethod({
enabled: true,
method: 'node.capabilities.get',
nodeRole: 'companion',
allowedRoles: ['companion'],
roleScopes: { companion: ['node.capabilities.get'] },
});
expect(allowed.authenticated).toBe(true);
});
});
+40
View File
@@ -13,6 +13,14 @@ export interface AuthResult {
error?: string;
}
export interface NodeAuthScopeConfig {
enabled: boolean;
method: string;
nodeRole?: string;
allowedRoles?: string[];
roleScopes?: Record<string, string[]>;
}
/**
* Authenticates a WebSocket upgrade request or HTTP request.
*
@@ -69,6 +77,38 @@ export function authenticateRequest(req: IncomingMessage, config: AuthConfig): A
return { authenticated: true, identity: 'anonymous' };
}
export function authorizeNodeMethod(config: NodeAuthScopeConfig): AuthResult {
if (!config.method.startsWith('node.')) {
return { authenticated: true };
}
if (!config.enabled) {
return { authenticated: false, error: 'Node RPC is disabled' };
}
if (config.method === 'node.register') {
return { authenticated: true };
}
if (!config.nodeRole) {
return { authenticated: false, error: 'Node not registered for this connection' };
}
const allowedRoles = config.allowedRoles ?? [];
if (allowedRoles.length > 0 && !allowedRoles.includes(config.nodeRole)) {
return { authenticated: false, error: `Node role '${config.nodeRole}' is not allowed` };
}
const defaultScopes = ['node.capabilities.get'];
const roleScopes = config.roleScopes ?? {};
const permitted = roleScopes[config.nodeRole] ?? defaultScopes;
if (!permitted.includes(config.method)) {
return { authenticated: false, error: `Method '${config.method}' is not permitted for node role '${config.nodeRole}'` };
}
return { authenticated: true };
}
function extractQueryToken(req: IncomingMessage): string | undefined {
try {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
+2
View File
@@ -17,3 +17,5 @@ export { createRoutingHandlers } from './routing.js';
export type { RoutingHandlerDeps } from './routing.js';
export { createHistoryHandlers } from './history.js';
export type { HistoryHandlerDeps } from './history.js';
export { createNodeHandlers } from './node.js';
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState } from './node.js';
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { createNodeHandlers, type NodeConnectionState } from './node.js';
describe('node handlers', () => {
it('registers node and returns negotiated capabilities', async () => {
const states = new Map<string, NodeConnectionState>([['conn-1', {}]]);
const handlers = createNodeHandlers({
enabled: true,
allowedRoles: ['companion'],
featureGates: { 'ui.canvas': true, 'dangerous.write': false },
getConnectionState: (connectionId) => states.get(connectionId),
setNodeRegistration: (connectionId, registration) => {
const prior = states.get(connectionId) ?? {};
states.set(connectionId, { ...prior, node: registration });
},
});
const result = await handlers['node.register']({
id: 1,
method: 'node.register',
params: {
connectionId: 'conn-1',
nodeId: 'node-a',
role: 'companion',
protocolVersion: 1,
capabilities: ['ui.canvas', 'dangerous.write'],
},
});
expect((result as { result: { registered: boolean } }).result.registered).toBe(true);
const caps = (result as { result: { capabilities: { enabled: string[] } } }).result.capabilities.enabled;
expect(caps).toEqual(['ui.canvas']);
expect(states.get('conn-1')?.node?.role).toBe('companion');
});
it('denies registration for disallowed roles', async () => {
const states = new Map<string, NodeConnectionState>([['conn-1', {}]]);
const handlers = createNodeHandlers({
enabled: true,
allowedRoles: ['companion'],
featureGates: {},
getConnectionState: (connectionId) => states.get(connectionId),
setNodeRegistration: () => {},
});
const result = await handlers['node.register']({
id: 2,
method: 'node.register',
params: {
connectionId: 'conn-1',
nodeId: 'node-a',
role: 'observer',
protocolVersion: 1,
capabilities: [],
},
});
expect((result as { error: { message: string } }).error.message).toContain('not allowed');
});
it('returns capabilities for registered node connections', async () => {
const states = new Map<string, NodeConnectionState>([['conn-1', {
node: {
nodeId: 'node-a',
role: 'companion',
protocolVersion: 1,
capabilities: ['ui.canvas'],
registeredAt: Date.now(),
},
}]]);
const handlers = createNodeHandlers({
enabled: true,
allowedRoles: ['companion'],
featureGates: { 'ui.canvas': true },
getConnectionState: (connectionId) => states.get(connectionId),
setNodeRegistration: () => {},
});
const result = await handlers['node.capabilities.get']({
id: 3,
method: 'node.capabilities.get',
params: { connectionId: 'conn-1' },
});
const enabled = (result as { result: { capabilities: { enabled: string[] } } }).result.capabilities.enabled;
expect(enabled).toEqual(['ui.canvas']);
});
});
+129
View File
@@ -0,0 +1,129 @@
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeError, makeResponse, ErrorCode, GATEWAY_PROTOCOL_VERSION, parseNodeRegisterParams } from '../protocol.js';
export interface NodeRegistration {
nodeId: string;
role: string;
protocolVersion: number;
capabilities: string[];
registeredAt: number;
}
export interface NodeConnectionState {
identity?: string;
node?: NodeRegistration;
}
export interface NodeHandlerDeps {
enabled: boolean;
allowedRoles: string[];
featureGates: Record<string, boolean>;
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
}
export function createNodeHandlers(deps: NodeHandlerDeps) {
return {
'node.register': async (request: GatewayRequest): Promise<OutboundMessage> => {
if (!deps.enabled) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
}
const parsed = parseNodeRegisterParams(request.params);
if (!parsed) {
return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.register params');
}
if (deps.allowedRoles.length > 0 && !deps.allowedRoles.includes(parsed.role)) {
return makeError(request.id, ErrorCode.AuthFailed, `Node role '${parsed.role}' is not allowed`);
}
const negotiatedVersion = Math.min(GATEWAY_PROTOCOL_VERSION, parsed.protocolVersion);
if (negotiatedVersion < 1) {
return makeError(request.id, ErrorCode.InvalidRequest, 'Unsupported protocolVersion');
}
const dedupedCapabilities = Array.from(new Set(parsed.capabilities.map((entry) => entry.trim()).filter(Boolean)));
deps.setNodeRegistration(parsed.connectionId, {
nodeId: parsed.nodeId,
role: parsed.role,
protocolVersion: parsed.protocolVersion,
capabilities: dedupedCapabilities,
registeredAt: Date.now(),
});
const enabledCapabilities = dedupedCapabilities.filter((capability) => deps.featureGates[capability] !== false);
return makeResponse(request.id, {
registered: true,
node: {
id: parsed.nodeId,
role: parsed.role,
},
protocol: {
serverVersion: GATEWAY_PROTOCOL_VERSION,
clientVersion: parsed.protocolVersion,
negotiatedVersion,
},
capabilities: {
declared: dedupedCapabilities,
enabled: enabledCapabilities,
},
});
},
'node.capabilities.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
if (!deps.enabled) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
}
const params = request.params as { connectionId?: string } | undefined;
const connectionId = params?.connectionId;
if (!connectionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required');
}
const state = deps.getConnectionState(connectionId);
if (!state?.node) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection');
}
const enabledCapabilities = state.node.capabilities.filter((capability) => deps.featureGates[capability] !== false);
return makeResponse(request.id, {
protocol: {
serverVersion: GATEWAY_PROTOCOL_VERSION,
nodeVersion: state.node.protocolVersion,
negotiatedVersion: Math.min(GATEWAY_PROTOCOL_VERSION, state.node.protocolVersion),
},
node: {
id: state.node.nodeId,
role: state.node.role,
registeredAt: state.node.registeredAt,
},
capabilities: {
declared: state.node.capabilities,
enabled: enabledCapabilities,
featureGates: deps.featureGates,
},
});
},
'system.capabilities': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { connectionId?: string } | undefined;
const connectionId = params?.connectionId;
const state = connectionId ? deps.getConnectionState(connectionId) : undefined;
return makeResponse(request.id, {
protocol: {
version: GATEWAY_PROTOCOL_VERSION,
},
nodes: {
enabled: deps.enabled,
allowedRoles: deps.allowedRoles,
registered: Boolean(state?.node),
role: state?.node?.role,
nodeId: state?.node?.nodeId,
},
featureGates: deps.featureGates,
});
},
};
}
+44
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
isValidRequest,
parseMessage,
parseNodeRegisterParams,
makeResponse,
makeError,
makeEvent,
@@ -60,6 +61,49 @@ describe('protocol', () => {
});
});
describe('parseNodeRegisterParams', () => {
it('parses valid node registration params', () => {
const parsed = parseNodeRegisterParams({
connectionId: 'conn-1',
nodeId: 'node-a',
role: 'companion',
protocolVersion: 1,
capabilities: ['ui.canvas', 'notifications'],
});
expect(parsed).toEqual({
connectionId: 'conn-1',
nodeId: 'node-a',
role: 'companion',
protocolVersion: 1,
capabilities: ['ui.canvas', 'notifications'],
});
});
it('rejects invalid node registration params', () => {
expect(parseNodeRegisterParams({
connectionId: 'conn-1',
nodeId: '',
role: 'companion',
protocolVersion: 1,
capabilities: [],
})).toBeNull();
expect(parseNodeRegisterParams({
connectionId: 'conn-1',
nodeId: 'node',
role: 'companion',
protocolVersion: 0,
capabilities: [],
})).toBeNull();
expect(parseNodeRegisterParams({
connectionId: 'conn-1',
nodeId: 'node',
role: 'companion',
protocolVersion: 1,
capabilities: [1],
})).toBeNull();
});
});
describe('makeResponse', () => {
it('creates a response message', () => {
expect(makeResponse(1, { status: 'ok' })).toEqual({
+41
View File
@@ -8,6 +8,16 @@ export interface GatewayRequest {
params?: Record<string, unknown>;
}
export const GATEWAY_PROTOCOL_VERSION = 1;
export interface NodeRegisterParams {
connectionId: string;
nodeId: string;
role: string;
protocolVersion: number;
capabilities: string[];
}
// ── Server → Client ────────────────────────────────────────────
export interface GatewayResponse {
@@ -129,6 +139,37 @@ export function parseMessage(raw: string): GatewayRequest | null {
}
}
export function parseNodeRegisterParams(params: unknown): NodeRegisterParams | null {
if (!params || typeof params !== 'object') {
return null;
}
const p = params as Record<string, unknown>;
if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) {
return null;
}
if (typeof p.nodeId !== 'string' || !p.nodeId.trim()) {
return null;
}
if (typeof p.role !== 'string' || !p.role.trim()) {
return null;
}
if (typeof p.protocolVersion !== 'number' || !Number.isFinite(p.protocolVersion) || p.protocolVersion < 1) {
return null;
}
const capabilitiesRaw = p.capabilities;
if (!Array.isArray(capabilitiesRaw) || !capabilitiesRaw.every((entry) => typeof entry === 'string')) {
return null;
}
return {
connectionId: p.connectionId,
nodeId: p.nodeId,
role: p.role,
protocolVersion: Math.floor(p.protocolVersion),
capabilities: capabilitiesRaw,
};
}
export function makeResponse(id: number, result: unknown): GatewayResponse {
return { id, result };
}
+3
View File
@@ -14,6 +14,9 @@ export class Router {
}
async dispatch(request: GatewayRequest, send: SendFn): Promise<OutboundMessage | void> {
if (request.method.startsWith('node.') && !request.params) {
return makeError(request.id, ErrorCode.InvalidRequest, 'params are required for node methods');
}
const handler = this.handlers.get(request.method);
if (!handler) {
return makeError(request.id, ErrorCode.MethodNotFound, `Unknown method: ${request.method}`);
+83
View File
@@ -577,3 +577,86 @@ describe('GatewayServer WebSocket ingress rate limiting', () => {
}
});
});
describe('GatewayServer node registration and capability negotiation', () => {
const NODE_PORT = 18894;
let nodeServer: GatewayServer;
beforeAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
nodeServer = new GatewayServer({
port: NODE_PORT,
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
modelClient: mockModelClient,
systemPrompt: 'Test prompt',
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
uiDir: resolve(import.meta.dirname, 'ui'),
nodes: {
enabled: true,
allowedRoles: ['companion'],
featureGates: { 'ui.canvas': true },
},
});
await nodeServer.start();
});
afterAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
await nodeServer.stop();
});
it('enforces role allow/deny and node registration lifecycle', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const ws = await new Promise<WebSocket>((resolve, reject) => {
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
c.on('open', () => resolve(c));
c.on('error', reject);
});
try {
const beforeRegister = await sendAndReceive(ws, { id: 1, method: 'node.capabilities.get', params: {} });
expect((beforeRegister as GatewayError).error.code).toBe(ErrorCode.AuthFailed);
const badRole = await sendAndReceive(ws, {
id: 2,
method: 'node.register',
params: {
nodeId: 'node-bad',
role: 'observer',
protocolVersion: 1,
capabilities: ['ui.canvas'],
},
});
expect((badRole as GatewayError).error.code).toBe(ErrorCode.AuthFailed);
const registered = await sendAndReceive(ws, {
id: 3,
method: 'node.register',
params: {
nodeId: 'node-good',
role: 'companion',
protocolVersion: 1,
capabilities: ['ui.canvas'],
},
});
expect((registered as GatewayResponse).id).toBe(3);
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
const capabilities = await sendAndReceive(ws, { id: 4, method: 'node.capabilities.get', params: {} });
expect((capabilities as GatewayResponse).id).toBe(4);
expect(((capabilities as GatewayResponse).result as { node: { role: string } }).node.role).toBe('companion');
} finally {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
});
});
+49 -2
View File
@@ -8,7 +8,7 @@ import type { SessionBridgeConfig } from './session-bridge.js';
import { LaneQueue } from './lane-queue.js';
import type { LaneQueueConfig } from './lane-queue.js';
import { MetricsCollector } from './metrics.js';
import { authenticateRequest } from './auth.js';
import { authenticateRequest, authorizeNodeMethod } from './auth.js';
import type { AuthConfig } from './auth.js';
import { startGatewayDiscovery, type GatewayDiscoveryHandle } from './discovery.js';
import {
@@ -28,9 +28,11 @@ import {
createIntentHandlers,
createRoutingHandlers,
createHistoryHandlers,
createNodeHandlers,
} from './handlers/index.js';
import { discoverServices } from './handlers/services.js';
import type { TokenUsageEntry } from './handlers/system.js';
import type { NodeConnectionState } from './handlers/node.js';
import type { SessionManager } from '../session/manager.js';
import type { Config } from '../config/index.js';
import type { ToolRegistry } from '../tools/registry.js';
@@ -91,6 +93,11 @@ export interface GatewayServerConfig {
sessions?: Record<string, Partial<LaneQueueConfig>>;
};
};
nodes?: {
enabled: boolean;
allowedRoles: string[];
featureGates: Record<string, boolean>;
};
/** Optional pairing manager for DM pairing code management via gateway. */
pairingManager?: PairingManager;
memoryStore?: MemoryStore;
@@ -134,6 +141,7 @@ export class GatewayServer {
violations: number;
windowStartMs: number;
}> = new Map();
private connectionStateMap: Map<string, NodeConnectionState> = new Map();
private config: GatewayServerConfig;
private startTime: number = Date.now();
@@ -269,6 +277,23 @@ export class GatewayServer {
routingPolicy: this.config.routingPolicy,
});
const nodeHandlers = createNodeHandlers({
enabled: this.config.nodes?.enabled ?? false,
allowedRoles: this.config.nodes?.allowedRoles ?? [],
featureGates: this.config.nodes?.featureGates ?? {},
getConnectionState: (connectionId) => this.connectionStateMap.get(connectionId),
setNodeRegistration: (connectionId, registration) => {
const existing = this.connectionStateMap.get(connectionId);
if (!existing) {
return;
}
this.connectionStateMap.set(connectionId, {
...existing,
node: registration,
});
},
});
// Config handlers (only if config object is provided)
if (this.config.config) {
const configHandlers = createConfigHandlers({
@@ -310,6 +335,9 @@ export class GatewayServer {
for (const [method, handler] of Object.entries(routingHandlers)) {
this.router.register(method, handler);
}
for (const [method, handler] of Object.entries(nodeHandlers)) {
this.router.register(method, handler);
}
}
async start(): Promise<void> {
@@ -370,6 +398,7 @@ export class GatewayServer {
ws.close(1001, 'Server shutting down');
}
this.connectionMap.clear();
this.connectionStateMap.clear();
// Close WSS first, then the underlying HTTP server
await new Promise<void>((resolve) => {
@@ -395,7 +424,7 @@ export class GatewayServer {
});
}
private handleConnection(ws: WebSocket, _identity?: string): void {
private handleConnection(ws: WebSocket, identity?: string): void {
// Gateway lock — reject if another client is already connected
if (this.config.lock && this.connectionMap.size > 0) {
ws.close(4003, 'Gateway locked — another client is already connected');
@@ -405,6 +434,7 @@ export class GatewayServer {
const connectionId = randomUUID();
this.sessionBridge.connect(connectionId);
this.connectionMap.set(ws, connectionId);
this.connectionStateMap.set(connectionId, { identity });
this.connectionRateMap.set(connectionId, {
tokens: this.getWsRateConfig().capacity,
lastRefillMs: Date.now(),
@@ -429,6 +459,7 @@ export class GatewayServer {
this.sessionBridge.disconnect(connectionId);
this.connectionMap.delete(ws);
this.connectionRateMap.delete(connectionId);
this.connectionStateMap.delete(connectionId);
});
ws.on('error', (err) => {
@@ -598,6 +629,22 @@ export class GatewayServer {
if (!request.params) {request.params = {};}
request.params.connectionId = connectionId;
const nodeAuth = authorizeNodeMethod({
enabled: this.config.nodes?.enabled ?? false,
method: request.method,
nodeRole: this.connectionStateMap.get(connectionId)?.node?.role,
allowedRoles: this.config.nodes?.allowedRoles ?? [],
roleScopes: {
companion: ['node.capabilities.get'],
observer: ['node.capabilities.get'],
automation: ['node.capabilities.get'],
},
});
if (!nodeAuth.authenticated) {
this.send(ws, makeError(request.id, ErrorCode.AuthFailed, nodeAuth.error ?? 'Node authorization failed'));
return;
}
const send = (msg: OutboundMessage) => this.send(ws, msg);
const response = await this.router.dispatch(request, send);