@wingman-ai/gateway 0.4.0 → 0.4.2

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 (104) 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/toolRegistry.cjs +19 -0
  8. package/dist/agent/config/toolRegistry.d.ts +4 -0
  9. package/dist/agent/config/toolRegistry.js +17 -1
  10. package/dist/agent/middleware/additional-messages.cjs +115 -11
  11. package/dist/agent/middleware/additional-messages.d.ts +9 -0
  12. package/dist/agent/middleware/additional-messages.js +115 -11
  13. package/dist/agent/tests/agentLoader.test.cjs +45 -0
  14. package/dist/agent/tests/agentLoader.test.js +45 -0
  15. package/dist/agent/tests/toolRegistry.test.cjs +2 -0
  16. package/dist/agent/tests/toolRegistry.test.js +2 -0
  17. package/dist/agent/tools/node_invoke.cjs +146 -0
  18. package/dist/agent/tools/node_invoke.d.ts +86 -0
  19. package/dist/agent/tools/node_invoke.js +109 -0
  20. package/dist/cli/commands/gateway.cjs +1 -1
  21. package/dist/cli/commands/gateway.js +1 -1
  22. package/dist/cli/commands/init.cjs +135 -1
  23. package/dist/cli/commands/init.js +136 -2
  24. package/dist/cli/commands/skill.cjs +7 -3
  25. package/dist/cli/commands/skill.js +7 -3
  26. package/dist/cli/config/loader.cjs +7 -3
  27. package/dist/cli/config/loader.js +7 -3
  28. package/dist/cli/config/schema.cjs +27 -9
  29. package/dist/cli/config/schema.d.ts +18 -4
  30. package/dist/cli/config/schema.js +23 -8
  31. package/dist/cli/core/agentInvoker.cjs +70 -14
  32. package/dist/cli/core/agentInvoker.d.ts +10 -0
  33. package/dist/cli/core/agentInvoker.js +70 -14
  34. package/dist/cli/services/skillRepository.cjs +155 -69
  35. package/dist/cli/services/skillRepository.d.ts +7 -2
  36. package/dist/cli/services/skillRepository.js +155 -69
  37. package/dist/cli/services/skillService.cjs +93 -26
  38. package/dist/cli/services/skillService.d.ts +7 -0
  39. package/dist/cli/services/skillService.js +96 -29
  40. package/dist/cli/types/skill.d.ts +8 -3
  41. package/dist/gateway/http/nodes.cjs +247 -0
  42. package/dist/gateway/http/nodes.d.ts +20 -0
  43. package/dist/gateway/http/nodes.js +210 -0
  44. package/dist/gateway/node.cjs +10 -1
  45. package/dist/gateway/node.d.ts +10 -1
  46. package/dist/gateway/node.js +10 -1
  47. package/dist/gateway/server.cjs +414 -27
  48. package/dist/gateway/server.d.ts +34 -0
  49. package/dist/gateway/server.js +408 -27
  50. package/dist/gateway/types.d.ts +6 -1
  51. package/dist/gateway/validation.cjs +2 -0
  52. package/dist/gateway/validation.d.ts +4 -0
  53. package/dist/gateway/validation.js +2 -0
  54. package/dist/skills/activation.cjs +92 -0
  55. package/dist/skills/activation.d.ts +12 -0
  56. package/dist/skills/activation.js +58 -0
  57. package/dist/skills/bin-requirements.cjs +63 -0
  58. package/dist/skills/bin-requirements.d.ts +3 -0
  59. package/dist/skills/bin-requirements.js +26 -0
  60. package/dist/skills/metadata.cjs +141 -0
  61. package/dist/skills/metadata.d.ts +29 -0
  62. package/dist/skills/metadata.js +104 -0
  63. package/dist/skills/overlay.cjs +75 -0
  64. package/dist/skills/overlay.d.ts +2 -0
  65. package/dist/skills/overlay.js +38 -0
  66. package/dist/tests/additionalMessageMiddleware.test.cjs +92 -0
  67. package/dist/tests/additionalMessageMiddleware.test.js +92 -0
  68. package/dist/tests/cli-config-loader.test.cjs +7 -3
  69. package/dist/tests/cli-config-loader.test.js +7 -3
  70. package/dist/tests/cli-init.test.cjs +54 -0
  71. package/dist/tests/cli-init.test.js +54 -0
  72. package/dist/tests/config-json-schema.test.cjs +12 -0
  73. package/dist/tests/config-json-schema.test.js +12 -0
  74. package/dist/tests/gateway-http-security.test.cjs +277 -0
  75. package/dist/tests/gateway-http-security.test.d.ts +1 -0
  76. package/dist/tests/gateway-http-security.test.js +271 -0
  77. package/dist/tests/gateway-node-mode.test.cjs +174 -0
  78. package/dist/tests/gateway-node-mode.test.d.ts +1 -0
  79. package/dist/tests/gateway-node-mode.test.js +168 -0
  80. package/dist/tests/gateway-origin-policy.test.cjs +60 -0
  81. package/dist/tests/gateway-origin-policy.test.d.ts +1 -0
  82. package/dist/tests/gateway-origin-policy.test.js +54 -0
  83. package/dist/tests/gateway.test.cjs +1 -0
  84. package/dist/tests/gateway.test.js +1 -0
  85. package/dist/tests/node-tools.test.cjs +77 -0
  86. package/dist/tests/node-tools.test.d.ts +1 -0
  87. package/dist/tests/node-tools.test.js +71 -0
  88. package/dist/tests/nodes-api.test.cjs +86 -0
  89. package/dist/tests/nodes-api.test.d.ts +1 -0
  90. package/dist/tests/nodes-api.test.js +80 -0
  91. package/dist/tests/skill-activation.test.cjs +86 -0
  92. package/dist/tests/skill-activation.test.d.ts +1 -0
  93. package/dist/tests/skill-activation.test.js +80 -0
  94. package/dist/tests/skill-metadata.test.cjs +119 -0
  95. package/dist/tests/skill-metadata.test.d.ts +1 -0
  96. package/dist/tests/skill-metadata.test.js +113 -0
  97. package/dist/tests/skill-repository.test.cjs +363 -0
  98. package/dist/tests/skill-repository.test.js +363 -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 +4 -4
  102. package/skills/gog/SKILL.md +1 -1
  103. package/skills/weather/SKILL.md +1 -1
  104. package/skills/ui-registry/SKILL.md +0 -35
@@ -1,5 +1,14 @@
1
1
  import { GatewayAuth } from "./auth.js";
2
2
  import type { AgentRequestPayload, GatewayConfig } from "./types.js";
3
+ export declare function isLoopbackHostname(hostname: string): boolean;
4
+ export declare function isGatewayOriginAllowed(params: {
5
+ origin: string;
6
+ requestUrl: string | URL;
7
+ gatewayHost: string;
8
+ gatewayPort: number;
9
+ controlUiEnabled: boolean;
10
+ controlUiPort: number;
11
+ }): boolean;
3
12
  export declare function resolveExecutionWorkspaceOverride(payload: AgentRequestPayload | undefined): string | null;
4
13
  export declare function resolveExecutionConfigDirOverride(payload: AgentRequestPayload | undefined): string | null;
5
14
  /**
@@ -31,6 +40,7 @@ export declare class GatewayServer {
31
40
  private browserRelayServer;
32
41
  private webhookStore;
33
42
  private routineStore;
43
+ private nodeApprovalStore;
34
44
  private internalHooks;
35
45
  private discordAdapter;
36
46
  private sessionSubscriptions;
@@ -40,7 +50,9 @@ export declare class GatewayServer {
40
50
  private activeSessionRequests;
41
51
  private queuedSessionRequests;
42
52
  private requestSessionKeys;
53
+ private pendingNodeRequests;
43
54
  private terminalSessionManager;
55
+ private nodePairingRequired;
44
56
  private bridgeQueues;
45
57
  private bridgePollWaiters;
46
58
  constructor(config?: Partial<GatewayConfig>);
@@ -120,6 +132,14 @@ export declare class GatewayServer {
120
132
  * Handle direct message
121
133
  */
122
134
  private handleDirect;
135
+ /**
136
+ * Handle node tool invocation request
137
+ */
138
+ private handleNodeRequest;
139
+ /**
140
+ * Route node stream/response messages back to the original requester
141
+ */
142
+ private handleNodeResponse;
123
143
  /**
124
144
  * Handle ping message
125
145
  */
@@ -128,6 +148,15 @@ export declare class GatewayServer {
128
148
  * Handle pong message
129
149
  */
130
150
  private handlePong;
151
+ private cleanupPendingNodeRequestsForSocket;
152
+ private clearPendingNodeRequest;
153
+ private extractNodeErrorMessage;
154
+ private isNodeApprovedForExecution;
155
+ private listConnectedNodeIdsForRequester;
156
+ private listConnectedNodeTargetsForRequester;
157
+ private resolveNodeRequestTimeout;
158
+ private resolveNodeTarget;
159
+ private invokeNodeTool;
131
160
  /**
132
161
  * Send a message to a WebSocket
133
162
  */
@@ -179,6 +208,11 @@ export declare class GatewayServer {
179
208
  * Log a message
180
209
  */
181
210
  private log;
211
+ private isRequestOriginAllowed;
212
+ private withApiCors;
213
+ private resolveTrustedTailscaleUser;
214
+ private resolveHttpAuthPayload;
215
+ private requireHttpAuth;
182
216
  private handleUiRequest;
183
217
  /**
184
218
  * Get the auth instance
@@ -11,13 +11,14 @@ import { createLogger } from "../logger.js";
11
11
  import { ensureUvAvailableForFeature } from "../utils/uv.js";
12
12
  import { DiscordGatewayAdapter } from "./adapters/discord.js";
13
13
  import { GatewayAuth } from "./auth.js";
14
- import { BrowserRelayServer } from "./browserRelayServer.js";
15
14
  import { BroadcastGroupManager } from "./broadcast.js";
15
+ import { BrowserRelayServer } from "./browserRelayServer.js";
16
16
  import { MDNSDiscoveryService, TailscaleDiscoveryService } from "./discovery/index.js";
17
17
  import { getGatewayTokenFromEnv } from "./env.js";
18
18
  import { InternalHookRegistry } from "./hooks/registry.js";
19
19
  import { handleAgentsApi } from "./http/agents.js";
20
20
  import { handleFsApi } from "./http/fs.js";
21
+ import { createNodeApprovalStore, handleNodesApi } from "./http/nodes.js";
21
22
  import { handleProvidersApi } from "./http/providers.js";
22
23
  import { createRoutineStore, handleRoutinesApi } from "./http/routines.js";
23
24
  import { handleSessionsApi } from "./http/sessions.js";
@@ -37,19 +38,72 @@ function _define_property(obj, key, value) {
37
38
  return obj;
38
39
  }
39
40
  const API_CORS_HEADERS = {
40
- "Access-Control-Allow-Origin": "*",
41
41
  "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
42
42
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Wingman-Token, X-Wingman-Password",
43
43
  "Access-Control-Max-Age": "600"
44
44
  };
45
- function withApiCors(response) {
46
- const headers = new Headers(response.headers);
47
- for (const [key, value] of Object.entries(API_CORS_HEADERS))headers.set(key, value);
48
- return new Response(response.body, {
49
- status: response.status,
50
- statusText: response.statusText,
51
- headers
52
- });
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);
53
107
  }
54
108
  function resolveExecutionWorkspaceOverride(payload) {
55
109
  const rawWorkspace = payload?.execution?.workspace;
@@ -72,6 +126,7 @@ class GatewayServer {
72
126
  this.startedAt = Date.now();
73
127
  this.internalHooks = new InternalHookRegistry(this.getHttpContext(), this.wingmanConfig.hooks);
74
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.");
75
130
  this.server = Bun.serve({
76
131
  port: this.config.port,
77
132
  hostname: this.config.host,
@@ -79,10 +134,21 @@ class GatewayServer {
79
134
  const url = new URL(req.url);
80
135
  if ("/health" === url.pathname) return this.handleHealthCheck();
81
136
  if ("/stats" === url.pathname) return this.handleStats();
82
- if ("/bridge/send" === url.pathname) return this.handleBridgeSend(req);
83
- 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
+ }
84
147
  if ("/ws" === url.pathname) {
85
- 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);
86
152
  const upgraded = server.upgrade(req, {
87
153
  data: {
88
154
  nodeId: "",
@@ -272,6 +338,18 @@ class GatewayServer {
272
338
  case "direct":
273
339
  this.handleDirect(ws, msg);
274
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;
275
353
  case "ping":
276
354
  this.handlePing(ws, msg);
277
355
  break;
@@ -293,6 +371,7 @@ class GatewayServer {
293
371
  this.nodeManager.unregisterNode(nodeId);
294
372
  this.log("info", `Node disconnected: ${nodeId}`);
295
373
  }
374
+ this.cleanupPendingNodeRequestsForSocket(ws);
296
375
  this.connectedClients.delete(ws);
297
376
  this.clearSessionSubscriptions(ws);
298
377
  this.cancelSocketAgentRequests(ws);
@@ -321,8 +400,21 @@ class GatewayServer {
321
400
  ws.close();
322
401
  return;
323
402
  }
324
- ws.data.clientId = msg.client.instanceId;
325
- 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;
326
418
  ws.data.authenticated = true;
327
419
  this.connectedClients.add(ws);
328
420
  this.sendMessage(ws, {
@@ -517,7 +609,11 @@ class GatewayServer {
517
609
  terminalSessionManager: this.terminalSessionManager,
518
610
  workdir,
519
611
  defaultOutputDir,
520
- mcpProxyConfig: this.wingmanConfig.gateway?.mcpProxy
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)
521
617
  });
522
618
  const abortController = new AbortController();
523
619
  this.activeAgentRequests.set(msg.id, {
@@ -673,14 +769,19 @@ class GatewayServer {
673
769
  }
674
770
  handleRegister(ws, msg) {
675
771
  const payload = msg.payload;
676
- if (!this.auth.validate({
772
+ const hasSessionAuth = true === ws.data.authenticated;
773
+ const hasPayloadAuth = this.auth.validate({
677
774
  token: payload.token
678
- }, ws.data.tailscaleUser)) {
775
+ }, ws.data.tailscaleUser);
776
+ if (!hasSessionAuth && !hasPayloadAuth) {
679
777
  this.sendError(ws, "AUTH_FAILED", "Authentication failed");
680
778
  ws.close();
681
779
  return;
682
780
  }
683
- 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);
684
785
  if (!node) {
685
786
  this.sendError(ws, "MAX_NODES_REACHED", "Maximum nodes reached");
686
787
  ws.close();
@@ -697,6 +798,7 @@ class GatewayServer {
697
798
  },
698
799
  timestamp: Date.now()
699
800
  });
801
+ if (clientId) this.nodeApprovalStore.markSeen(clientId, payload.name);
700
802
  const sessionInfo = node.sessionId ? ` (session: ${node.sessionId})` : "";
701
803
  this.log("info", `Node registered: ${node.id} (${node.name})${sessionInfo}`);
702
804
  }
@@ -737,6 +839,7 @@ class GatewayServer {
737
839
  this.nodeManager.unregisterNode(nodeId);
738
840
  this.log("info", `Node unregistered: ${nodeId}`);
739
841
  }
842
+ this.cleanupPendingNodeRequestsForSocket(ws);
740
843
  }
741
844
  handleJoinGroup(ws, msg) {
742
845
  const nodeId = ws.data.nodeId;
@@ -803,6 +906,77 @@ class GatewayServer {
803
906
  const sent = this.nodeManager.sendToNode(payload.targetNodeId, directMsg);
804
907
  if (!sent) this.sendError(ws, "NODE_NOT_FOUND", "Target node not found");
805
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
+ }
806
980
  handlePing(ws, msg) {
807
981
  const nodeId = ws.data.nodeId;
808
982
  if (nodeId) this.nodeManager.updatePing(nodeId);
@@ -815,6 +989,127 @@ class GatewayServer {
815
989
  const nodeId = ws.data.nodeId;
816
990
  if (nodeId) this.nodeManager.updatePing(nodeId);
817
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
+ }
818
1113
  sendMessage(ws, message) {
819
1114
  try {
820
1115
  const result = ws.send(JSON.stringify(message));
@@ -1179,15 +1474,87 @@ class GatewayServer {
1179
1474
  break;
1180
1475
  }
1181
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
+ }
1182
1541
  async handleUiRequest(req) {
1183
1542
  const url = new URL(req.url);
1184
1543
  const ctx = this.getHttpContext();
1185
1544
  const webhookResponse = await handleWebhookInvoke(ctx, this.webhookStore, req, url);
1186
1545
  if (webhookResponse) return webhookResponse;
1187
1546
  if (url.pathname.startsWith("/api/")) {
1188
- 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, {
1189
1551
  status: 204
1190
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
+ }
1191
1558
  if ("/api/config" === url.pathname) {
1192
1559
  const agents = this.wingmanConfig.agents?.list?.map((agent)=>({
1193
1560
  id: agent.id,
@@ -1195,7 +1562,7 @@ class GatewayServer {
1195
1562
  default: agent.default
1196
1563
  })) || [];
1197
1564
  const defaultAgentId = this.router.selectAgent();
1198
- return withApiCors(new Response(JSON.stringify({
1565
+ return this.withApiCors(req, url, new Response(JSON.stringify({
1199
1566
  gatewayHost: this.config.host,
1200
1567
  gatewayPort: this.config.port,
1201
1568
  requireAuth: this.auth.isAuthRequired(),
@@ -1210,11 +1577,11 @@ class GatewayServer {
1210
1577
  }
1211
1578
  }));
1212
1579
  }
1213
- 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);
1214
- if (apiResponse) return withApiCors(apiResponse);
1215
- if ("/api/health" === url.pathname) return withApiCors(this.handleHealthCheck());
1216
- if ("/api/stats" === url.pathname) return withApiCors(this.handleStats());
1217
- 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", {
1218
1585
  status: 404
1219
1586
  }));
1220
1587
  }
@@ -1280,6 +1647,15 @@ class GatewayServer {
1280
1647
  const validatedMessage = validation.data;
1281
1648
  if ("register" === validatedMessage.type) {
1282
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
+ });
1283
1659
  const nodeId = this.generateNodeId();
1284
1660
  this.bridgeQueues.set(nodeId, []);
1285
1661
  const response = {
@@ -1411,6 +1787,7 @@ class GatewayServer {
1411
1787
  _define_property(this, "browserRelayServer", null);
1412
1788
  _define_property(this, "webhookStore", void 0);
1413
1789
  _define_property(this, "routineStore", void 0);
1790
+ _define_property(this, "nodeApprovalStore", void 0);
1414
1791
  _define_property(this, "internalHooks", null);
1415
1792
  _define_property(this, "discordAdapter", null);
1416
1793
  _define_property(this, "sessionSubscriptions", new Map());
@@ -1420,7 +1797,9 @@ class GatewayServer {
1420
1797
  _define_property(this, "activeSessionRequests", new Map());
1421
1798
  _define_property(this, "queuedSessionRequests", new Map());
1422
1799
  _define_property(this, "requestSessionKeys", new Map());
1800
+ _define_property(this, "pendingNodeRequests", new Map());
1423
1801
  _define_property(this, "terminalSessionManager", void 0);
1802
+ _define_property(this, "nodePairingRequired", void 0);
1424
1803
  _define_property(this, "bridgeQueues", new Map());
1425
1804
  _define_property(this, "bridgePollWaiters", new Map());
1426
1805
  this.workspace = config.workspace || process.cwd();
@@ -1430,6 +1809,7 @@ class GatewayServer {
1430
1809
  this.router = new GatewayRouter(this.wingmanConfig);
1431
1810
  this.webhookStore = createWebhookStore(()=>this.resolveConfigDirPath());
1432
1811
  this.routineStore = createRoutineStore(()=>this.resolveConfigDirPath());
1812
+ this.nodeApprovalStore = createNodeApprovalStore(()=>this.resolveConfigDirPath());
1433
1813
  const gatewayDefaults = this.wingmanConfig.gateway;
1434
1814
  const envToken = getGatewayTokenFromEnv();
1435
1815
  const authFromConfig = config.auth?.mode === "token" ? {
@@ -1469,6 +1849,7 @@ class GatewayServer {
1469
1849
  const controlUi = this.wingmanConfig.gateway?.controlUi;
1470
1850
  this.controlUiEnabled = controlUi?.enabled ?? false;
1471
1851
  this.controlUiPort = controlUi?.port || 18790;
1852
+ this.nodePairingRequired = controlUi?.pairingRequired ?? true;
1472
1853
  this.controlUiSamePort = this.controlUiEnabled && this.controlUiPort === this.config.port;
1473
1854
  this.uiDistDir = this.controlUiEnabled ? this.resolveControlUiDir() : null;
1474
1855
  const relayConfig = this.wingmanConfig.browser?.relay;
@@ -1525,4 +1906,4 @@ function isFileAttachment(attachment) {
1525
1906
  if ("file" === attachment.kind) return true;
1526
1907
  return "string" == typeof attachment.textContent;
1527
1908
  }
1528
- export { GatewayServer, resolveExecutionConfigDirOverride, resolveExecutionWorkspaceOverride };
1909
+ export { GatewayServer, isGatewayOriginAllowed, isLoopbackHostname, resolveExecutionConfigDirOverride, resolveExecutionWorkspaceOverride };