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
+19
View File
@@ -34,6 +34,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces.
- **Tailscale Serve**: Auto-expose gateway via Tailscale Serve on daemon start with lifecycle management
- **DM Pairing Codes**: Allow unknown senders to pair with the bot via time-limited codes across all channels, with SQLite-backed persistence across restarts
- **Lane Queue**: Per-session FIFO queue serializes concurrent gateway requests
- **Node Capability Negotiation**: Optional companion-node registration and capability discovery over gateway RPC
## Quick Start
@@ -846,6 +847,24 @@ Runtime session controls from chat commands:
- `/queue set <mode|cap|overflow|debounce_ms|summarize_overflow> <value>` sets a per-session override.
- `/queue reset` clears per-session queue overrides.
## Gateway Node Capability Negotiation
Optional gateway surface for companion clients and node-role negotiation:
```yaml
server:
nodes:
enabled: true
allowed_roles: [companion]
feature_gates:
ui.canvas: true
```
Methods:
- `node.register` registers role + declared capabilities for the current connection.
- `node.capabilities.get` returns negotiated protocol version and enabled capabilities.
- `system.capabilities` returns gateway protocol and node policy snapshot.
## Gateway Request Body Limit
Cap inbound HTTP POST body size (webhooks and Gmail push) to reduce memory-DoS risk.
+5
View File
@@ -79,6 +79,11 @@ server:
overrides:
channels: {} # e.g. ws: { mode: followup, cap: 10, debounce_ms: 100 }
sessions: {} # e.g. ws:vip-user: { mode: interrupt, overflow: drop_new }
# Companion-node capability negotiation surface (default disabled).
nodes:
enabled: false
allowed_roles: [companion]
feature_gates: {}
# Local-network service discovery (mDNS/Bonjour). Keep disabled by default.
# Requires server.localhost: false so LAN clients can actually connect.
discovery:
+45
View File
@@ -21,6 +21,7 @@ The gateway provides:
- **JSON-RPC 2.0**: Structured request/response protocol
- **Streaming Events**: Real-time updates during agent processing
- **HTTP Server**: Serves static dashboard and handles webhook endpoints
- **Node Capability Negotiation**: Optional companion-node role/capability registration
### Execution Model (Sessions + Per-Session Queue)
@@ -563,6 +564,50 @@ When queue policy rejects/supersedes a request before execution, the server emit
Cancel the current agent operation.
### Node Methods
#### `node.register`
Register node role/capabilities for the current WebSocket connection.
**Request:**
```json
{
"id": 9,
"method": "node.register",
"params": {
"nodeId": "companion-desktop",
"role": "companion",
"protocolVersion": 1,
"capabilities": ["ui.canvas", "notifications"]
}
}
```
**Response:**
```json
{
"id": 9,
"result": {
"registered": true,
"node": { "id": "companion-desktop", "role": "companion" },
"protocol": { "serverVersion": 1, "clientVersion": 1, "negotiatedVersion": 1 },
"capabilities": {
"declared": ["ui.canvas", "notifications"],
"enabled": ["ui.canvas", "notifications"]
}
}
}
```
#### `node.capabilities.get`
Return negotiated capabilities for the currently registered node connection.
#### `system.capabilities`
Return gateway protocol version, node policy status, and feature-gate snapshot.
**Request:**
```json
{
+31 -4
View File
@@ -33,10 +33,10 @@
"test_status": "pnpm test:run src/gateway/lane-queue.test.ts src/gateway/handlers/agent.test.ts src/config/schema.test.ts + pnpm typecheck passing"
},
"openclaw-gap-next-steps-3phase": {
"status": "in_progress",
"status": "completed",
"date": "2026-02-16",
"updated": "2026-02-16",
"summary": "Defined and began executing a concrete 3-phase implementation plan for remaining high-impact OpenClaw parity work: queue policy parity v2, channel reach expansion (Mattermost first), and companion-node capability negotiation foundation.",
"summary": "Completed the 3-phase implementation plan for remaining high-impact OpenClaw parity gaps: queue policy parity v2, Mattermost channel expansion, and companion-node capability negotiation foundation.",
"file": "2026-02-16-openclaw-gap-next-steps-3phase.md"
},
"openclaw-gap-phase1-queue-parity-v2": {
@@ -92,6 +92,33 @@
],
"test_status": "pnpm test:run src/channels/mattermost/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck + pnpm build passing"
},
"openclaw-gap-phase3-node-capability-foundation": {
"status": "completed",
"date": "2026-02-16",
"updated": "2026-02-16",
"summary": "Completed companion-node foundation: protocol payload typing + parsing, node registration/capability handlers (`node.register`, `node.capabilities.get`, `system.capabilities`), connection-level node state tracking, scoped node RPC authorization, node policy config (`server.nodes.*`), docs, and tests.",
"files_created": [
"src/gateway/handlers/node.ts",
"src/gateway/handlers/node.test.ts"
],
"files_modified": [
"src/gateway/protocol.ts",
"src/gateway/protocol.test.ts",
"src/gateway/auth.ts",
"src/gateway/auth.test.ts",
"src/gateway/handlers/index.ts",
"src/gateway/router.ts",
"src/gateway/server.ts",
"src/gateway/server.test.ts",
"src/config/schema.ts",
"src/config/schema.test.ts",
"src/daemon/services.ts",
"config/default.yaml",
"README.md",
"docs/api/PROTOCOL.md"
],
"test_status": "pnpm test:run src/gateway/protocol.test.ts src/gateway/auth.test.ts src/gateway/handlers/node.test.ts src/gateway/server.test.ts src/config/schema.test.ts + pnpm typecheck + pnpm build passing"
},
"docs-gateway-auth-config-keys": {
"status": "completed",
"date": "2026-02-15",
@@ -3015,12 +3042,12 @@
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
"tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings",
"tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes",
"feature_gap_scorecard": "117/128 match (91%), 0 partial (0%), 11 missing (9%)",
"feature_gap_scorecard": "118/128 match (92%), 0 partial (0%), 10 missing (8%)",
"operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done",
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
"next_up": "OpenClaw gap phase 3: companion-node capability/version negotiation foundation"
"next_up": "OpenClaw gap: Location access (open next scoped implementation checklist)"
},
"soul_md_and_cron_create": {
"date": "2026-02-11",
+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);