@kortix/agent-tunnel 0.1.0

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.
@@ -0,0 +1,270 @@
1
+ export interface TunnelClientConfig {
2
+ apiUrl: string;
3
+ token: string;
4
+ tunnelId?: string;
5
+ cacheTtlMs?: number;
6
+ }
7
+
8
+ export class TunnelClientError extends Error {
9
+ constructor(
10
+ public readonly code: number,
11
+ message: string,
12
+ public readonly requestId?: string,
13
+ public readonly isPermissionRequest = false,
14
+ ) {
15
+ super(message);
16
+ this.name = 'TunnelClientError';
17
+ }
18
+ }
19
+
20
+ export class TunnelClient {
21
+ private apiUrl: string;
22
+ private token: string;
23
+ private explicitTunnelId: string | undefined;
24
+ private cachedTunnelId: string | null = null;
25
+ private cacheTimestamp = 0;
26
+ private cacheTtlMs: number;
27
+
28
+ readonly fs: FsNamespace;
29
+ readonly shell: ShellNamespace;
30
+ readonly desktop: DesktopNamespace;
31
+
32
+ constructor(config: TunnelClientConfig) {
33
+ this.apiUrl = config.apiUrl.replace(/\/+$/, '');
34
+ this.token = config.token;
35
+ this.explicitTunnelId = config.tunnelId;
36
+ this.cacheTtlMs = config.cacheTtlMs ?? 10_000;
37
+
38
+ this.fs = new FsNamespace(this);
39
+ this.shell = new ShellNamespace(this);
40
+ this.desktop = new DesktopNamespace(this);
41
+ }
42
+
43
+ async rpc(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
44
+ const tunnelId = await this.resolveTunnelId();
45
+
46
+ const res = await fetch(`${this.apiUrl}/rpc/${tunnelId}`, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
51
+ },
52
+ body: JSON.stringify({ method, params }),
53
+ });
54
+
55
+ const data = (await res.json()) as Record<string, unknown>;
56
+
57
+ if (!res.ok) {
58
+ if (res.status === 404) this.cachedTunnelId = null;
59
+
60
+ throw new TunnelClientError(
61
+ (data.code as number) ?? -1,
62
+ (data.error as string) ?? `HTTP ${res.status}`,
63
+ data.requestId as string | undefined,
64
+ res.status === 403 && !!data.requestId,
65
+ );
66
+ }
67
+
68
+ return data.result;
69
+ }
70
+
71
+ async rpcWithPermissionFlow(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
72
+ try {
73
+ return await this.rpc(method, params);
74
+ } catch (err) {
75
+ if (err instanceof TunnelClientError && err.isPermissionRequest) {
76
+ return `Permission required. A permission request (${err.requestId}) has been sent to the user for approval. The user needs to approve this request before you can access their local machine. Please inform the user and try again after they approve.`;
77
+ }
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ async getConnections(): Promise<Array<Record<string, unknown>>> {
83
+ const res = await fetch(`${this.apiUrl}/connections`, {
84
+ headers: {
85
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
86
+ },
87
+ });
88
+
89
+ if (!res.ok) {
90
+ throw new TunnelClientError(-1, `Failed to list connections: HTTP ${res.status}`);
91
+ }
92
+
93
+ return (await res.json()) as Array<Record<string, unknown>>;
94
+ }
95
+
96
+ async resolveTunnelId(): Promise<string> {
97
+ if (this.explicitTunnelId) return this.explicitTunnelId;
98
+
99
+ if (this.cachedTunnelId && (Date.now() - this.cacheTimestamp) < this.cacheTtlMs) {
100
+ return this.cachedTunnelId;
101
+ }
102
+
103
+ const res = await fetch(`${this.apiUrl}/connections`, {
104
+ headers: {
105
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
106
+ },
107
+ });
108
+
109
+ if (res.ok) {
110
+ const connections = (await res.json()) as Array<{ tunnelId: string; isLive?: boolean }>;
111
+ const online = connections.find((c) => c.isLive);
112
+ if (online) {
113
+ this.cachedTunnelId = online.tunnelId;
114
+ 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;
121
+ }
122
+ }
123
+
124
+ this.cachedTunnelId = null;
125
+ throw new TunnelClientError(
126
+ -1,
127
+ 'No tunnel connection found. The user needs to set up Agent Tunnel first:\n' +
128
+ '1. Create a tunnel connection\n' +
129
+ '2. Run `npx @kortix/agent-tunnel connect` on their local machine',
130
+ );
131
+ }
132
+ }
133
+
134
+ class FsNamespace {
135
+ constructor(private client: TunnelClient) {}
136
+
137
+ async read(path: string, encoding = 'utf-8'): Promise<{ content: string; size: number; path: string }> {
138
+ return (await this.client.rpc('fs.read', { path, encoding })) as any;
139
+ }
140
+
141
+ async write(path: string, content: string, encoding = 'utf-8'): Promise<{ path: string; size: number }> {
142
+ return (await this.client.rpc('fs.write', { path, content, encoding })) as any;
143
+ }
144
+
145
+ async list(path: string, recursive = false): Promise<{ entries: Array<{ name: string; path: string; isDirectory: boolean; isFile: boolean }>; count: number }> {
146
+ return (await this.client.rpc('fs.list', { path, recursive })) as any;
147
+ }
148
+
149
+ async stat(path: string): Promise<Record<string, unknown>> {
150
+ return (await this.client.rpc('fs.stat', { path })) as any;
151
+ }
152
+
153
+ async delete(path: string): Promise<Record<string, unknown>> {
154
+ return (await this.client.rpc('fs.delete', { path })) as any;
155
+ }
156
+ }
157
+
158
+ class ShellNamespace {
159
+ constructor(private client: TunnelClient) {}
160
+
161
+ async exec(
162
+ command: string,
163
+ args: string[] = [],
164
+ options?: { cwd?: string; timeout?: number },
165
+ ): Promise<{ exitCode: number | null; signal: string | null; stdout: string; stderr: string; stdoutTruncated: boolean; stderrTruncated: boolean }> {
166
+ return (await this.client.rpc('shell.exec', {
167
+ command,
168
+ args,
169
+ cwd: options?.cwd,
170
+ timeout: options?.timeout,
171
+ })) as any;
172
+ }
173
+ }
174
+
175
+ class DesktopNamespace {
176
+ constructor(private client: TunnelClient) {}
177
+
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 });
192
+ }
193
+
194
+ async mouseMove(x: number, y: number): Promise<unknown> {
195
+ return this.client.rpc('desktop.mouse.move', { x, y });
196
+ }
197
+
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 });
200
+ }
201
+
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 });
204
+ }
205
+
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;
208
+ }
209
+
210
+ async windowFocus(windowId: number): Promise<unknown> {
211
+ return this.client.rpc('desktop.window.focus', { windowId });
212
+ }
213
+
214
+ async appLaunch(app: string): Promise<unknown> {
215
+ return this.client.rpc('desktop.app.launch', { app });
216
+ }
217
+
218
+ async appQuit(app: string): Promise<unknown> {
219
+ return this.client.rpc('desktop.app.quit', { app });
220
+ }
221
+
222
+ async clipboardRead(): Promise<{ text: string }> {
223
+ return (await this.client.rpc('desktop.clipboard.read', {})) as any;
224
+ }
225
+
226
+ async clipboardWrite(text: string): Promise<unknown> {
227
+ return this.client.rpc('desktop.clipboard.write', { text });
228
+ }
229
+
230
+ async screenInfo(): Promise<{ width: number; height: number; scaleFactor: number }> {
231
+ return (await this.client.rpc('desktop.screen.info', {})) as any;
232
+ }
233
+
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;
236
+ }
237
+
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;
240
+ }
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ // ─── Shared: Types + Crypto ─────────────────────────────────────────────────
2
+ export {
3
+ deriveSigningKey,
4
+ signMessage,
5
+ verifyMessageSignature,
6
+ generateToken,
7
+ hashToken,
8
+ timingSafeStringEqual,
9
+ TunnelErrorCode,
10
+ TunnelMethods,
11
+ } from './shared';
12
+
13
+ export type {
14
+ JsonRpcRequest,
15
+ JsonRpcSuccessResponse,
16
+ JsonRpcErrorResponse,
17
+ JsonRpcError,
18
+ JsonRpcResponse,
19
+ JsonRpcNotification,
20
+ JsonRpcMessage,
21
+ SignedJsonRpcRequest,
22
+ SignedJsonRpcNotification,
23
+ PendingRPC,
24
+ TunnelRpcParams,
25
+ RelayRpcOptions,
26
+ AgentInfo,
27
+ TunnelRelayEvents,
28
+ TunnelRelayConfig,
29
+ HeartbeatConfig,
30
+ TunnelServerConfig,
31
+ TunnelCapability,
32
+ TunnelMethod,
33
+ TunnelErrorCodeValue,
34
+ } from './shared';
35
+
36
+ // ─── Server: Relay ──────────────────────────────────────────────────────────
37
+ export { TunnelRelay, TunnelRelayError } from './server';
38
+ export { HeartbeatManager } from './server';
39
+ export { createWsHandlers } from './server';
40
+ export type { WsHandlers, WsHandlerOptions } from './server';
41
+ export { createTunnelRouter } from './server';
42
+ export { startTunnelServer } from './server';
43
+ export type { TunnelServer } from './server';
44
+
45
+ // ─── Client: SDK ────────────────────────────────────────────────────────────
46
+ export { TunnelClient, TunnelClientError } from './client';
47
+ export type { TunnelClientConfig, AXElement } from './client';
48
+ export { createTunnelTools } from './client';
49
+ export type { TunnelToolDefinition, TunnelToolParameter } from './client';
50
+
51
+ // ─── Agent: Local Machine ───────────────────────────────────────────────────
52
+ export { TunnelAgent } from './agent';
53
+ export { loadConfig, type TunnelConfig } from './agent';
54
+ export { CapabilityRegistry } from './agent';
55
+ export type { Capability, RpcHandler } from './agent';
56
+ export { createFilesystemCapability } from './agent';
57
+ export { createShellCapability } from './agent';
58
+ export { createDesktopCapability } from './agent';
59
+ export { PermissionGuard } from './agent';
60
+ export type { LocalPermission } from './agent';
61
+ export { validateCommand } from './agent';
62
+ export { validatePath } from './agent';
@@ -0,0 +1,84 @@
1
+ import type { TunnelRelay } from './relay';
2
+ import type { HeartbeatConfig } from '../shared/types';
3
+
4
+ interface HeartbeatState {
5
+ missedPongs: number;
6
+ lastPongAt: number;
7
+ }
8
+
9
+ const DEFAULT_INTERVAL_MS = 30_000;
10
+ const DEFAULT_MAX_MISSED = 3;
11
+
12
+ export class HeartbeatManager {
13
+ private states = new Map<string, HeartbeatState>();
14
+ private intervalHandle: ReturnType<typeof setInterval> | null = null;
15
+ private relay: TunnelRelay;
16
+ private intervalMs: number;
17
+ private maxMissed: number;
18
+
19
+ constructor(relay: TunnelRelay, config?: HeartbeatConfig) {
20
+ this.relay = relay;
21
+ this.intervalMs = config?.intervalMs ?? DEFAULT_INTERVAL_MS;
22
+ this.maxMissed = config?.maxMissed ?? DEFAULT_MAX_MISSED;
23
+ }
24
+
25
+ start(): void {
26
+ if (this.intervalHandle) return;
27
+
28
+ this.intervalHandle = setInterval(
29
+ () => this.tick(),
30
+ this.intervalMs,
31
+ );
32
+
33
+ console.log(`[tunnel-heartbeat] Started (interval: ${this.intervalMs}ms, max missed: ${this.maxMissed})`);
34
+ }
35
+
36
+ stop(): void {
37
+ if (this.intervalHandle) {
38
+ clearInterval(this.intervalHandle);
39
+ this.intervalHandle = null;
40
+ }
41
+ this.states.clear();
42
+ console.log('[tunnel-heartbeat] Stopped');
43
+ }
44
+
45
+ register(tunnelId: string): void {
46
+ this.states.set(tunnelId, {
47
+ missedPongs: 0,
48
+ lastPongAt: Date.now(),
49
+ });
50
+ }
51
+
52
+ unregister(tunnelId: string): void {
53
+ this.states.delete(tunnelId);
54
+ }
55
+
56
+ recordPong(tunnelId: string): void {
57
+ const state = this.states.get(tunnelId);
58
+ if (state) {
59
+ state.missedPongs = 0;
60
+ state.lastPongAt = Date.now();
61
+ }
62
+ }
63
+
64
+ private tick(): void {
65
+ for (const [tunnelId, state] of this.states) {
66
+ const sent = this.relay.sendNotification(tunnelId, 'tunnel.ping', {
67
+ timestamp: Date.now(),
68
+ });
69
+
70
+ if (!sent) {
71
+ this.states.delete(tunnelId);
72
+ continue;
73
+ }
74
+
75
+ state.missedPongs++;
76
+
77
+ if (state.missedPongs >= this.maxMissed) {
78
+ console.warn(`[tunnel-heartbeat] Agent ${tunnelId} missed ${state.missedPongs} pongs — timing out`);
79
+ this.relay.emitEvent('agent:timeout', { tunnelId });
80
+ this.states.delete(tunnelId);
81
+ }
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,7 @@
1
+ export { TunnelRelay, TunnelRelayError } from './relay';
2
+ export { HeartbeatManager } from './heartbeat';
3
+ export { createWsHandlers } from './ws-handler';
4
+ export type { WsHandlers, WsHandlerOptions } from './ws-handler';
5
+ export { createTunnelRouter } from './routes';
6
+ export { startTunnelServer } from './server';
7
+ export type { TunnelServer } from './server';
@@ -0,0 +1,266 @@
1
+ import { EventEmitter } from 'events';
2
+ import { signMessage } from '../shared/crypto';
3
+ import {
4
+ type JsonRpcRequest,
5
+ type JsonRpcResponse,
6
+ type JsonRpcNotification,
7
+ type PendingRPC,
8
+ type RelayRpcOptions,
9
+ type AgentInfo,
10
+ type TunnelRelayConfig,
11
+ type TunnelRelayEvents,
12
+ TunnelErrorCode,
13
+ } from '../shared/types';
14
+
15
+ interface AgentConnection {
16
+ ws: WebSocket;
17
+ signingKey: string;
18
+ nonce: number;
19
+ connectedAt: number;
20
+ metadata?: Record<string, unknown>;
21
+ }
22
+
23
+ const DEFAULT_RPC_TIMEOUT_MS = 30_000;
24
+
25
+ export class TunnelRelay extends EventEmitter {
26
+ private agents = new Map<string, AgentConnection>();
27
+ private pendingRPCs = new Map<string, PendingRPC>();
28
+ private config: Required<TunnelRelayConfig>;
29
+
30
+ constructor(config?: TunnelRelayConfig) {
31
+ super();
32
+ this.config = {
33
+ rpcTimeoutMs: config?.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
34
+ maxWsMessageSize: config?.maxWsMessageSize ?? 5 * 1024 * 1024,
35
+ };
36
+ }
37
+
38
+ emitEvent<K extends keyof TunnelRelayEvents>(event: K, data: TunnelRelayEvents[K]): boolean {
39
+ return this.emit(event, data);
40
+ }
41
+
42
+ registerAgent(
43
+ tunnelId: string,
44
+ ws: WebSocket,
45
+ signingKey: string,
46
+ metadata?: Record<string, unknown>,
47
+ ): void {
48
+ const existing = this.agents.get(tunnelId);
49
+ if (existing) {
50
+ try { existing.ws.close(1000, 'replaced by new connection'); } catch {}
51
+ this.emitEvent('connection:replaced', { tunnelId });
52
+ }
53
+
54
+ this.agents.set(tunnelId, { ws, signingKey, nonce: 0, connectedAt: Date.now(), metadata });
55
+ this.emitEvent('agent:connect', { tunnelId, metadata });
56
+ console.log(`[tunnel-relay] Agent registered: ${tunnelId} (total: ${this.agents.size})`);
57
+ }
58
+
59
+ unregisterAgent(tunnelId: string): void {
60
+ this.agents.delete(tunnelId);
61
+
62
+ for (const [requestId, pending] of this.pendingRPCs) {
63
+ if (pending.tunnelId === tunnelId) {
64
+ clearTimeout(pending.timer);
65
+ pending.reject(new TunnelRelayError(
66
+ TunnelErrorCode.NOT_CONNECTED,
67
+ 'Agent disconnected while RPC was pending',
68
+ ));
69
+ this.pendingRPCs.delete(requestId);
70
+ }
71
+ }
72
+
73
+ this.emitEvent('agent:disconnect', { tunnelId });
74
+ console.log(`[tunnel-relay] Agent unregistered: ${tunnelId} (total: ${this.agents.size})`);
75
+ }
76
+
77
+ isConnected(tunnelId: string): boolean {
78
+ return this.agents.has(tunnelId);
79
+ }
80
+
81
+ getConnectedCount(): number {
82
+ return this.agents.size;
83
+ }
84
+
85
+ getConnectedAgents(): Map<string, AgentInfo> {
86
+ const result = new Map<string, AgentInfo>();
87
+ for (const [tunnelId, conn] of this.agents) {
88
+ result.set(tunnelId, {
89
+ tunnelId,
90
+ signingKey: conn.signingKey,
91
+ connectedAt: conn.connectedAt,
92
+ metadata: conn.metadata,
93
+ });
94
+ }
95
+ return result;
96
+ }
97
+
98
+ getAgentMetadata(tunnelId: string): Record<string, unknown> | undefined {
99
+ return this.agents.get(tunnelId)?.metadata;
100
+ }
101
+
102
+ handleAgentMessage(tunnelId: string, raw: string | Buffer): void {
103
+ let msg: JsonRpcResponse;
104
+ try {
105
+ msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
106
+ } catch {
107
+ console.warn(`[tunnel-relay] Invalid JSON from agent ${tunnelId}`);
108
+ return;
109
+ }
110
+
111
+ if ('method' in msg && (msg as any).method === 'tunnel.pong') {
112
+ this.emitEvent('message:pong', { tunnelId, params: (msg as any).params });
113
+ return;
114
+ }
115
+
116
+ if (!('id' in msg) || !msg.id) {
117
+ this.emitEvent('message:raw', { tunnelId, message: msg });
118
+ return;
119
+ }
120
+
121
+ const pending = this.pendingRPCs.get(msg.id);
122
+ if (!pending) {
123
+ return;
124
+ }
125
+
126
+ clearTimeout(pending.timer);
127
+ this.pendingRPCs.delete(msg.id);
128
+
129
+ const durationMs = Date.now() - pending.startedAt;
130
+
131
+ if ('error' in msg && msg.error) {
132
+ const error = new TunnelRelayError(msg.error.code, msg.error.message, msg.error.data);
133
+ this.emitEvent('rpc:error', {
134
+ tunnelId,
135
+ method: pending.method,
136
+ requestId: msg.id,
137
+ error,
138
+ });
139
+ pending.reject(error);
140
+ } else {
141
+ this.emitEvent('rpc:response', {
142
+ tunnelId,
143
+ method: pending.method,
144
+ requestId: msg.id,
145
+ durationMs,
146
+ });
147
+ pending.resolve((msg as any).result);
148
+ }
149
+ }
150
+
151
+ async relayRPC(
152
+ tunnelId: string,
153
+ method: string,
154
+ params: Record<string, unknown>,
155
+ options?: RelayRpcOptions,
156
+ ): Promise<unknown> {
157
+ const agent = this.agents.get(tunnelId);
158
+ if (!agent) {
159
+ throw new TunnelRelayError(
160
+ TunnelErrorCode.NOT_CONNECTED,
161
+ `Tunnel agent ${tunnelId} is not connected`,
162
+ );
163
+ }
164
+
165
+ const requestId = crypto.randomUUID();
166
+ const timeoutMs = options?.timeoutMs ?? this.config.rpcTimeoutMs;
167
+
168
+ const request: JsonRpcRequest = {
169
+ jsonrpc: '2.0',
170
+ id: requestId,
171
+ method,
172
+ params,
173
+ };
174
+
175
+ const nonce = ++agent.nonce;
176
+ const payload = JSON.stringify(request);
177
+ const sig = signMessage(agent.signingKey, payload, nonce);
178
+ const signedRequest = { ...request, _sig: sig, _nonce: nonce };
179
+
180
+ this.emitEvent('rpc:request', { tunnelId, method, requestId });
181
+
182
+ return new Promise((resolve, reject) => {
183
+ const timer = setTimeout(() => {
184
+ this.pendingRPCs.delete(requestId);
185
+ const error = new TunnelRelayError(
186
+ TunnelErrorCode.TIMEOUT,
187
+ `RPC timeout after ${timeoutMs}ms for ${method}`,
188
+ );
189
+ this.emitEvent('rpc:error', { tunnelId, method, requestId, error });
190
+ reject(error);
191
+ }, timeoutMs);
192
+
193
+ this.pendingRPCs.set(requestId, {
194
+ resolve,
195
+ reject,
196
+ timer,
197
+ method,
198
+ tunnelId,
199
+ startedAt: Date.now(),
200
+ });
201
+
202
+ try {
203
+ agent.ws.send(JSON.stringify(signedRequest));
204
+ } catch (err) {
205
+ clearTimeout(timer);
206
+ this.pendingRPCs.delete(requestId);
207
+ reject(new TunnelRelayError(
208
+ TunnelErrorCode.NOT_CONNECTED,
209
+ `Failed to send RPC to agent: ${err}`,
210
+ ));
211
+ }
212
+ });
213
+ }
214
+
215
+ sendNotification(tunnelId: string, method: string, params?: Record<string, unknown>): boolean {
216
+ const agent = this.agents.get(tunnelId);
217
+ if (!agent) return false;
218
+
219
+ const notification: JsonRpcNotification = {
220
+ jsonrpc: '2.0',
221
+ method,
222
+ params,
223
+ };
224
+
225
+ const nonce = ++agent.nonce;
226
+ const payload = JSON.stringify(notification);
227
+ const sig = signMessage(agent.signingKey, payload, nonce);
228
+ const signedNotification = { ...notification, _sig: sig, _nonce: nonce };
229
+
230
+ try {
231
+ agent.ws.send(JSON.stringify(signedNotification));
232
+ return true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ shutdown(): void {
239
+ for (const [_requestId, pending] of this.pendingRPCs) {
240
+ clearTimeout(pending.timer);
241
+ pending.reject(new TunnelRelayError(
242
+ TunnelErrorCode.NOT_CONNECTED,
243
+ 'Tunnel relay shutting down',
244
+ ));
245
+ }
246
+ this.pendingRPCs.clear();
247
+
248
+ for (const [_tunnelId, agent] of this.agents) {
249
+ try { agent.ws.close(1001, 'server shutting down'); } catch {}
250
+ }
251
+ this.agents.clear();
252
+
253
+ console.log('[tunnel-relay] Shutdown complete');
254
+ }
255
+ }
256
+
257
+ export class TunnelRelayError extends Error {
258
+ constructor(
259
+ public readonly code: number,
260
+ message: string,
261
+ public readonly data?: unknown,
262
+ ) {
263
+ super(message);
264
+ this.name = 'TunnelRelayError';
265
+ }
266
+ }