@kortix/agent-tunnel 0.1.3 → 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.
- package/README.md +65 -0
- package/dist/agent-cli.js +4676 -3174
- package/dist/client-cli.js +203 -494
- package/package.json +24 -29
- package/src/agent/agent.ts +67 -17
- package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
- package/src/agent/capabilities/desktop.ts +100 -167
- package/src/agent/capabilities/enabled-registry.ts +24 -0
- package/src/agent/capabilities/filesystem.ts +136 -32
- package/src/agent/capabilities/index.ts +1 -1
- package/src/agent/capabilities/security.test.ts +204 -0
- package/src/agent/capabilities/shell.ts +37 -3
- package/src/agent/cli-device-auth.test.ts +179 -0
- package/src/agent/cli-help.test.ts +25 -0
- package/src/agent/cli.ts +475 -38
- package/src/agent/config.test.ts +53 -0
- package/src/agent/config.ts +169 -7
- package/src/agent/index.ts +1 -0
- package/src/agent/security/command-validator.ts +4 -2
- package/src/agent/security/path-validator.ts +73 -18
- package/src/agent/security/permission-guard.test.ts +52 -0
- package/src/agent/security/permission-guard.ts +35 -8
- package/src/agent/service.test.ts +63 -0
- package/src/agent/service.ts +410 -0
- package/src/client/cli.test.ts +150 -547
- package/src/client/cli.ts +116 -537
- package/src/client/index.ts +1 -1
- package/src/client/tools.ts +95 -356
- package/src/client/tunnel-client.ts +50 -80
- package/src/index.ts +7 -1
- package/src/node-ws-polyfill.test.ts +18 -0
- package/src/node-ws-polyfill.ts +5 -3
- package/src/server/heartbeat.ts +13 -6
- package/src/server/relay.test.ts +72 -0
- package/src/server/relay.ts +50 -9
- package/src/server/server.test.ts +33 -0
- package/src/server/server.ts +26 -6
- package/src/server/ws-handler.test.ts +158 -0
- package/src/server/ws-handler.ts +94 -36
- package/src/shared/crypto.ts +2 -3
- package/src/shared/index.ts +8 -0
- package/src/shared/permissions.ts +292 -0
- package/src/shared/types.ts +70 -41
- package/dist/agent/index.d.ts +0 -140
- package/dist/agent/index.js +0 -21
- package/dist/agent/index.js.map +0 -1
- package/dist/chunk-7N7GSU6K.js +0 -34
- package/dist/client/index.d.ts +0 -183
- package/dist/client/index.js +0 -8
- package/dist/client/index.js.map +0 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -55
- package/dist/index.js.map +0 -1
- package/dist/server/index.d.ts +0 -89
- package/dist/server/index.js +0 -14
- package/dist/server/index.js.map +0 -1
- package/dist/shared/index.d.ts +0 -10
- package/dist/shared/index.js +0 -20
- package/dist/shared/index.js.map +0 -1
- package/dist/types-Dpwrd8Ai.d.ts +0 -194
- package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
- package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
- package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
- package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
- package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
- package/src/agent/capabilities/desktop/types.ts +0 -201
- 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
|
|
36
|
+
readonly cua: CuaNamespace;
|
|
31
37
|
|
|
32
38
|
constructor(config: TunnelClientConfig) {
|
|
33
|
-
|
|
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.
|
|
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
|
-
|
|
113
|
-
|
|
127
|
+
const chosen = online ?? connections[0];
|
|
128
|
+
if (chosen) {
|
|
129
|
+
this.cachedTunnelId = chosen.tunnelId;
|
|
114
130
|
this.cacheTimestamp = Date.now();
|
|
115
|
-
return
|
|
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.
|
|
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
|
|
186
|
+
class CuaNamespace {
|
|
176
187
|
constructor(private client: TunnelClient) {}
|
|
177
188
|
|
|
178
|
-
async
|
|
179
|
-
return (await this.client.rpc('desktop.
|
|
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
|
|
195
|
-
return this.client.rpc('desktop.
|
|
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
|
|
199
|
-
return this.client.rpc('desktop.
|
|
197
|
+
async status(): Promise<{ status: string }> {
|
|
198
|
+
return (await this.client.rpc('desktop.cua.status', {})) as any;
|
|
200
199
|
}
|
|
201
200
|
|
|
202
|
-
async
|
|
203
|
-
return this.client.rpc('desktop.
|
|
201
|
+
async version(): Promise<{ version: string }> {
|
|
202
|
+
return (await this.client.rpc('desktop.cua.version', {})) as any;
|
|
204
203
|
}
|
|
205
204
|
|
|
206
|
-
async
|
|
207
|
-
return (await this.client.rpc('desktop.
|
|
205
|
+
async listTools(): Promise<{ tools: string }> {
|
|
206
|
+
return (await this.client.rpc('desktop.cua.list_tools', {})) as any;
|
|
208
207
|
}
|
|
209
208
|
|
|
210
|
-
async
|
|
211
|
-
return this.client.rpc('desktop.
|
|
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
|
|
215
|
-
return this.client.rpc('desktop.
|
|
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
|
|
219
|
-
return this.client.rpc('desktop.
|
|
217
|
+
async listApps(): Promise<unknown> {
|
|
218
|
+
return this.client.rpc('desktop.cua.list_apps', {});
|
|
220
219
|
}
|
|
221
220
|
|
|
222
|
-
async
|
|
223
|
-
return
|
|
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
|
|
227
|
-
return this.client.rpc('desktop.
|
|
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
|
|
231
|
-
return
|
|
229
|
+
async click(params: Record<string, unknown>): Promise<unknown> {
|
|
230
|
+
return this.client.rpc('desktop.cua.click', params);
|
|
232
231
|
}
|
|
233
232
|
|
|
234
|
-
async
|
|
235
|
-
return
|
|
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
|
|
239
|
-
return
|
|
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
|
|
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
|
+
});
|
package/src/node-ws-polyfill.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
if (typeof globalThis.WebSocket === 'undefined') {
|
|
7
7
|
try {
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
globalThis.WebSocket =
|
|
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 {};
|
package/src/server/heartbeat.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
+
});
|
package/src/server/relay.ts
CHANGED
|
@@ -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
|
-
|
|
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):
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|
package/src/server/server.ts
CHANGED
|
@@ -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: '
|
|
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 {
|
|
126
|
+
return {
|
|
127
|
+
port: bunServer.port ?? port,
|
|
128
|
+
app,
|
|
129
|
+
relay,
|
|
130
|
+
heartbeat,
|
|
131
|
+
wsHandlers,
|
|
132
|
+
stop,
|
|
133
|
+
};
|
|
114
134
|
}
|