feat(gateway): add node capability negotiation foundation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user