@elizaos/plugin-ngrok 2.0.0-beta.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 (48) hide show
  1. package/README.md +325 -0
  2. package/dist/__tests__/NgrokTestSuite.d.ts +6 -0
  3. package/dist/__tests__/NgrokTestSuite.d.ts.map +1 -0
  4. package/dist/__tests__/NgrokTestSuite.js +92 -0
  5. package/dist/__tests__/NgrokTestSuite.js.map +1 -0
  6. package/dist/actions/get-tunnel-status.d.ts +4 -0
  7. package/dist/actions/get-tunnel-status.d.ts.map +1 -0
  8. package/dist/actions/get-tunnel-status.js +186 -0
  9. package/dist/actions/get-tunnel-status.js.map +1 -0
  10. package/dist/actions/start-tunnel.d.ts +4 -0
  11. package/dist/actions/start-tunnel.d.ts.map +1 -0
  12. package/dist/actions/start-tunnel.js +221 -0
  13. package/dist/actions/start-tunnel.js.map +1 -0
  14. package/dist/actions/stop-tunnel.d.ts +4 -0
  15. package/dist/actions/stop-tunnel.d.ts.map +1 -0
  16. package/dist/actions/stop-tunnel.js +174 -0
  17. package/dist/actions/stop-tunnel.js.map +1 -0
  18. package/dist/environment.d.ts +12 -0
  19. package/dist/environment.d.ts.map +1 -0
  20. package/dist/environment.js +68 -0
  21. package/dist/environment.js.map +1 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/services/NgrokService.d.ts +30 -0
  27. package/dist/services/NgrokService.d.ts.map +1 -0
  28. package/dist/services/NgrokService.js +333 -0
  29. package/dist/services/NgrokService.js.map +1 -0
  30. package/package.json +63 -0
  31. package/src/__tests__/NgrokTestSuite.ts +110 -0
  32. package/src/__tests__/debug-mock.test.ts +15 -0
  33. package/src/__tests__/e2e/real-ngrok.test.ts +543 -0
  34. package/src/__tests__/integration/webhook-scenarios.test.ts +463 -0
  35. package/src/__tests__/mocks/NgrokServiceMock.ts +76 -0
  36. package/src/__tests__/ngrok-integration.test.ts +521 -0
  37. package/src/__tests__/test-config.ts +83 -0
  38. package/src/__tests__/test-helpers.ts +43 -0
  39. package/src/__tests__/test-setup.ts +174 -0
  40. package/src/__tests__/test-utils.ts +155 -0
  41. package/src/__tests__/unit/actions.test.ts +402 -0
  42. package/src/__tests__/unit/environment.test.ts +352 -0
  43. package/src/actions/get-tunnel-status.ts +218 -0
  44. package/src/actions/start-tunnel.ts +255 -0
  45. package/src/actions/stop-tunnel.ts +203 -0
  46. package/src/environment.ts +75 -0
  47. package/src/index.ts +33 -0
  48. package/src/services/NgrokService.ts +401 -0
@@ -0,0 +1,352 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import type { IAgentRuntime } from '@elizaos/core';
3
+ import { ngrokEnvSchema, validateNgrokConfig } from '../../environment';
4
+
5
+ type SettingMock = ((key: string) => unknown) & {
6
+ mockImplementation: (implementation: (key: string) => unknown) => SettingMock;
7
+ mockReturnValue: (value: unknown) => SettingMock;
8
+ };
9
+
10
+ type MockRuntimeWithSettings = IAgentRuntime & {
11
+ getSetting: SettingMock;
12
+ };
13
+
14
+ interface NgrokConfigTestContext {
15
+ mockRuntime: MockRuntimeWithSettings;
16
+ originalEnv: NodeJS.ProcessEnv;
17
+ }
18
+
19
+ interface TestSuiteConfig<TContext> {
20
+ beforeEach?: () => TContext;
21
+ afterEach?: (context: TContext) => void | Promise<void>;
22
+ }
23
+
24
+ interface UnitTest<TContext> {
25
+ name: string;
26
+ fn: (context: TContext) => Promise<void> | void;
27
+ }
28
+
29
+ function createSettingMock(implementation: (key: string) => unknown): SettingMock {
30
+ return mock(implementation) as SettingMock;
31
+ }
32
+
33
+ function createRuntimeWithSettingMock(
34
+ implementation: (key: string) => unknown = () => undefined
35
+ ): MockRuntimeWithSettings {
36
+ return {
37
+ getSetting: createSettingMock(implementation),
38
+ } as MockRuntimeWithSettings;
39
+ }
40
+
41
+ // Simplified TestSuite implementation for local use
42
+ class TestSuite<TContext> {
43
+ constructor(
44
+ private name: string,
45
+ private config: TestSuiteConfig<TContext>
46
+ ) {}
47
+
48
+ addTest(test: UnitTest<TContext>) {
49
+ it(test.name, async () => {
50
+ const context = this.config.beforeEach?.();
51
+ if (!context) {
52
+ throw new Error(`Missing test context for ${this.name}`);
53
+ }
54
+ await test.fn(context);
55
+ await this.config.afterEach?.(context);
56
+ });
57
+ }
58
+
59
+ run() {
60
+ // No-op, bun:test handles execution
61
+ }
62
+ }
63
+
64
+ const createUnitTest = (config: UnitTest<NgrokConfigTestContext>) => config;
65
+
66
+ describe('Ngrok Environment Configuration', () => {
67
+ const ngrokConfigSuite = new TestSuite<NgrokConfigTestContext>(
68
+ 'Ngrok Environment Configuration',
69
+ {
70
+ beforeEach: () => {
71
+ // Save original env
72
+ const originalEnv = { ...process.env };
73
+
74
+ // Clear relevant env vars
75
+ delete process.env.NGROK_AUTH_TOKEN;
76
+ delete process.env.NGROK_REGION;
77
+ delete process.env.NGROK_SUBDOMAIN;
78
+ delete process.env.NGROK_DEFAULT_PORT;
79
+
80
+ // Setup mock runtime
81
+ const mockRuntime = createRuntimeWithSettingMock((key: string) => {
82
+ const settings: Record<string, string> = {};
83
+ return settings[key];
84
+ });
85
+
86
+ return { mockRuntime, originalEnv };
87
+ },
88
+ afterEach: ({ originalEnv }) => {
89
+ // Restore original env
90
+ process.env = originalEnv;
91
+ },
92
+ }
93
+ );
94
+
95
+ ngrokConfigSuite.addTest(
96
+ createUnitTest({
97
+ name: 'should accept valid configuration',
98
+ fn: () => {
99
+ const validConfig = {
100
+ NGROK_AUTH_TOKEN: 'test-token',
101
+ NGROK_REGION: 'eu',
102
+ NGROK_SUBDOMAIN: 'my-subdomain',
103
+ NGROK_DEFAULT_PORT: '8080',
104
+ };
105
+
106
+ const result = ngrokEnvSchema.parse(validConfig);
107
+
108
+ expect(result.NGROK_AUTH_TOKEN).toBe('test-token');
109
+ expect(result.NGROK_REGION).toBe('eu');
110
+ expect(result.NGROK_SUBDOMAIN).toBe('my-subdomain');
111
+ expect(result.NGROK_DEFAULT_PORT).toBe(8080);
112
+ },
113
+ })
114
+ );
115
+
116
+ ngrokConfigSuite.addTest(
117
+ createUnitTest({
118
+ name: 'should use defaults for optional fields',
119
+ fn: () => {
120
+ const minimalConfig = {};
121
+
122
+ const result = ngrokEnvSchema.parse(minimalConfig);
123
+
124
+ expect(result.NGROK_AUTH_TOKEN).toBeUndefined();
125
+ expect(result.NGROK_REGION).toBe('us');
126
+ expect(result.NGROK_SUBDOMAIN).toBeUndefined();
127
+ expect(result.NGROK_DEFAULT_PORT).toBe(3000);
128
+ },
129
+ })
130
+ );
131
+
132
+ ngrokConfigSuite.addTest(
133
+ createUnitTest({
134
+ name: 'should transform port string to number',
135
+ fn: () => {
136
+ const config = {
137
+ NGROK_DEFAULT_PORT: '5000',
138
+ };
139
+
140
+ const result = ngrokEnvSchema.parse(config);
141
+
142
+ expect(result.NGROK_DEFAULT_PORT).toBe(5000);
143
+ expect(typeof result.NGROK_DEFAULT_PORT).toBe('number');
144
+ },
145
+ })
146
+ );
147
+
148
+ ngrokConfigSuite.addTest(
149
+ createUnitTest({
150
+ name: 'should handle empty port string',
151
+ fn: () => {
152
+ const config = {
153
+ NGROK_DEFAULT_PORT: '',
154
+ };
155
+
156
+ const result = ngrokEnvSchema.parse(config);
157
+
158
+ expect(result.NGROK_DEFAULT_PORT).toBe(3000); // Default
159
+ },
160
+ })
161
+ );
162
+
163
+ ngrokConfigSuite.addTest(
164
+ createUnitTest({
165
+ name: 'should validate configuration from runtime settings',
166
+ fn: async ({ mockRuntime }) => {
167
+ mockRuntime.getSetting.mockImplementation((key: string) => {
168
+ const settings: Record<string, string> = {
169
+ NGROK_AUTH_TOKEN: 'runtime-token',
170
+ NGROK_REGION: 'ap',
171
+ NGROK_SUBDOMAIN: 'runtime-subdomain',
172
+ NGROK_DEFAULT_PORT: '4000',
173
+ };
174
+ return settings[key];
175
+ });
176
+
177
+ const config = await validateNgrokConfig(mockRuntime);
178
+
179
+ expect(config.NGROK_AUTH_TOKEN).toBe('runtime-token');
180
+ expect(config.NGROK_REGION).toBe('ap');
181
+ expect(config.NGROK_SUBDOMAIN).toBe('runtime-subdomain');
182
+ expect(config.NGROK_DEFAULT_PORT).toBe(4000);
183
+ },
184
+ })
185
+ );
186
+
187
+ ngrokConfigSuite.addTest(
188
+ createUnitTest({
189
+ name: 'should fall back to process.env if runtime setting is not available',
190
+ fn: async ({ mockRuntime }) => {
191
+ process.env.NGROK_AUTH_TOKEN = 'env-token';
192
+ process.env.NGROK_REGION = 'sa';
193
+
194
+ mockRuntime.getSetting.mockReturnValue(undefined);
195
+
196
+ const config = await validateNgrokConfig(mockRuntime);
197
+
198
+ expect(config.NGROK_AUTH_TOKEN).toBe('env-token');
199
+ expect(config.NGROK_REGION).toBe('sa');
200
+ },
201
+ })
202
+ );
203
+
204
+ ngrokConfigSuite.addTest(
205
+ createUnitTest({
206
+ name: 'should prefer runtime settings over process.env',
207
+ fn: async ({ mockRuntime }) => {
208
+ process.env.NGROK_AUTH_TOKEN = 'env-token';
209
+
210
+ mockRuntime.getSetting.mockImplementation((key: string) => {
211
+ if (key === 'NGROK_AUTH_TOKEN') {
212
+ return 'runtime-token';
213
+ }
214
+ return undefined;
215
+ });
216
+
217
+ const config = await validateNgrokConfig(mockRuntime);
218
+
219
+ expect(config.NGROK_AUTH_TOKEN).toBe('runtime-token');
220
+ },
221
+ })
222
+ );
223
+
224
+ ngrokConfigSuite.addTest(
225
+ createUnitTest({
226
+ name: 'should handle validation errors gracefully',
227
+ fn: async ({ mockRuntime }) => {
228
+ // Mock invalid data that will fail zod validation - now NGROK_REGION accepts numbers
229
+ mockRuntime.getSetting.mockImplementation((key: string) => {
230
+ if (key === 'NGROK_DEFAULT_PORT') {
231
+ return 'invalid-port'; // This will fail parsing
232
+ }
233
+ return undefined;
234
+ });
235
+
236
+ await expect(validateNgrokConfig(mockRuntime)).resolves.toEqual(
237
+ expect.objectContaining({
238
+ NGROK_DEFAULT_PORT: 3000, // Falls back to default on invalid input
239
+ })
240
+ );
241
+ },
242
+ })
243
+ );
244
+
245
+ ngrokConfigSuite.addTest(
246
+ createUnitTest({
247
+ name: 'should handle number inputs by converting them',
248
+ fn: async () => {
249
+ const mockRuntime = createRuntimeWithSettingMock((key: string) => {
250
+ const settings: Record<string, unknown> = {
251
+ NGROK_REGION: 123, // Will be converted to '123'
252
+ NGROK_DEFAULT_PORT: 'invalid', // Will use default 3000
253
+ };
254
+ return settings[key];
255
+ });
256
+
257
+ const config = await validateNgrokConfig(mockRuntime);
258
+ expect(config.NGROK_REGION).toBe('123'); // Number converted to string
259
+ expect(config.NGROK_DEFAULT_PORT).toBe(3000); // Invalid string uses default
260
+ },
261
+ })
262
+ );
263
+
264
+ ngrokConfigSuite.addTest(
265
+ createUnitTest({
266
+ name: 'should handle all supported regions',
267
+ fn: async ({ mockRuntime }) => {
268
+ const regions = ['us', 'eu', 'ap', 'au', 'sa', 'jp', 'in'];
269
+
270
+ for (const region of regions) {
271
+ mockRuntime.getSetting.mockImplementation((key: string) => {
272
+ if (key === 'NGROK_REGION') {
273
+ return region;
274
+ }
275
+ return undefined;
276
+ });
277
+
278
+ const config = await validateNgrokConfig(mockRuntime);
279
+ expect(config.NGROK_REGION).toBe(region);
280
+ }
281
+ },
282
+ })
283
+ );
284
+
285
+ ngrokConfigSuite.addTest(
286
+ createUnitTest({
287
+ name: 'should handle port zero',
288
+ fn: async () => {
289
+ const mockRuntime = createRuntimeWithSettingMock((key: string) => {
290
+ const settings: Record<string, unknown> = {
291
+ NGROK_DEFAULT_PORT: '0',
292
+ };
293
+ return settings[key];
294
+ });
295
+
296
+ const config = await validateNgrokConfig(mockRuntime);
297
+ expect(config.NGROK_DEFAULT_PORT).toBe(3000); // Should use default instead of 0
298
+ },
299
+ })
300
+ );
301
+
302
+ ngrokConfigSuite.addTest(
303
+ createUnitTest({
304
+ name: 'should handle very large port numbers',
305
+ fn: async ({ mockRuntime }) => {
306
+ mockRuntime.getSetting.mockImplementation((key: string) => {
307
+ if (key === 'NGROK_DEFAULT_PORT') {
308
+ return '65535';
309
+ }
310
+ return undefined;
311
+ });
312
+
313
+ const config = await validateNgrokConfig(mockRuntime);
314
+
315
+ expect(config.NGROK_DEFAULT_PORT).toBe(65535);
316
+ },
317
+ })
318
+ );
319
+
320
+ ngrokConfigSuite.addTest(
321
+ createUnitTest({
322
+ name: 'should handle null values from runtime settings',
323
+ fn: async ({ mockRuntime }) => {
324
+ mockRuntime.getSetting.mockReturnValue(null);
325
+
326
+ const config = await validateNgrokConfig(mockRuntime);
327
+
328
+ // Should use defaults
329
+ expect(config.NGROK_AUTH_TOKEN).toBeUndefined();
330
+ expect(config.NGROK_REGION).toBe('us');
331
+ expect(config.NGROK_DEFAULT_PORT).toBe(3000);
332
+ },
333
+ })
334
+ );
335
+
336
+ ngrokConfigSuite.addTest(
337
+ createUnitTest({
338
+ name: 'should handle undefined runtime',
339
+ fn: async () => {
340
+ const undefinedRuntime = createRuntimeWithSettingMock(() => undefined);
341
+
342
+ const config = await validateNgrokConfig(undefinedRuntime);
343
+
344
+ // Should use defaults
345
+ expect(config.NGROK_REGION).toBe('us');
346
+ expect(config.NGROK_DEFAULT_PORT).toBe(3000);
347
+ },
348
+ })
349
+ );
350
+
351
+ ngrokConfigSuite.run();
352
+ });
@@ -0,0 +1,218 @@
1
+ import {
2
+ type Action,
3
+ type ActionExample,
4
+ type ActionResult,
5
+ elizaLogger,
6
+ type HandlerCallback,
7
+ type IAgentRuntime,
8
+ type Memory,
9
+ type State,
10
+ } from '@elizaos/core';
11
+ import { getTunnelService } from '@elizaos/plugin-tunnel';
12
+
13
+ export const getTunnelStatusAction: Action = {
14
+ name: 'GET_TUNNEL_STATUS',
15
+ similes: ['TUNNEL_STATUS', 'CHECK_TUNNEL', 'NGROK_STATUS', 'TUNNEL_INFO'],
16
+ description:
17
+ 'Get the current status of the ngrok tunnel including URL, port, and uptime information. Supports action chaining by providing tunnel metadata for monitoring workflows, health checks, or conditional tunnel management.',
18
+ validate: async (runtime: IAgentRuntime, _message: Memory) => {
19
+ return !!getTunnelService(runtime);
20
+ },
21
+ handler: async (
22
+ runtime: IAgentRuntime,
23
+ _message: Memory,
24
+ _state?: State,
25
+ _options?: unknown,
26
+ callback?: HandlerCallback
27
+ ): Promise<ActionResult> => {
28
+ try {
29
+ elizaLogger.info('Getting ngrok tunnel status...');
30
+
31
+ const tunnelService = getTunnelService(runtime);
32
+ if (!tunnelService) {
33
+ throw new Error('Tunnel service not found');
34
+ }
35
+
36
+ const status = tunnelService.getStatus();
37
+
38
+ let responseText: string;
39
+ const response = {
40
+ ...status,
41
+ uptime: 'N/A',
42
+ };
43
+
44
+ if (status.active) {
45
+ if (status.startedAt) {
46
+ const uptimeMs = Date.now() - new Date(status.startedAt).getTime();
47
+ const minutes = Math.floor(uptimeMs / 60000);
48
+ const hours = Math.floor(minutes / 60);
49
+
50
+ if (hours > 0) {
51
+ response.uptime = `${hours} hour${hours > 1 ? 's' : ''}, ${minutes % 60} minute${
52
+ minutes % 60 !== 1 ? 's' : ''
53
+ }`;
54
+ } else {
55
+ response.uptime = `${minutes} minute${minutes !== 1 ? 's' : ''}`;
56
+ }
57
+ }
58
+
59
+ responseText = `āœ… Ngrok tunnel is active!\n\n🌐 Public URL: ${status.url}\nšŸ”Œ Local Port: ${status.port}\nā±ļø Uptime: ${response.uptime}\nšŸ¢ Provider: ${status.provider}\n\nYour local service is accessible from the internet.`;
60
+ } else {
61
+ responseText =
62
+ 'āŒ No active ngrok tunnel.\n\nTo start a tunnel, say "start ngrok tunnel on port [PORT]"';
63
+ }
64
+
65
+ const startedAtIso = status.startedAt ? status.startedAt.toISOString() : null;
66
+
67
+ if (callback) {
68
+ await callback({
69
+ text: responseText,
70
+ metadata: {
71
+ action: 'tunnel_status',
72
+ uptime: response.uptime,
73
+ active: status.active,
74
+ url: status.url,
75
+ port: status.port,
76
+ startedAt: startedAtIso,
77
+ provider: status.provider,
78
+ backend: status.backend ?? null,
79
+ },
80
+ });
81
+ }
82
+
83
+ return {
84
+ success: true,
85
+ text: responseText,
86
+ values: {
87
+ success: true,
88
+ isActive: status.active,
89
+ tunnelUrl: status.url,
90
+ port: status.port,
91
+ uptime: response.uptime,
92
+ provider: status.provider,
93
+ },
94
+ data: {
95
+ action: 'GET_TUNNEL_STATUS',
96
+ tunnelStatus: {
97
+ active: status.active,
98
+ url: status.url,
99
+ port: status.port,
100
+ startedAt: startedAtIso,
101
+ provider: status.provider,
102
+ backend: status.backend ?? null,
103
+ uptime: response.uptime,
104
+ checkedAt: new Date().toISOString(),
105
+ },
106
+ },
107
+ };
108
+ } catch (error) {
109
+ const message = error instanceof Error ? error.message : String(error);
110
+ const stack = error instanceof Error ? (error.stack ?? null) : null;
111
+ elizaLogger.error(`Failed to get tunnel status: ${message}`);
112
+
113
+ if (callback) {
114
+ await callback({
115
+ text: `āŒ Failed to get tunnel status: ${message}`,
116
+ metadata: {
117
+ error: message,
118
+ action: 'tunnel_status_failed',
119
+ },
120
+ });
121
+ }
122
+
123
+ return {
124
+ success: false,
125
+ text: `āŒ Failed to get tunnel status: ${message}`,
126
+ values: {
127
+ success: false,
128
+ error: message,
129
+ },
130
+ data: {
131
+ action: 'GET_TUNNEL_STATUS',
132
+ errorType: 'status_check_failed',
133
+ errorDetails: stack,
134
+ },
135
+ };
136
+ }
137
+ },
138
+ examples: [
139
+ [
140
+ {
141
+ name: '{{user}}',
142
+ content: {
143
+ text: 'What is the tunnel status?',
144
+ },
145
+ },
146
+ {
147
+ name: '{{agent}}',
148
+ content: {
149
+ text: 'āœ… Ngrok tunnel is active!\n\n🌐 Public URL: https://abc123.ngrok.io\nšŸ”Œ Local Port: 3000\nā±ļø Uptime: 15 minutes\nšŸ¢ Provider: ngrok\n\nYour local service is accessible from the internet.',
150
+ actions: ['GET_TUNNEL_STATUS'],
151
+ },
152
+ },
153
+ ],
154
+ [
155
+ {
156
+ name: '{{user}}',
157
+ content: {
158
+ text: "Check tunnel status and restart it if it's been running too long",
159
+ },
160
+ },
161
+ {
162
+ name: '{{agent}}',
163
+ content: {
164
+ text: "I'll check the current tunnel status and restart it if needed.",
165
+ thought:
166
+ 'User wants me to monitor tunnel uptime and restart if necessary - I should check status first, then decide whether to restart based on uptime.',
167
+ actions: ['GET_TUNNEL_STATUS'],
168
+ },
169
+ },
170
+ {
171
+ name: '{{agent}}',
172
+ content: {
173
+ text: "Tunnel has been running for 2 hours. That seems like a long time - I'll restart it for optimal performance.",
174
+ thought:
175
+ 'Status shows the tunnel has been up for 2 hours, which is quite long. I should stop and restart it as requested.',
176
+ actions: ['STOP_TUNNEL'],
177
+ },
178
+ },
179
+ {
180
+ name: '{{agent}}',
181
+ content: {
182
+ text: 'Tunnel stopped. Now starting a fresh tunnel...',
183
+ thought:
184
+ 'Old tunnel is down, now I can start a new fresh tunnel for optimal performance.',
185
+ actions: ['START_TUNNEL'],
186
+ },
187
+ },
188
+ ],
189
+ [
190
+ {
191
+ name: '{{user}}',
192
+ content: {
193
+ text: 'Get tunnel info and then update our webhook URLs',
194
+ },
195
+ },
196
+ {
197
+ name: '{{agent}}',
198
+ content: {
199
+ text: "I'll check the current tunnel status and then update the webhook URLs.",
200
+ thought:
201
+ 'User needs the current tunnel URL for webhook configuration - I should get the status first, then update webhooks with the public URL.',
202
+ actions: ['GET_TUNNEL_STATUS'],
203
+ },
204
+ },
205
+ {
206
+ name: '{{agent}}',
207
+ content: {
208
+ text: 'Tunnel is active at https://abc123.ngrok.io. Now updating webhook URLs...',
209
+ thought:
210
+ 'I have the current tunnel URL from the status check. I can now update the webhook configurations with this public URL.',
211
+ actions: ['UPDATE_WEBHOOKS'],
212
+ },
213
+ },
214
+ ],
215
+ ] as ActionExample[][],
216
+ };
217
+
218
+ export default getTunnelStatusAction;