@kortix/agent-tunnel 0.1.4 → 0.12.7

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 (67) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-cli.js +4676 -3174
  3. package/dist/client-cli.js +202 -493
  4. package/package.json +24 -29
  5. package/src/agent/agent.ts +67 -17
  6. package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
  7. package/src/agent/capabilities/desktop.ts +100 -167
  8. package/src/agent/capabilities/enabled-registry.ts +24 -0
  9. package/src/agent/capabilities/filesystem.ts +136 -32
  10. package/src/agent/capabilities/index.ts +1 -1
  11. package/src/agent/capabilities/security.test.ts +204 -0
  12. package/src/agent/capabilities/shell.ts +37 -3
  13. package/src/agent/cli-device-auth.test.ts +179 -0
  14. package/src/agent/cli-help.test.ts +25 -0
  15. package/src/agent/cli.ts +475 -38
  16. package/src/agent/config.test.ts +53 -0
  17. package/src/agent/config.ts +169 -7
  18. package/src/agent/index.ts +1 -0
  19. package/src/agent/security/command-validator.ts +4 -2
  20. package/src/agent/security/path-validator.ts +73 -18
  21. package/src/agent/security/permission-guard.test.ts +52 -0
  22. package/src/agent/security/permission-guard.ts +35 -8
  23. package/src/agent/service.test.ts +63 -0
  24. package/src/agent/service.ts +410 -0
  25. package/src/client/cli.test.ts +150 -547
  26. package/src/client/cli.ts +116 -539
  27. package/src/client/index.ts +1 -1
  28. package/src/client/tools.ts +95 -356
  29. package/src/client/tunnel-client.ts +50 -80
  30. package/src/index.ts +7 -1
  31. package/src/node-ws-polyfill.test.ts +18 -0
  32. package/src/node-ws-polyfill.ts +5 -3
  33. package/src/server/heartbeat.ts +13 -6
  34. package/src/server/relay.test.ts +72 -0
  35. package/src/server/relay.ts +50 -9
  36. package/src/server/server.test.ts +33 -0
  37. package/src/server/server.ts +26 -6
  38. package/src/server/ws-handler.test.ts +158 -0
  39. package/src/server/ws-handler.ts +94 -36
  40. package/src/shared/crypto.ts +2 -3
  41. package/src/shared/index.ts +8 -0
  42. package/src/shared/permissions.ts +292 -0
  43. package/src/shared/types.ts +70 -41
  44. package/dist/agent/index.d.ts +0 -140
  45. package/dist/agent/index.js +0 -21
  46. package/dist/agent/index.js.map +0 -1
  47. package/dist/chunk-7N7GSU6K.js +0 -34
  48. package/dist/client/index.d.ts +0 -183
  49. package/dist/client/index.js +0 -8
  50. package/dist/client/index.js.map +0 -1
  51. package/dist/index.d.ts +0 -7
  52. package/dist/index.js +0 -55
  53. package/dist/index.js.map +0 -1
  54. package/dist/server/index.d.ts +0 -89
  55. package/dist/server/index.js +0 -14
  56. package/dist/server/index.js.map +0 -1
  57. package/dist/shared/index.d.ts +0 -10
  58. package/dist/shared/index.js +0 -20
  59. package/dist/shared/index.js.map +0 -1
  60. package/dist/types-Dpwrd8Ai.d.ts +0 -194
  61. package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
  62. package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
  63. package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
  64. package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
  65. package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
  66. package/src/agent/capabilities/desktop/types.ts +0 -201
  67. package/src/agent/capabilities/desktop/windows-driver.ts +0 -220
@@ -5,6 +5,12 @@ export interface TunnelClientConfig {
5
5
  cacheTtlMs?: number;
6
6
  }
7
7
 
8
+ function trimTrailingSlashes(value: string): string {
9
+ let end = value.length;
10
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
11
+ return value.slice(0, end);
12
+ }
13
+
8
14
  export class TunnelClientError extends Error {
9
15
  constructor(
10
16
  public readonly code: number,
@@ -27,17 +33,26 @@ export class TunnelClient {
27
33
 
28
34
  readonly fs: FsNamespace;
29
35
  readonly shell: ShellNamespace;
30
- readonly desktop: DesktopNamespace;
36
+ readonly cua: CuaNamespace;
31
37
 
32
38
  constructor(config: TunnelClientConfig) {
33
- this.apiUrl = config.apiUrl.replace(/\/+$/, '');
39
+ const apiUrl = new URL(config.apiUrl);
40
+ const loopback =
41
+ apiUrl.hostname === 'localhost' ||
42
+ apiUrl.hostname === '127.0.0.1' ||
43
+ apiUrl.hostname === '[::1]' ||
44
+ apiUrl.hostname === '::1';
45
+ if (apiUrl.protocol !== 'https:' && !(apiUrl.protocol === 'http:' && loopback)) {
46
+ throw new Error('Remote tunnel API URLs must use https');
47
+ }
48
+ this.apiUrl = trimTrailingSlashes(apiUrl.toString());
34
49
  this.token = config.token;
35
50
  this.explicitTunnelId = config.tunnelId;
36
51
  this.cacheTtlMs = config.cacheTtlMs ?? 10_000;
37
52
 
38
53
  this.fs = new FsNamespace(this);
39
54
  this.shell = new ShellNamespace(this);
40
- this.desktop = new DesktopNamespace(this);
55
+ this.cua = new CuaNamespace(this);
41
56
  }
42
57
 
43
58
  async rpc(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
@@ -109,15 +124,11 @@ export class TunnelClient {
109
124
  if (res.ok) {
110
125
  const connections = (await res.json()) as Array<{ tunnelId: string; isLive?: boolean }>;
111
126
  const online = connections.find((c) => c.isLive);
112
- if (online) {
113
- this.cachedTunnelId = online.tunnelId;
127
+ const chosen = online ?? connections[0];
128
+ if (chosen) {
129
+ this.cachedTunnelId = chosen.tunnelId;
114
130
  this.cacheTimestamp = Date.now();
115
- return online.tunnelId;
116
- }
117
- if (connections.length > 0) {
118
- this.cachedTunnelId = connections[0].tunnelId;
119
- this.cacheTimestamp = Date.now();
120
- return connections[0].tunnelId;
131
+ return chosen.tunnelId;
121
132
  }
122
133
  }
123
134
 
@@ -126,7 +137,7 @@ export class TunnelClient {
126
137
  -1,
127
138
  'No tunnel connection found. The user needs to set up Agent Tunnel first:\n' +
128
139
  '1. Create a tunnel connection\n' +
129
- '2. Run `npx @kortix/agent-tunnel connect` on their local machine',
140
+ '2. Connect the local machine from the Kortix desktop app or run the tunnel connect command',
130
141
  );
131
142
  }
132
143
  }
@@ -172,99 +183,58 @@ class ShellNamespace {
172
183
  }
173
184
  }
174
185
 
175
- class DesktopNamespace {
186
+ class CuaNamespace {
176
187
  constructor(private client: TunnelClient) {}
177
188
 
178
- async screenshot(params?: { region?: { x: number; y: number; width: number; height: number }; windowId?: number }): Promise<{ image: string; width: number; height: number; format?: string }> {
179
- return (await this.client.rpc('desktop.screenshot', params ?? {})) as any;
180
- }
181
-
182
- async click(params: { x: number; y: number; button?: string; clicks?: number; modifiers?: string[] }): Promise<unknown> {
183
- return this.client.rpc('desktop.mouse.click', params);
184
- }
185
-
186
- async type(text: string, delay?: number): Promise<unknown> {
187
- return this.client.rpc('desktop.keyboard.type', { text, delay });
188
- }
189
-
190
- async key(keys: string[]): Promise<unknown> {
191
- return this.client.rpc('desktop.keyboard.key', { keys });
189
+ async ensure(): Promise<{ ok: boolean; binary: string; version?: string }> {
190
+ return (await this.client.rpc('desktop.cua.ensure', {})) as any;
192
191
  }
193
192
 
194
- async mouseMove(x: number, y: number): Promise<unknown> {
195
- return this.client.rpc('desktop.mouse.move', { x, y });
193
+ async startDaemon(): Promise<Record<string, unknown>> {
194
+ return (await this.client.rpc('desktop.cua.start_daemon', {})) as any;
196
195
  }
197
196
 
198
- async mouseDrag(fromX: number, fromY: number, toX: number, toY: number, button?: string): Promise<unknown> {
199
- return this.client.rpc('desktop.mouse.drag', { fromX, fromY, toX, toY, button });
197
+ async status(): Promise<{ status: string }> {
198
+ return (await this.client.rpc('desktop.cua.status', {})) as any;
200
199
  }
201
200
 
202
- async mouseScroll(x: number, y: number, deltaX?: number, deltaY?: number): Promise<unknown> {
203
- return this.client.rpc('desktop.mouse.scroll', { x, y, deltaX, deltaY });
201
+ async version(): Promise<{ version: string }> {
202
+ return (await this.client.rpc('desktop.cua.version', {})) as any;
204
203
  }
205
204
 
206
- async windowList(): Promise<{ windows: Array<{ id: number; app: string; title: string; bounds: { x: number; y: number; width: number; height: number }; minimized: boolean }> }> {
207
- return (await this.client.rpc('desktop.window.list', {})) as any;
205
+ async listTools(): Promise<{ tools: string }> {
206
+ return (await this.client.rpc('desktop.cua.list_tools', {})) as any;
208
207
  }
209
208
 
210
- async windowFocus(windowId: number): Promise<unknown> {
211
- return this.client.rpc('desktop.window.focus', { windowId });
209
+ async describe(tool: string): Promise<{ description: string }> {
210
+ return (await this.client.rpc('desktop.cua.describe', { tool })) as any;
212
211
  }
213
212
 
214
- async appLaunch(app: string): Promise<unknown> {
215
- return this.client.rpc('desktop.app.launch', { app });
213
+ async call(tool: string, args: Record<string, unknown> = {}): Promise<unknown> {
214
+ return this.client.rpc('desktop.cua.call', { tool, args });
216
215
  }
217
216
 
218
- async appQuit(app: string): Promise<unknown> {
219
- return this.client.rpc('desktop.app.quit', { app });
217
+ async listApps(): Promise<unknown> {
218
+ return this.client.rpc('desktop.cua.list_apps', {});
220
219
  }
221
220
 
222
- async clipboardRead(): Promise<{ text: string }> {
223
- return (await this.client.rpc('desktop.clipboard.read', {})) as any;
221
+ async listWindows(params?: { pid?: number; on_screen_only?: boolean }): Promise<unknown> {
222
+ return this.client.rpc('desktop.cua.list_windows', params ?? {});
224
223
  }
225
224
 
226
- async clipboardWrite(text: string): Promise<unknown> {
227
- return this.client.rpc('desktop.clipboard.write', { text });
225
+ async getWindowState(params: { pid: number; window_id: number; query?: string; capture_mode?: 'som' | 'vision' | 'ax'; screenshot_out_file?: string; session?: string }): Promise<unknown> {
226
+ return this.client.rpc('desktop.cua.get_window_state', params);
228
227
  }
229
228
 
230
- async screenInfo(): Promise<{ width: number; height: number; scaleFactor: number }> {
231
- return (await this.client.rpc('desktop.screen.info', {})) as any;
229
+ async click(params: Record<string, unknown>): Promise<unknown> {
230
+ return this.client.rpc('desktop.cua.click', params);
232
231
  }
233
232
 
234
- async cursorImage(radius?: number): Promise<{ image: string; width: number; height: number; format?: string }> {
235
- return (await this.client.rpc('desktop.cursor.image', { radius })) as any;
233
+ async typeText(params: Record<string, unknown>): Promise<unknown> {
234
+ return this.client.rpc('desktop.cua.type_text', params);
236
235
  }
237
236
 
238
- async axTree(params?: { pid?: number; maxDepth?: number; roles?: string[] }): Promise<{ root: AXElement; elementCount: number }> {
239
- return (await this.client.rpc('desktop.ax.tree', params ?? {})) as any;
237
+ async hotkey(params: Record<string, unknown>): Promise<unknown> {
238
+ return this.client.rpc('desktop.cua.hotkey', params);
240
239
  }
241
-
242
- async axAction(elementId: string, action: string, pid?: number): Promise<Record<string, unknown>> {
243
- return (await this.client.rpc('desktop.ax.action', { elementId, action, pid })) as any;
244
- }
245
-
246
- async axSetValue(elementId: string, value: string, pid?: number): Promise<Record<string, unknown>> {
247
- return (await this.client.rpc('desktop.ax.set_value', { elementId, value, pid })) as any;
248
- }
249
-
250
- async axFocus(elementId: string, pid?: number): Promise<Record<string, unknown>> {
251
- return (await this.client.rpc('desktop.ax.focus', { elementId, pid })) as any;
252
- }
253
-
254
- async axSearch(query: string, params?: { role?: string; pid?: number; maxResults?: number }): Promise<{ elements: AXElement[] }> {
255
- return (await this.client.rpc('desktop.ax.search', { query, ...params })) as any;
256
- }
257
- }
258
-
259
- export interface AXElement {
260
- id: string;
261
- role: string;
262
- title: string;
263
- value: string;
264
- description: string;
265
- bounds: { x: number; y: number; width: number; height: number };
266
- children: AXElement[];
267
- actions: string[];
268
- enabled: boolean;
269
- focused: boolean;
270
240
  }
package/src/index.ts CHANGED
@@ -8,6 +8,11 @@ export {
8
8
  timingSafeStringEqual,
9
9
  TunnelErrorCode,
10
10
  TunnelMethods,
11
+ capabilityForMethod,
12
+ desktopFeatureForMethod,
13
+ isTunnelCapability,
14
+ operationForMethod,
15
+ validateTunnelPermissionScope,
11
16
  } from './shared';
12
17
 
13
18
  export type {
@@ -33,6 +38,7 @@ export type {
33
38
  TunnelCapability,
34
39
  TunnelMethod,
35
40
  TunnelErrorCodeValue,
41
+ PermissionScopeValidationResult,
36
42
  } from './shared';
37
43
 
38
44
  // ─── Server: Relay ──────────────────────────────────────────────────────────
@@ -46,7 +52,7 @@ export type { TunnelServer } from './server';
46
52
 
47
53
  // ─── Client: SDK ────────────────────────────────────────────────────────────
48
54
  export { TunnelClient, TunnelClientError } from './client';
49
- export type { TunnelClientConfig, AXElement } from './client';
55
+ export type { TunnelClientConfig } from './client';
50
56
  export { createTunnelTools } from './client';
51
57
  export type { TunnelToolDefinition, TunnelToolParameter } from './client';
52
58
 
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ describe('node WebSocket polyfill', () => {
5
+ test('loads ws when the runtime has no global WebSocket', () => {
6
+ const polyfillUrl = pathToFileURL(`${import.meta.dir}/node-ws-polyfill.ts`).href;
7
+ const script = `
8
+ delete globalThis.WebSocket;
9
+ await import(${JSON.stringify(polyfillUrl)});
10
+ if (typeof globalThis.WebSocket !== 'function') process.exit(1);
11
+ `;
12
+
13
+ const result = Bun.spawnSync([process.execPath, '--eval', script]);
14
+
15
+ expect(result.exitCode).toBe(0);
16
+ expect(result.stderr.toString()).toBe('');
17
+ });
18
+ });
@@ -5,9 +5,9 @@
5
5
  */
6
6
  if (typeof globalThis.WebSocket === 'undefined') {
7
7
  try {
8
- // eslint-disable-next-line @typescript-eslint/no-require-imports
9
- const ws = require('ws');
10
- globalThis.WebSocket = ws.default || ws;
8
+ const ws = await import('ws');
9
+ const WebSocketImpl = ws.WebSocket ?? ws.default;
10
+ globalThis.WebSocket = WebSocketImpl as unknown as typeof WebSocket;
11
11
  } catch {
12
12
  console.error(
13
13
  '[agent-tunnel] WebSocket is not available. Install the "ws" package or use Node.js 22+.',
@@ -15,3 +15,5 @@ if (typeof globalThis.WebSocket === 'undefined') {
15
15
  process.exit(1);
16
16
  }
17
17
  }
18
+
19
+ export {};
@@ -25,12 +25,11 @@ export class HeartbeatManager {
25
25
  start(): void {
26
26
  if (this.intervalHandle) return;
27
27
 
28
- this.intervalHandle = setInterval(
29
- () => this.tick(),
30
- this.intervalMs,
31
- );
28
+ this.intervalHandle = setInterval(() => this.tick(), this.intervalMs);
32
29
 
33
- console.log(`[tunnel-heartbeat] Started (interval: ${this.intervalMs}ms, max missed: ${this.maxMissed})`);
30
+ console.log(
31
+ `[tunnel-heartbeat] Started (interval: ${this.intervalMs}ms, max missed: ${this.maxMissed})`,
32
+ );
34
33
  }
35
34
 
36
35
  stop(): void {
@@ -47,6 +46,11 @@ export class HeartbeatManager {
47
46
  missedPongs: 0,
48
47
  lastPongAt: Date.now(),
49
48
  });
49
+ // Collect the live handler list immediately. Waiting for the first interval
50
+ // can advertise stale DB capabilities for up to 30 seconds after connect.
51
+ this.relay.sendNotification(tunnelId, 'tunnel.ping', {
52
+ timestamp: Date.now(),
53
+ });
50
54
  }
51
55
 
52
56
  unregister(tunnelId: string): void {
@@ -75,8 +79,11 @@ export class HeartbeatManager {
75
79
  state.missedPongs++;
76
80
 
77
81
  if (state.missedPongs >= this.maxMissed) {
78
- console.warn(`[tunnel-heartbeat] Agent ${tunnelId} missed ${state.missedPongs} pongs — timing out`);
82
+ console.warn(
83
+ `[tunnel-heartbeat] Agent ${tunnelId} missed ${state.missedPongs} pongs — timing out`,
84
+ );
79
85
  this.relay.emitEvent('agent:timeout', { tunnelId });
86
+ this.relay.disconnectAgent(tunnelId, 4000, 'heartbeat timeout');
80
87
  this.states.delete(tunnelId);
81
88
  }
82
89
  }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { TunnelRelay } from './relay';
3
+
4
+ function fakeWs(closes: Array<{ code?: number; reason?: string }> = []): WebSocket {
5
+ return {
6
+ readyState: WebSocket.OPEN,
7
+ send: () => {},
8
+ close: (code?: number, reason?: string) => closes.push({ code, reason }),
9
+ } as unknown as WebSocket;
10
+ }
11
+
12
+ describe('TunnelRelay connection lifecycle', () => {
13
+ test('updates live metadata without exposing session signing state', () => {
14
+ const relay = new TunnelRelay();
15
+ relay.registerAgent('tnl_1', fakeWs(), 'signing-key', {
16
+ accountId: 'acct_1',
17
+ capabilities: ['filesystem'],
18
+ });
19
+
20
+ expect(relay.updateAgentMetadata('tnl_1', { capabilities: ['desktop'] })).toBe(true);
21
+ expect(relay.getAgentMetadata('tnl_1')).toEqual({
22
+ accountId: 'acct_1',
23
+ capabilities: ['desktop'],
24
+ });
25
+ });
26
+
27
+ test('stale close from a replaced socket does not unregister the active agent', () => {
28
+ const relay = new TunnelRelay();
29
+ const closes: Array<{ code?: number; reason?: string }> = [];
30
+ const first = fakeWs(closes);
31
+ const second = fakeWs();
32
+
33
+ relay.registerAgent('tnl_1', first, 'signing-key-1', {
34
+ accountId: 'acct_1',
35
+ });
36
+ relay.registerAgent('tnl_1', second, 'signing-key-2', {
37
+ accountId: 'acct_1',
38
+ });
39
+
40
+ const removed = relay.unregisterAgent('tnl_1', first);
41
+
42
+ expect(removed).toBe(false);
43
+ expect(relay.isConnected('tnl_1')).toBe(true);
44
+ expect(relay.getConnectedCount()).toBe(1);
45
+ expect(closes).toEqual([{ code: 4004, reason: 'replaced by another agent process' }]);
46
+ });
47
+
48
+ test('close from the active socket unregisters the agent and emits metadata', () => {
49
+ const relay = new TunnelRelay();
50
+ const ws = fakeWs();
51
+ const events: unknown[] = [];
52
+ relay.on('agent:disconnect', (event) => events.push(event));
53
+
54
+ relay.registerAgent('tnl_1', ws, 'signing-key', { accountId: 'acct_1' });
55
+ const removed = relay.unregisterAgent('tnl_1', ws);
56
+
57
+ expect(removed).toBe(true);
58
+ expect(relay.isConnected('tnl_1')).toBe(false);
59
+ expect(events).toEqual([{ tunnelId: 'tnl_1', metadata: { accountId: 'acct_1' } }]);
60
+ });
61
+
62
+ test('rejects an outgoing RPC larger than the configured byte limit', async () => {
63
+ const relay = new TunnelRelay({ maxWsMessageSize: 128 });
64
+ relay.registerAgent('tnl_1', fakeWs(), 'signing-key', {
65
+ accountId: 'acct_1',
66
+ });
67
+
68
+ await expect(relay.relayRPC('tnl_1', 'fs.write', { content: 'é'.repeat(128) })).rejects.toThrow(
69
+ 'exceeds the maximum tunnel message size',
70
+ );
71
+ });
72
+ });
@@ -2,7 +2,6 @@ import { EventEmitter } from 'events';
2
2
  import { signMessage, verifyMessageSignature } from '../shared/crypto';
3
3
  import {
4
4
  type JsonRpcRequest,
5
- type JsonRpcResponse,
6
5
  type JsonRpcNotification,
7
6
  type PendingRPC,
8
7
  type RelayRpcOptions,
@@ -60,7 +59,9 @@ export class TunnelRelay extends EventEmitter {
60
59
  this.pendingRPCs.delete(requestId);
61
60
  }
62
61
  }
63
- try { existing.ws.close(1000, 'replaced by new connection'); } catch {}
62
+ // A normal close makes the displaced agent reconnect and replace the new
63
+ // socket forever. Use a terminal code so one process wins deterministically.
64
+ try { existing.ws.close(4004, 'replaced by another agent process'); } catch {}
64
65
  this.emitEvent('connection:replaced', { tunnelId });
65
66
  }
66
67
 
@@ -69,7 +70,16 @@ export class TunnelRelay extends EventEmitter {
69
70
  console.log(`[tunnel-relay] Agent registered: ${tunnelId} (total: ${this.agents.size})`);
70
71
  }
71
72
 
72
- unregisterAgent(tunnelId: string): void {
73
+ unregisterAgent(tunnelId: string, ws?: WebSocket): boolean {
74
+ const existing = this.agents.get(tunnelId);
75
+ if (!existing) {
76
+ return false;
77
+ }
78
+ if (ws && existing && existing.ws !== ws) {
79
+ return false;
80
+ }
81
+
82
+ const metadata = existing?.metadata;
73
83
  this.agents.delete(tunnelId);
74
84
 
75
85
  for (const [requestId, pending] of this.pendingRPCs) {
@@ -83,8 +93,19 @@ export class TunnelRelay extends EventEmitter {
83
93
  }
84
94
  }
85
95
 
86
- this.emitEvent('agent:disconnect', { tunnelId });
96
+ this.emitEvent('agent:disconnect', { tunnelId, metadata });
87
97
  console.log(`[tunnel-relay] Agent unregistered: ${tunnelId} (total: ${this.agents.size})`);
98
+ return true;
99
+ }
100
+
101
+ disconnectAgent(tunnelId: string, code = 1000, reason = 'disconnected by server'): boolean {
102
+ const agent = this.agents.get(tunnelId);
103
+ if (!agent) return false;
104
+ const removed = this.unregisterAgent(tunnelId, agent.ws);
105
+ if (removed) {
106
+ try { agent.ws.close(code, reason); } catch {}
107
+ }
108
+ return removed;
88
109
  }
89
110
 
90
111
  isConnected(tunnelId: string): boolean {
@@ -111,7 +132,14 @@ export class TunnelRelay extends EventEmitter {
111
132
  return this.agents.get(tunnelId)?.metadata;
112
133
  }
113
134
 
114
- handleAgentMessage(tunnelId: string, raw: string | Buffer): void {
135
+ updateAgentMetadata(tunnelId: string, patch: Record<string, unknown>): boolean {
136
+ const agent = this.agents.get(tunnelId);
137
+ if (!agent) return false;
138
+ agent.metadata = { ...(agent.metadata ?? {}), ...patch };
139
+ return true;
140
+ }
141
+
142
+ handleAgentMessage(tunnelId: string, ws: WebSocket, raw: string | Buffer): void {
115
143
  let msg: any;
116
144
  try {
117
145
  msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
@@ -122,6 +150,10 @@ export class TunnelRelay extends EventEmitter {
122
150
 
123
151
  // Verify HMAC signature on ALL messages from agent (including pong)
124
152
  const agent = this.agents.get(tunnelId);
153
+ if (!agent || agent.ws !== ws) {
154
+ console.warn(`[tunnel-relay] Message from inactive socket for ${tunnelId}, discarding`);
155
+ return;
156
+ }
125
157
  if (agent && msg._sig !== undefined && msg._nonce !== undefined) {
126
158
  if (msg._nonce <= agent.lastResponseNonce) {
127
159
  console.warn(`[tunnel-relay] Replay detected from agent ${tunnelId}: nonce ${msg._nonce} <= ${agent.lastResponseNonce}`);
@@ -141,7 +173,7 @@ export class TunnelRelay extends EventEmitter {
141
173
  console.warn(`[tunnel-relay] Unsigned message from agent ${tunnelId}, discarding`);
142
174
  return;
143
175
  }
144
-
176
+
145
177
  if ('method' in msg && msg.method === 'tunnel.pong') {
146
178
  this.emitEvent('message:pong', { tunnelId, params: msg.params });
147
179
  return;
@@ -153,7 +185,7 @@ export class TunnelRelay extends EventEmitter {
153
185
  }
154
186
 
155
187
  const pending = this.pendingRPCs.get(msg.id);
156
- if (!pending) {
188
+ if (!pending || pending.tunnelId !== tunnelId) {
157
189
  return;
158
190
  }
159
191
 
@@ -220,6 +252,13 @@ export class TunnelRelay extends EventEmitter {
220
252
  const payload = JSON.stringify(request);
221
253
  const sig = signMessage(agent.signingKey, payload, nonce);
222
254
  const signedRequest = { ...request, _sig: sig, _nonce: nonce };
255
+ const encodedRequest = JSON.stringify(signedRequest);
256
+ if (Buffer.byteLength(encodedRequest, 'utf8') > this.config.maxWsMessageSize) {
257
+ throw new TunnelRelayError(
258
+ TunnelErrorCode.LOCAL_ERROR,
259
+ `RPC request exceeds the maximum tunnel message size for ${method}`,
260
+ );
261
+ }
223
262
 
224
263
  this.emitEvent('rpc:request', { tunnelId, method, requestId });
225
264
 
@@ -244,7 +283,7 @@ export class TunnelRelay extends EventEmitter {
244
283
  });
245
284
 
246
285
  try {
247
- agent.ws.send(JSON.stringify(signedRequest));
286
+ agent.ws.send(encodedRequest);
248
287
  } catch (err) {
249
288
  clearTimeout(timer);
250
289
  this.pendingRPCs.delete(requestId);
@@ -272,7 +311,9 @@ export class TunnelRelay extends EventEmitter {
272
311
  const signedNotification = { ...notification, _sig: sig, _nonce: nonce };
273
312
 
274
313
  try {
275
- agent.ws.send(JSON.stringify(signedNotification));
314
+ const encoded = JSON.stringify(signedNotification);
315
+ if (Buffer.byteLength(encoded, 'utf8') > this.config.maxWsMessageSize) return false;
316
+ agent.ws.send(encoded);
276
317
  return true;
277
318
  } catch {
278
319
  return false;
@@ -0,0 +1,33 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import { startTunnelServer, type TunnelServer } from './server';
4
+
5
+ let server: TunnelServer | null = null;
6
+
7
+ afterEach(() => {
8
+ server?.stop();
9
+ server = null;
10
+ });
11
+
12
+ describe('standalone tunnel WebSocket boundary', () => {
13
+ test('rejects browser-origin WebSocket upgrades before authentication', async () => {
14
+ server = startTunnelServer({ port: 0 });
15
+ const websocketKey = Buffer.from('fixed-test-nonce').toString('base64');
16
+ const response = await fetch(
17
+ `http://127.0.0.1:${server.port}/ws?tunnelId=00000000-0000-4000-8000-000000000001`,
18
+ {
19
+ headers: {
20
+ connection: 'Upgrade',
21
+ upgrade: 'websocket',
22
+ origin: 'https://attacker.example',
23
+ 'sec-websocket-key': websocketKey,
24
+ 'sec-websocket-version': '13',
25
+ },
26
+ },
27
+ );
28
+
29
+ expect(response.status).toBe(403);
30
+ expect(await response.json()).toEqual({ error: 'Browser tunnel WebSockets are not allowed' });
31
+ expect(server.relay.getConnectedCount()).toBe(0);
32
+ });
33
+ });
@@ -18,6 +18,7 @@ import { createWsHandlers } from './ws-handler';
18
18
  import type { TunnelServerConfig } from '../shared/types';
19
19
 
20
20
  export interface TunnelServer {
21
+ port: number;
21
22
  app: Hono;
22
23
  relay: TunnelRelay;
23
24
  heartbeat: HeartbeatManager;
@@ -70,13 +71,25 @@ export function startTunnelServer(config?: TunnelServerConfig): TunnelServer {
70
71
  if (url.pathname === '/ws') {
71
72
  const tunnelId = url.searchParams.get('tunnelId');
72
73
 
73
- if (!tunnelId) {
74
- return new Response(JSON.stringify({ error: 'Missing tunnelId' }), {
74
+ if (!tunnelId || !/^[A-Za-z0-9._:-]{1,128}$/.test(tunnelId)) {
75
+ return new Response(JSON.stringify({ error: 'Invalid tunnelId' }), {
75
76
  status: 400,
76
77
  headers: { 'Content-Type': 'application/json' },
77
78
  });
78
79
  }
79
80
 
81
+ if (req.headers.has('origin')) {
82
+ return new Response(
83
+ JSON.stringify({
84
+ error: 'Browser tunnel WebSockets are not allowed',
85
+ }),
86
+ {
87
+ status: 403,
88
+ headers: { 'Content-Type': 'application/json' },
89
+ },
90
+ );
91
+ }
92
+
80
93
  const success = server.upgrade(req, {
81
94
  data: { tunnelId } as any,
82
95
  });
@@ -94,15 +107,15 @@ export function startTunnelServer(config?: TunnelServerConfig): TunnelServer {
94
107
  wsHandlers.onOpen(ws.data.tunnelId, ws);
95
108
  },
96
109
  message(ws: any, message: string | Buffer) {
97
- wsHandlers.onMessage(ws.data.tunnelId, message);
110
+ wsHandlers.onMessage(ws.data.tunnelId, ws, message);
98
111
  },
99
112
  close(ws: any) {
100
- wsHandlers.onClose(ws.data.tunnelId);
113
+ wsHandlers.onClose(ws.data.tunnelId, ws);
101
114
  },
102
115
  },
103
116
  });
104
117
 
105
- console.log(`[agent-tunnel] Server listening on port ${port}`);
118
+ console.log(`[agent-tunnel] Server listening on port ${bunServer.port}`);
106
119
 
107
120
  const stop = () => {
108
121
  heartbeat.stop();
@@ -110,5 +123,12 @@ export function startTunnelServer(config?: TunnelServerConfig): TunnelServer {
110
123
  bunServer.stop();
111
124
  };
112
125
 
113
- return { app, relay, heartbeat, wsHandlers, stop };
126
+ return {
127
+ port: bunServer.port ?? port,
128
+ app,
129
+ relay,
130
+ heartbeat,
131
+ wsHandlers,
132
+ stop,
133
+ };
114
134
  }