diff --git a/README.md b/README.md index 53bab96..aba1d39 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/config/default.yaml b/config/default.yaml index 341cc39..7a24ca7 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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: diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index d77a7da..730cffd 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -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 { diff --git a/docs/plans/state.json b/docs/plans/state.json index fc05ad2..1f4512d 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 9494183..f6c95e6 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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, diff --git a/src/config/schema.ts b/src/config/schema.ts index 8bba95f..699ec82 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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, }); diff --git a/src/daemon/services.ts b/src/daemon/services.ts index 1686a91..4e8b150 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -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, diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 5dfefe9..155e7d7 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -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 = {}): 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); + }); +}); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 7388925..7ac5e20 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -13,6 +13,14 @@ export interface AuthResult { error?: string; } +export interface NodeAuthScopeConfig { + enabled: boolean; + method: string; + nodeRole?: string; + allowedRoles?: string[]; + roleScopes?: Record; +} + /** * 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'}`); diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index 40a6d1f..8426cf8 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -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'; diff --git a/src/gateway/handlers/node.test.ts b/src/gateway/handlers/node.test.ts new file mode 100644 index 0000000..3b31aaa --- /dev/null +++ b/src/gateway/handlers/node.test.ts @@ -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([['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([['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([['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']); + }); +}); diff --git a/src/gateway/handlers/node.ts b/src/gateway/handlers/node.ts new file mode 100644 index 0000000..b082eac --- /dev/null +++ b/src/gateway/handlers/node.ts @@ -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; + getConnectionState: (connectionId: string) => NodeConnectionState | undefined; + setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void; +} + +export function createNodeHandlers(deps: NodeHandlerDeps) { + return { + 'node.register': async (request: GatewayRequest): Promise => { + 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 => { + 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 => { + 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, + }); + }, + }; +} diff --git a/src/gateway/protocol.test.ts b/src/gateway/protocol.test.ts index 6ba4198..8563a0d 100644 --- a/src/gateway/protocol.test.ts +++ b/src/gateway/protocol.test.ts @@ -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({ diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts index 7864b5a..3a3dd54 100644 --- a/src/gateway/protocol.ts +++ b/src/gateway/protocol.ts @@ -8,6 +8,16 @@ export interface GatewayRequest { params?: Record; } +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; + 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 }; } diff --git a/src/gateway/router.ts b/src/gateway/router.ts index fb81f55..800382d 100644 --- a/src/gateway/router.ts +++ b/src/gateway/router.ts @@ -14,6 +14,9 @@ export class Router { } async dispatch(request: GatewayRequest, send: SendFn): Promise { + 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}`); diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index d7b644a..a0334f2 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -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((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(); + } + } + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d9a75ee..a339eeb 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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>; }; }; + nodes?: { + enabled: boolean; + allowedRoles: string[]; + featureGates: Record; + }; /** 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 = 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 { @@ -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((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);