@wingman-ai/gateway 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +29 -111
  2. package/dist/agent/config/agentConfig.cjs +2 -0
  3. package/dist/agent/config/agentConfig.d.ts +6 -0
  4. package/dist/agent/config/agentConfig.js +2 -0
  5. package/dist/agent/config/agentLoader.cjs +21 -18
  6. package/dist/agent/config/agentLoader.js +22 -19
  7. package/dist/agent/config/mcpClientManager.cjs +48 -9
  8. package/dist/agent/config/mcpClientManager.d.ts +12 -0
  9. package/dist/agent/config/mcpClientManager.js +48 -9
  10. package/dist/agent/config/toolRegistry.cjs +19 -0
  11. package/dist/agent/config/toolRegistry.d.ts +4 -0
  12. package/dist/agent/config/toolRegistry.js +17 -1
  13. package/dist/agent/middleware/additional-messages.cjs +115 -11
  14. package/dist/agent/middleware/additional-messages.d.ts +9 -0
  15. package/dist/agent/middleware/additional-messages.js +115 -11
  16. package/dist/agent/tests/agentLoader.test.cjs +45 -0
  17. package/dist/agent/tests/agentLoader.test.js +45 -0
  18. package/dist/agent/tests/mcpClientManager.test.cjs +50 -0
  19. package/dist/agent/tests/mcpClientManager.test.js +50 -0
  20. package/dist/agent/tests/toolRegistry.test.cjs +2 -0
  21. package/dist/agent/tests/toolRegistry.test.js +2 -0
  22. package/dist/agent/tools/node_invoke.cjs +146 -0
  23. package/dist/agent/tools/node_invoke.d.ts +86 -0
  24. package/dist/agent/tools/node_invoke.js +109 -0
  25. package/dist/cli/commands/gateway.cjs +1 -1
  26. package/dist/cli/commands/gateway.js +1 -1
  27. package/dist/cli/commands/skill.cjs +12 -4
  28. package/dist/cli/commands/skill.js +12 -4
  29. package/dist/cli/config/jsonSchema.cjs +55 -0
  30. package/dist/cli/config/jsonSchema.d.ts +2 -0
  31. package/dist/cli/config/jsonSchema.js +18 -0
  32. package/dist/cli/config/loader.cjs +33 -1
  33. package/dist/cli/config/loader.js +33 -1
  34. package/dist/cli/config/schema.cjs +119 -2
  35. package/dist/cli/config/schema.d.ts +40 -0
  36. package/dist/cli/config/schema.js +119 -2
  37. package/dist/cli/core/agentInvoker.cjs +25 -4
  38. package/dist/cli/core/agentInvoker.d.ts +13 -0
  39. package/dist/cli/core/agentInvoker.js +25 -4
  40. package/dist/cli/services/skillRepository.cjs +138 -20
  41. package/dist/cli/services/skillRepository.d.ts +10 -2
  42. package/dist/cli/services/skillRepository.js +138 -20
  43. package/dist/cli/services/skillSecurityScanner.cjs +158 -0
  44. package/dist/cli/services/skillSecurityScanner.d.ts +28 -0
  45. package/dist/cli/services/skillSecurityScanner.js +121 -0
  46. package/dist/cli/services/skillService.cjs +44 -12
  47. package/dist/cli/services/skillService.d.ts +2 -0
  48. package/dist/cli/services/skillService.js +46 -14
  49. package/dist/cli/types/skill.d.ts +9 -0
  50. package/dist/gateway/http/nodes.cjs +247 -0
  51. package/dist/gateway/http/nodes.d.ts +20 -0
  52. package/dist/gateway/http/nodes.js +210 -0
  53. package/dist/gateway/node.cjs +10 -1
  54. package/dist/gateway/node.d.ts +10 -1
  55. package/dist/gateway/node.js +10 -1
  56. package/dist/gateway/server.cjs +418 -27
  57. package/dist/gateway/server.d.ts +34 -0
  58. package/dist/gateway/server.js +412 -27
  59. package/dist/gateway/types.d.ts +15 -1
  60. package/dist/gateway/validation.cjs +2 -0
  61. package/dist/gateway/validation.d.ts +4 -0
  62. package/dist/gateway/validation.js +2 -0
  63. package/dist/tests/additionalMessageMiddleware.test.cjs +92 -0
  64. package/dist/tests/additionalMessageMiddleware.test.js +92 -0
  65. package/dist/tests/cli-config-loader.test.cjs +33 -1
  66. package/dist/tests/cli-config-loader.test.js +33 -1
  67. package/dist/tests/config-json-schema.test.cjs +25 -0
  68. package/dist/tests/config-json-schema.test.d.ts +1 -0
  69. package/dist/tests/config-json-schema.test.js +19 -0
  70. package/dist/tests/gateway-http-security.test.cjs +277 -0
  71. package/dist/tests/gateway-http-security.test.d.ts +1 -0
  72. package/dist/tests/gateway-http-security.test.js +271 -0
  73. package/dist/tests/gateway-node-mode.test.cjs +174 -0
  74. package/dist/tests/gateway-node-mode.test.d.ts +1 -0
  75. package/dist/tests/gateway-node-mode.test.js +168 -0
  76. package/dist/tests/gateway-origin-policy.test.cjs +60 -0
  77. package/dist/tests/gateway-origin-policy.test.d.ts +1 -0
  78. package/dist/tests/gateway-origin-policy.test.js +54 -0
  79. package/dist/tests/gateway.test.cjs +1 -0
  80. package/dist/tests/gateway.test.js +1 -0
  81. package/dist/tests/node-tools.test.cjs +77 -0
  82. package/dist/tests/node-tools.test.d.ts +1 -0
  83. package/dist/tests/node-tools.test.js +71 -0
  84. package/dist/tests/nodes-api.test.cjs +86 -0
  85. package/dist/tests/nodes-api.test.d.ts +1 -0
  86. package/dist/tests/nodes-api.test.js +80 -0
  87. package/dist/tests/skill-repository.test.cjs +106 -0
  88. package/dist/tests/skill-repository.test.d.ts +1 -0
  89. package/dist/tests/skill-repository.test.js +100 -0
  90. package/dist/tests/skill-security-scanner.test.cjs +126 -0
  91. package/dist/tests/skill-security-scanner.test.d.ts +1 -0
  92. package/dist/tests/skill-security-scanner.test.js +120 -0
  93. package/dist/tests/uv.test.cjs +47 -0
  94. package/dist/tests/uv.test.d.ts +1 -0
  95. package/dist/tests/uv.test.js +41 -0
  96. package/dist/utils/uv.cjs +64 -0
  97. package/dist/utils/uv.d.ts +3 -0
  98. package/dist/utils/uv.js +24 -0
  99. package/dist/webui/assets/{index-DHbfLOUR.js → index-BMekSELC.js} +106 -106
  100. package/dist/webui/index.html +1 -1
  101. package/package.json +2 -1
  102. package/skills/gog/SKILL.md +36 -0
  103. package/skills/weather/SKILL.md +49 -0
@@ -8,15 +8,17 @@ import { AgentInvoker } from "../cli/core/agentInvoker.js";
8
8
  import { OutputManager } from "../cli/core/outputManager.js";
9
9
  import { SessionManager } from "../cli/core/sessionManager.js";
10
10
  import { createLogger } from "../logger.js";
11
+ import { ensureUvAvailableForFeature } from "../utils/uv.js";
11
12
  import { DiscordGatewayAdapter } from "./adapters/discord.js";
12
13
  import { GatewayAuth } from "./auth.js";
13
- import { BrowserRelayServer } from "./browserRelayServer.js";
14
14
  import { BroadcastGroupManager } from "./broadcast.js";
15
+ import { BrowserRelayServer } from "./browserRelayServer.js";
15
16
  import { MDNSDiscoveryService, TailscaleDiscoveryService } from "./discovery/index.js";
16
17
  import { getGatewayTokenFromEnv } from "./env.js";
17
18
  import { InternalHookRegistry } from "./hooks/registry.js";
18
19
  import { handleAgentsApi } from "./http/agents.js";
19
20
  import { handleFsApi } from "./http/fs.js";
21
+ import { createNodeApprovalStore, handleNodesApi } from "./http/nodes.js";
20
22
  import { handleProvidersApi } from "./http/providers.js";
21
23
  import { createRoutineStore, handleRoutinesApi } from "./http/routines.js";
22
24
  import { handleSessionsApi } from "./http/sessions.js";
@@ -36,19 +38,72 @@ function _define_property(obj, key, value) {
36
38
  return obj;
37
39
  }
38
40
  const API_CORS_HEADERS = {
39
- "Access-Control-Allow-Origin": "*",
40
41
  "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
41
42
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Wingman-Token, X-Wingman-Password",
42
43
  "Access-Control-Max-Age": "600"
43
44
  };
44
- function withApiCors(response) {
45
- const headers = new Headers(response.headers);
46
- for (const [key, value] of Object.entries(API_CORS_HEADERS))headers.set(key, value);
47
- return new Response(response.body, {
48
- status: response.status,
49
- statusText: response.statusText,
50
- headers
51
- });
45
+ const LOOPBACK_HOSTNAMES = new Set([
46
+ "127.0.0.1",
47
+ "localhost",
48
+ "::1"
49
+ ]);
50
+ const CLIENT_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/;
51
+ const CLIENT_TYPE_PATTERN = /^[a-zA-Z0-9._:-]{1,64}$/;
52
+ const NODE_EXECUTION_CAPABILITIES = new Set([
53
+ "system.notify",
54
+ "system.run"
55
+ ]);
56
+ const DEFAULT_NODE_REQUEST_TIMEOUT_MS = 30000;
57
+ const MIN_NODE_REQUEST_TIMEOUT_MS = 1000;
58
+ const MAX_NODE_REQUEST_TIMEOUT_MS = 120000;
59
+ const MAX_PENDING_NODE_REQUESTS = 2000;
60
+ function normalizeHostname(hostname) {
61
+ const trimmed = hostname.trim().toLowerCase();
62
+ if (!trimmed) return "";
63
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed.slice(1, -1);
64
+ return trimmed;
65
+ }
66
+ function defaultPortForProtocol(protocol) {
67
+ return "https:" === protocol ? "443" : "80";
68
+ }
69
+ function normalizeClientIdentifier(raw, pattern) {
70
+ const trimmed = raw.trim();
71
+ if (!trimmed) return null;
72
+ if (trimmed.length > 128) return null;
73
+ if (!pattern.test(trimmed)) return null;
74
+ return trimmed;
75
+ }
76
+ function includesNodeExecutionCapability(capabilities) {
77
+ if (!Array.isArray(capabilities)) return false;
78
+ for (const capability of capabilities)if ("string" == typeof capability && NODE_EXECUTION_CAPABILITIES.has(capability.trim())) return true;
79
+ return false;
80
+ }
81
+ function isLoopbackHostname(hostname) {
82
+ return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname));
83
+ }
84
+ function isGatewayOriginAllowed(params) {
85
+ let originUrl;
86
+ let requestUrl;
87
+ try {
88
+ originUrl = new URL(params.origin);
89
+ requestUrl = "string" == typeof params.requestUrl ? new URL(params.requestUrl) : params.requestUrl;
90
+ } catch {
91
+ return false;
92
+ }
93
+ if ("http:" !== originUrl.protocol && "https:" !== originUrl.protocol) return false;
94
+ const originHost = normalizeHostname(originUrl.hostname);
95
+ const requestHost = normalizeHostname(requestUrl.hostname);
96
+ const configHost = normalizeHostname(params.gatewayHost);
97
+ if (isLoopbackHostname(originHost) && isLoopbackHostname(requestHost)) return true;
98
+ if (!originHost || originHost !== requestHost) return false;
99
+ const originPort = originUrl.port || defaultPortForProtocol(originUrl.protocol);
100
+ const allowedPorts = new Set([
101
+ String(params.gatewayPort)
102
+ ]);
103
+ if (params.controlUiEnabled) allowedPorts.add(String(params.controlUiPort));
104
+ if ("0.0.0.0" === configHost || "::" === configHost) return allowedPorts.has(originPort);
105
+ if (originHost !== configHost) return false;
106
+ return allowedPorts.has(originPort);
52
107
  }
53
108
  function resolveExecutionWorkspaceOverride(payload) {
54
109
  const rawWorkspace = payload?.execution?.workspace;
@@ -66,9 +121,12 @@ function resolveExecutionConfigDirOverride(payload) {
66
121
  class GatewayServer {
67
122
  async start() {
68
123
  if (void 0 === globalThis.Bun) throw new Error("Gateway server requires Bun runtime. Start with `bun ./bin/wingman gateway start`.");
124
+ const proxyConfig = this.wingmanConfig.gateway?.mcpProxy;
125
+ if (proxyConfig?.enabled) ensureUvAvailableForFeature(proxyConfig.command || "uvx", "gateway.mcpProxy.enabled");
69
126
  this.startedAt = Date.now();
70
127
  this.internalHooks = new InternalHookRegistry(this.getHttpContext(), this.wingmanConfig.hooks);
71
128
  await this.internalHooks.load();
129
+ if (this.config.auth?.allowTailscale && !isLoopbackHostname(this.config.host)) this.log("warn", "Tailscale header-based auth bypass is disabled on non-loopback gateway hosts; use token/password auth instead.");
72
130
  this.server = Bun.serve({
73
131
  port: this.config.port,
74
132
  hostname: this.config.host,
@@ -76,10 +134,21 @@ class GatewayServer {
76
134
  const url = new URL(req.url);
77
135
  if ("/health" === url.pathname) return this.handleHealthCheck();
78
136
  if ("/stats" === url.pathname) return this.handleStats();
79
- if ("/bridge/send" === url.pathname) return this.handleBridgeSend(req);
80
- if ("/bridge/poll" === url.pathname) return this.handleBridgePoll(req);
137
+ if ("/bridge/send" === url.pathname) {
138
+ const authFailure = this.requireHttpAuth(req);
139
+ if (authFailure) return authFailure;
140
+ return this.handleBridgeSend(req);
141
+ }
142
+ if ("/bridge/poll" === url.pathname) {
143
+ const authFailure = this.requireHttpAuth(req);
144
+ if (authFailure) return authFailure;
145
+ return this.handleBridgePoll(req);
146
+ }
81
147
  if ("/ws" === url.pathname) {
82
- const tailscaleUser = req.headers.get("tailscale-user-login") || req.headers.get("ts-user-login") || void 0;
148
+ if (!this.isRequestOriginAllowed(req, url)) return new Response("Forbidden origin", {
149
+ status: 403
150
+ });
151
+ const tailscaleUser = this.resolveTrustedTailscaleUser(req);
83
152
  const upgraded = server.upgrade(req, {
84
153
  data: {
85
154
  nodeId: "",
@@ -269,6 +338,18 @@ class GatewayServer {
269
338
  case "direct":
270
339
  this.handleDirect(ws, msg);
271
340
  break;
341
+ case "req:node":
342
+ this.handleNodeRequest(ws, msg);
343
+ break;
344
+ case "event:node":
345
+ this.handleNodeResponse(ws, msg);
346
+ break;
347
+ case "res":
348
+ this.handleNodeResponse(ws, msg);
349
+ break;
350
+ case "error":
351
+ this.handleNodeResponse(ws, msg);
352
+ break;
272
353
  case "ping":
273
354
  this.handlePing(ws, msg);
274
355
  break;
@@ -290,6 +371,7 @@ class GatewayServer {
290
371
  this.nodeManager.unregisterNode(nodeId);
291
372
  this.log("info", `Node disconnected: ${nodeId}`);
292
373
  }
374
+ this.cleanupPendingNodeRequestsForSocket(ws);
293
375
  this.connectedClients.delete(ws);
294
376
  this.clearSessionSubscriptions(ws);
295
377
  this.cancelSocketAgentRequests(ws);
@@ -318,8 +400,21 @@ class GatewayServer {
318
400
  ws.close();
319
401
  return;
320
402
  }
321
- ws.data.clientId = msg.client.instanceId;
322
- ws.data.clientType = msg.client.clientType;
403
+ const clientId = normalizeClientIdentifier(msg.client.instanceId, CLIENT_ID_PATTERN);
404
+ const clientType = normalizeClientIdentifier(msg.client.clientType, CLIENT_TYPE_PATTERN);
405
+ if (!clientId || !clientType) {
406
+ this.sendMessage(ws, {
407
+ type: "res",
408
+ id: msg.id,
409
+ ok: false,
410
+ payload: "invalid client info",
411
+ timestamp: Date.now()
412
+ });
413
+ ws.close();
414
+ return;
415
+ }
416
+ ws.data.clientId = clientId;
417
+ ws.data.clientType = clientType;
323
418
  ws.data.authenticated = true;
324
419
  this.connectedClients.add(ws);
325
420
  this.sendMessage(ws, {
@@ -513,7 +608,12 @@ class GatewayServer {
513
608
  sessionManager,
514
609
  terminalSessionManager: this.terminalSessionManager,
515
610
  workdir,
516
- defaultOutputDir
611
+ defaultOutputDir,
612
+ mcpProxyConfig: this.wingmanConfig.gateway?.mcpProxy,
613
+ nodeInvoker: (request)=>this.invokeNodeTool(ws, request),
614
+ nodeDefaultTargetClientId: "desktop" === ws.data.clientType ? ws.data.clientId : void 0,
615
+ nodeConnectedIdsProvider: ()=>this.listConnectedNodeIdsForRequester(ws),
616
+ nodeConnectedTargetsProvider: ()=>this.listConnectedNodeTargetsForRequester(ws)
517
617
  });
518
618
  const abortController = new AbortController();
519
619
  this.activeAgentRequests.set(msg.id, {
@@ -669,14 +769,19 @@ class GatewayServer {
669
769
  }
670
770
  handleRegister(ws, msg) {
671
771
  const payload = msg.payload;
672
- if (!this.auth.validate({
772
+ const hasSessionAuth = true === ws.data.authenticated;
773
+ const hasPayloadAuth = this.auth.validate({
673
774
  token: payload.token
674
- }, ws.data.tailscaleUser)) {
775
+ }, ws.data.tailscaleUser);
776
+ if (!hasSessionAuth && !hasPayloadAuth) {
675
777
  this.sendError(ws, "AUTH_FAILED", "Authentication failed");
676
778
  ws.close();
677
779
  return;
678
780
  }
679
- const node = this.nodeManager.registerNode(ws, payload.name, payload.capabilities, payload.sessionId, payload.agentName);
781
+ const clientId = ws.data.clientId?.trim();
782
+ const requiresNodeApproval = this.nodePairingRequired && includesNodeExecutionCapability(payload.capabilities);
783
+ if (requiresNodeApproval && (!clientId || !this.nodeApprovalStore.isEnabled(clientId))) return void this.sendError(ws, "NODE_NOT_ENABLED", "This client is not approved for node execution");
784
+ const node = this.nodeManager.registerNode(ws, payload.name, payload.capabilities, payload.sessionId, payload.agentName, clientId);
680
785
  if (!node) {
681
786
  this.sendError(ws, "MAX_NODES_REACHED", "Maximum nodes reached");
682
787
  ws.close();
@@ -693,6 +798,7 @@ class GatewayServer {
693
798
  },
694
799
  timestamp: Date.now()
695
800
  });
801
+ if (clientId) this.nodeApprovalStore.markSeen(clientId, payload.name);
696
802
  const sessionInfo = node.sessionId ? ` (session: ${node.sessionId})` : "";
697
803
  this.log("info", `Node registered: ${node.id} (${node.name})${sessionInfo}`);
698
804
  }
@@ -733,6 +839,7 @@ class GatewayServer {
733
839
  this.nodeManager.unregisterNode(nodeId);
734
840
  this.log("info", `Node unregistered: ${nodeId}`);
735
841
  }
842
+ this.cleanupPendingNodeRequestsForSocket(ws);
736
843
  }
737
844
  handleJoinGroup(ws, msg) {
738
845
  const nodeId = ws.data.nodeId;
@@ -799,6 +906,77 @@ class GatewayServer {
799
906
  const sent = this.nodeManager.sendToNode(payload.targetNodeId, directMsg);
800
907
  if (!sent) this.sendError(ws, "NODE_NOT_FOUND", "Target node not found");
801
908
  }
909
+ handleNodeRequest(ws, msg) {
910
+ if (!ws.data.authenticated) return void this.sendError(ws, "AUTH_REQUIRED", "Client is not authenticated");
911
+ if (!msg.id) return void this.sendError(ws, "INVALID_REQUEST", "Node request id is required");
912
+ const targetNodeId = "string" == typeof msg.targetNodeId ? msg.targetNodeId.trim() : "";
913
+ if (!targetNodeId) return void this.sendError(ws, "INVALID_REQUEST", "targetNodeId is required");
914
+ const target = this.nodeManager.getNode(targetNodeId);
915
+ if (!target) return void this.sendError(ws, "NODE_NOT_FOUND", "Target node not found");
916
+ if (!this.isNodeApprovedForExecution(target.clientId)) return void this.sendError(ws, "NODE_REVOKED", "Target node has been revoked");
917
+ if (this.pendingNodeRequests.has(msg.id)) return void this.sendError(ws, "DUPLICATE_REQUEST_ID", "Node request id is already in use");
918
+ if (this.pendingNodeRequests.size >= MAX_PENDING_NODE_REQUESTS) return void this.sendError(ws, "NODE_REQUEST_OVERLOADED", "Too many pending node requests");
919
+ const timeoutMs = this.resolveNodeRequestTimeout(msg.payload);
920
+ const pendingRequest = {
921
+ id: msg.id,
922
+ requester: ws,
923
+ targetNodeId,
924
+ createdAt: Date.now()
925
+ };
926
+ pendingRequest.timeoutHandle = setTimeout(()=>{
927
+ if (!this.pendingNodeRequests.has(msg.id)) return;
928
+ this.clearPendingNodeRequest(msg.id, new Error(`Node request timed out after ${timeoutMs}ms`));
929
+ this.sendMessageWithRetry(ws, {
930
+ type: "error",
931
+ id: msg.id,
932
+ payload: {
933
+ code: "NODE_TIMEOUT",
934
+ message: `Node request timed out after ${timeoutMs}ms`
935
+ },
936
+ timestamp: Date.now()
937
+ });
938
+ }, timeoutMs);
939
+ this.pendingNodeRequests.set(msg.id, pendingRequest);
940
+ const forwarded = {
941
+ type: "req:node",
942
+ id: msg.id,
943
+ clientId: ws.data.clientId || ws.data.nodeId || "gateway",
944
+ targetNodeId,
945
+ payload: msg.payload,
946
+ timestamp: Date.now()
947
+ };
948
+ const sent = this.nodeManager.sendToNode(targetNodeId, forwarded);
949
+ if (!sent) {
950
+ this.clearPendingNodeRequest(msg.id);
951
+ this.sendError(ws, "NODE_NOT_FOUND", "Target node not found");
952
+ }
953
+ }
954
+ handleNodeResponse(ws, msg) {
955
+ if (!msg.id) return;
956
+ const pending = this.pendingNodeRequests.get(msg.id);
957
+ if (!pending) return;
958
+ const sourceNodeId = ws.data.nodeId;
959
+ if (!sourceNodeId || sourceNodeId !== pending.targetNodeId) return void this.sendError(ws, "NODE_RESPONSE_REJECTED", "Only the target node may respond to this request");
960
+ const forwarded = {
961
+ ...msg,
962
+ nodeId: sourceNodeId,
963
+ timestamp: Date.now()
964
+ };
965
+ if (pending.requester) this.sendMessageWithRetry(pending.requester, forwarded);
966
+ if ("res" === msg.type) {
967
+ if (false === msg.ok) pending.reject?.(new Error(this.extractNodeErrorMessage(msg)));
968
+ else pending.resolve?.({
969
+ nodeId: sourceNodeId,
970
+ payload: msg.payload
971
+ });
972
+ this.clearPendingNodeRequest(msg.id);
973
+ return;
974
+ }
975
+ if ("error" === msg.type) {
976
+ pending.reject?.(new Error(this.extractNodeErrorMessage(msg)));
977
+ this.clearPendingNodeRequest(msg.id);
978
+ }
979
+ }
802
980
  handlePing(ws, msg) {
803
981
  const nodeId = ws.data.nodeId;
804
982
  if (nodeId) this.nodeManager.updatePing(nodeId);
@@ -811,6 +989,127 @@ class GatewayServer {
811
989
  const nodeId = ws.data.nodeId;
812
990
  if (nodeId) this.nodeManager.updatePing(nodeId);
813
991
  }
992
+ cleanupPendingNodeRequestsForSocket(ws) {
993
+ for (const [requestId, pending] of this.pendingNodeRequests)if (pending.requester === ws || pending.targetNodeId === ws.data.nodeId) this.clearPendingNodeRequest(requestId, new Error("Node request cancelled due to disconnect"));
994
+ }
995
+ clearPendingNodeRequest(requestId, error) {
996
+ const pending = this.pendingNodeRequests.get(requestId);
997
+ if (!pending) return;
998
+ this.pendingNodeRequests.delete(requestId);
999
+ if (pending.timeoutHandle) clearTimeout(pending.timeoutHandle);
1000
+ if (error) pending.reject?.(error);
1001
+ }
1002
+ extractNodeErrorMessage(msg) {
1003
+ const payload = msg.payload;
1004
+ if (payload && "object" == typeof payload && !Array.isArray(payload) && "string" == typeof payload.message) return String(payload.message);
1005
+ if ("string" == typeof payload && payload.trim()) return payload;
1006
+ return "Node invocation failed";
1007
+ }
1008
+ isNodeApprovedForExecution(clientId) {
1009
+ if (!this.nodePairingRequired) return true;
1010
+ const trimmed = "string" == typeof clientId ? clientId.trim() : "";
1011
+ if (!trimmed) return false;
1012
+ return this.nodeApprovalStore.isEnabled(trimmed);
1013
+ }
1014
+ listConnectedNodeIdsForRequester(requester) {
1015
+ return this.listConnectedNodeTargetsForRequester(requester).map((node)=>node.nodeId);
1016
+ }
1017
+ listConnectedNodeTargetsForRequester(requester) {
1018
+ const requesterClientId = "desktop" === requester.data.clientType ? requester.data.clientId?.trim() || "" : "";
1019
+ const nodes = this.nodeManager.getAllNodes().filter((node)=>{
1020
+ if (!this.isNodeApprovedForExecution(node.clientId)) return false;
1021
+ if (!includesNodeExecutionCapability(node.capabilities)) return false;
1022
+ return true;
1023
+ }).sort((a, b)=>b.connectedAt - a.connectedAt);
1024
+ const preferred = requesterClientId ? nodes.filter((node)=>node.clientId === requesterClientId) : [];
1025
+ const others = requesterClientId ? nodes.filter((node)=>node.clientId !== requesterClientId) : nodes;
1026
+ return [
1027
+ ...preferred,
1028
+ ...others
1029
+ ].map((node)=>({
1030
+ nodeId: node.id,
1031
+ clientId: node.clientId,
1032
+ name: node.name,
1033
+ capabilities: Array.isArray(node.capabilities) ? node.capabilities.filter((capability)=>"string" == typeof capability) : void 0
1034
+ }));
1035
+ }
1036
+ resolveNodeRequestTimeout(payload) {
1037
+ let requestedTimeout;
1038
+ if (payload && "object" == typeof payload && !Array.isArray(payload)) {
1039
+ const timeoutValue = payload.timeoutMs;
1040
+ if ("number" == typeof timeoutValue && Number.isFinite(timeoutValue)) requestedTimeout = Math.trunc(timeoutValue);
1041
+ }
1042
+ const timeout = requestedTimeout ?? DEFAULT_NODE_REQUEST_TIMEOUT_MS;
1043
+ return Math.min(Math.max(timeout, MIN_NODE_REQUEST_TIMEOUT_MS), MAX_NODE_REQUEST_TIMEOUT_MS);
1044
+ }
1045
+ resolveNodeTarget(request, defaultTargetClientId) {
1046
+ const requiredCapability = "string" == typeof request.capability ? request.capability.trim() : "";
1047
+ const targetNodeId = "string" == typeof request.targetNodeId ? request.targetNodeId.trim() : "";
1048
+ const targetClientId = ("string" == typeof request.targetClientId ? request.targetClientId.trim() : "") || defaultTargetClientId?.trim() || "";
1049
+ const canUseNode = (node)=>{
1050
+ if (!this.isNodeApprovedForExecution(node.clientId)) return false;
1051
+ if (!requiredCapability) return true;
1052
+ return Array.isArray(node.capabilities) && node.capabilities.includes(requiredCapability);
1053
+ };
1054
+ if (targetNodeId) {
1055
+ const node = this.nodeManager.getNode(targetNodeId);
1056
+ if (!node) throw new Error("Target node not found");
1057
+ if (!canUseNode(node)) throw new Error(requiredCapability ? `Target node does not support capability ${requiredCapability}` : "Target node is not available");
1058
+ return {
1059
+ nodeId: node.id
1060
+ };
1061
+ }
1062
+ let candidates = this.nodeManager.getAllNodes().filter((node)=>canUseNode(node));
1063
+ if (targetClientId) {
1064
+ candidates = candidates.filter((node)=>node.clientId === targetClientId);
1065
+ if (0 === candidates.length) throw new Error(`No available node found for client ${targetClientId}`);
1066
+ }
1067
+ if (0 === candidates.length) throw new Error(requiredCapability ? `No available node supports capability ${requiredCapability}` : "No available node found");
1068
+ candidates.sort((a, b)=>b.connectedAt - a.connectedAt);
1069
+ const selected = candidates[0];
1070
+ return {
1071
+ nodeId: selected.id
1072
+ };
1073
+ }
1074
+ async invokeNodeTool(requester, request) {
1075
+ if (!requester.data.authenticated) throw new Error("Client is not authenticated");
1076
+ const toolName = request.tool;
1077
+ if ("system.notify" !== toolName && "system.run" !== toolName) throw new Error(`Unsupported node tool: ${toolName}`);
1078
+ const timeoutMsRaw = "number" == typeof request.timeoutMs ? Math.trunc(request.timeoutMs) : DEFAULT_NODE_REQUEST_TIMEOUT_MS;
1079
+ const timeoutMs = Math.min(Math.max(timeoutMsRaw, MIN_NODE_REQUEST_TIMEOUT_MS), MAX_NODE_REQUEST_TIMEOUT_MS);
1080
+ if (this.pendingNodeRequests.size >= MAX_PENDING_NODE_REQUESTS) throw new Error("Too many pending node requests");
1081
+ const target = this.resolveNodeTarget(request, "desktop" === requester.data.clientType ? requester.data.clientId : void 0);
1082
+ let requestId = "";
1083
+ do requestId = `node-tool-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1084
+ while (this.pendingNodeRequests.has(requestId));
1085
+ return await new Promise((resolve, reject)=>{
1086
+ const pending = {
1087
+ id: requestId,
1088
+ targetNodeId: target.nodeId,
1089
+ createdAt: Date.now(),
1090
+ resolve,
1091
+ reject
1092
+ };
1093
+ pending.timeoutHandle = setTimeout(()=>{
1094
+ this.clearPendingNodeRequest(requestId, new Error(`Node invocation timed out after ${timeoutMs}ms`));
1095
+ }, timeoutMs);
1096
+ this.pendingNodeRequests.set(requestId, pending);
1097
+ const forwarded = {
1098
+ type: "req:node",
1099
+ id: requestId,
1100
+ clientId: requester.data.clientId || requester.data.nodeId || "gateway",
1101
+ targetNodeId: target.nodeId,
1102
+ payload: {
1103
+ tool: toolName,
1104
+ args: request.args || {},
1105
+ timeoutMs
1106
+ },
1107
+ timestamp: Date.now()
1108
+ };
1109
+ const sent = this.nodeManager.sendToNode(target.nodeId, forwarded);
1110
+ if (!sent) this.clearPendingNodeRequest(requestId, new Error("Target node not found"));
1111
+ });
1112
+ }
814
1113
  sendMessage(ws, message) {
815
1114
  try {
816
1115
  const result = ws.send(JSON.stringify(message));
@@ -1175,15 +1474,87 @@ class GatewayServer {
1175
1474
  break;
1176
1475
  }
1177
1476
  }
1477
+ isRequestOriginAllowed(req, url) {
1478
+ const origin = req.headers.get("origin");
1479
+ if (!origin) return true;
1480
+ return isGatewayOriginAllowed({
1481
+ origin,
1482
+ requestUrl: url,
1483
+ gatewayHost: this.config.host,
1484
+ gatewayPort: this.config.port,
1485
+ controlUiEnabled: this.controlUiEnabled,
1486
+ controlUiPort: this.controlUiPort
1487
+ });
1488
+ }
1489
+ withApiCors(req, url, response) {
1490
+ const headers = new Headers(response.headers);
1491
+ for (const [key, value] of Object.entries(API_CORS_HEADERS))headers.set(key, value);
1492
+ const existingVary = headers.get("Vary");
1493
+ if (existingVary) {
1494
+ if (!existingVary.toLowerCase().includes("origin")) headers.set("Vary", `${existingVary}, Origin`);
1495
+ } else headers.set("Vary", "Origin");
1496
+ const origin = req.headers.get("origin");
1497
+ if (origin && this.isRequestOriginAllowed(req, url)) headers.set("Access-Control-Allow-Origin", origin);
1498
+ return new Response(response.body, {
1499
+ status: response.status,
1500
+ statusText: response.statusText,
1501
+ headers
1502
+ });
1503
+ }
1504
+ resolveTrustedTailscaleUser(req) {
1505
+ if (!this.config.auth?.allowTailscale) return;
1506
+ if (!isLoopbackHostname(this.config.host)) return;
1507
+ const raw = req.headers.get("tailscale-user-login") || req.headers.get("ts-user-login") || "";
1508
+ const normalized = raw.trim();
1509
+ if (!normalized) return;
1510
+ if (normalized.length > 256) return;
1511
+ if (!/^[a-zA-Z0-9._:@+-]+$/.test(normalized)) return;
1512
+ return normalized;
1513
+ }
1514
+ resolveHttpAuthPayload(req) {
1515
+ const authorization = req.headers.get("authorization") || "";
1516
+ let bearerToken;
1517
+ if (authorization.toLowerCase().startsWith("bearer ")) {
1518
+ const value = authorization.slice(7).trim();
1519
+ bearerToken = value || void 0;
1520
+ }
1521
+ const headerToken = req.headers.get("x-wingman-token")?.trim();
1522
+ const password = req.headers.get("x-wingman-password")?.trim();
1523
+ return {
1524
+ token: headerToken || bearerToken,
1525
+ password: password || void 0
1526
+ };
1527
+ }
1528
+ requireHttpAuth(req) {
1529
+ if (!this.auth.isAuthRequired()) return null;
1530
+ const tailscaleUser = this.resolveTrustedTailscaleUser(req);
1531
+ const authPayload = this.resolveHttpAuthPayload(req);
1532
+ const allowed = this.auth.validate(authPayload, tailscaleUser);
1533
+ if (allowed) return null;
1534
+ return new Response("Unauthorized", {
1535
+ status: 401,
1536
+ headers: {
1537
+ "WWW-Authenticate": 'Bearer realm="wingman-gateway"'
1538
+ }
1539
+ });
1540
+ }
1178
1541
  async handleUiRequest(req) {
1179
1542
  const url = new URL(req.url);
1180
1543
  const ctx = this.getHttpContext();
1181
1544
  const webhookResponse = await handleWebhookInvoke(ctx, this.webhookStore, req, url);
1182
1545
  if (webhookResponse) return webhookResponse;
1183
1546
  if (url.pathname.startsWith("/api/")) {
1184
- if ("OPTIONS" === req.method) return withApiCors(new Response(null, {
1547
+ if (!this.isRequestOriginAllowed(req, url)) return this.withApiCors(req, url, new Response("Forbidden origin", {
1548
+ status: 403
1549
+ }));
1550
+ if ("OPTIONS" === req.method) return this.withApiCors(req, url, new Response(null, {
1185
1551
  status: 204
1186
1552
  }));
1553
+ const publicApiRoute = "/api/config" === url.pathname || "/api/health" === url.pathname;
1554
+ if (!publicApiRoute) {
1555
+ const authFailure = this.requireHttpAuth(req);
1556
+ if (authFailure) return this.withApiCors(req, url, authFailure);
1557
+ }
1187
1558
  if ("/api/config" === url.pathname) {
1188
1559
  const agents = this.wingmanConfig.agents?.list?.map((agent)=>({
1189
1560
  id: agent.id,
@@ -1191,7 +1562,7 @@ class GatewayServer {
1191
1562
  default: agent.default
1192
1563
  })) || [];
1193
1564
  const defaultAgentId = this.router.selectAgent();
1194
- return withApiCors(new Response(JSON.stringify({
1565
+ return this.withApiCors(req, url, new Response(JSON.stringify({
1195
1566
  gatewayHost: this.config.host,
1196
1567
  gatewayPort: this.config.port,
1197
1568
  requireAuth: this.auth.isAuthRequired(),
@@ -1206,11 +1577,11 @@ class GatewayServer {
1206
1577
  }
1207
1578
  }));
1208
1579
  }
1209
- const apiResponse = await handleWebhooksApi(ctx, this.webhookStore, req, url) || await handleRoutinesApi(ctx, this.routineStore, req, url) || await handleAgentsApi(ctx, req, url) || await handleProvidersApi(ctx, req, url) || await handleVoiceApi(ctx, req, url) || await handleFsApi(ctx, req, url) || await handleSessionsApi(ctx, req, url);
1210
- if (apiResponse) return withApiCors(apiResponse);
1211
- if ("/api/health" === url.pathname) return withApiCors(this.handleHealthCheck());
1212
- if ("/api/stats" === url.pathname) return withApiCors(this.handleStats());
1213
- return withApiCors(new Response("Not Found", {
1580
+ const apiResponse = await handleWebhooksApi(ctx, this.webhookStore, req, url) || await handleRoutinesApi(ctx, this.routineStore, req, url) || await handleNodesApi(ctx, this.nodeManager, this.nodeApprovalStore, req, url) || await handleAgentsApi(ctx, req, url) || await handleProvidersApi(ctx, req, url) || await handleVoiceApi(ctx, req, url) || await handleFsApi(ctx, req, url) || await handleSessionsApi(ctx, req, url);
1581
+ if (apiResponse) return this.withApiCors(req, url, apiResponse);
1582
+ if ("/api/health" === url.pathname) return this.withApiCors(req, url, this.handleHealthCheck());
1583
+ if ("/api/stats" === url.pathname) return this.withApiCors(req, url, this.handleStats());
1584
+ return this.withApiCors(req, url, new Response("Not Found", {
1214
1585
  status: 404
1215
1586
  }));
1216
1587
  }
@@ -1276,6 +1647,15 @@ class GatewayServer {
1276
1647
  const validatedMessage = validation.data;
1277
1648
  if ("register" === validatedMessage.type) {
1278
1649
  const payload = validatedMessage.payload;
1650
+ const maxBridgeNodes = this.config.maxNodes || 1000;
1651
+ if (this.bridgeQueues.size >= maxBridgeNodes) return new Response(JSON.stringify({
1652
+ error: "Bridge capacity reached"
1653
+ }), {
1654
+ status: 429,
1655
+ headers: {
1656
+ "Content-Type": "application/json"
1657
+ }
1658
+ });
1279
1659
  const nodeId = this.generateNodeId();
1280
1660
  this.bridgeQueues.set(nodeId, []);
1281
1661
  const response = {
@@ -1407,6 +1787,7 @@ class GatewayServer {
1407
1787
  _define_property(this, "browserRelayServer", null);
1408
1788
  _define_property(this, "webhookStore", void 0);
1409
1789
  _define_property(this, "routineStore", void 0);
1790
+ _define_property(this, "nodeApprovalStore", void 0);
1410
1791
  _define_property(this, "internalHooks", null);
1411
1792
  _define_property(this, "discordAdapter", null);
1412
1793
  _define_property(this, "sessionSubscriptions", new Map());
@@ -1416,7 +1797,9 @@ class GatewayServer {
1416
1797
  _define_property(this, "activeSessionRequests", new Map());
1417
1798
  _define_property(this, "queuedSessionRequests", new Map());
1418
1799
  _define_property(this, "requestSessionKeys", new Map());
1800
+ _define_property(this, "pendingNodeRequests", new Map());
1419
1801
  _define_property(this, "terminalSessionManager", void 0);
1802
+ _define_property(this, "nodePairingRequired", void 0);
1420
1803
  _define_property(this, "bridgeQueues", new Map());
1421
1804
  _define_property(this, "bridgePollWaiters", new Map());
1422
1805
  this.workspace = config.workspace || process.cwd();
@@ -1426,6 +1809,7 @@ class GatewayServer {
1426
1809
  this.router = new GatewayRouter(this.wingmanConfig);
1427
1810
  this.webhookStore = createWebhookStore(()=>this.resolveConfigDirPath());
1428
1811
  this.routineStore = createRoutineStore(()=>this.resolveConfigDirPath());
1812
+ this.nodeApprovalStore = createNodeApprovalStore(()=>this.resolveConfigDirPath());
1429
1813
  const gatewayDefaults = this.wingmanConfig.gateway;
1430
1814
  const envToken = getGatewayTokenFromEnv();
1431
1815
  const authFromConfig = config.auth?.mode === "token" ? {
@@ -1465,6 +1849,7 @@ class GatewayServer {
1465
1849
  const controlUi = this.wingmanConfig.gateway?.controlUi;
1466
1850
  this.controlUiEnabled = controlUi?.enabled ?? false;
1467
1851
  this.controlUiPort = controlUi?.port || 18790;
1852
+ this.nodePairingRequired = controlUi?.pairingRequired ?? true;
1468
1853
  this.controlUiSamePort = this.controlUiEnabled && this.controlUiPort === this.config.port;
1469
1854
  this.uiDistDir = this.controlUiEnabled ? this.resolveControlUiDir() : null;
1470
1855
  const relayConfig = this.wingmanConfig.browser?.relay;
@@ -1521,4 +1906,4 @@ function isFileAttachment(attachment) {
1521
1906
  if ("file" === attachment.kind) return true;
1522
1907
  return "string" == typeof attachment.textContent;
1523
1908
  }
1524
- export { GatewayServer, resolveExecutionConfigDirOverride, resolveExecutionWorkspaceOverride };
1909
+ export { GatewayServer, isGatewayOriginAllowed, isLoopbackHostname, resolveExecutionConfigDirOverride, resolveExecutionWorkspaceOverride };
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from "bun";
2
2
  /**
3
3
  * Message types for gateway communication
4
4
  */
5
- export type MessageType = "connect" | "res" | "req:agent" | "req:agent:cancel" | "event:agent" | "session_subscribe" | "session_unsubscribe" | "register" | "registered" | "unregister" | "join_group" | "leave_group" | "broadcast" | "direct" | "ping" | "pong" | "error" | "ack" | "upgrade";
5
+ export type MessageType = "connect" | "res" | "req:agent" | "req:agent:cancel" | "event:agent" | "req:node" | "event:node" | "session_subscribe" | "session_unsubscribe" | "register" | "registered" | "unregister" | "join_group" | "leave_group" | "broadcast" | "direct" | "ping" | "pong" | "error" | "ack" | "upgrade";
6
6
  /**
7
7
  * Gateway message structure
8
8
  */
@@ -87,6 +87,7 @@ export interface RoutingInfo {
87
87
  export interface NodeMetadata {
88
88
  id: string;
89
89
  name: string;
90
+ clientId?: string;
90
91
  capabilities?: string[];
91
92
  groups: Set<string>;
92
93
  connectedAt: number;
@@ -102,6 +103,10 @@ export interface NodeMetadata {
102
103
  export interface Node extends NodeMetadata {
103
104
  ws: ServerWebSocket<{
104
105
  nodeId: string;
106
+ clientId?: string;
107
+ clientType?: string;
108
+ authenticated?: boolean;
109
+ tailscaleUser?: string;
105
110
  }>;
106
111
  }
107
112
  /**
@@ -143,6 +148,15 @@ export interface GatewayConfig {
143
148
  method: "mdns" | "tailscale";
144
149
  name: string;
145
150
  };
151
+ mcpProxy?: {
152
+ enabled: boolean;
153
+ command?: string;
154
+ baseArgs?: string[];
155
+ projectName?: string;
156
+ pushExplorer?: boolean;
157
+ apiKey?: string;
158
+ apiUrl?: string;
159
+ };
146
160
  }
147
161
  export interface GatewayAuthConfig {
148
162
  mode: "token" | "password" | "none";
@@ -44,6 +44,8 @@ const MessageTypeSchema = external_zod_namespaceObject["enum"]([
44
44
  "req:agent",
45
45
  "req:agent:cancel",
46
46
  "event:agent",
47
+ "req:node",
48
+ "event:node",
47
49
  "session_subscribe",
48
50
  "session_unsubscribe",
49
51
  "register",