@tyvm/knowhow 0.0.108-dev.bd8f104-dev.bd8f104-dev.bd8f104 → 0.0.108-dev.c47492f

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 (53) hide show
  1. package/package.json +2 -2
  2. package/scripts/publish.sh +20 -4
  3. package/src/agents/base/base.ts +9 -0
  4. package/src/chat/CliChatService.ts +7 -1
  5. package/src/chat/renderer/CompactRenderer.ts +20 -0
  6. package/src/chat/renderer/ConsoleRenderer.ts +19 -0
  7. package/src/chat/renderer/FancyRenderer.ts +19 -0
  8. package/src/chat/renderer/types.ts +11 -0
  9. package/src/cli.ts +45 -21
  10. package/src/clients/types.ts +12 -4
  11. package/src/processors/JsonCompressor.ts +3 -3
  12. package/src/services/modules/index.ts +21 -17
  13. package/src/tunnel.ts +216 -0
  14. package/src/worker.ts +65 -336
  15. package/src/workers/auth/WsMiddleware.ts +99 -0
  16. package/src/workers/auth/authMiddleware.ts +104 -0
  17. package/src/workers/auth/types.ts +14 -2
  18. package/ts_build/package.json +2 -2
  19. package/ts_build/src/agents/base/base.js +10 -0
  20. package/ts_build/src/agents/base/base.js.map +1 -1
  21. package/ts_build/src/chat/CliChatService.js +10 -1
  22. package/ts_build/src/chat/CliChatService.js.map +1 -1
  23. package/ts_build/src/chat/renderer/CompactRenderer.d.ts +4 -0
  24. package/ts_build/src/chat/renderer/CompactRenderer.js +16 -0
  25. package/ts_build/src/chat/renderer/CompactRenderer.js.map +1 -1
  26. package/ts_build/src/chat/renderer/ConsoleRenderer.d.ts +4 -0
  27. package/ts_build/src/chat/renderer/ConsoleRenderer.js +16 -0
  28. package/ts_build/src/chat/renderer/ConsoleRenderer.js.map +1 -1
  29. package/ts_build/src/chat/renderer/FancyRenderer.d.ts +4 -0
  30. package/ts_build/src/chat/renderer/FancyRenderer.js +16 -0
  31. package/ts_build/src/chat/renderer/FancyRenderer.js.map +1 -1
  32. package/ts_build/src/chat/renderer/types.d.ts +2 -0
  33. package/ts_build/src/cli.js +17 -9
  34. package/ts_build/src/cli.js.map +1 -1
  35. package/ts_build/src/clients/types.d.ts +2 -2
  36. package/ts_build/src/processors/JsonCompressor.js +3 -3
  37. package/ts_build/src/processors/JsonCompressor.js.map +1 -1
  38. package/ts_build/src/services/modules/index.d.ts +30 -0
  39. package/ts_build/src/services/modules/index.js +9 -14
  40. package/ts_build/src/services/modules/index.js.map +1 -1
  41. package/ts_build/src/tunnel.d.ts +27 -0
  42. package/ts_build/src/tunnel.js +112 -0
  43. package/ts_build/src/tunnel.js.map +1 -0
  44. package/ts_build/src/worker.d.ts +1 -4
  45. package/ts_build/src/worker.js +38 -244
  46. package/ts_build/src/worker.js.map +1 -1
  47. package/ts_build/src/workers/auth/WsMiddleware.d.ts +8 -0
  48. package/ts_build/src/workers/auth/WsMiddleware.js +65 -0
  49. package/ts_build/src/workers/auth/WsMiddleware.js.map +1 -0
  50. package/ts_build/src/workers/auth/authMiddleware.d.ts +3 -0
  51. package/ts_build/src/workers/auth/authMiddleware.js +60 -0
  52. package/ts_build/src/workers/auth/authMiddleware.js.map +1 -0
  53. package/ts_build/src/workers/auth/types.d.ts +8 -1
package/src/tunnel.ts ADDED
@@ -0,0 +1,216 @@
1
+ import os from "os";
2
+ import { WebSocket } from "ws";
3
+ import { createTunnelHandler, TunnelHandler } from "@tyvm/knowhow-tunnel";
4
+ import { loadJwt } from "./login";
5
+ import { wait } from "./utils";
6
+ import { getConfig } from "./config";
7
+ import { KNOWHOW_API_URL } from "./services/KnowhowClient";
8
+ import { ModulesService } from "./services/modules";
9
+ import { WorkerPasskeyAuthService } from "./workers/auth/WorkerPasskeyAuth";
10
+ import { WsMiddlewareStack } from "./workers/auth/WsMiddleware";
11
+ import { makeAuthMiddleware } from "./workers/auth/authMiddleware";
12
+
13
+ /**
14
+ * Extract the tunnel domain and protocol from the API URL.
15
+ * e.g., "https://api.knowhow.tyvm.ai" -> { domain: "worker.knowhow.tyvm.ai", useHttps: true }
16
+ * e.g., "http://localhost:4000" -> { domain: "worker.localhost:4000", useHttps: false }
17
+ */
18
+ export function extractTunnelDomain(apiUrl: string): {
19
+ domain: string;
20
+ useHttps: boolean;
21
+ } {
22
+ try {
23
+ const url = new URL(apiUrl);
24
+ const useHttps = url.protocol === "https:";
25
+
26
+ // For localhost, include port; for production, just use hostname
27
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
28
+ return {
29
+ domain: `worker.${url.hostname}:${url.port || "80"}`,
30
+ useHttps,
31
+ };
32
+ }
33
+ return { domain: `worker.${url.hostname}`, useHttps };
34
+ } catch (err) {
35
+ console.error("Failed to parse API_URL for tunnel domain:", err);
36
+ return { domain: "worker.localhost:4000", useHttps: false }; // fallback
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Initialize a tunnel handler and load tunnel modules.
42
+ */
43
+ export async function initTunnelHandler(
44
+ tunnelConnection: WebSocket,
45
+ tunnelConfig: Parameters<typeof createTunnelHandler>[1]
46
+ ): Promise<TunnelHandler> {
47
+ const handler = createTunnelHandler(tunnelConnection, tunnelConfig);
48
+ console.log("🌐 Tunnel handler initialized");
49
+ console.log(tunnelConfig);
50
+
51
+ const tunnelModuleService = new ModulesService();
52
+ const tunnelContext = await tunnelModuleService.overrideDefaultContext({
53
+ Tunnel: handler,
54
+ });
55
+ tunnelModuleService.loadModulesFromConfig(tunnelContext).catch((err) => {
56
+ console.error("Failed to load tunnel modules:", err);
57
+ });
58
+
59
+ return handler;
60
+ }
61
+
62
+ /**
63
+ * Resolve tunnel local host, log port mapping, and return shared tunnel setup values.
64
+ * Extracted to avoid duplication between worker() and tunnel().
65
+ */
66
+ export function resolveTunnelConfig(
67
+ config: Awaited<ReturnType<typeof getConfig>>,
68
+ isInsideDocker: boolean
69
+ ): { tunnelLocalHost: string; portMapping: Record<string, number> } {
70
+ // Determine localHost based on environment
71
+ let tunnelLocalHost = config.worker?.tunnel?.localHost;
72
+ if (!tunnelLocalHost) {
73
+ if (isInsideDocker) {
74
+ tunnelLocalHost = "host.docker.internal";
75
+ console.log(
76
+ "🐳 Docker detected: tunnel will use host.docker.internal to reach host services"
77
+ );
78
+ } else {
79
+ tunnelLocalHost = "127.0.0.1";
80
+ }
81
+ }
82
+
83
+ // Check for port mapping configuration
84
+ const portMapping = (config.worker?.tunnel?.portMapping || {}) as Record<string, number>;
85
+ if (Object.keys(portMapping).length > 0) {
86
+ console.log("🔀 Port mapping configured:");
87
+ for (const [containerPort, hostPort] of Object.entries(portMapping)) {
88
+ console.log(` Container port ${containerPort} → Host port ${hostPort}`);
89
+ }
90
+ }
91
+
92
+ return { tunnelLocalHost, portMapping };
93
+ }
94
+
95
+ /**
96
+ * Options for connectTunnelWebSocket helper.
97
+ */
98
+ export interface TunnelWebSocketOptions {
99
+ /** Already-resolved tunnel domain (hostname only, no protocol) */
100
+ tunnelDomain: string;
101
+ /** Whether the tunnel should use HTTPS */
102
+ tunnelUseHttps: boolean;
103
+ /** Local host to forward tunnel traffic to */
104
+ tunnelLocalHost: string;
105
+ /** Port mapping configuration */
106
+ portMapping: Record<string, number>;
107
+ /** Worker config (for tunnel sub-config) */
108
+ config: Awaited<ReturnType<typeof getConfig>>;
109
+ /** HTTP headers to attach to the WebSocket upgrade request */
110
+ headers: Record<string, string>;
111
+ /** Callback invoked with the TunnelHandler once the connection opens */
112
+ onOpen?: (handler: TunnelHandler) => void;
113
+ /** Called when the connection closes; receives code + reason string */
114
+ onClose?: (code: number, reason: string) => void;
115
+ /** Called on error */
116
+ onError?: (error: Error) => void;
117
+ /** Optional passkey auth service — if provided, applies WS middleware to gate tunnel traffic */
118
+ authService?: WorkerPasskeyAuthService | null;
119
+ }
120
+
121
+ /**
122
+ * Create a tunnel WebSocket connection, build the tunnelConfig, and
123
+ * initialize the tunnel handler. Returns the WebSocket.
124
+ *
125
+ * The caller is responsible for storing a reference to the returned TunnelHandler
126
+ * (via onOpen) and performing any outer-state cleanup (via onClose / onError).
127
+ */
128
+ export function connectTunnelWebSocket(
129
+ options: TunnelWebSocketOptions
130
+ ): WebSocket {
131
+ const {
132
+ tunnelDomain,
133
+ tunnelUseHttps,
134
+ tunnelLocalHost,
135
+ portMapping,
136
+ config,
137
+ headers,
138
+ onOpen,
139
+ onClose,
140
+ onError,
141
+ authService,
142
+ } = options;
143
+
144
+ const tunnelConnection = new WebSocket(`${KNOWHOW_API_URL}/ws/tunnel`, { headers });
145
+
146
+ tunnelConnection.on("open", async () => {
147
+ console.log("Tunnel WebSocket connected");
148
+
149
+ // Apply passkey auth middleware FIRST, before tunnel handler registers its
150
+ // "message" listener. Node.js EventEmitter fires listeners in registration
151
+ // order, so our middleware runs first. wrapSocket() also redirects future
152
+ // ws.on("message", ...) calls to an inner emitter, ensuring the tunnel
153
+ // handler only receives messages that passed the middleware.
154
+ if (authService) {
155
+ const stack = new WsMiddlewareStack();
156
+ stack.use(makeAuthMiddleware(authService));
157
+ stack.wrapSocket(tunnelConnection);
158
+ }
159
+
160
+ const allowedPorts = config.worker?.tunnel?.allowedPorts || [];
161
+
162
+ // Create URL rewriter callback that returns the hostname (without protocol).
163
+ // The tunnel package will add the protocol based on the useHttps config.
164
+ const urlRewriter = (port: number, metadata?: any) => {
165
+ const workerId = metadata?.workerId;
166
+ const secret = metadata?.secret;
167
+ // Examples: secret-p3000.worker.example.com / workerId-p3000.worker.example.com
168
+ const subdomain = secret
169
+ ? `${secret}-p${port}`
170
+ : `${workerId}-p${port}`;
171
+ return `${subdomain}.${tunnelDomain}`;
172
+ };
173
+
174
+ const tunnelConfig = {
175
+ allowedPorts,
176
+ maxConcurrentStreams: config.worker?.tunnel?.maxConcurrentStreams || 50,
177
+ tunnelUseHttps,
178
+ localHost: tunnelLocalHost,
179
+ urlRewriter,
180
+ enableUrlRewriting: config.worker?.tunnel?.enableUrlRewriting !== false,
181
+ portMapping,
182
+ logLevel: "debug" as const,
183
+ };
184
+
185
+ const handler = await initTunnelHandler(tunnelConnection, tunnelConfig);
186
+ onOpen?.(handler);
187
+ });
188
+
189
+ tunnelConnection.on("close", (code, reason) => {
190
+ console.log(
191
+ `Tunnel WebSocket closed. Code: ${code}, Reason: ${reason.toString()}`
192
+ );
193
+ onClose?.(code, reason.toString());
194
+ });
195
+
196
+ tunnelConnection.on("error", (error) => {
197
+ console.error("Tunnel WebSocket error:", error);
198
+ onError?.(error);
199
+ });
200
+
201
+ return tunnelConnection;
202
+ }
203
+
204
+ /**
205
+ * The minimal set of tool names that are always registered when running in
206
+ * tunnel mode. These are the tools the backend and frontend need to interact
207
+ * with the tunnel worker (port discovery, passkey auth).
208
+ *
209
+ * Additional tools can be added here in the future without changing the CLI.
210
+ */
211
+ export const TUNNEL_MINIMAL_TOOLS = [
212
+ "listAllowedPorts",
213
+ "unlock",
214
+ "lock",
215
+ "reloadConfig",
216
+ ];
package/src/worker.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import os from "os";
2
2
  import { WebSocket } from "ws";
3
- import { createTunnelHandler, TunnelHandler } from "@tyvm/knowhow-tunnel";
3
+ import { TunnelHandler } from "@tyvm/knowhow-tunnel";
4
4
  import { includedTools } from "./agents/tools/list";
5
5
  import { loadJwt } from "./login";
6
6
  import { services } from "./services";
7
7
  import { PasskeySetupService } from "./workers/auth/PasskeySetup";
8
8
  import { WorkerPasskeyAuthService } from "./workers/auth/WorkerPasskeyAuth";
9
- import { makeUnlockTool, makeLockTool, makeReloadConfigTool } from "./workers/tools";
9
+ import {
10
+ makeUnlockTool,
11
+ makeLockTool,
12
+ makeReloadConfigTool,
13
+ } from "./workers/tools";
10
14
  import { McpServerService } from "./services/Mcp";
11
15
  import * as allTools from "./agents/tools";
12
16
  import workerTools from "./workers/tools";
@@ -14,7 +18,11 @@ import { wait } from "./utils";
14
18
  import { getConfig, updateConfig } from "./config";
15
19
  import { KNOWHOW_API_URL } from "./services/KnowhowClient";
16
20
  import { registerWorkerPath } from "./workerRegistry";
17
- import { ModulesService } from "./services/modules";
21
+ import {
22
+ extractTunnelDomain,
23
+ resolveTunnelConfig,
24
+ connectTunnelWebSocket,
25
+ } from "./tunnel";
18
26
 
19
27
  const API_URL = KNOWHOW_API_URL;
20
28
 
@@ -91,6 +99,7 @@ export async function worker(options?: {
91
99
  noSandbox?: boolean;
92
100
  passkey?: boolean;
93
101
  passkeyReset?: boolean;
102
+ allowedTools?: string[];
94
103
  }) {
95
104
  const config = await getConfig();
96
105
 
@@ -210,9 +219,9 @@ export async function worker(options?: {
210
219
  console.log(`🖥️ Using host mode (${sandboxSource})`);
211
220
  }
212
221
 
213
- // Use the config we already loaded above
214
-
215
- if (!config.worker || !config.worker.allowedTools) {
222
+ // If a tool list override was passed (e.g. from tunnel mode), skip the
223
+ // first-run config write and use it directly.
224
+ if (!options?.allowedTools && (!config.worker || !config.worker.allowedTools)) {
216
225
  console.log(
217
226
  "Worker tools configured! Update knowhow.json to adjust which tools are allowed by the worker."
218
227
  );
@@ -230,7 +239,8 @@ export async function worker(options?: {
230
239
  return;
231
240
  }
232
241
 
233
- let toolsToUse = Tools.getToolsByNames(config.worker.allowedTools);
242
+ const resolvedToolNames = options?.allowedTools ?? config.worker!.allowedTools;
243
+ let toolsToUse = Tools.getToolsByNames(resolvedToolNames);
234
244
 
235
245
  // If passkey auth is enabled, wrap all tool functions to check locked state
236
246
  // and register the unlock/lock auth tools
@@ -289,31 +299,14 @@ export async function worker(options?: {
289
299
  let lastJwt: string | null = null;
290
300
  let unauthorizedJwt: string | null = null;
291
301
 
292
- // Check if tunnel is enabled
293
- const tunnelEnabled = config.worker?.tunnel?.enabled ?? false;
302
+ // Check if tunnel is enabled.
303
+ // When allowedTools is passed as an override (e.g. from `knowhow tunnel`),
304
+ // the tunnel is always forced on — that's the whole point of tunnel mode.
305
+ const tunnelEnabled = options?.allowedTools
306
+ ? true
307
+ : (config.worker?.tunnel?.enabled ?? false);
294
308
 
295
- // Determine localHost based on environment
296
- let tunnelLocalHost = config.worker?.tunnel?.localHost;
297
- if (!tunnelLocalHost) {
298
- // Auto-detect based on Docker environment
299
- if (isInsideDocker) {
300
- tunnelLocalHost = "host.docker.internal";
301
- console.log(
302
- "🐳 Docker detected: tunnel will use host.docker.internal to reach host services"
303
- );
304
- } else {
305
- tunnelLocalHost = "127.0.0.1";
306
- }
307
- }
308
-
309
- // Check for port mapping configuration
310
- const portMapping = config.worker?.tunnel?.portMapping || {};
311
- if (Object.keys(portMapping).length > 0) {
312
- console.log("🔀 Port mapping configured:");
313
- for (const [containerPort, hostPort] of Object.entries(portMapping)) {
314
- console.log(` Container port ${containerPort} → Host port ${hostPort}`);
315
- }
316
- }
309
+ const { tunnelLocalHost, portMapping } = resolveTunnelConfig(config, isInsideDocker);
317
310
 
318
311
  if (tunnelEnabled) {
319
312
  const tunnelPorts = config.worker?.tunnel?.allowedPorts || [];
@@ -330,31 +323,6 @@ export async function worker(options?: {
330
323
  );
331
324
  }
332
325
 
333
- // Extract tunnel domain from API_URL
334
- // e.g., "https://api.knowhow.tyvm.ai" -> "knowhow.tyvm.ai"
335
- // e.g., "http://localhost:4000" -> "localhost:4000"
336
- function extractTunnelDomain(apiUrl: string): {
337
- domain: string;
338
- useHttps: boolean;
339
- } {
340
- try {
341
- const url = new URL(apiUrl);
342
- const useHttps = url.protocol === "https:";
343
-
344
- // For localhost, include port; for production, just use hostname
345
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
346
- return {
347
- domain: `worker.${url.hostname}:${url.port || "80"}`,
348
- useHttps,
349
- };
350
- }
351
- return { domain: `worker.${url.hostname}`, useHttps };
352
- } catch (err) {
353
- console.error("Failed to parse API_URL for tunnel domain:", err);
354
- return { domain: "worker.localhost:4000", useHttps: false }; // fallback
355
- }
356
- }
357
-
358
326
  async function connectWebSocket() {
359
327
  const jwt = await loadJwt();
360
328
  console.log(`Connecting to ${API_URL}`);
@@ -424,7 +392,9 @@ export async function worker(options?: {
424
392
  // Hot-reload: re-read config, reconnect MCPs, and rebuild the tool list
425
393
  // without restarting the worker process.
426
394
  if (parsed?.type === "reloadConfig") {
427
- console.log("🔄 Received reloadConfig — reloading MCPs, modules and tools...");
395
+ console.log(
396
+ "🔄 Received reloadConfig — reloading MCPs, modules and tools..."
397
+ );
428
398
  try {
429
399
  // Re-read fresh config from disk
430
400
  const freshConfig = await getConfig();
@@ -436,13 +406,16 @@ export async function worker(options?: {
436
406
  await Mcp.connectToConfigured(Tools);
437
407
 
438
408
  // Rebuild the allowed tools list from fresh config
439
- const allowedToolNames = freshConfig.worker?.allowedTools ?? Tools.getToolNames();
409
+ const allowedToolNames =
410
+ freshConfig.worker?.allowedTools ?? Tools.getToolNames();
440
411
  toolsToUse = Tools.getToolsByNames(allowedToolNames);
441
412
 
442
413
  // Update the MCP server with new tool list
443
414
  mcpServer.withTools(toolsToUse);
444
415
 
445
- console.log(`✅ Config reloaded: ${toolsToUse.length} tools active`);
416
+ console.log(
417
+ `✅ Config reloaded: ${toolsToUse.length} tools active`
418
+ );
446
419
  } catch (err) {
447
420
  console.error("❌ Failed to reload config:", err);
448
421
  }
@@ -456,100 +429,40 @@ export async function worker(options?: {
456
429
  // Create separate WebSocket connection for tunnel if enabled
457
430
  let tunnelConnection: WebSocket | null = null;
458
431
  if (tunnelEnabled) {
459
- tunnelConnection = new WebSocket(`${API_URL}/ws/tunnel`, {
432
+ tunnelConnection = connectTunnelWebSocket({
433
+ tunnelDomain,
434
+ tunnelUseHttps,
435
+ tunnelLocalHost,
436
+ portMapping,
437
+ config,
460
438
  headers,
461
- });
462
-
463
- tunnelConnection.on("open", () => {
464
- console.log("Tunnel WebSocket connected");
465
-
466
- // Get the allowedPorts configuration
467
- const allowedPorts = config.worker?.tunnel?.allowedPorts || [];
468
-
469
- // Create URL rewriter callback that returns the hostname (without protocol)
470
- // The tunnel package will add the protocol based on the useHttps config
471
- // This receives port and metadata from the tunnel request
472
- const urlRewriter = (port: number, metadata?: any) => {
473
- const workerId = metadata?.workerId;
474
- const secret = metadata?.secret;
475
-
476
- // Build the hostname/domain (without protocol) based on metadata
477
- // The tunnel handler will add the protocol using the useHttps config
478
- // Examples:
479
- // - secret-p3000.worker.example.com
480
- // - workerId-p3000.worker.example.com
481
- const subdomain = secret
482
- ? `${secret}-p${port}`
483
- : `${workerId}-p${port}`;
484
-
485
- // Return just the hostname - the tunnel package should add the protocol
486
- // based on the useHttps configuration passed below
487
- const replacementUrl = `${subdomain}.${tunnelDomain}`;
488
- return replacementUrl;
489
- };
490
-
491
- const tunnelConfig = {
492
- allowedPorts,
493
- maxConcurrentStreams:
494
- config.worker?.tunnel?.maxConcurrentStreams || 50,
495
- tunnelUseHttps,
496
- localHost: tunnelLocalHost,
497
- urlRewriter,
498
- enableUrlRewriting:
499
- config.worker?.tunnel?.enableUrlRewriting !== false,
500
- portMapping,
501
- logLevel: "debug" as const,
502
- };
503
-
504
- // Initialize tunnel handler with the tunnel-specific WebSocket
505
- // Pass useHttps flag so the tunnel package can add the correct protocol
506
- tunnelHandler = createTunnelHandler(tunnelConnection!, tunnelConfig);
507
- console.log("🌐 Tunnel handler initialized");
508
- console.log(tunnelConfig);
509
-
510
- // Let modules that need the tunnel handler register their addons now
511
- const tunnelModulesService = new ModulesService();
512
- const { Agents, Embeddings, Plugins, Clients, Tools, MediaProcessor } = services();
513
- tunnelModulesService.loadModulesFromConfig({
514
- Agents, Embeddings, Plugins, Clients, Tools, MediaProcessor,
515
- Tunnel: tunnelHandler,
516
- }).catch((err) => {
517
- console.error("Failed to load tunnel modules:", err);
518
- });
519
- });
520
-
521
- tunnelConnection.on("close", (code, reason) => {
522
- console.log(
523
- `Tunnel WebSocket closed. Code: ${code}, Reason: ${reason.toString()}`
524
- );
525
- if (code === 1008) {
526
- unauthorizedJwt = lastJwt;
527
- console.error(
528
- "❌ Tunnel received Unauthorized (1008). The JWT may be expired."
529
- );
530
- console.error(" Pausing reconnection until JWT changes...");
531
- } else {
532
- console.log(
533
- "Tunnel connection will reconnect on next connection cycle..."
534
- );
535
- }
536
-
537
- // Cleanup tunnel handler
538
- if (tunnelHandler) {
539
- tunnelHandler.cleanup();
540
- tunnelHandler = null;
541
- }
542
- tunnelWs = null;
543
-
544
- // Mark as disconnected to trigger reconnection
545
- // The tunnel websocket is separate but we should reconnect both
546
- connected = false;
547
- });
548
-
549
- tunnelConnection.on("error", (error) => {
550
- console.error("Tunnel WebSocket error:", error);
551
- // Mark as disconnected on error to trigger reconnection
552
- connected = false;
439
+ authService,
440
+ onOpen: (handler) => {
441
+ tunnelHandler = handler;
442
+ },
443
+ onClose: (code, _reason) => {
444
+ if (code === 1008) {
445
+ unauthorizedJwt = lastJwt;
446
+ console.error(
447
+ "❌ Tunnel received Unauthorized (1008). The JWT may be expired."
448
+ );
449
+ console.error(" Pausing reconnection until JWT changes...");
450
+ } else {
451
+ console.log(
452
+ "Tunnel connection will reconnect on next connection cycle..."
453
+ );
454
+ }
455
+ if (tunnelHandler) {
456
+ tunnelHandler.cleanup();
457
+ tunnelHandler = null;
458
+ }
459
+ tunnelWs = null;
460
+ // The tunnel websocket is separate but we should reconnect both
461
+ connected = false;
462
+ },
463
+ onError: (_error) => {
464
+ connected = false;
465
+ },
553
466
  });
554
467
 
555
468
  tunnelWs = tunnelConnection;
@@ -632,187 +545,3 @@ export async function worker(options?: {
632
545
  await wait(5000);
633
546
  }
634
547
  }
635
-
636
- /**
637
- * Run tunnel-only mode: connects to the Knowhow tunnel WebSocket without
638
- * registering any MCP tools. Useful for users who only want the web tunnel
639
- * feature to expose local ports to the cloud.
640
- */
641
- export async function tunnel(options?: {
642
- share?: boolean;
643
- unshare?: boolean;
644
- }) {
645
- const config = await getConfig();
646
-
647
- const isInsideDocker = process.env.KNOWHOW_DOCKER === "true";
648
-
649
- // Determine localHost based on environment
650
- let tunnelLocalHost = config.worker?.tunnel?.localHost;
651
- if (!tunnelLocalHost) {
652
- if (isInsideDocker) {
653
- tunnelLocalHost = "host.docker.internal";
654
- console.log(
655
- "🐳 Docker detected: tunnel will use host.docker.internal to reach host services"
656
- );
657
- } else {
658
- tunnelLocalHost = "127.0.0.1";
659
- }
660
- }
661
-
662
- // Check for port mapping configuration
663
- const portMapping = config.worker?.tunnel?.portMapping || {};
664
- if (Object.keys(portMapping).length > 0) {
665
- console.log("🔀 Port mapping configured:");
666
- for (const [containerPort, hostPort] of Object.entries(portMapping)) {
667
- console.log(` Container port ${containerPort} → Host port ${hostPort}`);
668
- }
669
- }
670
-
671
- const tunnelPorts = config.worker?.tunnel?.allowedPorts || [];
672
- if (tunnelPorts.length === 0) {
673
- console.warn(
674
- "⚠️ No allowedPorts configured. Add worker.tunnel.allowedPorts to knowhow.json"
675
- );
676
- } else {
677
- console.log(`🌐 Tunnel mode for ports: ${tunnelPorts.join(", ")}`);
678
- }
679
-
680
- // Extract tunnel domain from API_URL
681
- function extractTunnelDomain(apiUrl: string): {
682
- domain: string;
683
- useHttps: boolean;
684
- } {
685
- try {
686
- const url = new URL(apiUrl);
687
- const useHttps = url.protocol === "https:";
688
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
689
- return {
690
- domain: `worker.${url.hostname}:${url.port || "80"}`,
691
- useHttps,
692
- };
693
- }
694
- return { domain: `worker.${url.hostname}`, useHttps };
695
- } catch (err) {
696
- console.error("Failed to parse API_URL for tunnel domain:", err);
697
- return { domain: "worker.localhost:4000", useHttps: false };
698
- }
699
- }
700
-
701
- let connected = false;
702
- let tunnelHandler: TunnelHandler | null = null;
703
- let lastJwt: string | null = null;
704
- let unauthorizedJwt: string | null = null;
705
-
706
- async function connectTunnel() {
707
- const jwt = await loadJwt();
708
- lastJwt = jwt;
709
- console.log(`Connecting tunnel to ${API_URL}`);
710
-
711
- const dir = process.cwd();
712
- const homedir = os.homedir();
713
- const hostname = process.env.WORKER_HOSTNAME || os.hostname();
714
- const root =
715
- process.env.WORKER_ROOT ||
716
- (dir === homedir ? "~" : dir.replace(homedir, "~"));
717
-
718
- const headers: Record<string, string> = {
719
- Authorization: `Bearer ${jwt}`,
720
- "User-Agent": `knowhow-tunnel/1.0.0/${hostname}`,
721
- Root: root,
722
- };
723
-
724
- if (options?.share) {
725
- headers.Shared = "true";
726
- console.log("🔓 Tunnel shared with organization");
727
- } else if (options?.unshare) {
728
- headers.Shared = "false";
729
- console.log("🔒 Tunnel is now private (unshared)");
730
- } else {
731
- console.log("🔒 Tunnel is private (only you can use it)");
732
- }
733
-
734
- const { domain: tunnelDomain, useHttps: tunnelUseHttps } =
735
- extractTunnelDomain(API_URL);
736
-
737
- const tunnelConnection = new WebSocket(`${API_URL}/ws/tunnel`, { headers });
738
-
739
- tunnelConnection.on("open", () => {
740
- console.log("🌐 Tunnel WebSocket connected");
741
- connected = true;
742
-
743
- const allowedPorts = config.worker?.tunnel?.allowedPorts || [];
744
- const urlRewriter = (port: number, metadata?: any) => {
745
- const workerId = metadata?.workerId;
746
- const secret = metadata?.secret;
747
- const subdomain = secret ? `${secret}-p${port}` : `${workerId}-p${port}`;
748
- return `${subdomain}.${tunnelDomain}`;
749
- };
750
-
751
- const tunnelConfig = {
752
- allowedPorts,
753
- maxConcurrentStreams: config.worker?.tunnel?.maxConcurrentStreams || 50,
754
- tunnelUseHttps,
755
- localHost: tunnelLocalHost,
756
- urlRewriter,
757
- enableUrlRewriting: config.worker?.tunnel?.enableUrlRewriting !== false,
758
- portMapping,
759
- logLevel: "debug" as const,
760
- };
761
-
762
- tunnelHandler = createTunnelHandler(tunnelConnection, tunnelConfig);
763
- console.log("🌐 Tunnel handler initialized");
764
- console.log(tunnelConfig);
765
-
766
- // Let modules that need the tunnel handler register their addons now
767
- const tunnelModulesService2 = new ModulesService();
768
- const { Agents: A2, Embeddings: E2, Plugins: P2, Clients: C2, Tools: T2, MediaProcessor: MP2 } = services();
769
- tunnelModulesService2.loadModulesFromConfig({
770
- Agents: A2, Embeddings: E2, Plugins: P2, Clients: C2, Tools: T2, MediaProcessor: MP2,
771
- Tunnel: tunnelHandler,
772
- }).catch((err) => {
773
- console.error("Failed to load tunnel modules:", err);
774
- });
775
- });
776
-
777
- tunnelConnection.on("close", (code, reason) => {
778
- console.log(`Tunnel WebSocket closed. Code: ${code}, Reason: ${reason.toString()}`);
779
- if (code === 1008) {
780
- unauthorizedJwt = lastJwt;
781
- console.error("❌ Tunnel received Unauthorized (1008). The JWT may be expired.");
782
- console.error(" Run 'knowhow login' to refresh your token, then restart.");
783
- console.error(" Pausing reconnection until JWT changes...");
784
- } else {
785
- console.log("Tunnel connection will reconnect on next cycle...");
786
- }
787
- if (tunnelHandler) {
788
- tunnelHandler.cleanup();
789
- tunnelHandler = null;
790
- }
791
- connected = false;
792
- });
793
-
794
- tunnelConnection.on("error", (error) => {
795
- console.error("Tunnel WebSocket error:", error);
796
- connected = false;
797
- });
798
-
799
- return tunnelConnection;
800
- }
801
-
802
- while (true) {
803
- if (!connected) {
804
- if (unauthorizedJwt !== null) {
805
- const currentJwt = await loadJwt().catch(() => null);
806
- if (currentJwt === unauthorizedJwt) {
807
- await wait(5000);
808
- continue;
809
- }
810
- console.log("🔄 JWT has changed, attempting to reconnect tunnel...");
811
- unauthorizedJwt = null;
812
- }
813
- console.log("Attempting to connect tunnel...");
814
- await connectTunnel();
815
- }
816
- await wait(5000);
817
- }
818
- }